From 82376e73f1d12c5e94e85847b307b49819524b89 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:20:00 +0800 Subject: [PATCH 1/3] perf: skip the proxy session-refresh getUser for API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 phase 3, auth part (isolated for security review). Every authenticated request previously paid two serial auth-server round trips: src/proxy.ts ran supabase.auth.getUser() for every matched path including /api/*, and the route handler then independently validated the caller via getOptionalAuthenticatedUser. Nothing consumed the proxy's result on API routes — the call existed only to keep session cookies fresh. The proxy now skips only the session-refresh getUser for /api/* paths: - API handlers keep validating the caller themselves (bearer token and/or @supabase/ssr cookie) — no authorization decision moves or weakens, and the handlers remain fail-closed exactly as before. - The CSP nonce header still applies to every matched response including /api/* (matcher unchanged). - Session cookies still refresh on every page navigation, and the browser Supabase client refreshes its own token (autoRefreshToken) for bearer-based API calls, so long-lived sessions behave as before. Deliberately NOT done: the trusted-internal-header design (proxy forwards a validated user id for handlers to trust) was rejected — handlers validating tokens independently is the stronger defense-in-depth posture. The legacy cookie-token extraction in supabase/auth.ts was left untouched: tests define legacy sb-access-token / plain-JSON cookie support as intended behavior. New tests pin the contract: getUser is skipped for /api/* (CSP still stamped), runs on page navigations with an sb- cookie, and never runs without one. Existing CSP nonce tests unchanged and green. Co-Authored-By: Claude Fable 5 --- src/proxy.ts | 23 ++++++++--- tests/proxy-session-refresh.test.ts | 64 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 tests/proxy-session-refresh.test.ts diff --git a/src/proxy.ts b/src/proxy.ts index 4a3bf84d0..a5a6c9136 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -17,9 +17,11 @@ import { buildContentSecurityPolicy, resolveRuntimeFlags } from "@/lib/security- // opts pages into dynamic rendering (app/layout.tsx reads the nonce), which // is inherent to nonce-based CSP. // 2. Session refresh. Keep the user's @supabase/ssr session cookie fresh on -// navigation and API calls so persistent logins survive refreshes. It is a -// no-op unless the public Supabase env is configured AND an `sb-` auth -// cookie is present, so demo / local-no-auth traffic is untouched. +// page navigations so persistent logins survive refreshes. It is a no-op +// unless the public Supabase env is configured AND an `sb-` auth cookie is +// present, so demo / local-no-auth traffic is untouched. API routes are +// excluded: their handlers validate the caller themselves, so refreshing +// here only added a serial auth round trip to every authenticated call. const documentFlowRedirects: Record = { "/mockups/document-search-command": "/documents/search", @@ -70,7 +72,15 @@ export async function proxy(request: NextRequest) { const url = env.NEXT_PUBLIC_SUPABASE_URL; const key = env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; const hasAuthCookie = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-")); - if (!url || !key || !hasAuthCookie) { + // API routes skip the session-refresh getUser: every /api handler validates + // the caller itself (bearer token and/or ssr cookie via getOptionalAuthenticatedUser), + // and nothing here consumed the result — the call existed only to keep + // cookies fresh, which page navigations still do on every non-API request. + // This removes one auth-server round trip from every authenticated API call; + // the CSP header above still applies to API responses. The browser client + // refreshes its own token (autoRefreshToken) for bearer-based API calls. + const isApiRoute = pathname.startsWith("/api/"); + if (!url || !key || !hasAuthCookie || isApiRoute) { return withCsp(NextResponse.next({ request: { headers: requestHeadersWithNonce() } })); } @@ -96,7 +106,8 @@ export async function proxy(request: NextRequest) { } export const config = { - // Run on everything except static assets and image files. API routes are - // intentionally included so cookie-based sessions refresh for them too. + // Run on everything except static assets and image files. API routes stay in + // the matcher so their responses carry the CSP header; they skip only the + // session-refresh getUser (see above). matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"], }; diff --git a/tests/proxy-session-refresh.test.ts b/tests/proxy-session-refresh.test.ts new file mode 100644 index 000000000..7e3fc4fb8 --- /dev/null +++ b/tests/proxy-session-refresh.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +// The proxy's second job (session-cookie refresh via supabase.auth.getUser) +// must run on page navigations but NOT on /api routes: API handlers validate +// the caller themselves, so the proxy call added a serial auth-server round +// trip to every authenticated API request without gating anything. + +const getUser = vi.fn(async () => ({ data: { user: null }, error: null })); + +vi.mock("@supabase/ssr", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createServerClient: vi.fn(() => ({ auth: { getUser } })), + }; +}); + +vi.mock("@/lib/env", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + env: { + ...actual.env, + NEXT_PUBLIC_SUPABASE_URL: "https://unit-test.supabase.co", + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_unit_test", + }, + }; +}); + +function requestWithSessionCookie(path: string): NextRequest { + const request = new NextRequest(new URL(`http://localhost${path}`)); + request.cookies.set("sb-unit-test-auth-token", "base64-opaque-session"); + return request; +} + +beforeEach(() => { + getUser.mockClear(); +}); + +describe("proxy session refresh scoping", () => { + it("skips the session-refresh getUser for API routes but still stamps the CSP", async () => { + const { proxy } = await import("../src/proxy"); + const response = await proxy(requestWithSessionCookie("/api/answer")); + + expect(getUser).not.toHaveBeenCalled(); + expect(response.headers.get("content-security-policy")).toBeTruthy(); + }); + + it("refreshes the session on page navigations that carry an sb- cookie", async () => { + const { proxy } = await import("../src/proxy"); + const response = await proxy(requestWithSessionCookie("/documents/some-id")); + + expect(getUser).toHaveBeenCalledTimes(1); + expect(response.headers.get("content-security-policy")).toBeTruthy(); + }); + + it("never calls getUser without an sb- cookie", async () => { + const { proxy } = await import("../src/proxy"); + await proxy(new NextRequest(new URL("http://localhost/documents/some-id"))); + + expect(getUser).not.toHaveBeenCalled(); + }); +}); From a21a2a9690e3a5c64d672255052c7fbf9b20f950 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:52:32 +0800 Subject: [PATCH 2/3] perf: trim the answer payload to what the client renders (#482) --- src/app/api/answer/route.ts | 5 +- src/app/api/answer/stream/route.ts | 5 +- src/lib/answer-client-payload.ts | 42 ++++++++++++++ tests/answer-client-payload.test.ts | 85 +++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 src/lib/answer-client-payload.ts create mode 100644 tests/answer-client-payload.test.ts diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index b63f20705..f02022dbc 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -20,6 +20,7 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { parseJsonBody } from "@/lib/validation/body"; +import { toClientAnswerPayload } from "@/lib/answer-client-payload"; import { answerServerTimingEntries, buildServerTimingHeader } from "@/lib/server-timing"; import { createAdminClient } from "@/lib/supabase/admin"; import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; @@ -166,7 +167,9 @@ export async function POST(request: Request) { ); return NextResponse.json( { - ...answer, + // Boundary trim only — governance warnings and diagnostics above + // consumed the full answer (see answer-client-payload.ts). + ...toClientAnswerPayload(answer), degradedMode: answerDegradedModeSignal(answer), scope: { ...scope, queryMode: answerBody.queryMode }, sourceGovernanceWarnings: warnings, diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index d3dbc6732..3183bf3ad 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -8,6 +8,7 @@ import { type ApiRateLimitResult, } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; +import { toClientAnswerPayload } from "@/lib/answer-client-payload"; import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag"; import { classifyRagQuery } from "@/lib/clinical-search"; import { annotateSearchResults, buildEvidenceRelevance } from "@/lib/evidence-relevance"; @@ -231,7 +232,9 @@ function streamAnswer( } send("final", { - ...answer, + // Boundary trim only — governance warnings and diagnostics above + // consumed the full answer (see answer-client-payload.ts). + ...toClientAnswerPayload(answer), degradedMode: answerDegradedModeSignal(answer), scope: scope ? { ...scope, queryMode: body.queryMode } : undefined, sourceGovernanceWarnings: warnings, diff --git a/src/lib/answer-client-payload.ts b/src/lib/answer-client-payload.ts new file mode 100644 index 000000000..0a42ee9e7 --- /dev/null +++ b/src/lib/answer-client-payload.ts @@ -0,0 +1,42 @@ +import type { RagAnswer, SearchResult } from "@/lib/types"; + +// Route-boundary trim of the answer payload. The retrieval pipeline carries +// full chunk text plus server-only context on every source (adjacent_context +// for generation packing, memory cards, table facts, index-unit matches, +// document summaries), but the client renders only a snippet +// (retrieval_synopsis ?? content) plus identity, scoring, governance, and +// label fields. Trimming at the route boundary — never inside rag.ts — keeps +// caches, generation inputs, and eval behavior byte-identical while cutting +// the final SSE/JSON event the user waits on after the prose has streamed. +// +// Ordering contract: sourceGovernanceWarnings and logAnswerDiagnostics consume +// the FULL answer and must run before this trim (both routes do). + +// Longest snippet the source cards can usefully show; the render policy falls +// back to `content` only when `retrieval_synopsis` is absent. +const clientSourceContentMaxChars = 700; + +function truncateAtWordBoundary(text: string, maxChars: number) { + if (text.length <= maxChars) return text; + const slice = text.slice(0, maxChars); + const lastSpace = slice.lastIndexOf(" "); + return `${slice.slice(0, lastSpace > maxChars * 0.6 ? lastSpace : maxChars).trimEnd()}…`; +} + +function trimSourceForClient(source: SearchResult): SearchResult { + const trimmed: SearchResult = { + ...source, + content: truncateAtWordBoundary(source.content ?? "", clientSourceContentMaxChars), + }; + delete trimmed.adjacent_context; + delete trimmed.memory_cards; + delete trimmed.table_facts; + delete trimmed.index_unit; + delete trimmed.document_summary; + return trimmed; +} + +export function toClientAnswerPayload>(answer: T): T { + if (!answer.sources?.length) return answer; + return { ...answer, sources: answer.sources.map(trimSourceForClient) }; +} diff --git a/tests/answer-client-payload.test.ts b/tests/answer-client-payload.test.ts new file mode 100644 index 000000000..182298af4 --- /dev/null +++ b/tests/answer-client-payload.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { toClientAnswerPayload } from "@/lib/answer-client-payload"; +import type { RagAnswer, SearchResult } from "@/lib/types"; + +function fullSource(overrides: Partial = {}): SearchResult { + return { + id: "chunk-1", + document_id: "doc-1", + title: "Clozapine monitoring guideline", + file_name: "clozapine.pdf", + page_number: 4, + chunk_index: 7, + section_heading: "Monitoring", + content: "Full blood count weekly for 18 weeks. ".repeat(60), + retrieval_synopsis: "FBC weekly for 18 weeks, then monthly.", + image_ids: [], + similarity: 0.82, + source_metadata: { document_status: "current" } as SearchResult["source_metadata"], + adjacent_context: "Preceding paragraph context. ".repeat(20), + document_summary: "A long document summary. ".repeat(30), + memory_cards: [{ id: "m1" } as never], + table_facts: [{ id: "t1" } as never], + index_unit: { unit_type: "table" } as never, + ...overrides, + } as SearchResult; +} + +function answerWith(sources: SearchResult[]): Pick { + return { sources }; +} + +describe("toClientAnswerPayload", () => { + it("drops server-only per-source fields the client never renders", () => { + const trimmed = toClientAnswerPayload(answerWith([fullSource()])).sources![0]; + expect(trimmed.adjacent_context).toBeUndefined(); + expect(trimmed.memory_cards).toBeUndefined(); + expect(trimmed.table_facts).toBeUndefined(); + expect(trimmed.index_unit).toBeUndefined(); + expect(trimmed.document_summary).toBeUndefined(); + }); + + it("keeps identity, snippet, scoring, and governance fields intact", () => { + const trimmed = toClientAnswerPayload(answerWith([fullSource()])).sources![0]; + expect(trimmed.id).toBe("chunk-1"); + expect(trimmed.title).toBe("Clozapine monitoring guideline"); + expect(trimmed.retrieval_synopsis).toBe("FBC weekly for 18 weeks, then monthly."); + expect(trimmed.similarity).toBe(0.82); + expect(trimmed.source_metadata).toEqual({ document_status: "current" }); + expect(trimmed.page_number).toBe(4); + }); + + it("truncates long chunk content at a word boundary", () => { + const trimmed = toClientAnswerPayload(answerWith([fullSource()])).sources![0]; + expect(trimmed.content.length).toBeLessThanOrEqual(701); + expect(trimmed.content.endsWith("…")).toBe(true); + // Word-boundary cut: no partial trailing token before the ellipsis. + expect(trimmed.content.at(-2)).not.toBe(" "); + }); + + it("leaves short content untouched", () => { + const short = fullSource({ content: "Short snippet." }); + expect(toClientAnswerPayload(answerWith([short])).sources![0].content).toBe("Short snippet."); + }); + + it("does not mutate the original answer (caches keep the full sources)", () => { + const source = fullSource(); + const answer = answerWith([source]); + toClientAnswerPayload(answer); + expect(answer.sources![0].adjacent_context).toBeTruthy(); + expect(answer.sources![0].content.length).toBeGreaterThan(700); + }); + + it("passes through answers without sources", () => { + const empty = answerWith([]); + expect(toClientAnswerPayload(empty)).toBe(empty); + }); + + it("materially shrinks a representative payload", () => { + const answer = answerWith(Array.from({ length: 8 }, (_, index) => fullSource({ id: `chunk-${index}` }))); + const fullBytes = JSON.stringify(answer).length; + const trimmedBytes = JSON.stringify(toClientAnswerPayload(answer)).length; + expect(trimmedBytes).toBeLessThan(fullBytes * 0.5); + }); +}); From 24fad070fb9834510309e74e1dc0e216cd08646b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:25:42 +0800 Subject: [PATCH 3/3] fix: preserve cookie refresh and safety evidence --- docs/branch-review-ledger.md | 7 ++++--- src/lib/answer-client-payload.ts | 17 ++++------------- src/proxy.ts | 20 ++++++-------------- tests/answer-client-payload.test.ts | 21 ++++++++++++++------- tests/proxy-session-refresh.test.ts | 26 ++++++++++++++++++++------ 5 files changed, 48 insertions(+), 43 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index e4d61b8c5..c58897f63 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -18,6 +18,7 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD ## Review Records -| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | -| ---------- | -------------- | ------------- | -------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | +| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | +| ---------- | --------------------------------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | +| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | a21a2a9690e3a5c64d672255052c7fbf9b20f950 | open-PR review and unresolved comments | Two P2 findings fixed: cookie-authenticated API requests again return rotated SSR cookies, and route-boundary payload trimming preserves full source content required by client safety scanning while still removing server-only fields. Added focused regression coverage. No additional high-confidence defect was found in the six-file diff. | Focused proxy, payload, and clinical-safety Vitest (17/17); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | diff --git a/src/lib/answer-client-payload.ts b/src/lib/answer-client-payload.ts index 0a42ee9e7..e88d94465 100644 --- a/src/lib/answer-client-payload.ts +++ b/src/lib/answer-client-payload.ts @@ -10,23 +10,14 @@ import type { RagAnswer, SearchResult } from "@/lib/types"; // the final SSE/JSON event the user waits on after the prose has streamed. // // Ordering contract: sourceGovernanceWarnings and logAnswerDiagnostics consume -// the FULL answer and must run before this trim (both routes do). - -// Longest snippet the source cards can usefully show; the render policy falls -// back to `content` only when `retrieval_synopsis` is absent. -const clientSourceContentMaxChars = 700; - -function truncateAtWordBoundary(text: string, maxChars: number) { - if (text.length <= maxChars) return text; - const slice = text.slice(0, maxChars); - const lastSpace = slice.lastIndexOf(" "); - return `${slice.slice(0, lastSpace > maxChars * 0.6 ? lastSpace : maxChars).trimEnd()}…`; -} +// the FULL answer and must run before this trim (both routes do). Full source +// content remains client-visible because the safety panel scans it for clinical +// warnings that may occur beyond the display synopsis. function trimSourceForClient(source: SearchResult): SearchResult { const trimmed: SearchResult = { ...source, - content: truncateAtWordBoundary(source.content ?? "", clientSourceContentMaxChars), + content: source.content ?? "", }; delete trimmed.adjacent_context; delete trimmed.memory_cards; diff --git a/src/proxy.ts b/src/proxy.ts index a5a6c9136..0b80dff55 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -19,9 +19,9 @@ import { buildContentSecurityPolicy, resolveRuntimeFlags } from "@/lib/security- // 2. Session refresh. Keep the user's @supabase/ssr session cookie fresh on // page navigations so persistent logins survive refreshes. It is a no-op // unless the public Supabase env is configured AND an `sb-` auth cookie is -// present, so demo / local-no-auth traffic is untouched. API routes are -// excluded: their handlers validate the caller themselves, so refreshing -// here only added a serial auth round trip to every authenticated call. +// present, so demo / local-no-auth traffic is untouched. Cookie-authenticated +// API requests still pass through this refresh path because route handlers +// cannot write rotated SSR cookies back to the browser themselves. const documentFlowRedirects: Record = { "/mockups/document-search-command": "/documents/search", @@ -72,15 +72,7 @@ export async function proxy(request: NextRequest) { const url = env.NEXT_PUBLIC_SUPABASE_URL; const key = env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; const hasAuthCookie = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-")); - // API routes skip the session-refresh getUser: every /api handler validates - // the caller itself (bearer token and/or ssr cookie via getOptionalAuthenticatedUser), - // and nothing here consumed the result — the call existed only to keep - // cookies fresh, which page navigations still do on every non-API request. - // This removes one auth-server round trip from every authenticated API call; - // the CSP header above still applies to API responses. The browser client - // refreshes its own token (autoRefreshToken) for bearer-based API calls. - const isApiRoute = pathname.startsWith("/api/"); - if (!url || !key || !hasAuthCookie || isApiRoute) { + if (!url || !key || !hasAuthCookie) { return withCsp(NextResponse.next({ request: { headers: requestHeadersWithNonce() } })); } @@ -107,7 +99,7 @@ export async function proxy(request: NextRequest) { export const config = { // Run on everything except static assets and image files. API routes stay in - // the matcher so their responses carry the CSP header; they skip only the - // session-refresh getUser (see above). + // the matcher so cookie-authenticated requests can return rotated cookies and + // every response carries the CSP header. matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"], }; diff --git a/tests/answer-client-payload.test.ts b/tests/answer-client-payload.test.ts index 182298af4..91cea09a3 100644 --- a/tests/answer-client-payload.test.ts +++ b/tests/answer-client-payload.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { toClientAnswerPayload } from "@/lib/answer-client-payload"; +import { extractSafetyFindings } from "@/lib/clinical-safety"; import type { RagAnswer, SearchResult } from "@/lib/types"; function fullSource(overrides: Partial = {}): SearchResult { @@ -50,12 +51,18 @@ describe("toClientAnswerPayload", () => { expect(trimmed.page_number).toBe(4); }); - it("truncates long chunk content at a word boundary", () => { - const trimmed = toClientAnswerPayload(answerWith([fullSource()])).sources![0]; - expect(trimmed.content.length).toBeLessThanOrEqual(701); - expect(trimmed.content.endsWith("…")).toBe(true); - // Word-boundary cut: no partial trailing token before the ellipsis. - expect(trimmed.content.at(-2)).not.toBe(" "); + it("preserves full source content so client-side safety scanning cannot miss later warnings", () => { + const source = fullSource({ content: `${"Routine context. ".repeat(60)}Contraindicated in severe disease.` }); + const payload = toClientAnswerPayload({ + answer: "Review the source.", + grounded: true, + confidence: "medium", + citations: [], + sources: [source], + } as RagAnswer); + + expect(payload.sources![0].content).toBe(source.content); + expect(extractSafetyFindings(payload)).toHaveLength(1); }); it("leaves short content untouched", () => { @@ -80,6 +87,6 @@ describe("toClientAnswerPayload", () => { const answer = answerWith(Array.from({ length: 8 }, (_, index) => fullSource({ id: `chunk-${index}` }))); const fullBytes = JSON.stringify(answer).length; const trimmedBytes = JSON.stringify(toClientAnswerPayload(answer)).length; - expect(trimmedBytes).toBeLessThan(fullBytes * 0.5); + expect(trimmedBytes).toBeLessThan(fullBytes * 0.8); }); }); diff --git a/tests/proxy-session-refresh.test.ts b/tests/proxy-session-refresh.test.ts index 7e3fc4fb8..026fc6018 100644 --- a/tests/proxy-session-refresh.test.ts +++ b/tests/proxy-session-refresh.test.ts @@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; // The proxy's second job (session-cookie refresh via supabase.auth.getUser) -// must run on page navigations but NOT on /api routes: API handlers validate -// the caller themselves, so the proxy call added a serial auth-server round -// trip to every authenticated API request without gating anything. +// must run on page navigations and cookie-authenticated API routes. Route +// handlers can validate an SSR cookie, but their read-only adapter cannot return +// rotated Set-Cookie headers to the browser. const getUser = vi.fn(async () => ({ data: { user: null }, error: null })); @@ -12,7 +12,20 @@ vi.mock("@supabase/ssr", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - createServerClient: vi.fn(() => ({ auth: { getUser } })), + createServerClient: vi.fn((_url, _key, options: { cookies: { setAll: (cookies: never[]) => void } }) => ({ + auth: { + getUser: async () => { + options.cookies.setAll([ + { + name: "sb-unit-test-auth-token", + value: "rotated-session", + options: { path: "/", httpOnly: true }, + }, + ] as never[]); + return getUser(); + }, + }, + })), }; }); @@ -39,11 +52,12 @@ beforeEach(() => { }); describe("proxy session refresh scoping", () => { - it("skips the session-refresh getUser for API routes but still stamps the CSP", async () => { + it("refreshes SSR cookies for cookie-authenticated API routes and stamps the CSP", async () => { const { proxy } = await import("../src/proxy"); const response = await proxy(requestWithSessionCookie("/api/answer")); - expect(getUser).not.toHaveBeenCalled(); + expect(getUser).toHaveBeenCalledTimes(1); + expect(response.cookies.get("sb-unit-test-auth-token")?.value).toBe("rotated-session"); expect(response.headers.get("content-security-policy")).toBeTruthy(); });