From b67ba5afaa0acd182bdbd70f87c77d6b9f5d22da Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 29 May 2026 09:40:50 -0500 Subject: [PATCH] feat(chats): add GET /api/chats/[id] returning chat row incl. sessionId Lets a client holding only a /chat/[roomId] URL recover the sessionId the workflow transport and chat_messages history read require. Authenticates via validateAuthContext, loads the chat with selectChats, and confirms the caller owns the chat's parent session (mirrors validateGetSessionChatRequest, resolving the session from the chat instead of the path). Reuses toChatResponse for camelCase wire format. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/chats/[id]/route.ts | 36 +++++++ lib/chats/__tests__/getChatHandler.test.ts | 42 ++++++++ .../__tests__/validateGetChatRequest.test.ts | 96 +++++++++++++++++++ lib/chats/getChatHandler.ts | 31 ++++++ lib/chats/validateGetChatRequest.ts | 55 +++++++++++ 5 files changed, 260 insertions(+) create mode 100644 app/api/chats/[id]/route.ts create mode 100644 lib/chats/__tests__/getChatHandler.test.ts create mode 100644 lib/chats/__tests__/validateGetChatRequest.test.ts create mode 100644 lib/chats/getChatHandler.ts create mode 100644 lib/chats/validateGetChatRequest.ts 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; +}