diff --git a/eslint.config.mjs b/eslint.config.mjs index 115449954..1445dcede 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,9 +41,9 @@ const eslintConfig = defineConfig([ ".tmp-visual/**", "sample-documents/**", "scratch/**", - ".claude/**", + // Recursive `**/` variants cover nested worktrees; the bare globs above already + // ignore the repo-root dirs, so only the recursive forms are kept here. "**/.claude/**", - ".tmp-visual/**", "**/.tmp-visual/**", "next-env.d.ts", ]), diff --git a/package-lock.json b/package-lock.json index fd823379d..01dc224f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "@next/bundle-analyzer": "^16.2.10", "@tailwindcss/postcss": "^4.3.2", "@types/node": "^24.13.2", - "@types/pdf-parse": "^1.1.5", "@types/pdfkit": "^0.17.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -2835,16 +2834,6 @@ "undici-types": "~7.18.0" } }, - "node_modules/@types/pdf-parse": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz", - "integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/pdfkit": { "version": "0.17.6", "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", diff --git a/package.json b/package.json index 950af5169..f38968eaa 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack && node scripts/check-client-bundle-secrets.mjs", "build:analyze": "node scripts/build-analyze.mjs", "start": "node scripts/dev-free-port.mjs start", - "lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --no-error-on-unmatched-pattern", + "lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --max-warnings 0 --no-error-on-unmatched-pattern", "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", "test": "node scripts/run-vitest.mjs run --reporter=dot", "test:coverage": "node scripts/run-vitest.mjs run --coverage", @@ -145,7 +145,6 @@ "@next/bundle-analyzer": "^16.2.10", "@tailwindcss/postcss": "^4.3.2", "@types/node": "^24.13.2", - "@types/pdf-parse": "^1.1.5", "@types/pdfkit": "^0.17.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 0a0ab8c3a..0e2521f50 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,21 +1,10 @@ -import { timingSafeEqual } from "node:crypto"; import { NextResponse } from "next/server"; import { env, isDemoMode } from "@/lib/env"; +import { allowDeepHealthProbe } from "@/lib/deep-probe-auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -function allowDeepHealthProbe(request: Request): boolean { - const secret = env.HEALTH_DEEP_PROBE_SECRET; - if (!secret) return false; - const token = request.headers.get("x-health-deep-token"); - if (!token) return false; - if (token.length !== secret.length) return false; - const expected = Buffer.from(secret, "utf8"); - const received = Buffer.from(token, "utf8"); - return timingSafeEqual(expected, received); -} - export async function GET(request: Request) { const deep = new URL(request.url).searchParams.get("deep") === "1"; const supabaseConfigured = Boolean(env.NEXT_PUBLIC_SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY); diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index 081568601..83067157b 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { env, isDemoMode } from "@/lib/env"; +import { env, isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { allowDeepHealthProbe } from "@/lib/deep-probe-auth"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; @@ -284,6 +285,24 @@ function setupStatusResponse(payload: SetupStatusPayload) { }); } +// Coarse per-check detail for anonymous production callers. The full `detail` strings can carry +// raw Supabase error text and project-config specifics (schema/search/worker fan-out errors, +// formatSupabaseProjectCheck), which must not leak to unauthenticated internet clients. Status +// and label are preserved so the polled setup UI still renders; operators fetch full detail with +// the shared deep-probe secret. +const COARSE_SETUP_DETAIL: Record = { + ready: "Ready.", + needs_setup: "Setup incomplete. Operators can see specifics via the health deep probe or server logs.", + unknown: "Status unavailable. Operators can see specifics via the health deep probe or server logs.", +}; + +function coarseSetupStatusPayload(payload: SetupStatusPayload): SetupStatusPayload { + return { + ...payload, + checks: payload.checks.map((item) => ({ ...item, detail: COARSE_SETUP_DETAIL[item.status] })), + }; +} + async function buildSetupStatusPayload(): Promise { const supabase = supabaseProjectCanBeQueried ? createAdminClient() : null; const unavailable = await readSupabaseAvailability(supabase); @@ -418,5 +437,13 @@ export async function GET(request: Request) { return unsafeLocalProjectResponse(identity); } - return setupStatusResponse(await readSetupStatusPayload()); + const payload = await readSetupStatusPayload(); + // Whether the caller may see raw per-check detail (raw Supabase error text / project posture). + // Gate on a TRUSTED server-side runtime signal, never on request.url's host — behind a proxy the + // Host header is client-controlled, so a spoofed `localhost:` must not unlock + // detail. In a real production runtime, only the operator deep-probe token unlocks it; local dev + // and single-instance local-no-auth keep full detail (their `detail` is not an internet leak). + const requiresOperatorToken = process.env.NODE_ENV === "production" && !isDemoMode() && !isLocalNoAuthMode(); + const authorizedForDetail = !requiresOperatorToken || allowDeepHealthProbe(request); + return setupStatusResponse(authorizedForDetail ? payload : coarseSetupStatusPayload(payload)); } diff --git a/src/lib/answer-verification.ts b/src/lib/answer-verification.ts index d094269e8..77103c0e7 100644 --- a/src/lib/answer-verification.ts +++ b/src/lib/answer-verification.ts @@ -236,8 +236,12 @@ function hasActionableNumericContext(answer: RagAnswer) { return actionableNumericAnswerPattern.test(text); } -export function applyNumericVerification(answer: RagAnswer): RagAnswer { - const sources = answer.sources ?? []; +// `verificationSources` overrides the corpus numbers are checked against. The model path +// passes the packed context it actually generated from (answer.sources stays the unpacked +// answer-input set for the client/eval boundary); other callers omit it and verify against +// answer.sources as before. +export function applyNumericVerification(answer: RagAnswer, verificationSources?: SearchResult[]): RagAnswer { + const sources = verificationSources ?? answer.sources ?? []; const unverified = new Set(); // B4: the model is instructed to put dose details in structured diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index d786934ca..1c1720090 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -9,11 +9,19 @@ export function allowRateLimitInMemoryFallbackOnUnavailable() { return isLocalNoAuthMode() || process.env.NODE_ENV === "production"; } +// Buckets that must FAIL CLOSED (503) rather than fall back to a per-instance in-memory limiter +// when the durable limiter is unavailable. A per-process Map gives N× the intended limit across N +// horizontally-scaled instances during a limiter outage — unacceptable for expensive/abusable +// paths: `answer` (paid provider generation) and `document_upload` (storage writes + ingestion +// cost). Local-no-auth dev keeps the in-memory fallback for single-instance usability. +function failsClosedOnLimiterUnavailable(bucket: ApiRateLimitBucket) { + return bucket === "answer" || bucket === "document_upload"; +} + function allowAnonymousRateLimitFallback(bucket: ApiRateLimitBucket, allowInMemoryFallbackOnUnavailable?: boolean) { - // Paid answer generation must not fall back to a per-instance limiter in a - // distributed production runtime. If the durable limiter is unavailable, - // fail closed before retrieval or provider generation can start. - if (bucket === "answer" && !isLocalNoAuthMode()) return false; + // Fail-closed buckets must not fall back to a per-instance limiter in a distributed production + // runtime. If the durable limiter is unavailable, fail closed before any expensive work starts. + if (failsClosedOnLimiterUnavailable(bucket) && !isLocalNoAuthMode()) return false; if (allowInMemoryFallbackOnUnavailable) return true; // Anonymous public read/search paths must stay reachable if the durable limiter @@ -150,7 +158,9 @@ export async function consumeSubjectApiRateLimit(args: { allowInMemoryFallbackOnUnavailable?: boolean; }): Promise { const allowInMemoryFallbackOnUnavailable = - args.bucket === "answer" && !isLocalNoAuthMode() ? false : args.allowInMemoryFallbackOnUnavailable; + failsClosedOnLimiterUnavailable(args.bucket) && !isLocalNoAuthMode() + ? false + : args.allowInMemoryFallbackOnUnavailable; if (args.subject.kind === "owner") { return consumeApiRateLimit({ diff --git a/src/lib/deep-probe-auth.ts b/src/lib/deep-probe-auth.ts new file mode 100644 index 000000000..4ac567dd3 --- /dev/null +++ b/src/lib/deep-probe-auth.ts @@ -0,0 +1,23 @@ +import { timingSafeEqual } from "node:crypto"; +import { env } from "@/lib/env"; + +// Shared operator gate for internal health/status detail. A caller proves it is an operator +// (not an anonymous internet client) by presenting HEALTH_DEEP_PROBE_SECRET via the +// `x-health-deep-token` header. Used by /api/health (deep Supabase probe) and +// /api/setup-status (detailed setup checks) so both surfaces gate internal error text and +// project posture behind the same secret. Constant-time compare; fails closed when the secret +// is unset or the token length/value differs. +export function allowDeepHealthProbe(request: Request): boolean { + const secret = env.HEALTH_DEEP_PROBE_SECRET; + if (!secret) return false; + const token = request.headers.get("x-health-deep-token"); + if (!token) return false; + // Compare UTF-8 BYTE lengths, not JS string (UTF-16 code-unit) lengths: a crafted multi-byte + // token with the same code-unit count but a different byte count would otherwise pass a + // `token.length === secret.length` check and make timingSafeEqual throw on mismatched buffers + // (an unhandled RangeError → crafted-header 500). Build the buffers first, then length-gate. + const expected = Buffer.from(secret, "utf8"); + const received = Buffer.from(token, "utf8"); + if (expected.length !== received.length) return false; + return timingSafeEqual(expected, received); +} diff --git a/src/lib/rag-cache.ts b/src/lib/rag-cache.ts index 748ecd118..b9fce6b59 100644 --- a/src/lib/rag-cache.ts +++ b/src/lib/rag-cache.ts @@ -687,3 +687,24 @@ export async function packAdjacentSourceContext( return results; } } + +// The numeric-faithfulness gate must verify answer figures against the SAME text +// the model was shown. Generation runs on the packed context (packAdjacentSourceContext +// merges neighbour-chunk text into adjacent_context), but answer.sources is the +// unpacked answer-input set — so a dose/threshold the model faithfully copied from a +// neighbour chunk would be absent from the finalize-time verification corpus and wrongly +// flagged unverified, blanking a correct answer. Overlay the packed adjacent_context onto +// the answer-input results (by chunk id) to rebuild the exact verification corpus, WITHOUT +// mutating answer.sources itself (the route-boundary client trim and eval byte-identity +// both depend on answer.sources staying unpacked — see answer-client-payload.ts). +export function attachAdjacentContext(results: SearchResult[], packed: SearchResult[]): SearchResult[] { + const adjacentById = new Map(); + for (const source of packed) { + if (source.adjacent_context) adjacentById.set(source.id, source.adjacent_context); + } + if (adjacentById.size === 0) return results; + return results.map((result) => { + const adjacent = adjacentById.get(result.id); + return adjacent && adjacent !== result.adjacent_context ? { ...result, adjacent_context: adjacent } : result; + }); +} diff --git a/src/lib/rag-extractive-answer.ts b/src/lib/rag-extractive-answer.ts index bb756a8f3..46eb26cd0 100644 --- a/src/lib/rag-extractive-answer.ts +++ b/src/lib/rag-extractive-answer.ts @@ -1730,12 +1730,22 @@ function applyProviderLabels(answer: RagAnswer): RagAnswer { // Public wrapper: runs quality finalization, then stamps provider/quality labels so the UI can // disclose source-only (lower-quality) answers and verify-against-sources guidance. /** Finalize rag answer quality. */ -export function finalizeRagAnswerQuality(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer { - return applyProviderLabels(finalizeRagAnswerQualityCore(answer, query, queryClass)); +export function finalizeRagAnswerQuality( + answer: RagAnswer, + query: string, + queryClass: RagQueryClass, + verificationSources?: SearchResult[], +): RagAnswer { + return applyProviderLabels(finalizeRagAnswerQualityCore(answer, query, queryClass, verificationSources)); } /** Finalize rag answer quality core. */ -function finalizeRagAnswerQualityCore(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer { +function finalizeRagAnswerQualityCore( + answer: RagAnswer, + query: string, + queryClass: RagQueryClass, + verificationSources?: SearchResult[], +): RagAnswer { // Deterministic, template-built answers (document-support lists, table/visual source // references) are well-formed by construction and carry no free-text clinical claims. // The clinical-prose sanitizer/quality gate below is designed for model prose and would @@ -1812,9 +1822,12 @@ function finalizeRagAnswerQualityCore(answer: RagAnswer, query: string, queryCla }) .filter((section): section is Exclude => Boolean(section)); - return applyNumericVerification({ - ...answer, - answer: boldHighYieldClinicalText(cleanedAnswer, query), - answerSections, - }); + return applyNumericVerification( + { + ...answer, + answer: boldHighYieldClinicalText(cleanedAnswer, query), + answerSections, + }, + verificationSources, + ); } diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 043d81e7d..b4d11feeb 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -69,6 +69,7 @@ import { cacheIndexingVersion, cloneAnswer, getCachedAnswer, + attachAdjacentContext, getCachedSearch, getSharedCachedAnswer, getSharedCachedSearch, @@ -3611,15 +3612,22 @@ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs) message: "Waiting for an identical cited answer request already in progress.", reason: "answer_inflight_coalesced", }); - const answer = cloneAnswer(await existing); - answer.routingReason = answer.routingReason - ? `${answer.routingReason}; answer_inflight_coalesced` - : "answer_inflight_coalesced"; - answer.latencyTimings = { - ...answer.latencyTimings, - total_latency_ms: Date.now() - startedAt, - }; - return answer; + try { + const answer = cloneAnswer(await existing); + answer.routingReason = answer.routingReason + ? `${answer.routingReason}; answer_inflight_coalesced` + : "answer_inflight_coalesced"; + answer.latencyTimings = { + ...answer.latencyTimings, + total_latency_ms: Date.now() - startedAt, + }; + return answer; + } catch { + // The in-flight request we coalesced onto failed — most often because the ORIGINATING + // caller aborted mid-flight (its AbortSignal is not ours) or its search phase threw. Do + // not propagate another caller's failure to this still-connected request: fall through to + // an independent, uncoalesced run so this request is decided only by its own inputs. + } } const pending = answerQuestionWithScopeUncoalesced(args, startedAt).finally(() => { @@ -4640,6 +4648,11 @@ ${qualityRetryInstruction}` // citations from the retrieved results, so trigger recovery whenever the // generated answer is unusable and we have retrieved results to extract from. const canRecoverExtractively = !usedStrongModel && (answer.citations.length > 0 || answerInputResults.length > 0); + // Numeric faithfulness at finalize time must verify against the packed context the model + // actually generated from, not the unpacked answer.sources — otherwise a figure copied from + // a neighbour chunk's adjacent_context reads as unverified and blanks a correct dose/threshold + // answer. Only the model path needs this; the extractive branch verifies against its own sources. + let numericVerificationSources: SearchResult[] | undefined; if (canRecoverExtractively && isUnusableGeneratedAnswer(answer)) { answer = buildExtractiveAnswer({ query: args.query, @@ -4661,6 +4674,7 @@ ${qualityRetryInstruction}` } else { answer = boldRagAnswerHighYieldText(answer, args.query); answer.sources = answerInputResults; + numericVerificationSources = attachAdjacentContext(answerInputResults, packedContextResults); answer.quoteCards = reconcileQuoteCards(answer.quoteCards, answerInputResults, args.query); answer.documentBreakdown = documentBreakdown; answer.evidenceSummary = evidenceSummary; @@ -4692,7 +4706,7 @@ ${qualityRetryInstruction}` ...retrievalDiagnostics, routeMode: answer.routingMode ?? retrievalDiagnostics.routeMode, }); - answer = finalizeRagAnswerQuality(answer, args.query, queryClass); + answer = finalizeRagAnswerQuality(answer, args.query, queryClass, numericVerificationSources); if (args.logQuery !== false) await logRagQuery({ diff --git a/src/lib/security-headers.ts b/src/lib/security-headers.ts index acff2f410..42777bcb6 100644 --- a/src/lib/security-headers.ts +++ b/src/lib/security-headers.ts @@ -70,11 +70,12 @@ export function buildContentSecurityPolicy({ "frame-ancestors 'none'; " + "form-action 'self'; " + upgradeInsecureRequests + - // img-src must allow https: so cross-origin Supabase Storage signed-URL - // images (document pages) can load. connect-src must include *.supabase.co - // for the signed-URL/API fetches. - "img-src 'self' data: blob: https:; " + - "media-src 'self' https:; " + + // img-src/media-src are scoped to the Supabase Storage origin that serves the + // signed-URL images (document pages) — the app loads no other cross-origin + // media, so a bare `https:` would needlessly widen the exfil surface. connect-src + // must include *.supabase.co for the signed-URL/API fetches. + "img-src 'self' data: blob: https://*.supabase.co; " + + "media-src 'self' https://*.supabase.co; " + "connect-src 'self' https://*.supabase.co https://api.openai.com; " + scriptSrc + "style-src 'self' 'unsafe-inline'" diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts index aa23292cf..cba820e8a 100644 --- a/tests/api-rate-limit-fallback.test.ts +++ b/tests/api-rate-limit-fallback.test.ts @@ -104,3 +104,48 @@ describe("paid anonymous answer limits", () => { expect(rpc.mock.calls[0]?.[1]).toMatchObject({ p_subject_key: "anon:caller" }); }); }); + +describe("document_upload fail-closed limiter", () => { + it("fails closed (does not fall back to per-instance memory) when the durable limiter is unavailable", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => false, + })); + const { ApiRateLimitUnavailableError, consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit"); + const supabase = { + rpc: vi.fn(async () => ({ data: null, error: { code: "PGRST202", message: "missing RPC" } })), + }; + + // Even when the caller opts into the in-memory fallback, document_upload (storage writes + + // ingestion cost) must fail closed rather than grant N× the limit across instances. + await expect( + consumeSubjectApiRateLimit({ + supabase: supabase as never, + subject: { kind: "owner", ownerId: "owner-1" }, + bucket: "document_upload", + allowInMemoryFallbackOnUnavailable: true, + }), + ).rejects.toBeInstanceOf(ApiRateLimitUnavailableError); + }); + + it("still allows the in-memory fallback for a non-fail-closed bucket (document_read)", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => false, + })); + const { consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit"); + const supabase = { + rpc: vi.fn(async () => ({ data: null, error: { code: "PGRST202", message: "missing RPC" } })), + }; + + const result = await consumeSubjectApiRateLimit({ + supabase: supabase as never, + subject: { kind: "owner", ownerId: "owner-1" }, + bucket: "document_read", + allowInMemoryFallbackOnUnavailable: true, + }); + + // document_read degrades to the per-instance limiter instead of failing closed. + expect(result.limited).toBe(false); + }); +}); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index da57ea13e..aad8e608e 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -37,7 +37,7 @@ describe("proxy content-security-policy", () => { expect(csp).toContain("default-src 'self'"); expect(csp).toContain("object-src 'none'"); expect(csp).toContain("frame-ancestors 'none'"); - expect(csp).toContain("img-src 'self' data: blob: https:"); + expect(csp).toContain("img-src 'self' data: blob: https://*.supabase.co"); expect(csp).toContain("connect-src 'self' https://*.supabase.co https://api.openai.com"); expect(csp).toContain("style-src 'self' 'unsafe-inline'"); }); diff --git a/tests/rag-answer-fallback.test.ts b/tests/rag-answer-fallback.test.ts index ddce99cd1..1a1a0d9de 100644 --- a/tests/rag-answer-fallback.test.ts +++ b/tests/rag-answer-fallback.test.ts @@ -1198,6 +1198,72 @@ describe("RAG structured-output fallback", () => { expect(secondAnswer.routingReason).toContain("answer_inflight_coalesced"); }); + it("does not propagate an originating request's abort to a coalesced concurrent caller", async () => { + vi.stubEnv("OPENAI_API_KEY", "test-key"); + vi.stubEnv("RAG_SEARCH_CACHE_TTL_MS", "0"); + vi.stubEnv("RAG_ANSWER_CACHE_TTL_MS", "300000"); + vi.stubEnv("RAG_ANSWER_CACHE_SIZE", "100"); + + const sources = [source()]; + const rpc = vi.fn(async (name: string) => { + if (name === "match_document_chunks_text") return { data: sources, error: null }; + if (name === "get_related_document_metadata") return { data: [], error: null }; + return { data: [], error: null }; + }); + const generateStructuredTextResult = vi.fn(async () => ({ + text: JSON.stringify({ + answer: "Use a stepwise agitation and arousal approach based on rating and route.", + grounded: true, + confidence: "high", + answerSections: [], + citations: [{ chunk_id: "agitation-chunk-1" }], + quoteCards: [], + conflictsOrGaps: [], + }), + model: "gpt-4.1-mini", + operation: "answer", + latencyMs: 12, + requestId: "req_independent", + usage: { input_tokens: 100, output_tokens: 120, total_tokens: 220 }, + })); + + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => ({ rpc, from: vi.fn(() => new EmptyQuery()) }), + })); + vi.doMock("@/lib/openai", () => ({ + embedTextWithTelemetry: vi.fn(async () => ({ embedding: [0.1, 0.2, 0.3], cacheHit: false })), + generateStructuredTextResult, + })); + + const { answerQuestionWithScope } = await import("../src/lib/rag"); + + // Originating request aborts before it can produce an answer; its shared in-flight promise rejects. + const controller = new AbortController(); + controller.abort(); + const first = answerQuestionWithScope({ + query: "Summarize inpatient approach", + ownerId: "owner-1", + logQuery: false, + signal: controller.signal, + }); + // A second identical request coalesces onto the (now-doomed) in-flight promise. + const second = answerQuestionWithScope({ + query: "Summarize inpatient approach", + ownerId: "owner-1", + logQuery: false, + }); + + // The originator's failure stays with the originator... + await expect(first).rejects.toBeTruthy(); + // ...and the coalesced caller still gets a real, independently generated answer rather than a 500. + const secondAnswer = await second; + expect(secondAnswer.openAIRequestIds).toEqual(["req_independent"]); + expect(secondAnswer.routingReason ?? "").not.toContain("answer_inflight_coalesced"); + // It ran its OWN pipeline (search + generation once) instead of cloning the failed one. + expect(generateStructuredTextResult).toHaveBeenCalledTimes(1); + expect(rpc.mock.calls.filter(([name]) => name === "match_document_chunks_text")).toHaveLength(1); + }); + it("retries fast model output that cites evidence IDs outside retrieved chunks", async () => { vi.stubEnv("OPENAI_API_KEY", "test-key"); vi.stubEnv("RAG_SEARCH_CACHE_TTL_MS", "0"); diff --git a/tests/rag-content-accuracy.test.ts b/tests/rag-content-accuracy.test.ts index 19658ec85..c7badef3e 100644 --- a/tests/rag-content-accuracy.test.ts +++ b/tests/rag-content-accuracy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { VERIFY_AGAINST_SOURCE_NOTE } from "../src/lib/answer-verification"; import { applyNumericVerification, truncateForModel, unboldUnverifiedNumbers } from "../src/lib/rag"; +import { attachAdjacentContext } from "../src/lib/rag-cache"; import type { RagAnswer, SearchResult } from "../src/lib/types"; describe("truncateForModel — boundary-aware, number-safe source truncation (P7)", () => { @@ -117,4 +118,82 @@ describe("applyNumericVerification — single faithfulness caveat even when the expect(gated.routingReason).toContain("numeric_faithfulness_gate_source_gap"); expect(gated.answer).not.toContain("500 mg"); }); + + // Regression: the model generates from the PACKED context (adjacent_context carries neighbour + // -chunk text), but answer.sources is the UNPACKED answer-input set. Re-verifying finalize-time + // against the unpacked sources blanked correct dose answers whose figure lived only in the packed + // adjacent_context. Passing the packed corpus as verificationSources keeps the number verified. + it("does not suppress a dose the model saw only in the packed adjacent_context (corpus-parity fix)", () => { + const packedCorpus: SearchResult[] = [ + { + id: "c1", + document_id: "d1", + title: "Service Overview", + file_name: "service-overview.pdf", + page_number: 1, + chunk_index: 0, + section_heading: "Overview", + content: "The service supports patients through their recovery journey with structured input.", + image_ids: [], + similarity: 0.9, + hybrid_score: 0.9, + images: [], + adjacent_context: "Adults: the usual therapeutic dose is 500 mg daily.", + }, + ]; + + // Bug repro: verifying against the unpacked sources (no adjacent_context) fails the answer closed. + const suppressed = applyNumericVerification(unverifiedAnswer("The usual therapeutic dose is **500 mg** daily.")); + expect(suppressed.grounded).toBe(false); + expect(suppressed.confidence).toBe("unsupported"); + + // Fix: verifying against the packed corpus the model actually saw keeps the answer intact. + const verified = applyNumericVerification( + unverifiedAnswer("The usual therapeutic dose is **500 mg** daily."), + packedCorpus, + ); + expect(verified.grounded).toBe(true); + expect(verified.confidence).toBe("high"); + expect(verified.answer).toContain("500 mg"); + expect(verified.routingReason ?? "").not.toContain("numeric_faithfulness_gate_source_gap"); + }); +}); + +describe("attachAdjacentContext — rebuild the packed verification corpus by chunk id", () => { + const base: SearchResult = { + id: "c1", + document_id: "d1", + title: "Doc", + file_name: "doc.pdf", + page_number: 1, + chunk_index: 0, + section_heading: null, + content: "See the neighbouring row.", + image_ids: [], + similarity: 0.9, + images: [], + }; + + it("overlays adjacent_context from the packed set onto matching ids", () => { + const unpacked: SearchResult[] = [base]; + const packed: SearchResult[] = [{ ...base, adjacent_context: "Dose is 500 mg daily." }]; + const merged = attachAdjacentContext(unpacked, packed); + expect(merged[0]!.adjacent_context).toBe("Dose is 500 mg daily."); + // Does not mutate the input source object. + expect(base.adjacent_context).toBeUndefined(); + }); + + it("returns the original array reference when the packed set adds nothing", () => { + const unpacked: SearchResult[] = [base]; + expect(attachAdjacentContext(unpacked, [])).toBe(unpacked); + expect(attachAdjacentContext(unpacked, [{ ...base, adjacent_context: undefined }])).toBe(unpacked); + }); + + it("leaves non-matching ids untouched", () => { + const unpacked: SearchResult[] = [base, { ...base, id: "c2" }]; + const packed: SearchResult[] = [{ ...base, id: "c1", adjacent_context: "neighbour text" }]; + const merged = attachAdjacentContext(unpacked, packed); + expect(merged[0]!.adjacent_context).toBe("neighbour text"); + expect(merged[1]!.adjacent_context).toBeUndefined(); + }); }); diff --git a/tests/security-headers.test.ts b/tests/security-headers.test.ts index 22ba595bb..b1273bd0b 100644 --- a/tests/security-headers.test.ts +++ b/tests/security-headers.test.ts @@ -7,7 +7,7 @@ import { buildContentSecurityPolicy, buildSecurityHeaders, resolveRuntimeFlags } // while all server-side tests still pass: // 1. Cross-Origin-Embedder-Policy: require-corp — blocks cross-origin // subresources that lack a CORP/CORS opt-in (Supabase does not send one). -// 2. A CSP that stops allowing https: images or the *.supabase.co origin. +// 2. A CSP that stops allowing the *.supabase.co image origin. // These assertions fail loudly if either is reintroduced. const flagVariants = [ @@ -30,10 +30,13 @@ describe("security headers", () => { expect(headers.some((header) => /require-corp/i.test(header.value))).toBe(false); }); - it("allows https: images so Supabase Storage signed-URL images can load", () => { + it("scopes img-src to the Supabase Storage origin (no bare https: wildcard)", () => { const imgSrc = csp.split(";").find((directive) => directive.trim().startsWith("img-src")); expect(imgSrc).toBeDefined(); - expect(imgSrc).toContain("https:"); + const sources = imgSrc!.trim().split(/\s+/); + // Supabase signed-URL images still load, but a broad `https:` wildcard is not allowed. + expect(sources).toContain("https://*.supabase.co"); + expect(sources).not.toContain("https:"); }); it("allows the Supabase origin in connect-src for signed-URL/API fetches", () => { diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts index aa7014c2f..e83e078f3 100644 --- a/tests/setup-status-route.test.ts +++ b/tests/setup-status-route.test.ts @@ -93,4 +93,110 @@ describe("/api/setup-status", () => { expect(response.status).toBe(200); expect(body.checks).toEqual(expect.arrayContaining([expect.objectContaining({ id: "project", status: "ready" })])); }); + + it("coarsens per-check detail for anonymous production callers and restores it with the operator token", async () => { + vi.stubEnv("NODE_ENV", "production"); + const from = vi.fn(async () => ({ error: null, data: [], count: 0 })); + const createAdminClient = vi.fn(() => ({ from, rpc: vi.fn() })); + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "service-role-key", + OPENAI_API_KEY: "openai-key", + SUPABASE_DOCUMENT_BUCKET: "clinical-documents", + SUPABASE_IMAGE_BUCKET: "clinical-images", + WORKER_POLL_MS: 1500, + HEALTH_DEEP_PROBE_SECRET: "operator-secret", + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/supabase/health", () => ({ + probeSupabaseHealth: vi.fn(async () => ({ ok: true })), + isSupabaseUnavailableError: () => false, + formatSupabaseUnavailableError: (error: unknown) => String(error), + })); + vi.doMock("@/lib/supabase/project", () => ({ + checkSupabaseProjectConfig: () => ({ status: "warning", detail: "raw project posture detail" }), + formatSupabaseProjectCheck: () => "SECRET-PROJECT-POSTURE-DETAIL", + })); + const { GET } = await import("../src/app/api/setup-status/route"); + + // Anonymous production caller: raw per-check detail (Supabase/project posture) is redacted. + const anon = await (await GET(new Request("https://clinical.example/api/setup-status"))).json(); + expect(JSON.stringify(anon)).not.toContain("SECRET-PROJECT-POSTURE-DETAIL"); + const anonProject = anon.checks.find((c: { id: string }) => c.id === "project"); + expect(anonProject.detail).not.toContain("SECRET-PROJECT-POSTURE-DETAIL"); + + // Operator presenting the shared deep-probe token: full detail is restored. + const operator = await ( + await GET( + new Request("https://clinical.example/api/setup-status", { + headers: { "x-health-deep-token": "operator-secret" }, + }), + ) + ).json(); + const operatorProject = operator.checks.find((c: { id: string }) => c.id === "project"); + expect(operatorProject.detail).toContain("SECRET-PROJECT-POSTURE-DETAIL"); + }); + + it("does not unlock detail for a spoofed local-looking Host in production (trusted-signal gate)", async () => { + vi.stubEnv("NODE_ENV", "production"); + const from = vi.fn(async () => ({ error: null, data: [], count: 0 })); + const createAdminClient = vi.fn(() => ({ from, rpc: vi.fn() })); + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "service-role-key", + OPENAI_API_KEY: "openai-key", + SUPABASE_DOCUMENT_BUCKET: "clinical-documents", + SUPABASE_IMAGE_BUCKET: "clinical-images", + WORKER_POLL_MS: 1500, + HEALTH_DEEP_PROBE_SECRET: "operator-secret", + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/supabase/health", () => ({ + probeSupabaseHealth: vi.fn(async () => ({ ok: true })), + isSupabaseUnavailableError: () => false, + formatSupabaseUnavailableError: (error: unknown) => String(error), + })); + vi.doMock("@/lib/supabase/project", () => ({ + checkSupabaseProjectConfig: () => ({ status: "warning", detail: "raw project posture detail" }), + formatSupabaseProjectCheck: () => "SECRET-PROJECT-POSTURE-DETAIL", + })); + const { GET } = await import("../src/app/api/setup-status/route"); + + // localhost:3100 is a managed project port (3100-4599), so it passes the local-project guard and + // reaches the detail gate. The Host is client-controllable behind a proxy — it must NOT unlock + // raw detail in a production runtime just because it looks local. + const response = await GET(new Request("http://localhost:3100/api/setup-status")); + const body = await response.json(); + expect(response.status).toBe(200); + expect(JSON.stringify(body)).not.toContain("SECRET-PROJECT-POSTURE-DETAIL"); + }); + + it("allowDeepHealthProbe rejects a crafted multi-byte token without throwing (byte-length safety)", async () => { + // The HTTP layer decodes header bytes as latin1, so a raw multi-byte UTF-8 token arrives as a + // string whose code-unit length can match the secret while its re-encoded UTF-8 byte length + // differs — which made a `token.length === secret.length` gate pass and timingSafeEqual throw. + // Test the helper directly with a fake request (the Headers API rejects non-latin1 values). + vi.doMock("@/lib/env", () => ({ + env: { HEALTH_DEEP_PROBE_SECRET: "abcdefghijklmnop" }, // 16 code units / 16 bytes + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + })); + const { allowDeepHealthProbe } = await import("../src/lib/deep-probe-auth"); + const fakeRequest = (token: string) => + ({ headers: { get: (name: string) => (name === "x-health-deep-token" ? token : null) } }) as unknown as Request; + + // 8 emoji = 16 UTF-16 code units (matches the 16-char secret) but 32 UTF-8 bytes. + expect(() => allowDeepHealthProbe(fakeRequest("😀".repeat(8)))).not.toThrow(); + expect(allowDeepHealthProbe(fakeRequest("😀".repeat(8)))).toBe(false); + // An exact match still authorizes. + expect(allowDeepHealthProbe(fakeRequest("abcdefghijklmnop"))).toBe(true); + }); }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index c22b55644..5f1c8ff20 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -937,7 +937,7 @@ test.describe("Clinical KB UI smoke coverage", () => { // Regression guard for the "all images fail to render" incident: document // page images load cross-origin from Supabase Storage signed URLs. A // Cross-Origin-Embedder-Policy: require-corp header (or a CSP that drops - // https: images / the *.supabase.co origin) silently breaks every image + // the *.supabase.co image origin) silently breaks every image // while all other tests still pass. Assert the actual served headers. const response = await page.request.get("/"); expect(response.status()).toBe(200); @@ -948,7 +948,8 @@ test.describe("Clinical KB UI smoke coverage", () => { const csp = headers["content-security-policy"] ?? ""; expect(csp).toContain("img-src"); const imgSrc = csp.split(";").find((directive) => directive.trim().startsWith("img-src")); - expect(imgSrc).toContain("https:"); + expect(imgSrc).toContain("https://*.supabase.co"); + expect(imgSrc?.trim().split(/\s+/)).not.toContain("https:"); expect(csp).toContain("https://*.supabase.co"); });