diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts new file mode 100644 index 000000000..b8e50746f --- /dev/null +++ b/app/api/chats/[id]/route.ts @@ -0,0 +1,36 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getChatHandler } from "@/lib/chats/getChatHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty response with CORS headers. + */ +export async function OPTIONS(): Promise { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/chats/[id] + * + * Returns the chat row in camelCase wire format (incl. `sessionId`) for + * the authenticated owner of its parent session. + * + * @param request - Incoming request. + * @param context - Next.js route context. + * @param context.params - Async route params containing the chat id. + * @returns JSON response with `{ chat }`, or a 401/403/404 error. + */ +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> }, +): Promise { + const { params } = context; + const { id } = await params; + return getChatHandler(request, id); +} diff --git a/lib/chats/__tests__/getChatHandler.test.ts b/lib/chats/__tests__/getChatHandler.test.ts new file mode 100644 index 000000000..b156b7f45 --- /dev/null +++ b/lib/chats/__tests__/getChatHandler.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/chats/validateGetChatRequest", () => ({ + validateGetChatRequest: vi.fn(), +})); + +const { validateGetChatRequest } = await import("@/lib/chats/validateGetChatRequest"); +const { getChatHandler } = await import("@/lib/chats/getChatHandler"); + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/chats/chat_1"); +} + +describe("getChatHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the validation NextResponse on failure", async () => { + const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 }); + vi.mocked(validateGetChatRequest).mockResolvedValue(failure); + + const res = await getChatHandler(makeReq(), "chat_1"); + expect(res).toBe(failure); + }); + + it("returns 200 with the camelCase chat (incl. sessionId) on success", async () => { + vi.mocked(validateGetChatRequest).mockResolvedValue( + baseChatRow({ id: "chat_1", session_id: "sess_1", title: "My chat" }), + ); + + const res = await getChatHandler(makeReq(), "chat_1"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.chat).toMatchObject({ id: "chat_1", sessionId: "sess_1", title: "My chat" }); + }); +}); diff --git a/lib/chats/__tests__/validateGetChatRequest.test.ts b/lib/chats/__tests__/validateGetChatRequest.test.ts new file mode 100644 index 000000000..817c50113 --- /dev/null +++ b/lib/chats/__tests__/validateGetChatRequest.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/selectChats", () => ({ + selectChats: vi.fn(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { selectChats } = await import("@/lib/supabase/chats/selectChats"); +const { validateGetChatRequest } = await import("@/lib/chats/validateGetChatRequest"); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/chats/chat_1"); +} + +describe("validateGetChatRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the auth NextResponse when validateAuthContext rejects", async () => { + const failure = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + + const res = await validateGetChatRequest(makeReq(), "chat_1"); + expect(res).toBe(failure); + expect(selectChats).not.toHaveBeenCalled(); + expect(selectSessions).not.toHaveBeenCalled(); + }); + + it("returns 404 when the chat is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "tok" }); + vi.mocked(selectChats).mockResolvedValue([]); + + const res = await validateGetChatRequest(makeReq(), "chat_missing"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + } + expect(selectSessions).not.toHaveBeenCalled(); + }); + + it("returns 403 when the chat's session belongs to another account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "tok" }); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "someone-else" }), + ]); + + const res = await validateGetChatRequest(makeReq(), "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + } + }); + + it("returns 403 when the chat's session is missing (orphan)", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "tok" }); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await validateGetChatRequest(makeReq(), "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + } + }); + + it("returns the chat row when the caller owns its session", async () => { + const chat = baseChatRow({ id: "chat_1", session_id: "sess_1" }); + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "tok" }); + vi.mocked(selectChats).mockResolvedValue([chat]); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + + const res = await validateGetChatRequest(makeReq(), "chat_1"); + expect(res).toEqual(chat); + expect(selectChats).toHaveBeenCalledWith({ id: "chat_1" }); + expect(selectSessions).toHaveBeenCalledWith({ id: "sess_1" }); + }); +}); diff --git a/lib/chats/getChatHandler.ts b/lib/chats/getChatHandler.ts new file mode 100644 index 000000000..dc3e11f1e --- /dev/null +++ b/lib/chats/getChatHandler.ts @@ -0,0 +1,31 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetChatRequest } from "@/lib/chats/validateGetChatRequest"; +import { toChatResponse } from "@/lib/sessions/toChatResponse"; + +export interface ChatResponse { + chat: ReturnType; +} + +/** + * Handles `GET /api/chats/{chatId}`. Returns the chat row in camelCase + * wire format (incl. `sessionId`), so a client holding only a + * `/chat/[roomId]` URL can recover the `sessionId` the workflow + * transport and `chat_messages` history read require. + * + * @param request - The incoming request (carries auth). + * @param chatId - The chat id from the route params. + * @returns A NextResponse with `{ chat }` on 200, or a 401/403/404 error. + */ +export async function getChatHandler(request: NextRequest, chatId: string): Promise { + const chat = await validateGetChatRequest(request, chatId); + if (chat instanceof NextResponse) { + return chat; + } + + return NextResponse.json({ chat: toChatResponse(chat) } satisfies ChatResponse, { + status: 200, + headers: getCorsHeaders(), + }); +} diff --git a/lib/chats/validateGetChatRequest.ts b/lib/chats/validateGetChatRequest.ts new file mode 100644 index 000000000..1c91ac8d3 --- /dev/null +++ b/lib/chats/validateGetChatRequest.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import type { Tables } from "@/types/database.types"; + +/** + * Validates a `GET /api/chats/{chatId}` request end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Loads the chat by id + * 3. Confirms the caller owns the chat's parent session + * + * Mirrors `validateGetSessionChatRequest`'s ownership check, but resolves + * the session from the chat row instead of requiring `sessionId` in the + * path — so a client holding only a `/chat/[roomId]` URL can recover the + * `sessionId` it needs for the workflow transport. + * + * Returns a 401/403/404 NextResponse for the first failure, or the chat row. + * + * @param request - The incoming request. + * @param chatId - The id of the chat being fetched. + * @returns A NextResponse on failure, or the chat row on success. + */ +export async function validateGetChatRequest( + request: NextRequest, + chatId: string, +): Promise> { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const chatRows = await selectChats({ id: chatId }); + const chat = chatRows[0] ?? null; + + if (!chat) { + return NextResponse.json( + { status: "error", error: "Chat not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const sessionRows = await selectSessions({ id: chat.session_id }); + const session = sessionRows[0] ?? null; + + if (!session || session.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return chat; +}