From b05f604a3abd9efd136a39af2ead098b14ac566e Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 04:42:19 +0530 Subject: [PATCH 1/9] feat(sessions): migrate GET/POST /api/sessions/[sessionId]/chats Ports the chat list + create endpoints from open-agents' apps/web/app/api/sessions/[sessionId]/chats/route.ts. GET returns `{ chats, defaultModelId }` with per-account unread state derived from `chat_reads`. POST accepts an optional `{ id }` for deterministic / idempotent retries (409 on cross-session conflict). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[sessionId]/chats/__tests__/route.test.ts | 57 +++++ app/api/sessions/[sessionId]/chats/route.ts | 64 ++++++ lib/const.ts | 6 + .../createSessionChatHandler.test.ts | 185 ++++++++++++++++ .../__tests__/getSessionChatsHandler.test.ts | 200 ++++++++++++++++++ .../validateCreateSessionChatBody.test.ts | 49 +++++ lib/sessions/createSessionChatHandler.ts | 80 +++++++ lib/sessions/getSessionChatsHandler.ts | 57 +++++ lib/sessions/requireOwnedSession.ts | 65 ++++++ lib/sessions/toChatSummaryResponse.ts | 32 +++ lib/sessions/validateCreateSessionChatBody.ts | 34 +++ lib/supabase/chat_reads/selectChatReads.ts | 34 +++ 12 files changed, 863 insertions(+) create mode 100644 app/api/sessions/[sessionId]/chats/__tests__/route.test.ts create mode 100644 app/api/sessions/[sessionId]/chats/route.ts create mode 100644 lib/sessions/__tests__/createSessionChatHandler.test.ts create mode 100644 lib/sessions/__tests__/getSessionChatsHandler.test.ts create mode 100644 lib/sessions/__tests__/validateCreateSessionChatBody.test.ts create mode 100644 lib/sessions/createSessionChatHandler.ts create mode 100644 lib/sessions/getSessionChatsHandler.ts create mode 100644 lib/sessions/requireOwnedSession.ts create mode 100644 lib/sessions/toChatSummaryResponse.ts create mode 100644 lib/sessions/validateCreateSessionChatBody.ts create mode 100644 lib/supabase/chat_reads/selectChatReads.ts diff --git a/app/api/sessions/[sessionId]/chats/__tests__/route.test.ts b/app/api/sessions/[sessionId]/chats/__tests__/route.test.ts new file mode 100644 index 000000000..37bd88099 --- /dev/null +++ b/app/api/sessions/[sessionId]/chats/__tests__/route.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/getSessionChatsHandler", () => ({ + getSessionChatsHandler: vi.fn(async () => NextResponse.json({ ok: "get" })), +})); +vi.mock("@/lib/sessions/createSessionChatHandler", () => ({ + createSessionChatHandler: vi.fn(async () => NextResponse.json({ ok: "post" })), +})); + +const { getSessionChatsHandler } = await import("@/lib/sessions/getSessionChatsHandler"); +const { createSessionChatHandler } = await import("@/lib/sessions/createSessionChatHandler"); +const { GET, POST, OPTIONS } = await import("../route"); + +function makeReq(method: "GET" | "POST", body?: unknown): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats", { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +describe("OPTIONS /api/sessions/[sessionId]/chats", () => { + it("returns 200 with CORS headers", async () => { + const res = await OPTIONS(); + expect(res.status).toBe(200); + }); +}); + +describe("GET /api/sessions/[sessionId]/chats", () => { + it("delegates to getSessionChatsHandler with the resolved sessionId", async () => { + const req = makeReq("GET"); + + const res = await GET(req, { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + + expect(res.status).toBe(200); + expect(getSessionChatsHandler).toHaveBeenCalledWith(req, "sess_1"); + }); +}); + +describe("POST /api/sessions/[sessionId]/chats", () => { + it("delegates to createSessionChatHandler with the resolved sessionId", async () => { + const req = makeReq("POST", { id: "chat_1" }); + + const res = await POST(req, { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + + expect(res.status).toBe(200); + expect(createSessionChatHandler).toHaveBeenCalledWith(req, "sess_1"); + }); +}); diff --git a/app/api/sessions/[sessionId]/chats/route.ts b/app/api/sessions/[sessionId]/chats/route.ts new file mode 100644 index 000000000..db96ce9f4 --- /dev/null +++ b/app/api/sessions/[sessionId]/chats/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSessionChatsHandler } from "@/lib/sessions/getSessionChatsHandler"; +import { createSessionChatHandler } from "@/lib/sessions/createSessionChatHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/sessions/{sessionId}/chats + * + * Lists every chat that belongs to the session, plus the caller's + * default model id. Authenticates via Privy Bearer token or + * `x-api-key`; 404s when the session is missing and 403s when it + * exists but is owned by a different account. + * + * Response shape mirrors open-agents' `/api/sessions/[sessionId]/chats`. + * + * @param request - The incoming request. + * @param options - Route options containing the async params. + * @param options.params - Route params containing the session id. + * @returns A NextResponse with `{ chats, defaultModelId }` on 200, or an error. + */ +export async function GET( + request: NextRequest, + options: { params: Promise<{ sessionId: string }> }, +) { + const { sessionId } = await options.params; + return getSessionChatsHandler(request, sessionId); +} + +/** + * POST /api/sessions/{sessionId}/chats + * + * Creates a new chat in the session. Callers may pass `{ id }` to + * claim a deterministic chat id; the call is idempotent when the id + * already belongs to the same session, and 409s when it belongs to a + * different session. + * + * @param request - The incoming request. + * @param options - Route options containing the async params. + * @param options.params - Route params containing the session id. + * @returns A NextResponse with `{ chat }` on 200, or an error. + */ +export async function POST( + request: NextRequest, + options: { params: Promise<{ sessionId: string }> }, +) { + const { sessionId } = await options.params; + return createSessionChatHandler(request, sessionId); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/const.ts b/lib/const.ts index 2362805ef..3b1ce5252 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -11,6 +11,12 @@ export const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${p export const IMAGE_GENERATE_PRICE = "0.15"; export const DEFAULT_MODEL = "openai/gpt-5-mini"; export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini"; +/** + * Default model id surfaced to open-agents clients when the user has no + * explicit preference. Mirrors `APP_DEFAULT_MODEL_ID` from open-agents + * so the wire format stays aligned during the cutover. + */ +export const APP_DEFAULT_MODEL_ID = "openai/gpt-5.4"; export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; /** Domain for receiving inbound emails (e.g., support@mail.recoupable.com) */ export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com"; diff --git a/lib/sessions/__tests__/createSessionChatHandler.test.ts b/lib/sessions/__tests__/createSessionChatHandler.test.ts new file mode 100644 index 000000000..236cc5f3e --- /dev/null +++ b/lib/sessions/__tests__/createSessionChatHandler.test.ts @@ -0,0 +1,185 @@ +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(), +})); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ + insertChat: vi.fn(), +})); +vi.mock("@/lib/uuid/generateUUID", () => ({ + generateUUID: vi.fn(() => "generated-uuid"), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { selectChats } = await import("@/lib/supabase/chats/selectChats"); +const { insertChat } = await import("@/lib/supabase/chats/insertChat"); +const { createSessionChatHandler } = await import("@/lib/sessions/createSessionChatHandler"); + +const accountId = "acc-uuid-1"; + +function makeReq(body: unknown): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +function authedSession() { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); +} + +describe("createSessionChatHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const res = await createSessionChatHandler(makeReq({}), "sess_1"); + expect(res.status).toBe(401); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("returns 404 when session does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await createSessionChatHandler(makeReq({}), "sess_missing"); + expect(res.status).toBe(404); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("returns 403 when session is owned by a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), + ]); + + const res = await createSessionChatHandler(makeReq({}), "sess_1"); + expect(res.status).toBe(403); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("returns 400 when id is an empty string", async () => { + authedSession(); + + const res = await createSessionChatHandler(makeReq({ id: "" }), "sess_1"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid chat id" }); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("returns 400 when id is not a string", async () => { + authedSession(); + + const res = await createSessionChatHandler(makeReq({ id: 42 }), "sess_1"); + expect(res.status).toBe(400); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("returns existing chat when requested id already exists in the same session", async () => { + authedSession(); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_existing", session_id: "sess_1", title: "Existing" }), + ]); + + const res = await createSessionChatHandler(makeReq({ id: "chat_existing" }), "sess_1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chat: { id: string; title: string } }; + expect(body.chat.id).toBe("chat_existing"); + expect(body.chat.title).toBe("Existing"); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("returns 409 when requested id exists in a different session", async () => { + authedSession(); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_existing", session_id: "sess_OTHER" }), + ]); + + const res = await createSessionChatHandler(makeReq({ id: "chat_existing" }), "sess_1"); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "Chat ID conflict" }); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("creates a chat with the requested id when none exists yet", async () => { + authedSession(); + vi.mocked(selectChats).mockResolvedValue([]); + vi.mocked(insertChat).mockResolvedValue( + baseChatRow({ id: "chat_requested", session_id: "sess_1" }), + ); + + const res = await createSessionChatHandler(makeReq({ id: "chat_requested" }), "sess_1"); + expect(res.status).toBe(200); + const insertArgs = vi.mocked(insertChat).mock.calls[0][0]; + expect(insertArgs.id).toBe("chat_requested"); + expect(insertArgs.session_id).toBe("sess_1"); + expect(insertArgs.title).toBe("New chat"); + }); + + it("creates a chat with a generated id when no id is provided", async () => { + authedSession(); + vi.mocked(insertChat).mockResolvedValue( + baseChatRow({ id: "generated-uuid", session_id: "sess_1" }), + ); + + const res = await createSessionChatHandler(makeReq({}), "sess_1"); + expect(res.status).toBe(200); + const insertArgs = vi.mocked(insertChat).mock.calls[0][0]; + expect(insertArgs.id).toBe("generated-uuid"); + expect(insertArgs.title).toBe("New chat"); + expect(selectChats).not.toHaveBeenCalled(); + }); + + it("treats malformed JSON like an empty body and creates a new chat", async () => { + authedSession(); + vi.mocked(insertChat).mockResolvedValue( + baseChatRow({ id: "generated-uuid", session_id: "sess_1" }), + ); + + const res = await createSessionChatHandler(makeReq("{not json"), "sess_1"); + expect(res.status).toBe(200); + expect(insertChat).toHaveBeenCalledTimes(1); + }); + + it("returns 500 when insertChat fails", async () => { + authedSession(); + vi.mocked(insertChat).mockResolvedValue(null); + + const res = await createSessionChatHandler(makeReq({}), "sess_1"); + expect(res.status).toBe(500); + }); +}); diff --git a/lib/sessions/__tests__/getSessionChatsHandler.test.ts b/lib/sessions/__tests__/getSessionChatsHandler.test.ts new file mode 100644 index 000000000..a615d27c9 --- /dev/null +++ b/lib/sessions/__tests__/getSessionChatsHandler.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import type { Tables } from "@/types/database.types"; +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(), +})); +vi.mock("@/lib/supabase/chat_reads/selectChatReads", () => ({ + selectChatReads: 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 { selectChatReads } = await import("@/lib/supabase/chat_reads/selectChatReads"); +const { getSessionChatsHandler } = await import("@/lib/sessions/getSessionChatsHandler"); + +const accountId = "acc-uuid-1"; + +function makeReq(url = "https://example.com/api/sessions/sess_1/chats"): NextRequest { + return new NextRequest(url); +} + +function chatRow(overrides: Partial>): Tables<"chats"> { + return baseChatRow({ session_id: "sess_1", ...overrides }); +} + +describe("getSessionChatsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const res = await getSessionChatsHandler(makeReq(), "sess_1"); + expect(res.status).toBe(401); + expect(selectSessions).not.toHaveBeenCalled(); + expect(selectChats).not.toHaveBeenCalled(); + }); + + it("returns 404 when session does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await getSessionChatsHandler(makeReq(), "sess_missing"); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Session not found", + }); + expect(selectChats).not.toHaveBeenCalled(); + }); + + it("returns 403 when session is owned by a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-uuid-OTHER" }), + ]); + + const res = await getSessionChatsHandler(makeReq(), "sess_1"); + expect(res.status).toBe(403); + expect(selectChats).not.toHaveBeenCalled(); + }); + + it("returns 200 with chats sorted by created_at and APP_DEFAULT_MODEL_ID", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([ + chatRow({ + id: "chat_late", + title: "Late", + created_at: "2026-05-04T00:00:00.000Z", + }), + chatRow({ + id: "chat_early", + title: "Early", + created_at: "2026-05-01T00:00:00.000Z", + }), + ]); + vi.mocked(selectChatReads).mockResolvedValue([]); + + const res = await getSessionChatsHandler(makeReq(), "sess_1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + chats: Array<{ id: string; hasUnread: boolean; isStreaming: boolean }>; + defaultModelId: string; + }; + expect(body.chats.map(c => c.id)).toEqual(["chat_early", "chat_late"]); + expect(body.defaultModelId).toBe("openai/gpt-5.4"); + expect(selectChatReads).toHaveBeenCalledWith({ + accountId, + chatIds: ["chat_late", "chat_early"], + }); + }); + + it("skips chat_reads lookup when the session has no chats", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([]); + + const res = await getSessionChatsHandler(makeReq(), "sess_1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + chats: unknown[]; + defaultModelId: string; + }; + expect(body.chats).toEqual([]); + expect(body.defaultModelId).toBe("openai/gpt-5.4"); + expect(selectChatReads).not.toHaveBeenCalled(); + }); + + it("derives hasUnread from last_assistant_message_at vs last_read_at", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([ + chatRow({ + id: "chat_unread", + last_assistant_message_at: "2026-05-04T10:00:00.000Z", + active_stream_id: null, + }), + chatRow({ + id: "chat_read", + last_assistant_message_at: "2026-05-03T10:00:00.000Z", + active_stream_id: null, + }), + chatRow({ + id: "chat_streaming", + last_assistant_message_at: null, + active_stream_id: "stream_xyz", + }), + ]); + vi.mocked(selectChatReads).mockResolvedValue([ + { + account_id: accountId, + chat_id: "chat_unread", + last_read_at: "2026-05-04T09:00:00.000Z", + created_at: "2026-05-01T00:00:00.000Z", + updated_at: "2026-05-04T09:00:00.000Z", + }, + { + account_id: accountId, + chat_id: "chat_read", + last_read_at: "2026-05-04T00:00:00.000Z", + created_at: "2026-05-01T00:00:00.000Z", + updated_at: "2026-05-04T00:00:00.000Z", + }, + ]); + + const res = await getSessionChatsHandler(makeReq(), "sess_1"); + const body = (await res.json()) as { + chats: Array<{ id: string; hasUnread: boolean; isStreaming: boolean }>; + }; + const byId = new Map(body.chats.map(c => [c.id, c])); + expect(byId.get("chat_unread")?.hasUnread).toBe(true); + expect(byId.get("chat_read")?.hasUnread).toBe(false); + expect(byId.get("chat_streaming")?.hasUnread).toBe(false); + expect(byId.get("chat_streaming")?.isStreaming).toBe(true); + expect(byId.get("chat_unread")?.isStreaming).toBe(false); + }); +}); diff --git a/lib/sessions/__tests__/validateCreateSessionChatBody.test.ts b/lib/sessions/__tests__/validateCreateSessionChatBody.test.ts new file mode 100644 index 000000000..6af962cf3 --- /dev/null +++ b/lib/sessions/__tests__/validateCreateSessionChatBody.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); + +const { validateCreateSessionChatBody } = await import( + "@/lib/sessions/validateCreateSessionChatBody" +); + +describe("validateCreateSessionChatBody", () => { + it("accepts an empty body", () => { + const result = validateCreateSessionChatBody({}); + expect(result).toEqual({}); + }); + + it("accepts a null body", () => { + const result = validateCreateSessionChatBody(null); + expect(result).toEqual({}); + }); + + it("accepts a body with a valid id", () => { + const result = validateCreateSessionChatBody({ id: "chat_abc" }); + expect(result).toEqual({ id: "chat_abc" }); + }); + + it("rejects an empty id with 400 + parity error", async () => { + const result = validateCreateSessionChatBody({ id: "" }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + expect(await result.json()).toEqual({ error: "Invalid chat id" }); + } + }); + + it("rejects a non-string id with 400", () => { + const result = validateCreateSessionChatBody({ id: 42 }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("ignores unknown fields on the body", () => { + const result = validateCreateSessionChatBody({ id: "chat_abc", junk: 1 }); + expect(result).toEqual({ id: "chat_abc" }); + }); +}); diff --git a/lib/sessions/createSessionChatHandler.ts b/lib/sessions/createSessionChatHandler.ts new file mode 100644 index 000000000..7ca0cf6fb --- /dev/null +++ b/lib/sessions/createSessionChatHandler.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { requireOwnedSession } from "@/lib/sessions/requireOwnedSession"; +import { validateCreateSessionChatBody } from "@/lib/sessions/validateCreateSessionChatBody"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; +import { toChatResponse } from "@/lib/sessions/toChatResponse"; + +const INITIAL_CHAT_TITLE = "New chat"; + +/** + * Handles `POST /api/sessions/{sessionId}/chats`. + * + * Authenticates the caller, verifies session ownership, then creates a + * new chat row. Callers may pass `{ id }` to claim a deterministic chat + * id: if a row already exists with that id and belongs to this + * session, it is returned as-is (idempotent retry); if it exists on a + * different session, 409 is returned. Otherwise a new chat is + * inserted with title "New chat". + * + * Response shape mirrors open-agents' `POST /api/sessions/[sessionId]/chats` + * so the existing frontend can cut over without code changes. + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @returns A NextResponse with `{ chat }` on 200, `{ error }` on 4xx, or an error. + */ +export async function createSessionChatHandler( + request: NextRequest, + sessionId: string, +): Promise { + const gate = await requireOwnedSession(request, sessionId); + if (!gate.ok) { + return gate.response; + } + + const rawBody = await safeParseJson(request); + const validated = validateCreateSessionChatBody(rawBody); + if (validated instanceof NextResponse) { + return validated; + } + + const requestedChatId = validated.id ?? null; + + if (requestedChatId) { + const existing = (await selectChats({ id: requestedChatId }))[0] ?? null; + if (existing) { + if (existing.session_id !== sessionId) { + return NextResponse.json( + { error: "Chat ID conflict" }, + { status: 409, headers: getCorsHeaders() }, + ); + } + return NextResponse.json( + { chat: toChatResponse(existing) }, + { status: 200, headers: getCorsHeaders() }, + ); + } + } + + const chatRow = await insertChat({ + id: requestedChatId ?? generateUUID(), + session_id: sessionId, + title: INITIAL_CHAT_TITLE, + }); + + if (!chatRow) { + return NextResponse.json( + { status: "error", error: "Failed to create chat" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { chat: toChatResponse(chatRow) }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/getSessionChatsHandler.ts b/lib/sessions/getSessionChatsHandler.ts new file mode 100644 index 000000000..f348728cf --- /dev/null +++ b/lib/sessions/getSessionChatsHandler.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { APP_DEFAULT_MODEL_ID } from "@/lib/const"; +import { requireOwnedSession } from "@/lib/sessions/requireOwnedSession"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import { selectChatReads } from "@/lib/supabase/chat_reads/selectChatReads"; +import { toChatSummaryResponse } from "@/lib/sessions/toChatSummaryResponse"; + +/** + * Handles `GET /api/sessions/{sessionId}/chats`. + * + * Authenticates the caller, verifies they own the session, then + * returns every chat in the session plus the caller's default model + * id. Per-chat unread state is derived from the caller's `chat_reads` + * row (if any). Response shape mirrors open-agents' + * `/api/sessions/[sessionId]/chats` so the existing frontend can cut + * over without code changes. + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @returns A NextResponse with `{ chats, defaultModelId }` on 200, or an error. + */ +export async function getSessionChatsHandler( + request: NextRequest, + sessionId: string, +): Promise { + const gate = await requireOwnedSession(request, sessionId); + if (!gate.ok) { + return gate.response; + } + + const chats = await selectChats({ sessionId }); + const reads = + chats.length > 0 + ? await selectChatReads({ + accountId: gate.auth.accountId, + chatIds: chats.map(row => row.id), + }) + : []; + + const lastReadByChatId = new Map(); + for (const read of reads) { + lastReadByChatId.set(read.chat_id, read.last_read_at); + } + + const sorted = [...chats].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + ); + + return NextResponse.json( + { + chats: sorted.map(row => toChatSummaryResponse(row, lastReadByChatId.get(row.id) ?? null)), + defaultModelId: APP_DEFAULT_MODEL_ID, + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/requireOwnedSession.ts b/lib/sessions/requireOwnedSession.ts new file mode 100644 index 000000000..1c12e3f42 --- /dev/null +++ b/lib/sessions/requireOwnedSession.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import type { Tables } from "@/types/database.types"; + +export type RequireOwnedSessionResult = + | { + ok: true; + auth: AuthContext; + session: Tables<"sessions">; + } + | { + ok: false; + response: NextResponse; + }; + +/** + * Authenticates the caller and verifies they own the session at the + * given id. Returns a discriminated union so callers can either bail + * with the prepared `NextResponse` or keep going with the resolved + * `{ auth, session }` pair. + * + * Mirrors open-agents' `requireOwnedSession` so behaviour stays aligned + * across routes that gate on session ownership. + * + * @param request - The incoming request. + * @param sessionId - The id of the session to gate access on. + * @returns Either an `ok: true` carrier with auth + row, or an `ok: false` carrier with a 401/403/404 NextResponse. + */ +export async function requireOwnedSession( + request: NextRequest, + sessionId: string, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return { ok: false, response: auth }; + } + + const rows = await selectSessions({ id: sessionId }); + const session = rows[0] ?? null; + + if (!session) { + return { + ok: false, + response: NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ), + }; + } + + if (session.account_id !== auth.accountId) { + return { + ok: false, + response: NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ), + }; + } + + return { ok: true, auth, session }; +} diff --git a/lib/sessions/toChatSummaryResponse.ts b/lib/sessions/toChatSummaryResponse.ts new file mode 100644 index 000000000..33592cb7d --- /dev/null +++ b/lib/sessions/toChatSummaryResponse.ts @@ -0,0 +1,32 @@ +import type { Tables } from "@/types/database.types"; + +/** + * Translates a Supabase `chats` row into the `ChatSummary` shape the + * open-agents frontend already consumes. `hasUnread` is derived from + * `chat.last_assistant_message_at` vs the caller's last read, and + * `isStreaming` from the row's `active_stream_id`. + * + * @param row - The Supabase chats row. + * @param lastReadAt - The caller's last-read timestamp for this chat, if any. + * @returns The camelCase chat summary payload for HTTP responses. + */ +export function toChatSummaryResponse(row: Tables<"chats">, lastReadAt: string | null) { + const lastAssistantMessageAt = row.last_assistant_message_at; + const hasUnread = + lastAssistantMessageAt !== null && + (lastReadAt === null || + new Date(lastAssistantMessageAt).getTime() > new Date(lastReadAt).getTime()); + + return { + id: row.id, + sessionId: row.session_id, + title: row.title, + modelId: row.model_id, + activeStreamId: row.active_stream_id, + lastAssistantMessageAt, + createdAt: row.created_at, + updatedAt: row.updated_at, + hasUnread, + isStreaming: row.active_stream_id !== null, + }; +} diff --git a/lib/sessions/validateCreateSessionChatBody.ts b/lib/sessions/validateCreateSessionChatBody.ts new file mode 100644 index 000000000..568321917 --- /dev/null +++ b/lib/sessions/validateCreateSessionChatBody.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +export const createSessionChatBodySchema = z.object({ + id: z.string({ error: "Invalid chat id" }).min(1, "Invalid chat id").optional(), +}); + +export type CreateSessionChatBody = z.infer; + +/** + * Validates the body for `POST /api/sessions/{sessionId}/chats`. + * + * The endpoint accepts an optional `id` so callers can deterministically + * "claim" a chat id and idempotently retry. An explicitly empty or + * non-string id is rejected with the same 400 shape open-agents used + * (`{ error: "Invalid chat id" }`) for parity. + * + * @param body - The parsed request body (may be `null` / non-object). + * @returns A 400 NextResponse on failure, or the validated body. + */ +export function validateCreateSessionChatBody(body: unknown): NextResponse | CreateSessionChatBody { + const candidate = body && typeof body === "object" && !Array.isArray(body) ? body : {}; + + const result = createSessionChatBodySchema.safeParse(candidate); + if (!result.success) { + return NextResponse.json( + { error: "Invalid chat id" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +} diff --git a/lib/supabase/chat_reads/selectChatReads.ts b/lib/supabase/chat_reads/selectChatReads.ts new file mode 100644 index 000000000..3cb1b3372 --- /dev/null +++ b/lib/supabase/chat_reads/selectChatReads.ts @@ -0,0 +1,34 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +interface SelectChatReadsFilter { + /** Required account filter — reads are always scoped to a single account. */ + accountId: string; + /** Optional list of chat ids to narrow the result set. */ + chatIds?: string[]; +} + +/** + * Reads rows from `chat_reads` for a single account. Used to derive + * per-account unread state on chat lists. Returns `[]` on error + * after logging. + * + * @param filter - Required account, optional chat-id list. + * @returns Matching rows, or `[]` on error / no match. + */ +export async function selectChatReads( + filter: SelectChatReadsFilter, +): Promise[]> { + let query = supabase.from("chat_reads").select("*").eq("account_id", filter.accountId); + + if (filter.chatIds && filter.chatIds.length > 0) { + query = query.in("chat_id", filter.chatIds); + } + + const { data, error } = await query; + if (error) { + console.error("[selectChatReads] error:", error); + return []; + } + return data ?? []; +} From 8e5db57d8b60f7c3a0350f9c4fcf33d5b6a76b46 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 04:49:23 +0530 Subject: [PATCH 2/9] fix(sessions): use NextResponse | T return shape for requireOwnedSession The discriminated-union shape (`{ok: true} | {ok: false}`) failed to narrow under Next 16's typechecker on Vercel, breaking the production build with "Property 'response' does not exist on type". Switch to the codebase's existing `NextResponse | Context` convention (same as `validateAuthContext`) so callers can early-return via `instanceof NextResponse`. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sessions/createSessionChatHandler.ts | 4 +- lib/sessions/getSessionChatsHandler.ts | 4 +- lib/sessions/requireOwnedSession.ts | 53 +++++++++--------------- 3 files changed, 23 insertions(+), 38 deletions(-) diff --git a/lib/sessions/createSessionChatHandler.ts b/lib/sessions/createSessionChatHandler.ts index 7ca0cf6fb..db5cf21aa 100644 --- a/lib/sessions/createSessionChatHandler.ts +++ b/lib/sessions/createSessionChatHandler.ts @@ -32,8 +32,8 @@ export async function createSessionChatHandler( sessionId: string, ): Promise { const gate = await requireOwnedSession(request, sessionId); - if (!gate.ok) { - return gate.response; + if (gate instanceof NextResponse) { + return gate; } const rawBody = await safeParseJson(request); diff --git a/lib/sessions/getSessionChatsHandler.ts b/lib/sessions/getSessionChatsHandler.ts index f348728cf..a5122b228 100644 --- a/lib/sessions/getSessionChatsHandler.ts +++ b/lib/sessions/getSessionChatsHandler.ts @@ -25,8 +25,8 @@ export async function getSessionChatsHandler( sessionId: string, ): Promise { const gate = await requireOwnedSession(request, sessionId); - if (!gate.ok) { - return gate.response; + if (gate instanceof NextResponse) { + return gate; } const chats = await selectChats({ sessionId }); diff --git a/lib/sessions/requireOwnedSession.ts b/lib/sessions/requireOwnedSession.ts index 1c12e3f42..80efd720e 100644 --- a/lib/sessions/requireOwnedSession.ts +++ b/lib/sessions/requireOwnedSession.ts @@ -5,61 +5,46 @@ import type { AuthContext } from "@/lib/auth/validateAuthContext"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import type { Tables } from "@/types/database.types"; -export type RequireOwnedSessionResult = - | { - ok: true; - auth: AuthContext; - session: Tables<"sessions">; - } - | { - ok: false; - response: NextResponse; - }; +export interface OwnedSessionContext { + auth: AuthContext; + session: Tables<"sessions">; +} /** * Authenticates the caller and verifies they own the session at the - * given id. Returns a discriminated union so callers can either bail - * with the prepared `NextResponse` or keep going with the resolved - * `{ auth, session }` pair. - * - * Mirrors open-agents' `requireOwnedSession` so behaviour stays aligned - * across routes that gate on session ownership. + * given id. Mirrors the `validateAuthContext` return convention so + * callers can early-return on the `NextResponse` branch and keep + * working with `{ auth, session }` otherwise. * * @param request - The incoming request. * @param sessionId - The id of the session to gate access on. - * @returns Either an `ok: true` carrier with auth + row, or an `ok: false` carrier with a 401/403/404 NextResponse. + * @returns A 401/403/404 NextResponse on failure, or the resolved auth + session row. */ export async function requireOwnedSession( request: NextRequest, sessionId: string, -): Promise { +): Promise { const auth = await validateAuthContext(request); if (auth instanceof NextResponse) { - return { ok: false, response: auth }; + return auth; } const rows = await selectSessions({ id: sessionId }); const session = rows[0] ?? null; if (!session) { - return { - ok: false, - response: NextResponse.json( - { status: "error", error: "Session not found" }, - { status: 404, headers: getCorsHeaders() }, - ), - }; + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); } if (session.account_id !== auth.accountId) { - return { - ok: false, - response: NextResponse.json( - { status: "error", error: "Forbidden" }, - { status: 403, headers: getCorsHeaders() }, - ), - }; + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); } - return { ok: true, auth, session }; + return { auth, session }; } From 870298ce72e241f1405ba78b74364d35e123d554 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 05:05:32 +0530 Subject: [PATCH 3/9] chore(sessions): drop route-shell test for /api/sessions/[sessionId]/chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route shell is a one-liner that just awaits params and delegates to the handler — covered by the handler tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[sessionId]/chats/__tests__/route.test.ts | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 app/api/sessions/[sessionId]/chats/__tests__/route.test.ts diff --git a/app/api/sessions/[sessionId]/chats/__tests__/route.test.ts b/app/api/sessions/[sessionId]/chats/__tests__/route.test.ts deleted file mode 100644 index 37bd88099..000000000 --- a/app/api/sessions/[sessionId]/chats/__tests__/route.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), -})); -vi.mock("@/lib/sessions/getSessionChatsHandler", () => ({ - getSessionChatsHandler: vi.fn(async () => NextResponse.json({ ok: "get" })), -})); -vi.mock("@/lib/sessions/createSessionChatHandler", () => ({ - createSessionChatHandler: vi.fn(async () => NextResponse.json({ ok: "post" })), -})); - -const { getSessionChatsHandler } = await import("@/lib/sessions/getSessionChatsHandler"); -const { createSessionChatHandler } = await import("@/lib/sessions/createSessionChatHandler"); -const { GET, POST, OPTIONS } = await import("../route"); - -function makeReq(method: "GET" | "POST", body?: unknown): NextRequest { - return new NextRequest("https://example.com/api/sessions/sess_1/chats", { - method, - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); -} - -describe("OPTIONS /api/sessions/[sessionId]/chats", () => { - it("returns 200 with CORS headers", async () => { - const res = await OPTIONS(); - expect(res.status).toBe(200); - }); -}); - -describe("GET /api/sessions/[sessionId]/chats", () => { - it("delegates to getSessionChatsHandler with the resolved sessionId", async () => { - const req = makeReq("GET"); - - const res = await GET(req, { - params: Promise.resolve({ sessionId: "sess_1" }), - }); - - expect(res.status).toBe(200); - expect(getSessionChatsHandler).toHaveBeenCalledWith(req, "sess_1"); - }); -}); - -describe("POST /api/sessions/[sessionId]/chats", () => { - it("delegates to createSessionChatHandler with the resolved sessionId", async () => { - const req = makeReq("POST", { id: "chat_1" }); - - const res = await POST(req, { - params: Promise.resolve({ sessionId: "sess_1" }), - }); - - expect(res.status).toBe(200); - expect(createSessionChatHandler).toHaveBeenCalledWith(req, "sess_1"); - }); -}); From 16f26017cdb1f467daad35d271fa3a2670c8227f Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 05:12:27 +0530 Subject: [PATCH 4/9] refactor(sessions): move ownership check into request validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the chats endpoints with the project's validator pattern (see validateCreateSessionBody). Handlers now consume the validated { auth, session[, body] } payload directly instead of running their own auth/ownership/body gates. - requireOwnedSession → validateOwnedSessionRequest (GET path) - validateCreateSessionChatBody → validateCreateSessionChatRequest (POST path, now bundles auth + ownership + body) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../createSessionChatHandler.test.ts | 116 +++---------- .../__tests__/getSessionChatsHandler.test.ts | 86 ++-------- .../validateCreateSessionChatBody.test.ts | 49 ------ .../validateCreateSessionChatRequest.test.ts | 156 ++++++++++++++++++ .../validateOwnedSessionRequest.test.ts | 94 +++++++++++ lib/sessions/createSessionChatHandler.ts | 26 +-- lib/sessions/getSessionChatsHandler.ts | 11 +- lib/sessions/validateCreateSessionChatBody.ts | 34 ---- .../validateCreateSessionChatRequest.ts | 54 ++++++ ...sion.ts => validateOwnedSessionRequest.ts} | 19 ++- 10 files changed, 373 insertions(+), 272 deletions(-) delete mode 100644 lib/sessions/__tests__/validateCreateSessionChatBody.test.ts create mode 100644 lib/sessions/__tests__/validateCreateSessionChatRequest.test.ts create mode 100644 lib/sessions/__tests__/validateOwnedSessionRequest.test.ts delete mode 100644 lib/sessions/validateCreateSessionChatBody.ts create mode 100644 lib/sessions/validateCreateSessionChatRequest.ts rename lib/sessions/{requireOwnedSession.ts => validateOwnedSessionRequest.ts} (67%) diff --git a/lib/sessions/__tests__/createSessionChatHandler.test.ts b/lib/sessions/__tests__/createSessionChatHandler.test.ts index 236cc5f3e..b2339d54c 100644 --- a/lib/sessions/__tests__/createSessionChatHandler.test.ts +++ b/lib/sessions/__tests__/createSessionChatHandler.test.ts @@ -6,11 +6,8 @@ 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/sessions/validateCreateSessionChatRequest", () => ({ + validateCreateSessionChatRequest: vi.fn(), })); vi.mock("@/lib/supabase/chats/selectChats", () => ({ selectChats: vi.fn(), @@ -22,31 +19,27 @@ vi.mock("@/lib/uuid/generateUUID", () => ({ generateUUID: vi.fn(() => "generated-uuid"), })); -const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); -const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { validateCreateSessionChatRequest } = await import( + "@/lib/sessions/validateCreateSessionChatRequest" +); const { selectChats } = await import("@/lib/supabase/chats/selectChats"); const { insertChat } = await import("@/lib/supabase/chats/insertChat"); const { createSessionChatHandler } = await import("@/lib/sessions/createSessionChatHandler"); const accountId = "acc-uuid-1"; -function makeReq(body: unknown): NextRequest { +function makeReq(): NextRequest { return new NextRequest("https://example.com/api/sessions/sess_1/chats", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: typeof body === "string" ? body : JSON.stringify(body), }); } -function authedSession() { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "tok", +function mockValidated(body: { id?: string } = {}) { + vi.mocked(validateCreateSessionChatRequest).mockResolvedValue({ + auth: { accountId, orgId: null, authToken: "tok" }, + session: baseSessionRow({ id: "sess_1", account_id: accountId }), + body, }); - vi.mocked(selectSessions).mockResolvedValue([ - baseSessionRow({ id: "sess_1", account_id: accountId }), - ]); } describe("createSessionChatHandler", () => { @@ -54,68 +47,22 @@ describe("createSessionChatHandler", () => { vi.clearAllMocks(); }); - it("returns 401 when auth fails", async () => { - vi.mocked(validateAuthContext).mockResolvedValue( - NextResponse.json({ error: "Unauthorized" }, { status: 401 }), - ); - - const res = await createSessionChatHandler(makeReq({}), "sess_1"); - expect(res.status).toBe(401); - expect(insertChat).not.toHaveBeenCalled(); - }); - - it("returns 404 when session does not exist", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "tok", - }); - vi.mocked(selectSessions).mockResolvedValue([]); - - const res = await createSessionChatHandler(makeReq({}), "sess_missing"); - expect(res.status).toBe(404); - expect(insertChat).not.toHaveBeenCalled(); - }); - - it("returns 403 when session is owned by a different account", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "tok", - }); - vi.mocked(selectSessions).mockResolvedValue([ - baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), - ]); - - const res = await createSessionChatHandler(makeReq({}), "sess_1"); - expect(res.status).toBe(403); - expect(insertChat).not.toHaveBeenCalled(); - }); - - it("returns 400 when id is an empty string", async () => { - authedSession(); + it("forwards the NextResponse from validateCreateSessionChatRequest as-is", async () => { + const failure = NextResponse.json({ error: "Invalid chat id" }, { status: 400 }); + vi.mocked(validateCreateSessionChatRequest).mockResolvedValue(failure); - const res = await createSessionChatHandler(makeReq({ id: "" }), "sess_1"); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "Invalid chat id" }); - expect(insertChat).not.toHaveBeenCalled(); - }); - - it("returns 400 when id is not a string", async () => { - authedSession(); - - const res = await createSessionChatHandler(makeReq({ id: 42 }), "sess_1"); - expect(res.status).toBe(400); + const res = await createSessionChatHandler(makeReq(), "sess_1"); + expect(res).toBe(failure); expect(insertChat).not.toHaveBeenCalled(); }); it("returns existing chat when requested id already exists in the same session", async () => { - authedSession(); + mockValidated({ id: "chat_existing" }); vi.mocked(selectChats).mockResolvedValue([ baseChatRow({ id: "chat_existing", session_id: "sess_1", title: "Existing" }), ]); - const res = await createSessionChatHandler(makeReq({ id: "chat_existing" }), "sess_1"); + const res = await createSessionChatHandler(makeReq(), "sess_1"); expect(res.status).toBe(200); const body = (await res.json()) as { chat: { id: string; title: string } }; expect(body.chat.id).toBe("chat_existing"); @@ -124,25 +71,25 @@ describe("createSessionChatHandler", () => { }); it("returns 409 when requested id exists in a different session", async () => { - authedSession(); + mockValidated({ id: "chat_existing" }); vi.mocked(selectChats).mockResolvedValue([ baseChatRow({ id: "chat_existing", session_id: "sess_OTHER" }), ]); - const res = await createSessionChatHandler(makeReq({ id: "chat_existing" }), "sess_1"); + const res = await createSessionChatHandler(makeReq(), "sess_1"); expect(res.status).toBe(409); expect(await res.json()).toEqual({ error: "Chat ID conflict" }); expect(insertChat).not.toHaveBeenCalled(); }); it("creates a chat with the requested id when none exists yet", async () => { - authedSession(); + mockValidated({ id: "chat_requested" }); vi.mocked(selectChats).mockResolvedValue([]); vi.mocked(insertChat).mockResolvedValue( baseChatRow({ id: "chat_requested", session_id: "sess_1" }), ); - const res = await createSessionChatHandler(makeReq({ id: "chat_requested" }), "sess_1"); + const res = await createSessionChatHandler(makeReq(), "sess_1"); expect(res.status).toBe(200); const insertArgs = vi.mocked(insertChat).mock.calls[0][0]; expect(insertArgs.id).toBe("chat_requested"); @@ -151,12 +98,12 @@ describe("createSessionChatHandler", () => { }); it("creates a chat with a generated id when no id is provided", async () => { - authedSession(); + mockValidated({}); vi.mocked(insertChat).mockResolvedValue( baseChatRow({ id: "generated-uuid", session_id: "sess_1" }), ); - const res = await createSessionChatHandler(makeReq({}), "sess_1"); + const res = await createSessionChatHandler(makeReq(), "sess_1"); expect(res.status).toBe(200); const insertArgs = vi.mocked(insertChat).mock.calls[0][0]; expect(insertArgs.id).toBe("generated-uuid"); @@ -164,22 +111,11 @@ describe("createSessionChatHandler", () => { expect(selectChats).not.toHaveBeenCalled(); }); - it("treats malformed JSON like an empty body and creates a new chat", async () => { - authedSession(); - vi.mocked(insertChat).mockResolvedValue( - baseChatRow({ id: "generated-uuid", session_id: "sess_1" }), - ); - - const res = await createSessionChatHandler(makeReq("{not json"), "sess_1"); - expect(res.status).toBe(200); - expect(insertChat).toHaveBeenCalledTimes(1); - }); - it("returns 500 when insertChat fails", async () => { - authedSession(); + mockValidated({}); vi.mocked(insertChat).mockResolvedValue(null); - const res = await createSessionChatHandler(makeReq({}), "sess_1"); + const res = await createSessionChatHandler(makeReq(), "sess_1"); expect(res.status).toBe(500); }); }); diff --git a/lib/sessions/__tests__/getSessionChatsHandler.test.ts b/lib/sessions/__tests__/getSessionChatsHandler.test.ts index a615d27c9..ca89fca40 100644 --- a/lib/sessions/__tests__/getSessionChatsHandler.test.ts +++ b/lib/sessions/__tests__/getSessionChatsHandler.test.ts @@ -7,11 +7,8 @@ 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/sessions/validateOwnedSessionRequest", () => ({ + validateOwnedSessionRequest: vi.fn(), })); vi.mock("@/lib/supabase/chats/selectChats", () => ({ selectChats: vi.fn(), @@ -20,8 +17,7 @@ vi.mock("@/lib/supabase/chat_reads/selectChatReads", () => ({ selectChatReads: vi.fn(), })); -const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); -const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { validateOwnedSessionRequest } = await import("@/lib/sessions/validateOwnedSessionRequest"); const { selectChats } = await import("@/lib/supabase/chats/selectChats"); const { selectChatReads } = await import("@/lib/supabase/chat_reads/selectChatReads"); const { getSessionChatsHandler } = await import("@/lib/sessions/getSessionChatsHandler"); @@ -36,63 +32,29 @@ function chatRow(overrides: Partial>): Tables<"chats"> { return baseChatRow({ session_id: "sess_1", ...overrides }); } +function mockOwned() { + vi.mocked(validateOwnedSessionRequest).mockResolvedValue({ + auth: { accountId, orgId: null, authToken: "tok" }, + session: baseSessionRow({ id: "sess_1", account_id: accountId }), + }); +} + describe("getSessionChatsHandler", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("returns 401 when auth fails", async () => { - vi.mocked(validateAuthContext).mockResolvedValue( - NextResponse.json({ error: "Unauthorized" }, { status: 401 }), - ); - - const res = await getSessionChatsHandler(makeReq(), "sess_1"); - expect(res.status).toBe(401); - expect(selectSessions).not.toHaveBeenCalled(); - expect(selectChats).not.toHaveBeenCalled(); - }); - - it("returns 404 when session does not exist", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "tok", - }); - vi.mocked(selectSessions).mockResolvedValue([]); - - const res = await getSessionChatsHandler(makeReq(), "sess_missing"); - expect(res.status).toBe(404); - expect(await res.json()).toEqual({ - status: "error", - error: "Session not found", - }); - expect(selectChats).not.toHaveBeenCalled(); - }); - - it("returns 403 when session is owned by a different account", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "tok", - }); - vi.mocked(selectSessions).mockResolvedValue([ - baseSessionRow({ id: "sess_1", account_id: "acc-uuid-OTHER" }), - ]); + it("forwards the NextResponse from validateOwnedSessionRequest as-is", async () => { + const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 }); + vi.mocked(validateOwnedSessionRequest).mockResolvedValue(failure); const res = await getSessionChatsHandler(makeReq(), "sess_1"); - expect(res.status).toBe(403); + expect(res).toBe(failure); expect(selectChats).not.toHaveBeenCalled(); }); it("returns 200 with chats sorted by created_at and APP_DEFAULT_MODEL_ID", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "tok", - }); - vi.mocked(selectSessions).mockResolvedValue([ - baseSessionRow({ id: "sess_1", account_id: accountId }), - ]); + mockOwned(); vi.mocked(selectChats).mockResolvedValue([ chatRow({ id: "chat_late", @@ -122,14 +84,7 @@ describe("getSessionChatsHandler", () => { }); it("skips chat_reads lookup when the session has no chats", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "tok", - }); - vi.mocked(selectSessions).mockResolvedValue([ - baseSessionRow({ id: "sess_1", account_id: accountId }), - ]); + mockOwned(); vi.mocked(selectChats).mockResolvedValue([]); const res = await getSessionChatsHandler(makeReq(), "sess_1"); @@ -144,14 +99,7 @@ describe("getSessionChatsHandler", () => { }); it("derives hasUnread from last_assistant_message_at vs last_read_at", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "tok", - }); - vi.mocked(selectSessions).mockResolvedValue([ - baseSessionRow({ id: "sess_1", account_id: accountId }), - ]); + mockOwned(); vi.mocked(selectChats).mockResolvedValue([ chatRow({ id: "chat_unread", diff --git a/lib/sessions/__tests__/validateCreateSessionChatBody.test.ts b/lib/sessions/__tests__/validateCreateSessionChatBody.test.ts deleted file mode 100644 index 6af962cf3..000000000 --- a/lib/sessions/__tests__/validateCreateSessionChatBody.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { NextResponse } from "next/server"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), -})); - -const { validateCreateSessionChatBody } = await import( - "@/lib/sessions/validateCreateSessionChatBody" -); - -describe("validateCreateSessionChatBody", () => { - it("accepts an empty body", () => { - const result = validateCreateSessionChatBody({}); - expect(result).toEqual({}); - }); - - it("accepts a null body", () => { - const result = validateCreateSessionChatBody(null); - expect(result).toEqual({}); - }); - - it("accepts a body with a valid id", () => { - const result = validateCreateSessionChatBody({ id: "chat_abc" }); - expect(result).toEqual({ id: "chat_abc" }); - }); - - it("rejects an empty id with 400 + parity error", async () => { - const result = validateCreateSessionChatBody({ id: "" }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - expect(await result.json()).toEqual({ error: "Invalid chat id" }); - } - }); - - it("rejects a non-string id with 400", () => { - const result = validateCreateSessionChatBody({ id: 42 }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("ignores unknown fields on the body", () => { - const result = validateCreateSessionChatBody({ id: "chat_abc", junk: 1 }); - expect(result).toEqual({ id: "chat_abc" }); - }); -}); diff --git a/lib/sessions/__tests__/validateCreateSessionChatRequest.test.ts b/lib/sessions/__tests__/validateCreateSessionChatRequest.test.ts new file mode 100644 index 000000000..71600f619 --- /dev/null +++ b/lib/sessions/__tests__/validateCreateSessionChatRequest.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; + +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(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { validateCreateSessionChatRequest } = await import( + "@/lib/sessions/validateCreateSessionChatRequest" +); + +const accountId = "acc-uuid-1"; + +function makeReq(body: unknown): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +function authedSession() { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); +} + +describe("validateCreateSessionChatRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const res = await validateCreateSessionChatRequest(makeReq({}), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(401); + } + }); + + it("returns 404 when session does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await validateCreateSessionChatRequest(makeReq({}), "sess_missing"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + } + }); + + it("returns 403 when session is owned by a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), + ]); + + const res = await validateCreateSessionChatRequest(makeReq({}), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + } + }); + + it("returns 400 with parity error when id is an empty string", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq({ id: "" }), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid chat id" }); + } + }); + + it("returns 400 when id is not a string", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq({ id: 42 }), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(400); + } + }); + + it("returns { auth, session, body } on an empty body", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq({}), "sess_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.auth.accountId).toBe(accountId); + expect(res.session.id).toBe("sess_1"); + expect(res.body).toEqual({}); + } + }); + + it("returns { auth, session, body } with a valid id", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq({ id: "chat_abc" }), "sess_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.body).toEqual({ id: "chat_abc" }); + } + }); + + it("treats malformed JSON like an empty body", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq("{not json"), "sess_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.body).toEqual({}); + } + }); + + it("ignores unknown fields on the body", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest( + makeReq({ id: "chat_abc", junk: 1 }), + "sess_1", + ); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.body).toEqual({ id: "chat_abc" }); + } + }); +}); diff --git a/lib/sessions/__tests__/validateOwnedSessionRequest.test.ts b/lib/sessions/__tests__/validateOwnedSessionRequest.test.ts new file mode 100644 index 000000000..0da95ee02 --- /dev/null +++ b/lib/sessions/__tests__/validateOwnedSessionRequest.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; + +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(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { validateOwnedSessionRequest } = await import("@/lib/sessions/validateOwnedSessionRequest"); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1"); +} + +describe("validateOwnedSessionRequest", () => { + 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 validateOwnedSessionRequest(makeReq(), "sess_1"); + expect(res).toBe(failure); + expect(selectSessions).not.toHaveBeenCalled(); + }); + + it("returns 404 when the session is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await validateOwnedSessionRequest(makeReq(), "sess_missing"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Session not found", + }); + } + }); + + it("returns 403 when the session belongs to a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), + ]); + + const res = await validateOwnedSessionRequest(makeReq(), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ status: "error", error: "Forbidden" }); + } + }); + + it("returns { auth, session } on the happy path", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + + const res = await validateOwnedSessionRequest(makeReq(), "sess_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.auth.accountId).toBe(accountId); + expect(res.session.id).toBe("sess_1"); + } + expect(selectSessions).toHaveBeenCalledWith({ id: "sess_1" }); + }); +}); diff --git a/lib/sessions/createSessionChatHandler.ts b/lib/sessions/createSessionChatHandler.ts index db5cf21aa..1c5d5e04a 100644 --- a/lib/sessions/createSessionChatHandler.ts +++ b/lib/sessions/createSessionChatHandler.ts @@ -1,9 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { generateUUID } from "@/lib/uuid/generateUUID"; -import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { requireOwnedSession } from "@/lib/sessions/requireOwnedSession"; -import { validateCreateSessionChatBody } from "@/lib/sessions/validateCreateSessionChatBody"; +import { validateCreateSessionChatRequest } from "@/lib/sessions/validateCreateSessionChatRequest"; import { selectChats } from "@/lib/supabase/chats/selectChats"; import { insertChat } from "@/lib/supabase/chats/insertChat"; import { toChatResponse } from "@/lib/sessions/toChatResponse"; @@ -13,12 +11,11 @@ const INITIAL_CHAT_TITLE = "New chat"; /** * Handles `POST /api/sessions/{sessionId}/chats`. * - * Authenticates the caller, verifies session ownership, then creates a - * new chat row. Callers may pass `{ id }` to claim a deterministic chat - * id: if a row already exists with that id and belongs to this - * session, it is returned as-is (idempotent retry); if it exists on a - * different session, 409 is returned. Otherwise a new chat is - * inserted with title "New chat". + * Callers may pass `{ id }` to claim a deterministic chat id: if a row + * already exists with that id and belongs to this session, it is + * returned as-is (idempotent retry); if it exists on a different + * session, 409 is returned. Otherwise a new chat is inserted with + * title "New chat". * * Response shape mirrors open-agents' `POST /api/sessions/[sessionId]/chats` * so the existing frontend can cut over without code changes. @@ -31,18 +28,13 @@ export async function createSessionChatHandler( request: NextRequest, sessionId: string, ): Promise { - const gate = await requireOwnedSession(request, sessionId); - if (gate instanceof NextResponse) { - return gate; - } - - const rawBody = await safeParseJson(request); - const validated = validateCreateSessionChatBody(rawBody); + const validated = await validateCreateSessionChatRequest(request, sessionId); if (validated instanceof NextResponse) { return validated; } + const { body } = validated; - const requestedChatId = validated.id ?? null; + const requestedChatId = body.id ?? null; if (requestedChatId) { const existing = (await selectChats({ id: requestedChatId }))[0] ?? null; diff --git a/lib/sessions/getSessionChatsHandler.ts b/lib/sessions/getSessionChatsHandler.ts index a5122b228..ac9544cef 100644 --- a/lib/sessions/getSessionChatsHandler.ts +++ b/lib/sessions/getSessionChatsHandler.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { APP_DEFAULT_MODEL_ID } from "@/lib/const"; -import { requireOwnedSession } from "@/lib/sessions/requireOwnedSession"; +import { validateOwnedSessionRequest } from "@/lib/sessions/validateOwnedSessionRequest"; import { selectChats } from "@/lib/supabase/chats/selectChats"; import { selectChatReads } from "@/lib/supabase/chat_reads/selectChatReads"; import { toChatSummaryResponse } from "@/lib/sessions/toChatSummaryResponse"; @@ -24,16 +24,17 @@ export async function getSessionChatsHandler( request: NextRequest, sessionId: string, ): Promise { - const gate = await requireOwnedSession(request, sessionId); - if (gate instanceof NextResponse) { - return gate; + const validated = await validateOwnedSessionRequest(request, sessionId); + if (validated instanceof NextResponse) { + return validated; } + const { auth } = validated; const chats = await selectChats({ sessionId }); const reads = chats.length > 0 ? await selectChatReads({ - accountId: gate.auth.accountId, + accountId: auth.accountId, chatIds: chats.map(row => row.id), }) : []; diff --git a/lib/sessions/validateCreateSessionChatBody.ts b/lib/sessions/validateCreateSessionChatBody.ts deleted file mode 100644 index 568321917..000000000 --- a/lib/sessions/validateCreateSessionChatBody.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; - -export const createSessionChatBodySchema = z.object({ - id: z.string({ error: "Invalid chat id" }).min(1, "Invalid chat id").optional(), -}); - -export type CreateSessionChatBody = z.infer; - -/** - * Validates the body for `POST /api/sessions/{sessionId}/chats`. - * - * The endpoint accepts an optional `id` so callers can deterministically - * "claim" a chat id and idempotently retry. An explicitly empty or - * non-string id is rejected with the same 400 shape open-agents used - * (`{ error: "Invalid chat id" }`) for parity. - * - * @param body - The parsed request body (may be `null` / non-object). - * @returns A 400 NextResponse on failure, or the validated body. - */ -export function validateCreateSessionChatBody(body: unknown): NextResponse | CreateSessionChatBody { - const candidate = body && typeof body === "object" && !Array.isArray(body) ? body : {}; - - const result = createSessionChatBodySchema.safeParse(candidate); - if (!result.success) { - return NextResponse.json( - { error: "Invalid chat id" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - return result.data; -} diff --git a/lib/sessions/validateCreateSessionChatRequest.ts b/lib/sessions/validateCreateSessionChatRequest.ts new file mode 100644 index 000000000..289488c2d --- /dev/null +++ b/lib/sessions/validateCreateSessionChatRequest.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateOwnedSessionRequest } from "@/lib/sessions/validateOwnedSessionRequest"; +import type { ValidatedOwnedSessionRequest } from "@/lib/sessions/validateOwnedSessionRequest"; + +export const createSessionChatBodySchema = z.object({ + id: z.string({ error: "Invalid chat id" }).min(1, "Invalid chat id").optional(), +}); + +export type CreateSessionChatBody = z.infer; + +export interface ValidatedCreateSessionChatRequest extends ValidatedOwnedSessionRequest { + body: CreateSessionChatBody; +} + +/** + * Validates a `POST /api/sessions/{sessionId}/chats` request end-to-end: + * 1. Authenticates the caller and verifies they own the session + * (via `validateOwnedSessionRequest`) + * 2. Parses the JSON body (malformed JSON is treated as empty) + * 3. Validates the optional `{ id }` field + * + * An explicitly empty or non-string id is rejected with the same 400 + * shape open-agents used (`{ error: "Invalid chat id" }`) for parity. + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @returns A NextResponse on failure, or the validated auth + session + body. + */ +export async function validateCreateSessionChatRequest( + request: NextRequest, + sessionId: string, +): Promise { + const owned = await validateOwnedSessionRequest(request, sessionId); + if (owned instanceof NextResponse) { + return owned; + } + + const rawBody = await safeParseJson(request); + const candidate = + rawBody && typeof rawBody === "object" && !Array.isArray(rawBody) ? rawBody : {}; + + const result = createSessionChatBodySchema.safeParse(candidate); + if (!result.success) { + return NextResponse.json( + { error: "Invalid chat id" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return { auth: owned.auth, session: owned.session, body: result.data }; +} diff --git a/lib/sessions/requireOwnedSession.ts b/lib/sessions/validateOwnedSessionRequest.ts similarity index 67% rename from lib/sessions/requireOwnedSession.ts rename to lib/sessions/validateOwnedSessionRequest.ts index 80efd720e..969043724 100644 --- a/lib/sessions/requireOwnedSession.ts +++ b/lib/sessions/validateOwnedSessionRequest.ts @@ -5,25 +5,28 @@ import type { AuthContext } from "@/lib/auth/validateAuthContext"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import type { Tables } from "@/types/database.types"; -export interface OwnedSessionContext { +export interface ValidatedOwnedSessionRequest { auth: AuthContext; session: Tables<"sessions">; } /** - * Authenticates the caller and verifies they own the session at the - * given id. Mirrors the `validateAuthContext` return convention so - * callers can early-return on the `NextResponse` branch and keep - * working with `{ auth, session }` otherwise. + * Validates a session-scoped request end-to-end: + * 1. Authenticates via Privy Bearer / x-api-key + * 2. Loads the session row at the given id + * 3. Confirms the authenticated account owns it + * + * Returns either a 401/403/404 NextResponse describing the first + * failure, or the resolved `{ auth, session }` for the handler. * * @param request - The incoming request. * @param sessionId - The id of the session to gate access on. - * @returns A 401/403/404 NextResponse on failure, or the resolved auth + session row. + * @returns A NextResponse on failure, or the validated auth + session row. */ -export async function requireOwnedSession( +export async function validateOwnedSessionRequest( request: NextRequest, sessionId: string, -): Promise { +): Promise { const auth = await validateAuthContext(request); if (auth instanceof NextResponse) { return auth; From d7ebc19eae679b61594c2ee90b30d284819a3bc5 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 05:16:45 +0530 Subject: [PATCH 5/9] refactor(sessions): give GET and POST their own request validators Each endpoint now has a self-contained validator that does auth + ownership (+ body for POST) inline, matching the pattern set by validateCreateSessionBody. Drops the shared validateOwnedSessionRequest helper so the per-endpoint validators are the single point of gating. - GET: validateGetSessionChatsRequest (new) - POST: validateCreateSessionChatRequest (inlined) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/getSessionChatsHandler.test.ts | 14 +++--- ...=> validateGetSessionChatsRequest.test.ts} | 16 ++++--- lib/sessions/getSessionChatsHandler.ts | 4 +- .../validateCreateSessionChatRequest.ts | 44 ++++++++++++++----- ...t.ts => validateGetSessionChatsRequest.ts} | 12 ++--- 5 files changed, 58 insertions(+), 32 deletions(-) rename lib/sessions/__tests__/{validateOwnedSessionRequest.test.ts => validateGetSessionChatsRequest.test.ts} (85%) rename lib/sessions/{validateOwnedSessionRequest.ts => validateGetSessionChatsRequest.ts} (80%) diff --git a/lib/sessions/__tests__/getSessionChatsHandler.test.ts b/lib/sessions/__tests__/getSessionChatsHandler.test.ts index ca89fca40..1695091ee 100644 --- a/lib/sessions/__tests__/getSessionChatsHandler.test.ts +++ b/lib/sessions/__tests__/getSessionChatsHandler.test.ts @@ -7,8 +7,8 @@ import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), })); -vi.mock("@/lib/sessions/validateOwnedSessionRequest", () => ({ - validateOwnedSessionRequest: vi.fn(), +vi.mock("@/lib/sessions/validateGetSessionChatsRequest", () => ({ + validateGetSessionChatsRequest: vi.fn(), })); vi.mock("@/lib/supabase/chats/selectChats", () => ({ selectChats: vi.fn(), @@ -17,7 +17,9 @@ vi.mock("@/lib/supabase/chat_reads/selectChatReads", () => ({ selectChatReads: vi.fn(), })); -const { validateOwnedSessionRequest } = await import("@/lib/sessions/validateOwnedSessionRequest"); +const { validateGetSessionChatsRequest } = await import( + "@/lib/sessions/validateGetSessionChatsRequest" +); const { selectChats } = await import("@/lib/supabase/chats/selectChats"); const { selectChatReads } = await import("@/lib/supabase/chat_reads/selectChatReads"); const { getSessionChatsHandler } = await import("@/lib/sessions/getSessionChatsHandler"); @@ -33,7 +35,7 @@ function chatRow(overrides: Partial>): Tables<"chats"> { } function mockOwned() { - vi.mocked(validateOwnedSessionRequest).mockResolvedValue({ + vi.mocked(validateGetSessionChatsRequest).mockResolvedValue({ auth: { accountId, orgId: null, authToken: "tok" }, session: baseSessionRow({ id: "sess_1", account_id: accountId }), }); @@ -44,9 +46,9 @@ describe("getSessionChatsHandler", () => { vi.clearAllMocks(); }); - it("forwards the NextResponse from validateOwnedSessionRequest as-is", async () => { + it("forwards the NextResponse from validateGetSessionChatsRequest as-is", async () => { const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 }); - vi.mocked(validateOwnedSessionRequest).mockResolvedValue(failure); + vi.mocked(validateGetSessionChatsRequest).mockResolvedValue(failure); const res = await getSessionChatsHandler(makeReq(), "sess_1"); expect(res).toBe(failure); diff --git a/lib/sessions/__tests__/validateOwnedSessionRequest.test.ts b/lib/sessions/__tests__/validateGetSessionChatsRequest.test.ts similarity index 85% rename from lib/sessions/__tests__/validateOwnedSessionRequest.test.ts rename to lib/sessions/__tests__/validateGetSessionChatsRequest.test.ts index 0da95ee02..9d6bfe1a6 100644 --- a/lib/sessions/__tests__/validateOwnedSessionRequest.test.ts +++ b/lib/sessions/__tests__/validateGetSessionChatsRequest.test.ts @@ -14,15 +14,17 @@ vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); -const { validateOwnedSessionRequest } = await import("@/lib/sessions/validateOwnedSessionRequest"); +const { validateGetSessionChatsRequest } = await import( + "@/lib/sessions/validateGetSessionChatsRequest" +); const accountId = "acc-uuid-1"; function makeReq(): NextRequest { - return new NextRequest("https://example.com/api/sessions/sess_1"); + return new NextRequest("https://example.com/api/sessions/sess_1/chats"); } -describe("validateOwnedSessionRequest", () => { +describe("validateGetSessionChatsRequest", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -31,7 +33,7 @@ describe("validateOwnedSessionRequest", () => { const failure = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); vi.mocked(validateAuthContext).mockResolvedValue(failure); - const res = await validateOwnedSessionRequest(makeReq(), "sess_1"); + const res = await validateGetSessionChatsRequest(makeReq(), "sess_1"); expect(res).toBe(failure); expect(selectSessions).not.toHaveBeenCalled(); }); @@ -44,7 +46,7 @@ describe("validateOwnedSessionRequest", () => { }); vi.mocked(selectSessions).mockResolvedValue([]); - const res = await validateOwnedSessionRequest(makeReq(), "sess_missing"); + const res = await validateGetSessionChatsRequest(makeReq(), "sess_missing"); expect(res).toBeInstanceOf(NextResponse); if (res instanceof NextResponse) { expect(res.status).toBe(404); @@ -65,7 +67,7 @@ describe("validateOwnedSessionRequest", () => { baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), ]); - const res = await validateOwnedSessionRequest(makeReq(), "sess_1"); + const res = await validateGetSessionChatsRequest(makeReq(), "sess_1"); expect(res).toBeInstanceOf(NextResponse); if (res instanceof NextResponse) { expect(res.status).toBe(403); @@ -83,7 +85,7 @@ describe("validateOwnedSessionRequest", () => { baseSessionRow({ id: "sess_1", account_id: accountId }), ]); - const res = await validateOwnedSessionRequest(makeReq(), "sess_1"); + const res = await validateGetSessionChatsRequest(makeReq(), "sess_1"); expect(res).not.toBeInstanceOf(NextResponse); if (!(res instanceof NextResponse)) { expect(res.auth.accountId).toBe(accountId); diff --git a/lib/sessions/getSessionChatsHandler.ts b/lib/sessions/getSessionChatsHandler.ts index ac9544cef..93dcfcaa9 100644 --- a/lib/sessions/getSessionChatsHandler.ts +++ b/lib/sessions/getSessionChatsHandler.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { APP_DEFAULT_MODEL_ID } from "@/lib/const"; -import { validateOwnedSessionRequest } from "@/lib/sessions/validateOwnedSessionRequest"; +import { validateGetSessionChatsRequest } from "@/lib/sessions/validateGetSessionChatsRequest"; import { selectChats } from "@/lib/supabase/chats/selectChats"; import { selectChatReads } from "@/lib/supabase/chat_reads/selectChatReads"; import { toChatSummaryResponse } from "@/lib/sessions/toChatSummaryResponse"; @@ -24,7 +24,7 @@ export async function getSessionChatsHandler( request: NextRequest, sessionId: string, ): Promise { - const validated = await validateOwnedSessionRequest(request, sessionId); + const validated = await validateGetSessionChatsRequest(request, sessionId); if (validated instanceof NextResponse) { return validated; } diff --git a/lib/sessions/validateCreateSessionChatRequest.ts b/lib/sessions/validateCreateSessionChatRequest.ts index 289488c2d..b02016471 100644 --- a/lib/sessions/validateCreateSessionChatRequest.ts +++ b/lib/sessions/validateCreateSessionChatRequest.ts @@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { validateOwnedSessionRequest } from "@/lib/sessions/validateOwnedSessionRequest"; -import type { ValidatedOwnedSessionRequest } from "@/lib/sessions/validateOwnedSessionRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import type { Tables } from "@/types/database.types"; export const createSessionChatBodySchema = z.object({ id: z.string({ error: "Invalid chat id" }).min(1, "Invalid chat id").optional(), @@ -11,16 +13,19 @@ export const createSessionChatBodySchema = z.object({ export type CreateSessionChatBody = z.infer; -export interface ValidatedCreateSessionChatRequest extends ValidatedOwnedSessionRequest { +export interface ValidatedCreateSessionChatRequest { + auth: AuthContext; + session: Tables<"sessions">; body: CreateSessionChatBody; } /** * Validates a `POST /api/sessions/{sessionId}/chats` request end-to-end: - * 1. Authenticates the caller and verifies they own the session - * (via `validateOwnedSessionRequest`) - * 2. Parses the JSON body (malformed JSON is treated as empty) - * 3. Validates the optional `{ id }` field + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Loads the session row at the given id + * 3. Confirms the authenticated account owns it + * 4. Parses the JSON body (malformed JSON is treated as empty) + * 5. Validates the optional `{ id }` field * * An explicitly empty or non-string id is rejected with the same 400 * shape open-agents used (`{ error: "Invalid chat id" }`) for parity. @@ -33,9 +38,26 @@ export async function validateCreateSessionChatRequest( request: NextRequest, sessionId: string, ): Promise { - const owned = await validateOwnedSessionRequest(request, sessionId); - if (owned instanceof NextResponse) { - return owned; + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rows = await selectSessions({ id: sessionId }); + const session = rows[0] ?? null; + + if (!session) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (session.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); } const rawBody = await safeParseJson(request); @@ -50,5 +72,5 @@ export async function validateCreateSessionChatRequest( ); } - return { auth: owned.auth, session: owned.session, body: result.data }; + return { auth, session, body: result.data }; } diff --git a/lib/sessions/validateOwnedSessionRequest.ts b/lib/sessions/validateGetSessionChatsRequest.ts similarity index 80% rename from lib/sessions/validateOwnedSessionRequest.ts rename to lib/sessions/validateGetSessionChatsRequest.ts index 969043724..593291f22 100644 --- a/lib/sessions/validateOwnedSessionRequest.ts +++ b/lib/sessions/validateGetSessionChatsRequest.ts @@ -5,14 +5,14 @@ import type { AuthContext } from "@/lib/auth/validateAuthContext"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import type { Tables } from "@/types/database.types"; -export interface ValidatedOwnedSessionRequest { +export interface ValidatedGetSessionChatsRequest { auth: AuthContext; session: Tables<"sessions">; } /** - * Validates a session-scoped request end-to-end: - * 1. Authenticates via Privy Bearer / x-api-key + * Validates a `GET /api/sessions/{sessionId}/chats` request end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key * 2. Loads the session row at the given id * 3. Confirms the authenticated account owns it * @@ -20,13 +20,13 @@ export interface ValidatedOwnedSessionRequest { * failure, or the resolved `{ auth, session }` for the handler. * * @param request - The incoming request. - * @param sessionId - The id of the session to gate access on. + * @param sessionId - The id of the parent session. * @returns A NextResponse on failure, or the validated auth + session row. */ -export async function validateOwnedSessionRequest( +export async function validateGetSessionChatsRequest( request: NextRequest, sessionId: string, -): Promise { +): Promise { const auth = await validateAuthContext(request); if (auth instanceof NextResponse) { return auth; From f8b2dbf663918aed337e0fc6efef08f3120e7a90 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 05:30:31 +0530 Subject: [PATCH 6/9] refactor(chats): move ChatSummary join to lib/supabase/chats/ Pushes the camelCase mapping + chat_reads join into a single get supabase function (mirrors the lib/supabase naming convention for joined reads). Handler now just calls getChatSummariesBySessionId and forwards the result. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/getSessionChatsHandler.test.ts | 129 ++++------------ lib/sessions/getSessionChatsHandler.ts | 42 ++--- lib/sessions/toChatSummaryResponse.ts | 32 ---- .../getChatSummariesBySessionId.test.ts | 144 ++++++++++++++++++ .../chats/getChatSummariesBySessionId.ts | 74 +++++++++ 5 files changed, 259 insertions(+), 162 deletions(-) delete mode 100644 lib/sessions/toChatSummaryResponse.ts create mode 100644 lib/supabase/chats/__tests__/getChatSummariesBySessionId.test.ts create mode 100644 lib/supabase/chats/getChatSummariesBySessionId.ts diff --git a/lib/sessions/__tests__/getSessionChatsHandler.test.ts b/lib/sessions/__tests__/getSessionChatsHandler.test.ts index 1695091ee..4ada5408f 100644 --- a/lib/sessions/__tests__/getSessionChatsHandler.test.ts +++ b/lib/sessions/__tests__/getSessionChatsHandler.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import type { Tables } from "@/types/database.types"; import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; -import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), @@ -10,31 +8,25 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ vi.mock("@/lib/sessions/validateGetSessionChatsRequest", () => ({ validateGetSessionChatsRequest: vi.fn(), })); -vi.mock("@/lib/supabase/chats/selectChats", () => ({ - selectChats: vi.fn(), -})); -vi.mock("@/lib/supabase/chat_reads/selectChatReads", () => ({ - selectChatReads: vi.fn(), +vi.mock("@/lib/supabase/chats/getChatSummariesBySessionId", () => ({ + getChatSummariesBySessionId: vi.fn(), })); const { validateGetSessionChatsRequest } = await import( "@/lib/sessions/validateGetSessionChatsRequest" ); -const { selectChats } = await import("@/lib/supabase/chats/selectChats"); -const { selectChatReads } = await import("@/lib/supabase/chat_reads/selectChatReads"); +const { getChatSummariesBySessionId } = await import( + "@/lib/supabase/chats/getChatSummariesBySessionId" +); const { getSessionChatsHandler } = await import("@/lib/sessions/getSessionChatsHandler"); const accountId = "acc-uuid-1"; -function makeReq(url = "https://example.com/api/sessions/sess_1/chats"): NextRequest { - return new NextRequest(url); -} - -function chatRow(overrides: Partial>): Tables<"chats"> { - return baseChatRow({ session_id: "sess_1", ...overrides }); +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats"); } -function mockOwned() { +function mockValidated() { vi.mocked(validateGetSessionChatsRequest).mockResolvedValue({ auth: { accountId, orgId: null, authToken: "tok" }, session: baseSessionRow({ id: "sess_1", account_id: accountId }), @@ -52,99 +44,38 @@ describe("getSessionChatsHandler", () => { const res = await getSessionChatsHandler(makeReq(), "sess_1"); expect(res).toBe(failure); - expect(selectChats).not.toHaveBeenCalled(); + expect(getChatSummariesBySessionId).not.toHaveBeenCalled(); }); - it("returns 200 with chats sorted by created_at and APP_DEFAULT_MODEL_ID", async () => { - mockOwned(); - vi.mocked(selectChats).mockResolvedValue([ - chatRow({ - id: "chat_late", - title: "Late", - created_at: "2026-05-04T00:00:00.000Z", - }), - chatRow({ - id: "chat_early", - title: "Early", - created_at: "2026-05-01T00:00:00.000Z", - }), - ]); - vi.mocked(selectChatReads).mockResolvedValue([]); + it("returns 200 with summaries from the db and APP_DEFAULT_MODEL_ID", async () => { + mockValidated(); + const summaries = [ + { + id: "chat_1", + sessionId: "sess_1", + title: "First", + modelId: null, + activeStreamId: null, + lastAssistantMessageAt: null, + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + hasUnread: false, + isStreaming: false, + }, + ]; + vi.mocked(getChatSummariesBySessionId).mockResolvedValue(summaries); const res = await getSessionChatsHandler(makeReq(), "sess_1"); expect(res.status).toBe(200); const body = (await res.json()) as { - chats: Array<{ id: string; hasUnread: boolean; isStreaming: boolean }>; + chats: typeof summaries; defaultModelId: string; }; - expect(body.chats.map(c => c.id)).toEqual(["chat_early", "chat_late"]); + expect(body.chats).toEqual(summaries); expect(body.defaultModelId).toBe("openai/gpt-5.4"); - expect(selectChatReads).toHaveBeenCalledWith({ + expect(getChatSummariesBySessionId).toHaveBeenCalledWith({ + sessionId: "sess_1", accountId, - chatIds: ["chat_late", "chat_early"], }); }); - - it("skips chat_reads lookup when the session has no chats", async () => { - mockOwned(); - vi.mocked(selectChats).mockResolvedValue([]); - - const res = await getSessionChatsHandler(makeReq(), "sess_1"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - chats: unknown[]; - defaultModelId: string; - }; - expect(body.chats).toEqual([]); - expect(body.defaultModelId).toBe("openai/gpt-5.4"); - expect(selectChatReads).not.toHaveBeenCalled(); - }); - - it("derives hasUnread from last_assistant_message_at vs last_read_at", async () => { - mockOwned(); - vi.mocked(selectChats).mockResolvedValue([ - chatRow({ - id: "chat_unread", - last_assistant_message_at: "2026-05-04T10:00:00.000Z", - active_stream_id: null, - }), - chatRow({ - id: "chat_read", - last_assistant_message_at: "2026-05-03T10:00:00.000Z", - active_stream_id: null, - }), - chatRow({ - id: "chat_streaming", - last_assistant_message_at: null, - active_stream_id: "stream_xyz", - }), - ]); - vi.mocked(selectChatReads).mockResolvedValue([ - { - account_id: accountId, - chat_id: "chat_unread", - last_read_at: "2026-05-04T09:00:00.000Z", - created_at: "2026-05-01T00:00:00.000Z", - updated_at: "2026-05-04T09:00:00.000Z", - }, - { - account_id: accountId, - chat_id: "chat_read", - last_read_at: "2026-05-04T00:00:00.000Z", - created_at: "2026-05-01T00:00:00.000Z", - updated_at: "2026-05-04T00:00:00.000Z", - }, - ]); - - const res = await getSessionChatsHandler(makeReq(), "sess_1"); - const body = (await res.json()) as { - chats: Array<{ id: string; hasUnread: boolean; isStreaming: boolean }>; - }; - const byId = new Map(body.chats.map(c => [c.id, c])); - expect(byId.get("chat_unread")?.hasUnread).toBe(true); - expect(byId.get("chat_read")?.hasUnread).toBe(false); - expect(byId.get("chat_streaming")?.hasUnread).toBe(false); - expect(byId.get("chat_streaming")?.isStreaming).toBe(true); - expect(byId.get("chat_unread")?.isStreaming).toBe(false); - }); }); diff --git a/lib/sessions/getSessionChatsHandler.ts b/lib/sessions/getSessionChatsHandler.ts index 93dcfcaa9..28b5b2a13 100644 --- a/lib/sessions/getSessionChatsHandler.ts +++ b/lib/sessions/getSessionChatsHandler.ts @@ -2,19 +2,16 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { APP_DEFAULT_MODEL_ID } from "@/lib/const"; import { validateGetSessionChatsRequest } from "@/lib/sessions/validateGetSessionChatsRequest"; -import { selectChats } from "@/lib/supabase/chats/selectChats"; -import { selectChatReads } from "@/lib/supabase/chat_reads/selectChatReads"; -import { toChatSummaryResponse } from "@/lib/sessions/toChatSummaryResponse"; +import { getChatSummariesBySessionId } from "@/lib/supabase/chats/getChatSummariesBySessionId"; /** * Handles `GET /api/sessions/{sessionId}/chats`. * - * Authenticates the caller, verifies they own the session, then - * returns every chat in the session plus the caller's default model - * id. Per-chat unread state is derived from the caller's `chat_reads` - * row (if any). Response shape mirrors open-agents' - * `/api/sessions/[sessionId]/chats` so the existing frontend can cut - * over without code changes. + * Lists every chat in the session as a camelCase `ChatSummary`, + * plus the caller's default model id. Per-chat unread state is + * derived from the caller's `chat_reads` row. Response shape + * mirrors open-agents' `/api/sessions/[sessionId]/chats` so the + * existing frontend can cut over without code changes. * * @param request - The incoming request. * @param sessionId - The id of the parent session. @@ -28,31 +25,14 @@ export async function getSessionChatsHandler( if (validated instanceof NextResponse) { return validated; } - const { auth } = validated; - const chats = await selectChats({ sessionId }); - const reads = - chats.length > 0 - ? await selectChatReads({ - accountId: auth.accountId, - chatIds: chats.map(row => row.id), - }) - : []; - - const lastReadByChatId = new Map(); - for (const read of reads) { - lastReadByChatId.set(read.chat_id, read.last_read_at); - } - - const sorted = [...chats].sort( - (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), - ); + const chats = await getChatSummariesBySessionId({ + sessionId, + accountId: validated.auth.accountId, + }); return NextResponse.json( - { - chats: sorted.map(row => toChatSummaryResponse(row, lastReadByChatId.get(row.id) ?? null)), - defaultModelId: APP_DEFAULT_MODEL_ID, - }, + { chats, defaultModelId: APP_DEFAULT_MODEL_ID }, { status: 200, headers: getCorsHeaders() }, ); } diff --git a/lib/sessions/toChatSummaryResponse.ts b/lib/sessions/toChatSummaryResponse.ts deleted file mode 100644 index 33592cb7d..000000000 --- a/lib/sessions/toChatSummaryResponse.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Tables } from "@/types/database.types"; - -/** - * Translates a Supabase `chats` row into the `ChatSummary` shape the - * open-agents frontend already consumes. `hasUnread` is derived from - * `chat.last_assistant_message_at` vs the caller's last read, and - * `isStreaming` from the row's `active_stream_id`. - * - * @param row - The Supabase chats row. - * @param lastReadAt - The caller's last-read timestamp for this chat, if any. - * @returns The camelCase chat summary payload for HTTP responses. - */ -export function toChatSummaryResponse(row: Tables<"chats">, lastReadAt: string | null) { - const lastAssistantMessageAt = row.last_assistant_message_at; - const hasUnread = - lastAssistantMessageAt !== null && - (lastReadAt === null || - new Date(lastAssistantMessageAt).getTime() > new Date(lastReadAt).getTime()); - - return { - id: row.id, - sessionId: row.session_id, - title: row.title, - modelId: row.model_id, - activeStreamId: row.active_stream_id, - lastAssistantMessageAt, - createdAt: row.created_at, - updatedAt: row.updated_at, - hasUnread, - isStreaming: row.active_stream_id !== null, - }; -} diff --git a/lib/supabase/chats/__tests__/getChatSummariesBySessionId.test.ts b/lib/supabase/chats/__tests__/getChatSummariesBySessionId.test.ts new file mode 100644 index 000000000..02e10fe4f --- /dev/null +++ b/lib/supabase/chats/__tests__/getChatSummariesBySessionId.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Tables } from "@/types/database.types"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/supabase/chats/selectChats", () => ({ + selectChats: vi.fn(), +})); +vi.mock("@/lib/supabase/chat_reads/selectChatReads", () => ({ + selectChatReads: vi.fn(), +})); + +const { selectChats } = await import("@/lib/supabase/chats/selectChats"); +const { selectChatReads } = await import("@/lib/supabase/chat_reads/selectChatReads"); +const { getChatSummariesBySessionId } = await import( + "@/lib/supabase/chats/getChatSummariesBySessionId" +); + +const accountId = "acc-uuid-1"; + +function chatRow(overrides: Partial>): Tables<"chats"> { + return baseChatRow({ session_id: "sess_1", ...overrides }); +} + +describe("getChatSummariesBySessionId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns [] without hitting chat_reads when the session has no chats", async () => { + vi.mocked(selectChats).mockResolvedValue([]); + + const result = await getChatSummariesBySessionId({ + sessionId: "sess_1", + accountId, + }); + + expect(result).toEqual([]); + expect(selectChatReads).not.toHaveBeenCalled(); + }); + + it("sorts summaries by createdAt ascending and uses APP_DEFAULT shape", async () => { + vi.mocked(selectChats).mockResolvedValue([ + chatRow({ + id: "chat_late", + title: "Late", + created_at: "2026-05-04T00:00:00.000Z", + }), + chatRow({ + id: "chat_early", + title: "Early", + created_at: "2026-05-01T00:00:00.000Z", + }), + ]); + vi.mocked(selectChatReads).mockResolvedValue([]); + + const result = await getChatSummariesBySessionId({ + sessionId: "sess_1", + accountId, + }); + + expect(result.map(c => c.id)).toEqual(["chat_early", "chat_late"]); + expect(result[0]).toMatchObject({ + sessionId: "sess_1", + title: "Early", + hasUnread: false, + isStreaming: false, + }); + expect(selectChatReads).toHaveBeenCalledWith({ + accountId, + chatIds: ["chat_late", "chat_early"], + }); + }); + + it("derives hasUnread from last_assistant_message_at vs last_read_at", async () => { + vi.mocked(selectChats).mockResolvedValue([ + chatRow({ + id: "chat_unread", + last_assistant_message_at: "2026-05-04T10:00:00.000Z", + active_stream_id: null, + }), + chatRow({ + id: "chat_read", + last_assistant_message_at: "2026-05-03T10:00:00.000Z", + active_stream_id: null, + }), + chatRow({ + id: "chat_never_assistant", + last_assistant_message_at: null, + active_stream_id: null, + }), + chatRow({ + id: "chat_streaming", + last_assistant_message_at: null, + active_stream_id: "stream_xyz", + }), + ]); + vi.mocked(selectChatReads).mockResolvedValue([ + { + account_id: accountId, + chat_id: "chat_unread", + last_read_at: "2026-05-04T09:00:00.000Z", + created_at: "2026-05-01T00:00:00.000Z", + updated_at: "2026-05-04T09:00:00.000Z", + }, + { + account_id: accountId, + chat_id: "chat_read", + last_read_at: "2026-05-04T00:00:00.000Z", + created_at: "2026-05-01T00:00:00.000Z", + updated_at: "2026-05-04T00:00:00.000Z", + }, + ]); + + const result = await getChatSummariesBySessionId({ + sessionId: "sess_1", + accountId, + }); + + const byId = new Map(result.map(c => [c.id, c])); + expect(byId.get("chat_unread")?.hasUnread).toBe(true); + expect(byId.get("chat_read")?.hasUnread).toBe(false); + expect(byId.get("chat_never_assistant")?.hasUnread).toBe(false); + expect(byId.get("chat_streaming")?.hasUnread).toBe(false); + expect(byId.get("chat_streaming")?.isStreaming).toBe(true); + expect(byId.get("chat_unread")?.isStreaming).toBe(false); + }); + + it("treats a missing chat_reads row as fully unread when assistant has replied", async () => { + vi.mocked(selectChats).mockResolvedValue([ + chatRow({ + id: "chat_no_read", + last_assistant_message_at: "2026-05-04T10:00:00.000Z", + }), + ]); + vi.mocked(selectChatReads).mockResolvedValue([]); + + const result = await getChatSummariesBySessionId({ + sessionId: "sess_1", + accountId, + }); + + expect(result[0].hasUnread).toBe(true); + }); +}); diff --git a/lib/supabase/chats/getChatSummariesBySessionId.ts b/lib/supabase/chats/getChatSummariesBySessionId.ts new file mode 100644 index 000000000..b13b4abb6 --- /dev/null +++ b/lib/supabase/chats/getChatSummariesBySessionId.ts @@ -0,0 +1,74 @@ +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import { selectChatReads } from "@/lib/supabase/chat_reads/selectChatReads"; + +export interface ChatSummary { + id: string; + sessionId: string; + title: string; + modelId: string | null; + activeStreamId: string | null; + lastAssistantMessageAt: string | null; + createdAt: string; + updatedAt: string; + hasUnread: boolean; + isStreaming: boolean; +} + +/** + * Returns every chat in the given session as a camelCase `ChatSummary` + * for the open-agents `/api/sessions/[sessionId]/chats` wire format. + * Per-account `hasUnread` is derived from the caller's `chat_reads` + * row (if any); `isStreaming` is derived from `active_stream_id`. + * + * Mirrors open-agents' `getChatSummariesBySessionId` so the existing + * frontend can cut over without code changes. + * + * @param params - The session id to list and the account id to scope reads to. + * @returns Chat summaries sorted by `createdAt` ascending. + */ +export async function getChatSummariesBySessionId({ + sessionId, + accountId, +}: { + sessionId: string; + accountId: string; +}): Promise { + const chats = await selectChats({ sessionId }); + if (chats.length === 0) { + return []; + } + + const reads = await selectChatReads({ + accountId, + chatIds: chats.map(row => row.id), + }); + + const lastReadByChatId = new Map(); + for (const read of reads) { + lastReadByChatId.set(read.chat_id, read.last_read_at); + } + + return [...chats] + .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) + .map(row => { + const lastReadAt = lastReadByChatId.get(row.id) ?? null; + const lastAssistantMessageAt = row.last_assistant_message_at; + const hasUnread = + lastAssistantMessageAt !== null && + (lastReadAt === null || + new Date(lastAssistantMessageAt).getTime() > new Date(lastReadAt).getTime()); + + return { + id: row.id, + sessionId: row.session_id, + title: row.title, + modelId: row.model_id, + activeStreamId: row.active_stream_id, + lastAssistantMessageAt, + createdAt: row.created_at, + updatedAt: row.updated_at, + hasUnread, + isStreaming: row.active_stream_id !== null, + }; + }); +} From a8a36b00017c342be4d4f9c77fede81b97439e9d Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 05:36:45 +0530 Subject: [PATCH 7/9] refactor(sessions): rename getChatSummaries + strip migration prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getChatSummariesBySessionId → getChatSummaries - Removes "mirrors open-agents", "cut over", "wire format parity" references from endpoint/handler/validator/db JSDoc Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/sessions/[sessionId]/chats/route.ts | 2 -- lib/const.ts | 6 +----- .../__tests__/getSessionChatsHandler.test.ts | 14 ++++++-------- lib/sessions/createSessionChatHandler.ts | 3 --- lib/sessions/getSessionChatsHandler.ts | 14 +++++--------- lib/sessions/validateCreateSessionChatRequest.ts | 4 ++-- ...ySessionId.test.ts => getChatSummaries.test.ts} | 14 ++++++-------- ...SummariesBySessionId.ts => getChatSummaries.ts} | 12 ++++-------- 8 files changed, 24 insertions(+), 45 deletions(-) rename lib/supabase/chats/__tests__/{getChatSummariesBySessionId.test.ts => getChatSummaries.test.ts} (91%) rename lib/supabase/chats/{getChatSummariesBySessionId.ts => getChatSummaries.ts} (80%) diff --git a/app/api/sessions/[sessionId]/chats/route.ts b/app/api/sessions/[sessionId]/chats/route.ts index db96ce9f4..67341e557 100644 --- a/app/api/sessions/[sessionId]/chats/route.ts +++ b/app/api/sessions/[sessionId]/chats/route.ts @@ -23,8 +23,6 @@ export async function OPTIONS() { * `x-api-key`; 404s when the session is missing and 403s when it * exists but is owned by a different account. * - * Response shape mirrors open-agents' `/api/sessions/[sessionId]/chats`. - * * @param request - The incoming request. * @param options - Route options containing the async params. * @param options.params - Route params containing the session id. diff --git a/lib/const.ts b/lib/const.ts index 3b1ce5252..7d4f1fda8 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -11,11 +11,7 @@ export const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${p export const IMAGE_GENERATE_PRICE = "0.15"; export const DEFAULT_MODEL = "openai/gpt-5-mini"; export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini"; -/** - * Default model id surfaced to open-agents clients when the user has no - * explicit preference. Mirrors `APP_DEFAULT_MODEL_ID` from open-agents - * so the wire format stays aligned during the cutover. - */ +/** Default model id surfaced to clients that have no explicit preference. */ export const APP_DEFAULT_MODEL_ID = "openai/gpt-5.4"; export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; /** Domain for receiving inbound emails (e.g., support@mail.recoupable.com) */ diff --git a/lib/sessions/__tests__/getSessionChatsHandler.test.ts b/lib/sessions/__tests__/getSessionChatsHandler.test.ts index 4ada5408f..2b4637947 100644 --- a/lib/sessions/__tests__/getSessionChatsHandler.test.ts +++ b/lib/sessions/__tests__/getSessionChatsHandler.test.ts @@ -8,16 +8,14 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ vi.mock("@/lib/sessions/validateGetSessionChatsRequest", () => ({ validateGetSessionChatsRequest: vi.fn(), })); -vi.mock("@/lib/supabase/chats/getChatSummariesBySessionId", () => ({ - getChatSummariesBySessionId: vi.fn(), +vi.mock("@/lib/supabase/chats/getChatSummaries", () => ({ + getChatSummaries: vi.fn(), })); const { validateGetSessionChatsRequest } = await import( "@/lib/sessions/validateGetSessionChatsRequest" ); -const { getChatSummariesBySessionId } = await import( - "@/lib/supabase/chats/getChatSummariesBySessionId" -); +const { getChatSummaries } = await import("@/lib/supabase/chats/getChatSummaries"); const { getSessionChatsHandler } = await import("@/lib/sessions/getSessionChatsHandler"); const accountId = "acc-uuid-1"; @@ -44,7 +42,7 @@ describe("getSessionChatsHandler", () => { const res = await getSessionChatsHandler(makeReq(), "sess_1"); expect(res).toBe(failure); - expect(getChatSummariesBySessionId).not.toHaveBeenCalled(); + expect(getChatSummaries).not.toHaveBeenCalled(); }); it("returns 200 with summaries from the db and APP_DEFAULT_MODEL_ID", async () => { @@ -63,7 +61,7 @@ describe("getSessionChatsHandler", () => { isStreaming: false, }, ]; - vi.mocked(getChatSummariesBySessionId).mockResolvedValue(summaries); + vi.mocked(getChatSummaries).mockResolvedValue(summaries); const res = await getSessionChatsHandler(makeReq(), "sess_1"); expect(res.status).toBe(200); @@ -73,7 +71,7 @@ describe("getSessionChatsHandler", () => { }; expect(body.chats).toEqual(summaries); expect(body.defaultModelId).toBe("openai/gpt-5.4"); - expect(getChatSummariesBySessionId).toHaveBeenCalledWith({ + expect(getChatSummaries).toHaveBeenCalledWith({ sessionId: "sess_1", accountId, }); diff --git a/lib/sessions/createSessionChatHandler.ts b/lib/sessions/createSessionChatHandler.ts index 1c5d5e04a..0a484dc47 100644 --- a/lib/sessions/createSessionChatHandler.ts +++ b/lib/sessions/createSessionChatHandler.ts @@ -17,9 +17,6 @@ const INITIAL_CHAT_TITLE = "New chat"; * session, 409 is returned. Otherwise a new chat is inserted with * title "New chat". * - * Response shape mirrors open-agents' `POST /api/sessions/[sessionId]/chats` - * so the existing frontend can cut over without code changes. - * * @param request - The incoming request. * @param sessionId - The id of the parent session. * @returns A NextResponse with `{ chat }` on 200, `{ error }` on 4xx, or an error. diff --git a/lib/sessions/getSessionChatsHandler.ts b/lib/sessions/getSessionChatsHandler.ts index 28b5b2a13..476ded9e8 100644 --- a/lib/sessions/getSessionChatsHandler.ts +++ b/lib/sessions/getSessionChatsHandler.ts @@ -2,16 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { APP_DEFAULT_MODEL_ID } from "@/lib/const"; import { validateGetSessionChatsRequest } from "@/lib/sessions/validateGetSessionChatsRequest"; -import { getChatSummariesBySessionId } from "@/lib/supabase/chats/getChatSummariesBySessionId"; +import { getChatSummaries } from "@/lib/supabase/chats/getChatSummaries"; /** - * Handles `GET /api/sessions/{sessionId}/chats`. - * - * Lists every chat in the session as a camelCase `ChatSummary`, - * plus the caller's default model id. Per-chat unread state is - * derived from the caller's `chat_reads` row. Response shape - * mirrors open-agents' `/api/sessions/[sessionId]/chats` so the - * existing frontend can cut over without code changes. + * Handles `GET /api/sessions/{sessionId}/chats`. Lists chats in the + * session as camelCase summaries with per-account unread state, plus + * the caller's default model id. * * @param request - The incoming request. * @param sessionId - The id of the parent session. @@ -26,7 +22,7 @@ export async function getSessionChatsHandler( return validated; } - const chats = await getChatSummariesBySessionId({ + const chats = await getChatSummaries({ sessionId, accountId: validated.auth.accountId, }); diff --git a/lib/sessions/validateCreateSessionChatRequest.ts b/lib/sessions/validateCreateSessionChatRequest.ts index b02016471..94e07ca36 100644 --- a/lib/sessions/validateCreateSessionChatRequest.ts +++ b/lib/sessions/validateCreateSessionChatRequest.ts @@ -27,8 +27,8 @@ export interface ValidatedCreateSessionChatRequest { * 4. Parses the JSON body (malformed JSON is treated as empty) * 5. Validates the optional `{ id }` field * - * An explicitly empty or non-string id is rejected with the same 400 - * shape open-agents used (`{ error: "Invalid chat id" }`) for parity. + * An explicitly empty or non-string id is rejected with a 400 + * `{ error: "Invalid chat id" }`. * * @param request - The incoming request. * @param sessionId - The id of the parent session. diff --git a/lib/supabase/chats/__tests__/getChatSummariesBySessionId.test.ts b/lib/supabase/chats/__tests__/getChatSummaries.test.ts similarity index 91% rename from lib/supabase/chats/__tests__/getChatSummariesBySessionId.test.ts rename to lib/supabase/chats/__tests__/getChatSummaries.test.ts index 02e10fe4f..921e0f5ad 100644 --- a/lib/supabase/chats/__tests__/getChatSummariesBySessionId.test.ts +++ b/lib/supabase/chats/__tests__/getChatSummaries.test.ts @@ -11,9 +11,7 @@ vi.mock("@/lib/supabase/chat_reads/selectChatReads", () => ({ const { selectChats } = await import("@/lib/supabase/chats/selectChats"); const { selectChatReads } = await import("@/lib/supabase/chat_reads/selectChatReads"); -const { getChatSummariesBySessionId } = await import( - "@/lib/supabase/chats/getChatSummariesBySessionId" -); +const { getChatSummaries } = await import("@/lib/supabase/chats/getChatSummaries"); const accountId = "acc-uuid-1"; @@ -21,7 +19,7 @@ function chatRow(overrides: Partial>): Tables<"chats"> { return baseChatRow({ session_id: "sess_1", ...overrides }); } -describe("getChatSummariesBySessionId", () => { +describe("getChatSummaries", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -29,7 +27,7 @@ describe("getChatSummariesBySessionId", () => { it("returns [] without hitting chat_reads when the session has no chats", async () => { vi.mocked(selectChats).mockResolvedValue([]); - const result = await getChatSummariesBySessionId({ + const result = await getChatSummaries({ sessionId: "sess_1", accountId, }); @@ -53,7 +51,7 @@ describe("getChatSummariesBySessionId", () => { ]); vi.mocked(selectChatReads).mockResolvedValue([]); - const result = await getChatSummariesBySessionId({ + const result = await getChatSummaries({ sessionId: "sess_1", accountId, }); @@ -111,7 +109,7 @@ describe("getChatSummariesBySessionId", () => { }, ]); - const result = await getChatSummariesBySessionId({ + const result = await getChatSummaries({ sessionId: "sess_1", accountId, }); @@ -134,7 +132,7 @@ describe("getChatSummariesBySessionId", () => { ]); vi.mocked(selectChatReads).mockResolvedValue([]); - const result = await getChatSummariesBySessionId({ + const result = await getChatSummaries({ sessionId: "sess_1", accountId, }); diff --git a/lib/supabase/chats/getChatSummariesBySessionId.ts b/lib/supabase/chats/getChatSummaries.ts similarity index 80% rename from lib/supabase/chats/getChatSummariesBySessionId.ts rename to lib/supabase/chats/getChatSummaries.ts index b13b4abb6..bddd1963a 100644 --- a/lib/supabase/chats/getChatSummariesBySessionId.ts +++ b/lib/supabase/chats/getChatSummaries.ts @@ -15,18 +15,14 @@ export interface ChatSummary { } /** - * Returns every chat in the given session as a camelCase `ChatSummary` - * for the open-agents `/api/sessions/[sessionId]/chats` wire format. - * Per-account `hasUnread` is derived from the caller's `chat_reads` - * row (if any); `isStreaming` is derived from `active_stream_id`. - * - * Mirrors open-agents' `getChatSummariesBySessionId` so the existing - * frontend can cut over without code changes. + * Returns chats in the given session as camelCase `ChatSummary` rows. + * `hasUnread` is derived from the caller's `chat_reads` row (if any); + * `isStreaming` is derived from `active_stream_id`. * * @param params - The session id to list and the account id to scope reads to. * @returns Chat summaries sorted by `createdAt` ascending. */ -export async function getChatSummariesBySessionId({ +export async function getChatSummaries({ sessionId, accountId, }: { From 2c2bcab511f6d17a891c0771934efa45551b1ed8 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 05:38:42 +0530 Subject: [PATCH 8/9] refactor(sessions): collocate session-chat libs under lib/sessions/chats/ - Move handlers, validators, and getChatSummaries into lib/sessions/chats/ - Drop the redundant lib/supabase/chats/getChatSummaries test (it's domain code, not a thin db reader) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/sessions/[sessionId]/chats/route.ts | 4 +- .../createSessionChatHandler.test.ts | 6 +- .../__tests__/getSessionChatsHandler.test.ts | 10 +- .../validateCreateSessionChatRequest.test.ts | 2 +- .../validateGetSessionChatsRequest.test.ts | 2 +- .../{ => chats}/createSessionChatHandler.ts | 2 +- .../chats/getChatSummaries.ts | 0 .../{ => chats}/getSessionChatsHandler.ts | 4 +- .../validateCreateSessionChatRequest.ts | 0 .../validateGetSessionChatsRequest.ts | 0 .../chats/__tests__/getChatSummaries.test.ts | 142 ------------------ 11 files changed, 15 insertions(+), 157 deletions(-) rename lib/sessions/{ => chats}/__tests__/createSessionChatHandler.test.ts (96%) rename lib/sessions/{ => chats}/__tests__/getSessionChatsHandler.test.ts (89%) rename lib/sessions/{ => chats}/__tests__/validateCreateSessionChatRequest.test.ts (98%) rename lib/sessions/{ => chats}/__tests__/validateGetSessionChatsRequest.test.ts (98%) rename lib/sessions/{ => chats}/createSessionChatHandler.ts (98%) rename lib/{supabase => sessions}/chats/getChatSummaries.ts (100%) rename lib/sessions/{ => chats}/getSessionChatsHandler.ts (86%) rename lib/sessions/{ => chats}/validateCreateSessionChatRequest.ts (100%) rename lib/sessions/{ => chats}/validateGetSessionChatsRequest.ts (100%) delete mode 100644 lib/supabase/chats/__tests__/getChatSummaries.test.ts diff --git a/app/api/sessions/[sessionId]/chats/route.ts b/app/api/sessions/[sessionId]/chats/route.ts index 67341e557..24b965f52 100644 --- a/app/api/sessions/[sessionId]/chats/route.ts +++ b/app/api/sessions/[sessionId]/chats/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getSessionChatsHandler } from "@/lib/sessions/getSessionChatsHandler"; -import { createSessionChatHandler } from "@/lib/sessions/createSessionChatHandler"; +import { getSessionChatsHandler } from "@/lib/sessions/chats/getSessionChatsHandler"; +import { createSessionChatHandler } from "@/lib/sessions/chats/createSessionChatHandler"; /** * OPTIONS handler for CORS preflight requests. diff --git a/lib/sessions/__tests__/createSessionChatHandler.test.ts b/lib/sessions/chats/__tests__/createSessionChatHandler.test.ts similarity index 96% rename from lib/sessions/__tests__/createSessionChatHandler.test.ts rename to lib/sessions/chats/__tests__/createSessionChatHandler.test.ts index b2339d54c..efc6797cd 100644 --- a/lib/sessions/__tests__/createSessionChatHandler.test.ts +++ b/lib/sessions/chats/__tests__/createSessionChatHandler.test.ts @@ -6,7 +6,7 @@ import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), })); -vi.mock("@/lib/sessions/validateCreateSessionChatRequest", () => ({ +vi.mock("@/lib/sessions/chats/validateCreateSessionChatRequest", () => ({ validateCreateSessionChatRequest: vi.fn(), })); vi.mock("@/lib/supabase/chats/selectChats", () => ({ @@ -20,11 +20,11 @@ vi.mock("@/lib/uuid/generateUUID", () => ({ })); const { validateCreateSessionChatRequest } = await import( - "@/lib/sessions/validateCreateSessionChatRequest" + "@/lib/sessions/chats/validateCreateSessionChatRequest" ); const { selectChats } = await import("@/lib/supabase/chats/selectChats"); const { insertChat } = await import("@/lib/supabase/chats/insertChat"); -const { createSessionChatHandler } = await import("@/lib/sessions/createSessionChatHandler"); +const { createSessionChatHandler } = await import("@/lib/sessions/chats/createSessionChatHandler"); const accountId = "acc-uuid-1"; diff --git a/lib/sessions/__tests__/getSessionChatsHandler.test.ts b/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts similarity index 89% rename from lib/sessions/__tests__/getSessionChatsHandler.test.ts rename to lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts index 2b4637947..4cbd040e4 100644 --- a/lib/sessions/__tests__/getSessionChatsHandler.test.ts +++ b/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts @@ -5,18 +5,18 @@ import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), })); -vi.mock("@/lib/sessions/validateGetSessionChatsRequest", () => ({ +vi.mock("@/lib/sessions/chats/validateGetSessionChatsRequest", () => ({ validateGetSessionChatsRequest: vi.fn(), })); -vi.mock("@/lib/supabase/chats/getChatSummaries", () => ({ +vi.mock("@/lib/sessions/chats/getChatSummaries", () => ({ getChatSummaries: vi.fn(), })); const { validateGetSessionChatsRequest } = await import( - "@/lib/sessions/validateGetSessionChatsRequest" + "@/lib/sessions/chats/validateGetSessionChatsRequest" ); -const { getChatSummaries } = await import("@/lib/supabase/chats/getChatSummaries"); -const { getSessionChatsHandler } = await import("@/lib/sessions/getSessionChatsHandler"); +const { getChatSummaries } = await import("@/lib/sessions/chats/getChatSummaries"); +const { getSessionChatsHandler } = await import("@/lib/sessions/chats/getSessionChatsHandler"); const accountId = "acc-uuid-1"; diff --git a/lib/sessions/__tests__/validateCreateSessionChatRequest.test.ts b/lib/sessions/chats/__tests__/validateCreateSessionChatRequest.test.ts similarity index 98% rename from lib/sessions/__tests__/validateCreateSessionChatRequest.test.ts rename to lib/sessions/chats/__tests__/validateCreateSessionChatRequest.test.ts index 71600f619..8f77e349b 100644 --- a/lib/sessions/__tests__/validateCreateSessionChatRequest.test.ts +++ b/lib/sessions/chats/__tests__/validateCreateSessionChatRequest.test.ts @@ -15,7 +15,7 @@ vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); const { validateCreateSessionChatRequest } = await import( - "@/lib/sessions/validateCreateSessionChatRequest" + "@/lib/sessions/chats/validateCreateSessionChatRequest" ); const accountId = "acc-uuid-1"; diff --git a/lib/sessions/__tests__/validateGetSessionChatsRequest.test.ts b/lib/sessions/chats/__tests__/validateGetSessionChatsRequest.test.ts similarity index 98% rename from lib/sessions/__tests__/validateGetSessionChatsRequest.test.ts rename to lib/sessions/chats/__tests__/validateGetSessionChatsRequest.test.ts index 9d6bfe1a6..b316c54a8 100644 --- a/lib/sessions/__tests__/validateGetSessionChatsRequest.test.ts +++ b/lib/sessions/chats/__tests__/validateGetSessionChatsRequest.test.ts @@ -15,7 +15,7 @@ vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); const { validateGetSessionChatsRequest } = await import( - "@/lib/sessions/validateGetSessionChatsRequest" + "@/lib/sessions/chats/validateGetSessionChatsRequest" ); const accountId = "acc-uuid-1"; diff --git a/lib/sessions/createSessionChatHandler.ts b/lib/sessions/chats/createSessionChatHandler.ts similarity index 98% rename from lib/sessions/createSessionChatHandler.ts rename to lib/sessions/chats/createSessionChatHandler.ts index 0a484dc47..868691246 100644 --- a/lib/sessions/createSessionChatHandler.ts +++ b/lib/sessions/chats/createSessionChatHandler.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { generateUUID } from "@/lib/uuid/generateUUID"; -import { validateCreateSessionChatRequest } from "@/lib/sessions/validateCreateSessionChatRequest"; +import { validateCreateSessionChatRequest } from "@/lib/sessions/chats/validateCreateSessionChatRequest"; import { selectChats } from "@/lib/supabase/chats/selectChats"; import { insertChat } from "@/lib/supabase/chats/insertChat"; import { toChatResponse } from "@/lib/sessions/toChatResponse"; diff --git a/lib/supabase/chats/getChatSummaries.ts b/lib/sessions/chats/getChatSummaries.ts similarity index 100% rename from lib/supabase/chats/getChatSummaries.ts rename to lib/sessions/chats/getChatSummaries.ts diff --git a/lib/sessions/getSessionChatsHandler.ts b/lib/sessions/chats/getSessionChatsHandler.ts similarity index 86% rename from lib/sessions/getSessionChatsHandler.ts rename to lib/sessions/chats/getSessionChatsHandler.ts index 476ded9e8..9cdee366c 100644 --- a/lib/sessions/getSessionChatsHandler.ts +++ b/lib/sessions/chats/getSessionChatsHandler.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { APP_DEFAULT_MODEL_ID } from "@/lib/const"; -import { validateGetSessionChatsRequest } from "@/lib/sessions/validateGetSessionChatsRequest"; -import { getChatSummaries } from "@/lib/supabase/chats/getChatSummaries"; +import { validateGetSessionChatsRequest } from "@/lib/sessions/chats/validateGetSessionChatsRequest"; +import { getChatSummaries } from "@/lib/sessions/chats/getChatSummaries"; /** * Handles `GET /api/sessions/{sessionId}/chats`. Lists chats in the diff --git a/lib/sessions/validateCreateSessionChatRequest.ts b/lib/sessions/chats/validateCreateSessionChatRequest.ts similarity index 100% rename from lib/sessions/validateCreateSessionChatRequest.ts rename to lib/sessions/chats/validateCreateSessionChatRequest.ts diff --git a/lib/sessions/validateGetSessionChatsRequest.ts b/lib/sessions/chats/validateGetSessionChatsRequest.ts similarity index 100% rename from lib/sessions/validateGetSessionChatsRequest.ts rename to lib/sessions/chats/validateGetSessionChatsRequest.ts diff --git a/lib/supabase/chats/__tests__/getChatSummaries.test.ts b/lib/supabase/chats/__tests__/getChatSummaries.test.ts deleted file mode 100644 index 921e0f5ad..000000000 --- a/lib/supabase/chats/__tests__/getChatSummaries.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Tables } from "@/types/database.types"; -import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; - -vi.mock("@/lib/supabase/chats/selectChats", () => ({ - selectChats: vi.fn(), -})); -vi.mock("@/lib/supabase/chat_reads/selectChatReads", () => ({ - selectChatReads: vi.fn(), -})); - -const { selectChats } = await import("@/lib/supabase/chats/selectChats"); -const { selectChatReads } = await import("@/lib/supabase/chat_reads/selectChatReads"); -const { getChatSummaries } = await import("@/lib/supabase/chats/getChatSummaries"); - -const accountId = "acc-uuid-1"; - -function chatRow(overrides: Partial>): Tables<"chats"> { - return baseChatRow({ session_id: "sess_1", ...overrides }); -} - -describe("getChatSummaries", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns [] without hitting chat_reads when the session has no chats", async () => { - vi.mocked(selectChats).mockResolvedValue([]); - - const result = await getChatSummaries({ - sessionId: "sess_1", - accountId, - }); - - expect(result).toEqual([]); - expect(selectChatReads).not.toHaveBeenCalled(); - }); - - it("sorts summaries by createdAt ascending and uses APP_DEFAULT shape", async () => { - vi.mocked(selectChats).mockResolvedValue([ - chatRow({ - id: "chat_late", - title: "Late", - created_at: "2026-05-04T00:00:00.000Z", - }), - chatRow({ - id: "chat_early", - title: "Early", - created_at: "2026-05-01T00:00:00.000Z", - }), - ]); - vi.mocked(selectChatReads).mockResolvedValue([]); - - const result = await getChatSummaries({ - sessionId: "sess_1", - accountId, - }); - - expect(result.map(c => c.id)).toEqual(["chat_early", "chat_late"]); - expect(result[0]).toMatchObject({ - sessionId: "sess_1", - title: "Early", - hasUnread: false, - isStreaming: false, - }); - expect(selectChatReads).toHaveBeenCalledWith({ - accountId, - chatIds: ["chat_late", "chat_early"], - }); - }); - - it("derives hasUnread from last_assistant_message_at vs last_read_at", async () => { - vi.mocked(selectChats).mockResolvedValue([ - chatRow({ - id: "chat_unread", - last_assistant_message_at: "2026-05-04T10:00:00.000Z", - active_stream_id: null, - }), - chatRow({ - id: "chat_read", - last_assistant_message_at: "2026-05-03T10:00:00.000Z", - active_stream_id: null, - }), - chatRow({ - id: "chat_never_assistant", - last_assistant_message_at: null, - active_stream_id: null, - }), - chatRow({ - id: "chat_streaming", - last_assistant_message_at: null, - active_stream_id: "stream_xyz", - }), - ]); - vi.mocked(selectChatReads).mockResolvedValue([ - { - account_id: accountId, - chat_id: "chat_unread", - last_read_at: "2026-05-04T09:00:00.000Z", - created_at: "2026-05-01T00:00:00.000Z", - updated_at: "2026-05-04T09:00:00.000Z", - }, - { - account_id: accountId, - chat_id: "chat_read", - last_read_at: "2026-05-04T00:00:00.000Z", - created_at: "2026-05-01T00:00:00.000Z", - updated_at: "2026-05-04T00:00:00.000Z", - }, - ]); - - const result = await getChatSummaries({ - sessionId: "sess_1", - accountId, - }); - - const byId = new Map(result.map(c => [c.id, c])); - expect(byId.get("chat_unread")?.hasUnread).toBe(true); - expect(byId.get("chat_read")?.hasUnread).toBe(false); - expect(byId.get("chat_never_assistant")?.hasUnread).toBe(false); - expect(byId.get("chat_streaming")?.hasUnread).toBe(false); - expect(byId.get("chat_streaming")?.isStreaming).toBe(true); - expect(byId.get("chat_unread")?.isStreaming).toBe(false); - }); - - it("treats a missing chat_reads row as fully unread when assistant has replied", async () => { - vi.mocked(selectChats).mockResolvedValue([ - chatRow({ - id: "chat_no_read", - last_assistant_message_at: "2026-05-04T10:00:00.000Z", - }), - ]); - vi.mocked(selectChatReads).mockResolvedValue([]); - - const result = await getChatSummaries({ - sessionId: "sess_1", - accountId, - }); - - expect(result[0].hasUnread).toBe(true); - }); -}); From 2ef66e441d0d4f671f469919ccb07c87dab746ff Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 22:05:16 +0530 Subject: [PATCH 9/9] refactor(sessions): use DEFAULT_MODEL for the client-facing default Drops the parallel APP_DEFAULT_MODEL_ID constant. The handler's defaultModelId hint should match the model the server actually falls back to when a request omits one (DEFAULT_MODEL, openai/gpt-5-mini) so the UI's pre-selected default lines up with execution behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/const.ts | 2 -- lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts | 4 ++-- lib/sessions/chats/getSessionChatsHandler.ts | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/const.ts b/lib/const.ts index 7d4f1fda8..2362805ef 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -11,8 +11,6 @@ export const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${p export const IMAGE_GENERATE_PRICE = "0.15"; export const DEFAULT_MODEL = "openai/gpt-5-mini"; export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini"; -/** Default model id surfaced to clients that have no explicit preference. */ -export const APP_DEFAULT_MODEL_ID = "openai/gpt-5.4"; export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; /** Domain for receiving inbound emails (e.g., support@mail.recoupable.com) */ export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com"; diff --git a/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts b/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts index 4cbd040e4..9cc00e9e3 100644 --- a/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts +++ b/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts @@ -45,7 +45,7 @@ describe("getSessionChatsHandler", () => { expect(getChatSummaries).not.toHaveBeenCalled(); }); - it("returns 200 with summaries from the db and APP_DEFAULT_MODEL_ID", async () => { + it("returns 200 with summaries from the db and DEFAULT_MODEL", async () => { mockValidated(); const summaries = [ { @@ -70,7 +70,7 @@ describe("getSessionChatsHandler", () => { defaultModelId: string; }; expect(body.chats).toEqual(summaries); - expect(body.defaultModelId).toBe("openai/gpt-5.4"); + expect(body.defaultModelId).toBe("openai/gpt-5-mini"); expect(getChatSummaries).toHaveBeenCalledWith({ sessionId: "sess_1", accountId, diff --git a/lib/sessions/chats/getSessionChatsHandler.ts b/lib/sessions/chats/getSessionChatsHandler.ts index 9cdee366c..f291be55a 100644 --- a/lib/sessions/chats/getSessionChatsHandler.ts +++ b/lib/sessions/chats/getSessionChatsHandler.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { APP_DEFAULT_MODEL_ID } from "@/lib/const"; +import { DEFAULT_MODEL } from "@/lib/const"; import { validateGetSessionChatsRequest } from "@/lib/sessions/chats/validateGetSessionChatsRequest"; import { getChatSummaries } from "@/lib/sessions/chats/getChatSummaries"; @@ -28,7 +28,7 @@ export async function getSessionChatsHandler( }); return NextResponse.json( - { chats, defaultModelId: APP_DEFAULT_MODEL_ID }, + { chats, defaultModelId: DEFAULT_MODEL }, { status: 200, headers: getCorsHeaders() }, ); }