diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index c467335dc..14570b6fe 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -45,4 +45,5 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | | 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | | 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. | -| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 162085d5177f01e503b498a04efe4597478163ef | open-PR review and CI | No unresolved review threads or additional high-confidence defects were found in the compact home-layout diff. Integrated the all-viewport hero composer and retained the branch's deliberate 44px icon and 24px mobile heading scale. | TypeScript; focused app-mode/search Vitest (28/28); full Vitest (1,594 passed, 1 skipped); runtime/action/sitemap/type-scale/lint checks passed; hosted browser verification pending on the final integrated head. | +| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. | +| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. | diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index ae6af8b56..0c902a48a 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"; @@ -162,7 +163,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 8061d377f..e44b57281 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(body: AnswerBody, ownerId?: string, signal?: AbortSignal, } 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..7e09f7095 --- /dev/null +++ b/src/lib/answer-client-payload.ts @@ -0,0 +1,68 @@ +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). Full source +// content remains client-visible because the safety panel scans it for clinical +// warnings that may occur beyond the display synopsis. + +const sourceFieldPolicy = { + id: "client", + document_id: "client", + title: "client", + file_name: "client", + page_number: "client", + chunk_index: "client", + section_heading: "client", + section_path: "client", + heading_level: "client", + parent_heading: "client", + anchor_id: "client", + content: "client", + retrieval_synopsis: "client", + image_ids: "client", + similarity: "client", + similarity_origin: "client", + text_rank: "client", + hybrid_score: "client", + lexical_score: "client", + rrf_score: "client", + score_explanation: "client", + source_strength: "client", + source_metadata: "client", + document_labels: "client", + document_summary: "server", + adjacent_context: "server", + memory_cards: "server", + memory_score: "client", + relevance: "client", + match_explanation: "client", + table_facts: "server", + index_unit: "server", + indexing_quality: "client", + images: "client", +} as const satisfies Record; + +function trimSourceForClient(source: SearchResult): SearchResult { + const trimmed = Object.fromEntries( + (Object.keys(sourceFieldPolicy) as Array) + .filter((key) => sourceFieldPolicy[key] === "client" && key in source) + .map((key) => [key, source[key]]), + ) as SearchResult; + trimmed.content = source.content ?? ""; + trimmed.images ??= []; + return trimmed; +} + +export function toClientAnswerPayload>(answer: T): T { + if (!answer.sources?.length) return answer; + return { ...answer, sources: answer.sources.map(trimSourceForClient) }; +} diff --git a/src/proxy.ts b/src/proxy.ts index 4a3bf84d0..0b80dff55 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. 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", @@ -96,7 +98,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 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 new file mode 100644 index 000000000..1cdc0f07a --- /dev/null +++ b/tests/answer-client-payload.test.ts @@ -0,0 +1,100 @@ +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 { + 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("does not pass unclassified runtime fields through the route boundary", () => { + const source = { ...fullSource(), future_server_secret: "private" } as SearchResult; + const trimmed = toClientAnswerPayload(answerWith([source])).sources![0] as SearchResult & { + future_server_secret?: string; + }; + expect(trimmed.future_server_secret).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("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", () => { + 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.8); + }); +}); diff --git a/tests/proxy-session-refresh.test.ts b/tests/proxy-session-refresh.test.ts new file mode 100644 index 000000000..66512b103 --- /dev/null +++ b/tests/proxy-session-refresh.test.ts @@ -0,0 +1,79 @@ +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 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 })); + +vi.mock("@supabase/ssr", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + 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(); + }, + }, + })), + }; +}); + +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("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).toHaveBeenCalledTimes(1); + expect(response.cookies.get("sb-unit-test-auth-token")?.value).toBe("rotated-session"); + 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.cookies.get("sb-unit-test-auth-token")?.value).toBe("rotated-session"); + 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(); + }); +});