From a7ea90a14ef89247aefc400f64ab4136457c5c52 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 11 May 2026 02:05:58 +0700 Subject: [PATCH 1/7] feat(api): add PATCH handler for updating session details - Implemented a new PATCH endpoint for updating a session's title or status, allowing optional fields to be modified. - Authenticates requests using a Privy Bearer token or x-api-key header. - Added comprehensive tests for the PATCH handler, covering scenarios such as unauthorized access, session not found, and successful updates. This enhancement improves the API's functionality by enabling users to modify session details dynamically. --- .../[sessionId]/__tests__/route.test.ts | 115 +++++++++++++++++- app/api/sessions/[sessionId]/route.ts | 21 ++++ lib/sessions/patchSessionByIdHandler.ts | 65 ++++++++++ lib/sessions/validatePatchSessionBody.ts | 58 +++++++++ 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 lib/sessions/patchSessionByIdHandler.ts create mode 100644 lib/sessions/validatePatchSessionBody.ts diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 685a4a205..997737c3b 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { GET, OPTIONS } from "../route"; +import { GET, PATCH, OPTIONS } from "../route"; import type { Tables } from "@/types/database.types"; type SessionRow = Tables<"sessions">; @@ -9,6 +9,10 @@ vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ selectSessions: vi.fn(), })); +vi.mock("@/lib/supabase/sessions/updateSession", () => ({ + updateSession: vi.fn(), +})); + vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); @@ -18,12 +22,24 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ })); const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { updateSession } = await import("@/lib/supabase/sessions/updateSession"); const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); function makeReq(url = "https://example.com/api/sessions/sess_1"): NextRequest { return new NextRequest(url); } +function makePatchReq( + body: Record, + url = "https://example.com/api/sessions/sess_1", +): NextRequest { + return new NextRequest(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + const mockRow: SessionRow = { id: "sess_1", account_id: "acc-uuid-1", @@ -161,3 +177,100 @@ describe("GET /api/sessions/[sessionId]", () => { }); }); }); + +describe("PATCH /api/sessions/[sessionId]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const res = await PATCH(makePatchReq({ title: "New title" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(401); + expect(selectSessions).not.toHaveBeenCalled(); + }); + + it("returns 404 when session does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await PATCH(makePatchReq({ title: "New title" }), { + params: Promise.resolve({ sessionId: "sess_missing" }), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Session not found", + }); + }); + + it("returns 403 when session is owned by a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-OTHER", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + + const res = await PATCH(makePatchReq({ title: "New title" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + status: "error", + error: "Forbidden", + }); + }); + + it("returns 400 when status value is invalid", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + + const res = await PATCH(makePatchReq({ status: "completed" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.status).toBe("error"); + }); + + it("returns 200 with updated session on happy path", async () => { + const updatedRow: SessionRow = { + ...mockRow, + title: "Renamed session", + status: "archived", + }; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + vi.mocked(updateSession).mockResolvedValue(updatedRow); + + const res = await PATCH( + makePatchReq({ title: "Renamed session", status: "archived" }), + { params: Promise.resolve({ sessionId: "sess_1" }) }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.session.title).toBe("Renamed session"); + expect(body.session.status).toBe("archived"); + expect(updateSession).toHaveBeenCalledWith("sess_1", { + title: "Renamed session", + status: "archived", + }); + }); +}); diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts index dbc04994a..8560780aa 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getSessionByIdHandler } from "@/lib/sessions/getSessionByIdHandler"; +import { patchSessionByIdHandler } from "@/lib/sessions/patchSessionByIdHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -37,6 +38,26 @@ export async function GET( return getSessionByIdHandler(request, sessionId); } +/** + * PATCH /api/sessions/{sessionId} + * + * Updates a session's title (rename) or status (archive/unarchive). + * All fields are optional; omitted fields are left unchanged. + * Authenticates via Privy Bearer token or x-api-key header. + * + * @param request - The request object + * @param options - Route options containing the async params + * @param options.params - Route params containing the session id + * @returns A NextResponse with `{ session }` on 200, or an error. + */ +export async function PATCH( + request: NextRequest, + options: { params: Promise<{ sessionId: string }> }, +) { + const { sessionId } = await options.params; + return patchSessionByIdHandler(request, sessionId); +} + export const dynamic = "force-dynamic"; export const fetchCache = "force-no-store"; export const revalidate = 0; diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts new file mode 100644 index 000000000..7837e4c94 --- /dev/null +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validatePatchSessionBody } from "@/lib/sessions/validatePatchSessionBody"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; + +/** + * Handles PATCH /api/sessions/{sessionId}. + * + * Updates a session's `title` (rename) or `status` (archive/unarchive). + * All fields are optional; omitted fields are left unchanged. + * Authenticates via Privy Bearer token or x-api-key header. + * Returns 404 if the session does not exist and 403 if it exists but + * is not owned by the authenticated account. + * + * @param request - The incoming request. + * @param sessionId - The id of the session to update. + * @returns A NextResponse with `{ session }` on 200, or an error. + */ +export async function patchSessionByIdHandler( + request: NextRequest, + sessionId: string, +): Promise { + const validated = await validatePatchSessionBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { body, auth } = validated; + + const rows = await selectSessions({ id: sessionId }); + const row = rows[0] ?? null; + + if (!row) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (row.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + const updated = await updateSession(sessionId, { + ...(body.title !== undefined && { title: body.title }), + ...(body.status !== undefined && { status: body.status }), + }); + + if (!updated) { + return NextResponse.json( + { status: "error", error: "Failed to update session" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { session: toSessionResponse(updated) }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/validatePatchSessionBody.ts b/lib/sessions/validatePatchSessionBody.ts new file mode 100644 index 000000000..d2200a123 --- /dev/null +++ b/lib/sessions/validatePatchSessionBody.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; + +export const patchSessionBodySchema = z.object({ + title: z.string().optional(), + status: z.enum(["running", "archived"]).optional(), +}); + +export type PatchSessionBody = z.infer; + +export interface ValidatedPatchSessionRequest { + body: PatchSessionBody; + auth: AuthContext; +} + +/** + * Validates a `PATCH /api/sessions/{sessionId}` request end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Parses the JSON body (treating malformed JSON as an empty body) + * 3. Validates the body against the Zod schema + * + * Returns either a 4xx NextResponse describing the first failure, or + * the validated `{ body, auth }` ready for the handler to consume. + * + * @param request - The incoming request. + * @returns A NextResponse on validation failure, or the validated body + auth. + */ +export async function validatePatchSessionBody( + request: NextRequest, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rawBody = await safeParseJson(request); + const result = patchSessionBodySchema.safeParse(rawBody); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return { body: result.data, auth }; +} From 3670385b9103266aef6bd128cfaaf14a2be1a8e5 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 11 May 2026 02:23:31 +0700 Subject: [PATCH 2/7] feat(tests): add error handling tests for session API endpoints - Implemented tests for the GET and PATCH session API endpoints to handle scenarios where the database returns an error, ensuring a 500 status response with appropriate error messages. - Updated the `selectSessions` function to return `null` on database errors, allowing for better error handling in the API responses. - Enhanced existing tests to verify that the API correctly responds to internal server errors, improving overall robustness and reliability of the session management functionality. --- .../[sessionId]/__tests__/route.test.ts | 43 +++++++++++++++++-- app/workflows/clearLifecycleRunIdIfOwned.ts | 2 +- app/workflows/computeLifecycleWakeDecision.ts | 2 +- lib/sandbox/createSandboxHandler.ts | 2 +- lib/sandbox/evaluateSandboxLifecycle.ts | 4 +- lib/sandbox/getSandboxStatusHandler.ts | 2 +- lib/sandbox/reclaimStaleLease.ts | 2 +- lib/sandbox/runKick.ts | 2 +- .../validateSandboxReconnectRequest.ts | 2 +- lib/sandbox/wasLifecycleTimingExtended.ts | 2 +- lib/sessions/getSessionByIdHandler.ts | 8 ++++ lib/sessions/patchSessionByIdHandler.ts | 10 ++++- lib/sessions/resolveSessionTitle.ts | 2 +- lib/supabase/sessions/selectSessions.ts | 16 ++++--- 14 files changed, 77 insertions(+), 22 deletions(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 997737c3b..8b696881f 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -94,6 +94,24 @@ describe("GET /api/sessions/[sessionId]", () => { expect(selectSessions).not.toHaveBeenCalled(); }); + it("returns 500 when the database returns an error", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue(null); + + const res = await GET(makeReq(), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + status: "error", + error: "Internal server error", + }); + }); + it("returns 404 when session does not exist", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc-uuid-1", @@ -195,6 +213,24 @@ describe("PATCH /api/sessions/[sessionId]", () => { expect(selectSessions).not.toHaveBeenCalled(); }); + it("returns 500 when the database returns an error", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue(null); + + const res = await PATCH(makePatchReq({ title: "New title" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + status: "error", + error: "Internal server error", + }); + }); + it("returns 404 when session does not exist", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc-uuid-1", @@ -260,10 +296,9 @@ describe("PATCH /api/sessions/[sessionId]", () => { vi.mocked(selectSessions).mockResolvedValue([mockRow]); vi.mocked(updateSession).mockResolvedValue(updatedRow); - const res = await PATCH( - makePatchReq({ title: "Renamed session", status: "archived" }), - { params: Promise.resolve({ sessionId: "sess_1" }) }, - ); + const res = await PATCH(makePatchReq({ title: "Renamed session", status: "archived" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); expect(res.status).toBe(200); const body = await res.json(); expect(body.session.title).toBe("Renamed session"); diff --git a/app/workflows/clearLifecycleRunIdIfOwned.ts b/app/workflows/clearLifecycleRunIdIfOwned.ts index df6f55228..c862a134b 100644 --- a/app/workflows/clearLifecycleRunIdIfOwned.ts +++ b/app/workflows/clearLifecycleRunIdIfOwned.ts @@ -14,7 +14,7 @@ import { updateSession } from "@/lib/supabase/sessions/updateSession"; export async function clearLifecycleRunIdIfOwned(sessionId: string, runId: string): Promise { "use step"; - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; const session = rows[0]; if (!session || session.lifecycle_run_id !== runId) return; diff --git a/app/workflows/computeLifecycleWakeDecision.ts b/app/workflows/computeLifecycleWakeDecision.ts index 9b084a770..ea10fc53c 100644 --- a/app/workflows/computeLifecycleWakeDecision.ts +++ b/app/workflows/computeLifecycleWakeDecision.ts @@ -26,7 +26,7 @@ export async function computeLifecycleWakeDecision( ): Promise { "use step"; - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; const session = rows[0]; if (!session) return { shouldContinue: false, reason: "session-not-found" }; if (session.status === "archived" || session.lifecycle_state === "archived") { diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index 6d391000c..a4f5237b5 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -44,7 +44,7 @@ export async function createSandboxHandler(request: NextRequest): Promise | null = null; if (sessionId) { - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; sessionRow = rows[0] ?? null; if (!sessionRow) { diff --git a/lib/sandbox/evaluateSandboxLifecycle.ts b/lib/sandbox/evaluateSandboxLifecycle.ts index cc8e9cb5b..0c182901b 100644 --- a/lib/sandbox/evaluateSandboxLifecycle.ts +++ b/lib/sandbox/evaluateSandboxLifecycle.ts @@ -35,7 +35,7 @@ export async function evaluateSandboxLifecycle( sessionId: string, reason: SandboxLifecycleReason, ): Promise { - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; const session = rows[0]; if (!session) return { action: "skipped", reason: "session-not-found" }; @@ -70,7 +70,7 @@ export async function evaluateSandboxLifecycle( } if (await wasLifecycleTimingExtended(sessionId, session)) { - const refreshed = (await selectSessions({ id: sessionId }))[0]; + const refreshed = ((await selectSessions({ id: sessionId })) ?? [])[0]; if (refreshed?.sandbox_state) { await restoreActiveLifecycleState(sessionId, refreshed.sandbox_state); } diff --git a/lib/sandbox/getSandboxStatusHandler.ts b/lib/sandbox/getSandboxStatusHandler.ts index 7423cdf9f..3aff48954 100644 --- a/lib/sandbox/getSandboxStatusHandler.ts +++ b/lib/sandbox/getSandboxStatusHandler.ts @@ -38,7 +38,7 @@ export async function getSandboxStatusHandler(request: NextRequest): Promise | null> { await updateSession(sessionId, { lifecycle_run_id: null }); - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; return rows[0] ?? null; } diff --git a/lib/sandbox/runKick.ts b/lib/sandbox/runKick.ts index f5e1fa918..512223468 100644 --- a/lib/sandbox/runKick.ts +++ b/lib/sandbox/runKick.ts @@ -30,7 +30,7 @@ interface RunKickInput { * design. */ export async function runKick(input: RunKickInput): Promise { - const rows = await selectSessions({ id: input.sessionId }); + const rows = (await selectSessions({ id: input.sessionId })) ?? []; const session = rows[0]; if (!session) return; diff --git a/lib/sandbox/validateSandboxReconnectRequest.ts b/lib/sandbox/validateSandboxReconnectRequest.ts index 3d8d3cb79..e648c9a76 100644 --- a/lib/sandbox/validateSandboxReconnectRequest.ts +++ b/lib/sandbox/validateSandboxReconnectRequest.ts @@ -39,7 +39,7 @@ export async function validateSandboxReconnectRequest( ); } - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; const row = rows[0]; if (!row) { diff --git a/lib/sandbox/wasLifecycleTimingExtended.ts b/lib/sandbox/wasLifecycleTimingExtended.ts index c3980a1df..d938d35a3 100644 --- a/lib/sandbox/wasLifecycleTimingExtended.ts +++ b/lib/sandbox/wasLifecycleTimingExtended.ts @@ -18,7 +18,7 @@ export async function wasLifecycleTimingExtended( sessionId: string, prior: Tables<"sessions">, ): Promise { - const refreshed = (await selectSessions({ id: sessionId }))[0]; + const refreshed = ((await selectSessions({ id: sessionId })) ?? [])[0]; if (!refreshed?.sandbox_state || !hasRuntimeSandboxState(refreshed.sandbox_state)) return false; const timingChanged = diff --git a/lib/sessions/getSessionByIdHandler.ts b/lib/sessions/getSessionByIdHandler.ts index 11a22f5a7..6fe515739 100644 --- a/lib/sessions/getSessionByIdHandler.ts +++ b/lib/sessions/getSessionByIdHandler.ts @@ -28,6 +28,14 @@ export async function getSessionByIdHandler( } const rows = await selectSessions({ id: sessionId }); + + if (rows === null) { + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + const row = rows[0] ?? null; if (!row) { diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index 7837e4c94..fd73e4402 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -30,6 +30,14 @@ export async function patchSessionByIdHandler( const { body, auth } = validated; const rows = await selectSessions({ id: sessionId }); + + if (rows === null) { + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + const row = rows[0] ?? null; if (!row) { @@ -53,7 +61,7 @@ export async function patchSessionByIdHandler( if (!updated) { return NextResponse.json( - { status: "error", error: "Failed to update session" }, + { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, ); } diff --git a/lib/sessions/resolveSessionTitle.ts b/lib/sessions/resolveSessionTitle.ts index 1e2270b40..4421c608e 100644 --- a/lib/sessions/resolveSessionTitle.ts +++ b/lib/sessions/resolveSessionTitle.ts @@ -24,7 +24,7 @@ export async function resolveSessionTitle(input: ResolveSessionTitleInput): Prom return trimmed; } - const rows = await selectSessions({ accountId: input.accountId }); + const rows = (await selectSessions({ accountId: input.accountId })) ?? []; const usedTitles = rows.map(row => row.title); return getRandomCityName(new Set(usedTitles)); } diff --git a/lib/supabase/sessions/selectSessions.ts b/lib/supabase/sessions/selectSessions.ts index 69477b9cb..228e39b78 100644 --- a/lib/supabase/sessions/selectSessions.ts +++ b/lib/supabase/sessions/selectSessions.ts @@ -10,19 +10,23 @@ interface SelectSessionsFilter { /** * General-purpose `sessions` reader. Pass any combination of filters - * to narrow the result set; an unset filter is ignored. Returns an - * empty array on Supabase error after logging. + * to narrow the result set; an unset filter is ignored. + * + * Returns `null` on Supabase error (DB unreachable / query failure) so + * callers can distinguish a transient backend failure from a legitimately + * empty result set. Returns `[]` when the query succeeds but matches no + * rows. * * Callers project to whatever shape they need (single row by id, * titles by account, etc.) — keeping this single function as the * sole entry point keeps `lib/supabase/sessions/` DRY. * * @param filter - Optional filters narrowing the query. - * @returns Matching rows, or `[]` on error / no match. + * @returns Matching rows, `[]` on no match, or `null` on DB error. */ export async function selectSessions( filter: SelectSessionsFilter = {}, -): Promise[]> { +): Promise[] | null> { let query = supabase.from("sessions").select("*"); if (filter.id) query = query.eq("id", filter.id); if (filter.accountId) query = query.eq("account_id", filter.accountId); @@ -31,11 +35,11 @@ export async function selectSessions( const { data, error } = await query; if (error) { console.error("[selectSessions] error:", error); - return []; + return null; } return data ?? []; } catch (e) { console.error("[selectSessions] threw:", e); - return []; + return null; } } From a1975b122327e2386765936fcf3f923a451753fe Mon Sep 17 00:00:00 2001 From: john Date: Mon, 11 May 2026 03:04:36 +0700 Subject: [PATCH 3/7] fix(workflows): improve error handling for session queries - Updated the `selectSessions` function calls in `clearLifecycleRunIdIfOwned`, `computeLifecycleWakeDecision`, and `evaluateSandboxLifecycle` to handle potential database errors more gracefully. - Added console error logging in `clearLifecycleRunIdIfOwned` for better debugging. - Enhanced return values in `computeLifecycleWakeDecision` and `evaluateSandboxLifecycle` to provide clearer failure reasons when session queries fail. These changes enhance the robustness of session management by ensuring that errors are properly logged and handled. --- app/workflows/clearLifecycleRunIdIfOwned.ts | 6 +++++- app/workflows/computeLifecycleWakeDecision.ts | 3 ++- lib/sandbox/evaluateSandboxLifecycle.ts | 7 +++++-- lib/sessions/validatePatchSessionBody.ts | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/workflows/clearLifecycleRunIdIfOwned.ts b/app/workflows/clearLifecycleRunIdIfOwned.ts index c862a134b..14677ee3f 100644 --- a/app/workflows/clearLifecycleRunIdIfOwned.ts +++ b/app/workflows/clearLifecycleRunIdIfOwned.ts @@ -14,7 +14,11 @@ import { updateSession } from "@/lib/supabase/sessions/updateSession"; export async function clearLifecycleRunIdIfOwned(sessionId: string, runId: string): Promise { "use step"; - const rows = (await selectSessions({ id: sessionId })) ?? []; + const rows = await selectSessions({ id: sessionId }); + if (!rows) { + console.error("[clearLifecycleRunIdIfOwned] DB error fetching session", sessionId); + return; + } const session = rows[0]; if (!session || session.lifecycle_run_id !== runId) return; diff --git a/app/workflows/computeLifecycleWakeDecision.ts b/app/workflows/computeLifecycleWakeDecision.ts index ea10fc53c..6fc177057 100644 --- a/app/workflows/computeLifecycleWakeDecision.ts +++ b/app/workflows/computeLifecycleWakeDecision.ts @@ -26,7 +26,8 @@ export async function computeLifecycleWakeDecision( ): Promise { "use step"; - const rows = (await selectSessions({ id: sessionId })) ?? []; + const rows = await selectSessions({ id: sessionId }); + if (!rows) return { shouldContinue: false, reason: "db-error" }; const session = rows[0]; if (!session) return { shouldContinue: false, reason: "session-not-found" }; if (session.status === "archived" || session.lifecycle_state === "archived") { diff --git a/lib/sandbox/evaluateSandboxLifecycle.ts b/lib/sandbox/evaluateSandboxLifecycle.ts index 0c182901b..3ea4c1ae2 100644 --- a/lib/sandbox/evaluateSandboxLifecycle.ts +++ b/lib/sandbox/evaluateSandboxLifecycle.ts @@ -35,7 +35,8 @@ export async function evaluateSandboxLifecycle( sessionId: string, reason: SandboxLifecycleReason, ): Promise { - const rows = (await selectSessions({ id: sessionId })) ?? []; + const rows = await selectSessions({ id: sessionId }); + if (!rows) return { action: "failed", reason: "session-query-failed" }; const session = rows[0]; if (!session) return { action: "skipped", reason: "session-not-found" }; @@ -70,7 +71,9 @@ export async function evaluateSandboxLifecycle( } if (await wasLifecycleTimingExtended(sessionId, session)) { - const refreshed = ((await selectSessions({ id: sessionId })) ?? [])[0]; + const refreshedRows = await selectSessions({ id: sessionId }); + if (!refreshedRows) throw new Error("Failed to refresh session during lifecycle extension check"); + const refreshed = refreshedRows[0]; if (refreshed?.sandbox_state) { await restoreActiveLifecycleState(sessionId, refreshed.sandbox_state); } diff --git a/lib/sessions/validatePatchSessionBody.ts b/lib/sessions/validatePatchSessionBody.ts index d2200a123..c6d235cb3 100644 --- a/lib/sessions/validatePatchSessionBody.ts +++ b/lib/sessions/validatePatchSessionBody.ts @@ -44,7 +44,7 @@ export async function validatePatchSessionBody( return NextResponse.json( { status: "error", - missing_fields: firstError.path, + path: firstError.path, error: firstError.message, }, { From e068734fb00196fa38e20009f0c4028e6a8b2ce7 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 11 May 2026 03:06:51 +0700 Subject: [PATCH 4/7] fix(sandbox): improve error handling in evaluateSandboxLifecycle - Enhanced error handling in the `evaluateSandboxLifecycle` function by ensuring that an error is thrown with a clear message when session refresh fails during lifecycle extension checks. This change improves the robustness of session management by providing better feedback in case of failures. --- lib/sandbox/evaluateSandboxLifecycle.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/sandbox/evaluateSandboxLifecycle.ts b/lib/sandbox/evaluateSandboxLifecycle.ts index 3ea4c1ae2..0c34e2bad 100644 --- a/lib/sandbox/evaluateSandboxLifecycle.ts +++ b/lib/sandbox/evaluateSandboxLifecycle.ts @@ -72,7 +72,8 @@ export async function evaluateSandboxLifecycle( if (await wasLifecycleTimingExtended(sessionId, session)) { const refreshedRows = await selectSessions({ id: sessionId }); - if (!refreshedRows) throw new Error("Failed to refresh session during lifecycle extension check"); + if (!refreshedRows) + throw new Error("Failed to refresh session during lifecycle extension check"); const refreshed = refreshedRows[0]; if (refreshed?.sandbox_state) { await restoreActiveLifecycleState(sessionId, refreshed.sandbox_state); From ad443007e518d4d3b93f1bd0d13dc887ce8d2e7d Mon Sep 17 00:00:00 2001 From: john Date: Thu, 14 May 2026 06:41:03 +0700 Subject: [PATCH 5/7] feat(api): enhance PATCH /api/sessions/{sessionId} to support additional status values and line counters - Updated the API documentation to reflect the new optional fields for line counters and expanded status options (running, completed, failed, archived). - Modified the patchSessionByIdHandler to handle updates for linesAdded and linesRemoved, returning a 200 status when no updates are provided. - Added tests to ensure correct handling of new status values and line counter mappings in the session update process. --- .../[sessionId]/__tests__/route.test.ts | 46 ++++++++++++++++++- app/api/sessions/[sessionId]/route.ts | 6 ++- lib/sessions/patchSessionByIdHandler.ts | 21 +++++++-- lib/sessions/validatePatchSessionBody.ts | 4 +- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 8b696881f..03b5c82a9 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -274,7 +274,7 @@ describe("PATCH /api/sessions/[sessionId]", () => { authToken: "tok", }); - const res = await PATCH(makePatchReq({ status: "completed" }), { + const res = await PATCH(makePatchReq({ status: "not-a-status" }), { params: Promise.resolve({ sessionId: "sess_1" }), }); expect(res.status).toBe(400); @@ -282,6 +282,23 @@ describe("PATCH /api/sessions/[sessionId]", () => { expect(body.status).toBe("error"); }); + it("returns 200 without calling updateSession when body has no updates", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + + const res = await PATCH(makePatchReq({}), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.session.id).toBe("sess_1"); + expect(updateSession).not.toHaveBeenCalled(); + }); + it("returns 200 with updated session on happy path", async () => { const updatedRow: SessionRow = { ...mockRow, @@ -308,4 +325,31 @@ describe("PATCH /api/sessions/[sessionId]", () => { status: "archived", }); }); + + it("accepts completed status and maps line counters to snake_case columns", async () => { + const updatedRow: SessionRow = { + ...mockRow, + status: "completed", + lines_added: 99, + lines_removed: 1, + }; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + vi.mocked(updateSession).mockResolvedValue(updatedRow); + + const res = await PATCH( + makePatchReq({ status: "completed", linesAdded: 99, linesRemoved: 1 }), + { params: Promise.resolve({ sessionId: "sess_1" }) }, + ); + expect(res.status).toBe(200); + expect(updateSession).toHaveBeenCalledWith("sess_1", { + status: "completed", + lines_added: 99, + lines_removed: 1, + }); + }); }); diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts index 8560780aa..40527e2ea 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -41,8 +41,10 @@ export async function GET( /** * PATCH /api/sessions/{sessionId} * - * Updates a session's title (rename) or status (archive/unarchive). - * All fields are optional; omitted fields are left unchanged. + * Updates a session's title, lifecycle `status` (including archive / + * unarchive), and optional line counters. `status` matches the merged + * docs: `running`, `completed`, `failed`, or `archived`. All body fields + * are optional; omitted fields are left unchanged. * Authenticates via Privy Bearer token or x-api-key header. * * @param request - The request object diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index fd73e4402..c93913667 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -8,8 +8,10 @@ import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; /** * Handles PATCH /api/sessions/{sessionId}. * - * Updates a session's `title` (rename) or `status` (archive/unarchive). - * All fields are optional; omitted fields are left unchanged. + * Updates a session's `title`, `status` (see DB CHECK / public docs: + * `running`, `completed`, `failed`, `archived`), and optional + * `linesAdded` / `linesRemoved` counters. All fields are optional; + * omitted fields are left unchanged. * Authenticates via Privy Bearer token or x-api-key header. * Returns 404 if the session does not exist and 403 if it exists but * is not owned by the authenticated account. @@ -54,10 +56,21 @@ export async function patchSessionByIdHandler( ); } - const updated = await updateSession(sessionId, { + const updates = { ...(body.title !== undefined && { title: body.title }), ...(body.status !== undefined && { status: body.status }), - }); + ...(body.linesAdded !== undefined && { lines_added: body.linesAdded }), + ...(body.linesRemoved !== undefined && { lines_removed: body.linesRemoved }), + }; + + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { session: toSessionResponse(row) }, + { status: 200, headers: getCorsHeaders() }, + ); + } + + const updated = await updateSession(sessionId, updates); if (!updated) { return NextResponse.json( diff --git a/lib/sessions/validatePatchSessionBody.ts b/lib/sessions/validatePatchSessionBody.ts index c6d235cb3..2d2858607 100644 --- a/lib/sessions/validatePatchSessionBody.ts +++ b/lib/sessions/validatePatchSessionBody.ts @@ -7,7 +7,9 @@ import type { AuthContext } from "@/lib/auth/validateAuthContext"; export const patchSessionBodySchema = z.object({ title: z.string().optional(), - status: z.enum(["running", "archived"]).optional(), + status: z.enum(["running", "completed", "failed", "archived"]).optional(), + linesAdded: z.number().int().min(0).optional(), + linesRemoved: z.number().int().min(0).optional(), }); export type PatchSessionBody = z.infer; From 0de5b1e3fddb5c1260854e4937f6389e5b85b8c3 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Wed, 13 May 2026 18:44:22 -0500 Subject: [PATCH 6/7] fix(stripe): off-session fallback for any card-level error (#561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(stripe): fall back to Checkout for any card-level off-session error Surfaced during open-agents preview UI testing of #559: an off-session charge against an account that has a card on file but where Stripe returned a non-authentication_required error (declined / fraud check / expired / invalid request) bubbled out as a 500 instead of falling through to Checkout. Before: only StripeCardError with code === "authentication_required" returned kind: "requires_action". Everything else rethrew → handler returned 500 → UI showed "Couldn't create a checkout session". After: any StripeCardError or StripeInvalidRequestError returns kind: "requires_action" so the caller falls back to a Stripe Checkout Session — the customer can update the card, complete 3DS, or pick a different payment method interactively. Only genuinely unexpected errors (network, our own bugs) still bubble as 500. Tests: existing 9 cases plus a parameterized check across 4 common card error codes plus an InvalidRequestError case. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(stripe): surface Stripe decline reason in the API response Per real-world finding: when an off-session charge fails with insufficient_funds / card_declined / expired_card / etc., the API was silently falling through to Checkout with no signal of why. UI and LLM-driven callers couldn't tell their human "your card has insufficient funds" — they just saw a new Checkout URL. Adds optional declineReason to the Checkout-fallback response shape: { id: "cs_…", url: "…", declineReason: { code: "card_declined", // Stripe error code declineCode: "insufficient_funds", // Stripe decline_code on card errors message: "Your card has insufficient funds." } } declineReason is omitted when fallback is from no_payment_method (no prior off-session attempt was made), so fresh users still see the clean { id, url } shape. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(stripe): narrow Stripe errors via instanceof, drop ad-hoc cast DRY: reuse Stripe SDK's typed error classes (StripeCardError / StripeInvalidRequestError) instead of defining our own structural type to read `type`, `code`, `decline_code`, `message`. TypeScript narrows each branch automatically; `decline_code` is only accessed on the StripeCardError branch where the SDK guarantees it. Tests updated to construct real Stripe error instances with the StripeCardError / StripeInvalidRequestError constructors so the `instanceof` path is what's exercised — the previous plain-object fixtures relied on duck-typing that no longer matches the impl. Addresses CodeRabbit nitpick on PR #561. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../chargeCustomerOffSession.test.ts | 67 ++++++++++++++++++- ...eateCreditsSessionHandler.fallback.test.ts | 41 ++++++++++++ lib/stripe/chargeCustomerOffSession.ts | 38 +++++++++-- lib/stripe/createCreditsSessionHandler.ts | 13 +++- 4 files changed, 149 insertions(+), 10 deletions(-) diff --git a/lib/stripe/__tests__/chargeCustomerOffSession.test.ts b/lib/stripe/__tests__/chargeCustomerOffSession.test.ts index 180d2b67f..31bf5eaa4 100644 --- a/lib/stripe/__tests__/chargeCustomerOffSession.test.ts +++ b/lib/stripe/__tests__/chargeCustomerOffSession.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import Stripe from "stripe"; const { paymentIntentsCreate, findDefaultPmMock } = vi.hoisted(() => ({ paymentIntentsCreate: vi.fn(), @@ -72,10 +73,70 @@ describe("chargeCustomerOffSession", () => { it("returns requires_action when Stripe throws StripeCardError with authentication_required", async () => { findDefaultPmMock.mockResolvedValue("pm_card"); - paymentIntentsCreate.mockRejectedValue({ - type: "StripeCardError", - code: "authentication_required", + paymentIntentsCreate.mockRejectedValue( + new Stripe.errors.StripeCardError({ + type: "card_error", + code: "authentication_required", + message: "Authentication required", + }), + ); + + const result = await chargeCustomerOffSession(params); + expect(result).toEqual({ + kind: "requires_action", + declineReason: { + code: "authentication_required", + message: "Authentication required", + }, }); + }); + + it("returns requires_action plus declineReason for any StripeCardError code (declined, expired, fraud)", async () => { + findDefaultPmMock.mockResolvedValue("pm_card"); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + + paymentIntentsCreate.mockRejectedValue( + new Stripe.errors.StripeCardError({ + type: "card_error", + code: "card_declined", + decline_code: "insufficient_funds", + message: "Your card has insufficient funds.", + }), + ); + expect(await chargeCustomerOffSession(params)).toEqual({ + kind: "requires_action", + declineReason: { + code: "card_declined", + declineCode: "insufficient_funds", + message: "Your card has insufficient funds.", + }, + }); + + paymentIntentsCreate.mockRejectedValue( + new Stripe.errors.StripeCardError({ + type: "card_error", + code: "expired_card", + message: "Your card has expired.", + }), + ); + expect(await chargeCustomerOffSession(params)).toEqual({ + kind: "requires_action", + declineReason: { + code: "expired_card", + message: "Your card has expired.", + }, + }); + }); + + it("returns requires_action when Stripe throws StripeInvalidRequestError (e.g. customer has no payment method)", async () => { + findDefaultPmMock.mockResolvedValue("pm_card"); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + paymentIntentsCreate.mockRejectedValue( + new Stripe.errors.StripeInvalidRequestError({ + type: "invalid_request_error", + message: "Customer has no attached payment source or default payment method", + }), + ); const result = await chargeCustomerOffSession(params); expect(result).toEqual({ kind: "requires_action" }); diff --git a/lib/stripe/__tests__/createCreditsSessionHandler.fallback.test.ts b/lib/stripe/__tests__/createCreditsSessionHandler.fallback.test.ts index 8b388cebf..a4bb2422f 100644 --- a/lib/stripe/__tests__/createCreditsSessionHandler.fallback.test.ts +++ b/lib/stripe/__tests__/createCreditsSessionHandler.fallback.test.ts @@ -53,6 +53,47 @@ describe("createCreditsSessionHandler — Checkout fallback paths", () => { }); }); + it("surfaces the Stripe declineReason in the Checkout-fallback response when an off-session charge was declined", async () => { + vi.mocked(chargeCustomerOffSession).mockResolvedValue({ + kind: "requires_action", + declineReason: { + code: "card_declined", + declineCode: "insufficient_funds", + message: "Your card has insufficient funds.", + }, + }); + vi.mocked(createCreditsStripeSession).mockResolvedValue({ + id: "cs_declined", + url: "https://checkout.stripe.com/pay/cs_declined", + } as Awaited>); + const res = await createCreditsSessionHandler(makeReq()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + id: "cs_declined", + url: "https://checkout.stripe.com/pay/cs_declined", + declineReason: { + code: "card_declined", + declineCode: "insufficient_funds", + message: "Your card has insufficient funds.", + }, + }); + }); + + it("omits declineReason in the fallback response when there was no off-session attempt (no card)", async () => { + vi.mocked(chargeCustomerOffSession).mockResolvedValue({ kind: "no_payment_method" }); + vi.mocked(createCreditsStripeSession).mockResolvedValue({ + id: "cs_no_pm", + url: "https://checkout.stripe.com/pay/cs_no_pm", + } as Awaited>); + const res = await createCreditsSessionHandler(makeReq()); + const body = (await res.json()) as Record; + expect(body).toEqual({ + id: "cs_no_pm", + url: "https://checkout.stripe.com/pay/cs_no_pm", + }); + expect(body.declineReason).toBeUndefined(); + }); + it("returns 400 when Checkout fallback returns no url", async () => { vi.mocked(chargeCustomerOffSession).mockResolvedValue({ kind: "no_payment_method" }); vi.mocked(createCreditsStripeSession).mockResolvedValue({ diff --git a/lib/stripe/chargeCustomerOffSession.ts b/lib/stripe/chargeCustomerOffSession.ts index 6bdba8b2c..a6a1d929a 100644 --- a/lib/stripe/chargeCustomerOffSession.ts +++ b/lib/stripe/chargeCustomerOffSession.ts @@ -1,4 +1,4 @@ -import type Stripe from "stripe"; +import Stripe from "stripe"; import stripeClient from "@/lib/stripe/client"; import { findDefaultPaymentMethodForCustomer } from "@/lib/stripe/findDefaultPaymentMethodForCustomer"; @@ -8,9 +8,18 @@ interface ChargeParams { metadata: { accountId: string; credits: string; purpose: string }; } +export type DeclineReason = { + /** Stripe error code, e.g. "card_declined", "expired_card", "authentication_required". */ + code: string; + /** Stripe decline_code on `card_declined` errors, e.g. "insufficient_funds", "fraudulent". */ + declineCode?: string; + /** Human-readable explanation Stripe returned. */ + message: string; +}; + export type OffSessionChargeResult = | { kind: "charged"; paymentIntentId: string } - | { kind: "requires_action" } + | { kind: "requires_action"; declineReason?: DeclineReason } | { kind: "no_payment_method" }; /** @@ -49,9 +58,28 @@ export async function chargeCustomerOffSession({ } return { kind: "requires_action" }; } catch (error) { - const e = error as { type?: string; code?: string }; - if (e?.type === "StripeCardError" && e.code === "authentication_required") { - return { kind: "requires_action" }; + // Card-level failures (declined, expired, fraud, 3DS required, …) and + // Stripe-rejected request shapes fall back to Checkout so the customer + // can update their card / authenticate interactively. Capture Stripe's + // own decline metadata so callers can surface "insufficient funds" / + // "expired card" instead of a silent fallback. + if ( + error instanceof Stripe.errors.StripeCardError || + error instanceof Stripe.errors.StripeInvalidRequestError + ) { + const declineCode = + error instanceof Stripe.errors.StripeCardError ? error.decline_code : undefined; + console.warn( + `[chargeCustomerOffSession] off-session charge failed (${error.type}/${error.code}/${declineCode ?? "-"}), falling back to Checkout: ${error.message}`, + ); + const declineReason: DeclineReason | undefined = error.code + ? { + code: error.code, + ...(declineCode ? { declineCode } : {}), + message: error.message, + } + : undefined; + return { kind: "requires_action", declineReason }; } console.error("[chargeCustomerOffSession]", error); throw error; diff --git a/lib/stripe/createCreditsSessionHandler.ts b/lib/stripe/createCreditsSessionHandler.ts index 126c0e770..49d89bb54 100644 --- a/lib/stripe/createCreditsSessionHandler.ts +++ b/lib/stripe/createCreditsSessionHandler.ts @@ -39,7 +39,10 @@ export async function createCreditsSessionHandler(request: NextRequest): Promise ); } - // No card on file, or card requires 3-D Secure — fall back to Checkout. + // No card on file, or card requires 3-D Secure / was declined — fall back + // to Checkout. When we have a Stripe decline reason from the off-session + // attempt, surface it so callers can tell their human "insufficient funds" + // instead of just opening Checkout silently. const session = await createCreditsStripeSession({ accountId, credits, @@ -53,7 +56,13 @@ export async function createCreditsSessionHandler(request: NextRequest): Promise ); } return NextResponse.json( - { id: session.id, url: session.url }, + { + id: session.id, + url: session.url, + ...(charge.kind === "requires_action" && charge.declineReason + ? { declineReason: charge.declineReason } + : {}), + }, { status: 200, headers: cors() }, ); } catch (error) { From 7717c574ff41acefaad401009905bd59e73712a6 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 14 May 2026 07:00:16 +0700 Subject: [PATCH 7/7] feat(api): enhance PATCH /api/sessions/{sessionId} to handle malformed JSON body - Added a new utility function to read and validate the JSON body of PATCH requests, returning an empty object for empty bodies and a 400 error for malformed JSON. - Updated the test suite to include a case for handling malformed JSON, ensuring the API responds correctly with a 400 status and an appropriate error message. - Adjusted the validation logic in `validatePatchSessionBody` to utilize the new JSON reading function, improving error handling for invalid input. --- .../[sessionId]/__tests__/route.test.ts | 29 +++++++++++++++++++ lib/sessions/validatePatchSessionBody.ts | 28 ++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 03b5c82a9..87bbe4bcd 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -40,6 +40,17 @@ function makePatchReq( }); } +function makePatchReqRaw( + body: string, + url = "https://example.com/api/sessions/sess_1", +): NextRequest { + return new NextRequest(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body, + }); +} + const mockRow: SessionRow = { id: "sess_1", account_id: "acc-uuid-1", @@ -282,6 +293,24 @@ describe("PATCH /api/sessions/[sessionId]", () => { expect(body.status).toBe("error"); }); + it("returns 400 when JSON body is malformed", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + + const res = await PATCH(makePatchReqRaw("{not-json"), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + status: "error", + error: "Invalid JSON body", + }); + expect(selectSessions).not.toHaveBeenCalled(); + }); + it("returns 200 without calling updateSession when body has no updates", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc-uuid-1", diff --git a/lib/sessions/validatePatchSessionBody.ts b/lib/sessions/validatePatchSessionBody.ts index 2d2858607..1de49ce68 100644 --- a/lib/sessions/validatePatchSessionBody.ts +++ b/lib/sessions/validatePatchSessionBody.ts @@ -1,10 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import type { AuthContext } from "@/lib/auth/validateAuthContext"; +/** + * Reads the PATCH body: empty/whitespace-only becomes `{}`. + * Non-empty text that is not valid JSON returns `null` (caller should 400). + */ +async function readPatchSessionJsonBody(request: NextRequest): Promise { + const text = await request.text(); + if (text.trim() === "") { + return {}; + } + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + export const patchSessionBodySchema = z.object({ title: z.string().optional(), status: z.enum(["running", "completed", "failed", "archived"]).optional(), @@ -22,7 +37,7 @@ export interface ValidatedPatchSessionRequest { /** * Validates a `PATCH /api/sessions/{sessionId}` request end-to-end: * 1. Authenticates the caller via Privy Bearer / x-api-key - * 2. Parses the JSON body (treating malformed JSON as an empty body) + * 2. Parses the JSON body (empty body → `{}`; non-empty invalid JSON → 400) * 3. Validates the body against the Zod schema * * Returns either a 4xx NextResponse describing the first failure, or @@ -39,7 +54,14 @@ export async function validatePatchSessionBody( return auth; } - const rawBody = await safeParseJson(request); + const rawBody = await readPatchSessionJsonBody(request); + if (rawBody === null) { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + const result = patchSessionBodySchema.safeParse(rawBody); if (!result.success) { const firstError = result.error.issues[0];