Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions src/app/api/connections/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { requireSessionUserId } from "@/lib/api/require-session-user-id"
import { connectorStore } from "@/lib/connector-store"
import type { Connection } from "@/lib/connector-types"

export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
type ConnectionUpdate = Partial<Omit<Connection, "id" | "ownerUserId" | "createdAt">>

export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const userIdOrError = requireSessionUserId(session)
if (userIdOrError instanceof NextResponse) return userIdOrError

const { id } = await params
const connections = connectorStore.getConnectionsByConnector(id)
// Path param is connectorId (legacy route shape).
const connections = connectorStore.getConnectionsByConnector(id, userIdOrError)
return NextResponse.json(connections)
}

export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const userIdOrError = requireSessionUserId(session)
if (userIdOrError instanceof NextResponse) return userIdOrError

const { id } = await params
const body = await request.json()
const connection = connectorStore.updateConnection(id, body)
const body = (await request.json()) as ConnectionUpdate & {
ownerUserId?: string
id?: string
}
const { ownerUserId: _ignoredOwner, id: _ignoredId, ...updates } = body
const connection = connectorStore.updateConnection(id, userIdOrError, updates)

if (!connection) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 })
Expand All @@ -25,12 +37,13 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
return NextResponse.json(connection)
}

export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const userIdOrError = requireSessionUserId(session)
if (userIdOrError instanceof NextResponse) return userIdOrError

const { id } = await params
const success = connectorStore.deleteConnection(id)
const success = connectorStore.deleteConnection(id, userIdOrError)

if (!success) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 })
Expand Down
11 changes: 8 additions & 3 deletions src/app/api/connections/[id]/test/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { requireSessionUserId } from "@/lib/api/require-session-user-id"
import { connectorStore } from "@/lib/connector-store"

export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const userIdOrError = requireSessionUserId(session)
if (userIdOrError instanceof NextResponse) return userIdOrError

const { id } = await params
const result = await connectorStore.testConnection(id)
const result = await connectorStore.testConnection(id, userIdOrError)
if (!result.success && result.message === "Connection not found") {
return NextResponse.json({ error: "Connection not found" }, { status: 404 })
}
return NextResponse.json(result)
}
23 changes: 18 additions & 5 deletions src/app/api/connections/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { requireSessionUserId } from "@/lib/api/require-session-user-id"
import { connectorStore } from "@/lib/connector-store"
import type { Connection } from "@/lib/connector-types"

type ConnectionCreateBody = Omit<Connection, "id" | "createdAt" | "ownerUserId">

export async function GET() {
const session = await auth()
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const userIdOrError = requireSessionUserId(session)
if (userIdOrError instanceof NextResponse) return userIdOrError

const connections = connectorStore.getConnections()
const connections = connectorStore.getConnections(userIdOrError)
return NextResponse.json(connections)
}

export async function POST(request: Request) {
const session = await auth()
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const userIdOrError = requireSessionUserId(session)
if (userIdOrError instanceof NextResponse) return userIdOrError

const body = await request.json()
const connection = connectorStore.addConnection(body)
const body = (await request.json()) as Partial<ConnectionCreateBody> & {
ownerUserId?: string
}
// Ignore any client-supplied ownerUserId — ownership always comes from the session.
const { ownerUserId: _ignored, ...rest } = body
const connection = connectorStore.addConnection({
...(rest as ConnectionCreateBody),
ownerUserId: userIdOrError,
})
return NextResponse.json(connection, { status: 201 })
}
14 changes: 13 additions & 1 deletion src/app/api/oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { cookies } from "next/headers"
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { requireSessionUserId } from "@/lib/api/require-session-user-id"
import {
OAUTH_PKCE_COOKIE_NAME,
getOAuthPkceCookieClearOptions,
Expand Down Expand Up @@ -30,6 +32,15 @@ export async function GET(request: Request) {
)
}

const session = await auth()
const userIdOrError = requireSessionUserId(session)
if (userIdOrError instanceof NextResponse) {
return withClearedPkceCookie(
NextResponse.redirect(new URL("/connectors?error=unauthorized", request.url)),
)
}
const userId = userIdOrError

const cookieStore = await cookies()
const pkceCookie = cookieStore.get(OAUTH_PKCE_COOKIE_NAME)?.value

Expand All @@ -41,7 +52,8 @@ export async function GET(request: Request) {

const tokens = await oauthManager.exchangeCodeForToken(code, oauthState)

const _connection = connectorStore.addConnection({
connectorStore.addConnection({
ownerUserId: userId,
connectorId: oauthState.connectorId,
name: `${oauthState.connectorId} Connection`,
status: "connected",
Expand Down
13 changes: 9 additions & 4 deletions src/app/api/oauth/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { requireSessionUserId } from "@/lib/api/require-session-user-id"
import { oauthManager } from "@/lib/oauth-manager"
import { connectorStore } from "@/lib/connector-store"

export async function POST(request: Request) {
const session = await auth()
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const userIdOrError = requireSessionUserId(session)
if (userIdOrError instanceof NextResponse) return userIdOrError

const body = await request.json()
const { connectionId } = body

if (typeof connectionId !== "string" || connectionId.length === 0) {
return NextResponse.json({ error: "connectionId must be a non-empty string" }, { status: 400 })
}

try {
const connections = connectorStore.getConnections()
const connection = connections.find((c) => c.id === connectionId)
const connection = connectorStore.getConnectionById(connectionId, userIdOrError)

if (!connection) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 })
Expand All @@ -27,7 +32,7 @@ export async function POST(request: Request) {
connection.config.credentials.refreshToken,
)

connectorStore.updateConnection(connectionId, {
connectorStore.updateConnection(connectionId, userIdOrError, {
config: {
...connection.config,
credentials: {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/api/require-session-user-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server"

/**
* Require an authenticated session with a non-empty user id.
* Callers must not trust client-supplied owner ids for connection ownership.
*/
export function requireSessionUserId(
session: { user?: { id?: string | null } } | null,
): string | NextResponse {
const userId = session?.user?.id
if (!session || typeof userId !== "string" || userId.length === 0) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
return userId
}
42 changes: 33 additions & 9 deletions src/lib/connector-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,25 @@ class ConnectorStore {
return this.connectors.filter((c) => c.category === category)
}

getConnections(): Connection[] {
return this.connections
getConnections(ownerUserId: string): Connection[] {
return this.connections.filter((c) => c.ownerUserId === ownerUserId)
}

getConnectionsByConnector(connectorId: string): Connection[] {
return this.connections.filter((c) => c.connectorId === connectorId)
getConnectionsByConnector(connectorId: string, ownerUserId: string): Connection[] {
return this.connections.filter(
(c) => c.connectorId === connectorId && c.ownerUserId === ownerUserId,
)
}

getConnectionById(id: string, ownerUserId: string): Connection | undefined {
return this.connections.find((c) => c.id === id && c.ownerUserId === ownerUserId)
}

addConnection(connection: Omit<Connection, "id" | "createdAt">): Connection {
if (!connection.ownerUserId) {
throw new Error("ownerUserId is required when adding a connection")
}

const newConnection: Connection = {
...connection,
id: `conn-${Date.now()}-${Math.random().toString(36).substring(7)}`,
Expand All @@ -153,16 +163,20 @@ class ConnectorStore {
return newConnection
}

updateConnection(id: string, updates: Partial<Connection>): Connection | null {
const index = this.connections.findIndex((c) => c.id === id)
updateConnection(
id: string,
ownerUserId: string,
updates: Partial<Omit<Connection, "id" | "ownerUserId" | "createdAt">>,
): Connection | null {
const index = this.connections.findIndex((c) => c.id === id && c.ownerUserId === ownerUserId)
if (index === -1) return null

this.connections[index] = { ...this.connections[index], ...updates }
return this.connections[index]
}

deleteConnection(id: string): boolean {
const connection = this.connections.find((c) => c.id === id)
deleteConnection(id: string, ownerUserId: string): boolean {
const connection = this.connections.find((c) => c.id === id && c.ownerUserId === ownerUserId)
if (!connection) return false

this.connections = this.connections.filter((c) => c.id !== id)
Expand All @@ -180,7 +194,12 @@ class ConnectorStore {
return true
}

testConnection(_id: string): Promise<{ success: boolean; message: string }> {
testConnection(id: string, ownerUserId: string): Promise<{ success: boolean; message: string }> {
const owned = this.getConnectionById(id, ownerUserId)
if (!owned) {
return Promise.resolve({ success: false, message: "Connection not found" })
}

return new Promise((resolve) => {
setTimeout(() => {
resolve({
Expand All @@ -190,6 +209,11 @@ class ConnectorStore {
}, 1500)
})
}

/** Test-only: drop all stored connections between cases. */
clearConnections(): void {
this.connections = []
}
}

export const connectorStore = new ConnectorStore()
2 changes: 2 additions & 0 deletions src/lib/connector-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface Connector {

export interface Connection {
id: string
/** Authenticated user that owns this connection; required for tenant isolation. */
ownerUserId: string
connectorId: string
name: string
status: ConnectionStatus
Expand Down
Loading
Loading