diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 685a4a205..87bbe4bcd 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,35 @@ 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), + }); +} + +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", @@ -78,6 +105,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", @@ -161,3 +206,179 @@ 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 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", + 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: "not-a-status" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + 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", + 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, + 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", + }); + }); + + 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 dbc04994a..40527e2ea 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,28 @@ export async function GET( return getSessionByIdHandler(request, sessionId); } +/** + * PATCH /api/sessions/{sessionId} + * + * 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 + * @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/app/workflows/clearLifecycleRunIdIfOwned.ts b/app/workflows/clearLifecycleRunIdIfOwned.ts index df6f55228..14677ee3f 100644 --- a/app/workflows/clearLifecycleRunIdIfOwned.ts +++ b/app/workflows/clearLifecycleRunIdIfOwned.ts @@ -15,6 +15,10 @@ export async function clearLifecycleRunIdIfOwned(sessionId: string, runId: strin "use step"; 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 9b084a770..6fc177057 100644 --- a/app/workflows/computeLifecycleWakeDecision.ts +++ b/app/workflows/computeLifecycleWakeDecision.ts @@ -27,6 +27,7 @@ export async function computeLifecycleWakeDecision( "use step"; 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/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..0c34e2bad 100644 --- a/lib/sandbox/evaluateSandboxLifecycle.ts +++ b/lib/sandbox/evaluateSandboxLifecycle.ts @@ -36,6 +36,7 @@ export async function evaluateSandboxLifecycle( reason: SandboxLifecycleReason, ): Promise { 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,10 @@ 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/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 new file mode 100644 index 000000000..c93913667 --- /dev/null +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -0,0 +1,86 @@ +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`, `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. + * + * @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 }); + + if (rows === null) { + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + 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 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( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { session: toSessionResponse(updated) }, + { status: 200, 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/sessions/validatePatchSessionBody.ts b/lib/sessions/validatePatchSessionBody.ts new file mode 100644 index 000000000..1de49ce68 --- /dev/null +++ b/lib/sessions/validatePatchSessionBody.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +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(), + linesAdded: z.number().int().min(0).optional(), + linesRemoved: z.number().int().min(0).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 (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 + * 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 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]; + return NextResponse.json( + { + status: "error", + path: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return { body: result.data, auth }; +} 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; } }