From 4f03aeffe599327c0ebf39a87da8293210bca87d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:00:12 +0800 Subject: [PATCH 01/16] test(eval): add force-embedding flag + 10 vector-exercising golden cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The golden retrieval set was 100% lexical fast-path (embedding_skipped_rate=1.0), so it could not measure whether a re-index changes vector/embedding retrieval quality. - forceEmbedding option on searchChunksWithTelemetry (SearchChunksArgs): bypasses every lexical text-fast-path so retrieval always exercises the embedding/vector stage. Diagnostic/eval-only; folded into the search cache key; never set on production paths. - eval-retrieval.ts: per-case `forceEmbedding` field + a global `--force-embedding` flag. - 10 `vector-*` cases (psychiatric monographs: PTSD, OCD, panic, anorexia, GAD, Tourette, postnatal, bipolar, ADHD, opioid) with forceEmbedding=true. Each is a clinical query that must be answered by vector retrieval of the right monograph — verified live at document_recall@5=1.0, content_recall@5=1.0, all via strategy=hybrid (embedding used). Rationale: forcing embedding is the correct instrument for re-index measurement — you want to measure the vector index directly, not have a lexical shortcut mask a regression. Wording alone can't reliably force the vector path (the fast-path is driven by emergent lexical-match strength), so the flag makes these probes deterministic. Live golden eval: 34/34 pass (24 existing + 10 new), no regression. verify:cheap green (980). Co-Authored-By: Claude Fable 5 --- scripts/eval-retrieval.ts | 11 ++ scripts/fixtures/rag-retrieval-golden.json | 194 +++++++++++++++++++++ src/lib/rag.ts | 11 +- 3 files changed, 213 insertions(+), 3 deletions(-) diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index 0a4332f2f..7218ae731 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -19,6 +19,10 @@ const goldenCaseSchema = z.object({ expectedContentTerms: z.array(contentExpectationSchema).default([]), topK: z.number().int().positive().default(8), expectTableEvidence: z.boolean().default(false), + // Bypass the lexical text-fast-path so this case always exercises the embedding/vector + // index. Use for "vector-*" probes that would otherwise be answered by a lexical shortcut, + // so a re-index's effect on vector retrieval is actually measured. + forceEmbedding: z.boolean().optional(), }); const goldenCasesSchema = z.array(goldenCaseSchema); @@ -37,6 +41,7 @@ type EvalArgs = { caseTimeoutMs: number; p90BudgetMs: number; p50BudgetMs: number; + forceEmbedding: boolean; }; export type GoldenRetrievalResult = { @@ -116,6 +121,7 @@ function parseArgs(argv: string[]): EvalArgs { caseTimeoutMs: inferredMode === "latency" ? 25_000 : 0, p90BudgetMs: 20_000, p50BudgetMs: 8_000, + forceEmbedding: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -138,6 +144,10 @@ function parseArgs(argv: string[]): EvalArgs { if (args.caseTimeoutMs <= 0) args.caseTimeoutMs = 25_000; continue; } + if (token === "--force-embedding") { + args.forceEmbedding = true; + continue; + } const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); @@ -743,6 +753,7 @@ async function main() { topK: testCase.topK, minSimilarity: 0.12, skipCache: args.mode !== "latency", + forceEmbedding: testCase.forceEmbedding || args.forceEmbedding, }), ); const searchOutcome = await withCaseTimeout(searchPromise, args.caseTimeoutMs); diff --git a/scripts/fixtures/rag-retrieval-golden.json b/scripts/fixtures/rag-retrieval-golden.json index b31176b76..5f3cdabb4 100644 --- a/scripts/fixtures/rag-retrieval-golden.json +++ b/scripts/fixtures/rag-retrieval-golden.json @@ -224,5 +224,199 @@ ], "topK": 12, "expectTableEvidence": true + }, + { + "id": "vector-ptsd", + "query": "How is post-traumatic stress disorder managed in a person with intrusive flashbacks, nightmares, hyperarousal and avoidance after a traumatic event?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Traumatic Stress" + ], + "expectedContentTerms": [ + [ + "trauma", + "traumatic", + "ptsd", + "flashback" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-ocd", + "query": "How is obsessive-compulsive disorder managed in a person with distressing intrusive obsessions and repetitive compulsive rituals?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Obsessive" + ], + "expectedContentTerms": [ + [ + "obsess", + "compuls", + "intrusive", + "ocd" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-panic", + "query": "How is panic disorder managed in a person with recurrent unexpected panic attacks, palpitations and anticipatory anxiety?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Panic" + ], + "expectedContentTerms": [ + [ + "panic", + "attack", + "anxiety" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-anorexia", + "query": "How is anorexia nervosa managed in a person with severe dietary restriction, intense fear of weight gain and body-image disturbance?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Anorexia" + ], + "expectedContentTerms": [ + [ + "anorexia", + "eating", + "weight" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-gad-worry", + "query": "How is a patient managed who has persistent, excessive worry about many everyday things that they find very hard to control, along with restlessness and muscle tension?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Generalised Anxiety" + ], + "expectedContentTerms": [ + [ + "worry", + "anxiety", + "generalised" + ], + [ + "cbt", + "ssri", + "snri", + "antidepressant", + "psychotherapy" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-tourette", + "query": "How is Tourette syndrome managed in a child with chronic motor tics and vocal tics?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Tourette" + ], + "expectedContentTerms": [ + [ + "tic", + "tics", + "tourette" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-postnatal", + "query": "How is postnatal (postpartum) depression managed in a new mother who develops low mood and poor bonding with her infant in the first weeks postpartum?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Postnatal" + ], + "expectedContentTerms": [ + [ + "postnatal", + "postpartum", + "perinatal", + "depression" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-bipolar", + "query": "How is bipolar disorder managed in an adult with recurrent episodes of mania and depression?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Bipolar" + ], + "expectedContentTerms": [ + [ + "bipolar", + "mania", + "manic", + "mood" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-adhd", + "query": "How is attention deficit hyperactivity disorder managed in an adult with inattention, distractibility and impulsivity?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Attention Deficit Hyperactivity" + ], + "expectedContentTerms": [ + [ + "attention", + "adhd", + "hyperactiv", + "impuls" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-opioid", + "query": "How is opioid use disorder managed in a person dependent on heroin?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Opioid Use Disorder" + ], + "expectedContentTerms": [ + [ + "opioid", + "heroin", + "opiate", + "methadone", + "buprenorphine" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true } ] diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 6764accd2..9f8e6f059 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -307,6 +307,10 @@ export type SearchChunksArgs = { // Internal: set when this call is a re-run on a trigram-corrected query, to prevent the // unsupported-short-circuit typo-correction path from recursing more than once. typoCorrected?: boolean; + // Diagnostic/eval-only: bypass every lexical text-fast-path so retrieval always exercises + // the embedding/vector stage. Lets the golden eval measure the vector index directly for a + // re-index, instead of being masked by lexical shortcuts. Never set on production paths. + forceEmbedding?: boolean; }; export type AnswerProgressEvent = { @@ -1455,6 +1459,7 @@ function scopedSearchCacheKey(args: SearchChunksArgs, queryClass?: RagQueryClass args.ownerId ?? "anonymous", scopeKey(args), retrievalPlanCacheQuery(args, queryClass, queryVariants), + args.forceEmbedding ? "force-embedding" : "", ].join("|"); } @@ -5516,7 +5521,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { }); const baseTextFastPath = decideTextFastPath(args.query, baseTextResults, queryClassification.queryClass); - if (shouldReturnBeforeMemory(queryClassification.queryClass, baseTextFastPath)) { + if (!args.forceEmbedding && shouldReturnBeforeMemory(queryClassification.queryClass, baseTextFastPath)) { textFastResults = await attachPageVisualEvidence(supabase, baseTextResults); textFastResults = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, @@ -5567,7 +5572,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.rerank_latency_ms += Date.now() - rerankStartedAt; const boostedTextFastPath = decideTextFastPath(args.query, textFastResults, queryClassification.queryClass); - if (boostedTextFastPath.returnFastPath) { + if (!args.forceEmbedding && boostedTextFastPath.returnFastPath) { markEmbeddingSkippedByTextFastPath(telemetry, boostedTextFastPath.reason); telemetry.retrieval_strategy = "text_fast_path"; recordSearchScoreTelemetry(telemetry, textFastResults); @@ -5673,7 +5678,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentLookupResults, queryClassification.queryClass, ); - if (documentLookupFastPath.returnFastPath) { + if (!args.forceEmbedding && documentLookupFastPath.returnFastPath) { markEmbeddingSkippedByTextFastPath( telemetry, documentLookupFastPath.reason ? `document_lookup_fast_path:${documentLookupFastPath.reason}` : null, From a17fa3f2e967958d3faa2a72fbc4b7086843529a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:47:36 +0800 Subject: [PATCH 02/16] test(eval): harden force-embedding flag and vector-path eval guards Wire forceEmbedding through eval runners and retrieval cache keys, bypass coverage/lexical shortcuts when forced, and add golden-case failure metrics so vector regressions cannot hide behind text-fast-path or cache hits. --- scripts/eval-quality.ts | 15 ++++++++ scripts/eval-retrieval.ts | 24 ++++++++++++ src/lib/rag.ts | 50 ++++++++++++++++++------- tests/eval-quality.test.ts | 52 +++++++++++++++++++++++++- tests/retrieval-query-variants.test.ts | 12 ++++++ 5 files changed, 139 insertions(+), 14 deletions(-) diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 52cbc50f2..92c93703b 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -44,6 +44,7 @@ type EvalQualityArgs = { retrievalOnly: boolean; ragOnly: boolean; skipPreflight: boolean; + forceEmbedding: boolean; }; export type RagQualityResult = { @@ -149,6 +150,7 @@ function parseArgs(argv: string[]): EvalQualityArgs { retrievalOnly: false, ragOnly: false, skipPreflight: false, + forceEmbedding: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -175,6 +177,10 @@ function parseArgs(argv: string[]): EvalQualityArgs { args.skipPreflight = true; continue; } + if (token === "--force-embedding") { + args.forceEmbedding = true; + continue; + } const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); @@ -475,6 +481,11 @@ export function buildEvalQualityReport(args: { `retrieval content_recall_at_5 ${retrievalSummary.content_recall_at_5} below ${qualityThresholds.retrievalContentRecallAt5}`, ); } + if (retrievalSummary.force_embedding_failure_count > 0) { + thresholdFailures.push( + `retrieval force_embedding_failure_count ${retrievalSummary.force_embedding_failure_count} above 0`, + ); + } if (governance.stale_rate > qualityThresholds.staleTopResultRate) { thresholdFailures.push( `top-result stale_rate ${governance.stale_rate} above ${qualityThresholds.staleTopResultRate}`, @@ -663,6 +674,8 @@ ${markdownTable([ ## Retrieval Decision Metrics ${markdownTable([ + ["Force-embedding cases", retrieval.force_embedding_case_count], + ["Force-embedding failures", retrieval.force_embedding_failure_count], ["Embedding skipped rate", retrieval.embedding_skipped_rate], ["Median text candidate budget", retrieval.median_text_candidate_budget], ["Second-stage rerank rate", retrieval.second_stage_rerank_rate], @@ -727,6 +740,7 @@ async function runRetrievalQualityCases(args: { ownerId?: string; limit?: number; query?: string; + forceEmbedding?: boolean; supabase: Awaited>; }) { const [{ searchChunksWithTelemetry }, capturedCases] = await Promise.all([ @@ -753,6 +767,7 @@ async function runRetrievalQualityCases(args: { topK: testCase.topK, minSimilarity: 0.12, skipCache: true, + forceEmbedding: testCase.forceEmbedding || args.forceEmbedding, }), ); const latencyMs = diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index 7218ae731..23194974c 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -47,6 +47,7 @@ type EvalArgs = { export type GoldenRetrievalResult = { id: string; query: string; + forceEmbedding: boolean; expectedQueryClass: string; actualQueryClass: string | null; expectedDocumentSubstrings: string[]; @@ -468,6 +469,11 @@ export function evaluateGoldenRetrievalCase(args: { const tableEvidenceFoundAtK = hasTableEvidence(args.results, topK); const actualQueryClass = args.telemetry.query_class ?? null; const failures: string[] = []; + const vectorLayerCount = Object.entries(args.telemetry.retrieval_layer_counts ?? {}).reduce( + (sum, [layer, count]) => + ["embedding_fields", "index_units", "hybrid_vector", "vector_fallback"].includes(layer) ? sum + count : sum, + 0, + ); const hitAtK = documentHitsAtK.missing.length === 0 && contentHitsAtK.missing.length === 0 && @@ -485,10 +491,23 @@ export function evaluateGoldenRetrievalCase(args: { if (args.testCase.expectTableEvidence && !tableEvidenceFound) { failures.push("expected table evidence in top 5"); } + if (args.testCase.forceEmbedding) { + if (args.telemetry.embedding_skipped) failures.push("forceEmbedding expected embedding to run"); + if (args.telemetry.retrieval_strategy === "search_cache") failures.push("forceEmbedding served search cache"); + if ( + args.telemetry.retrieval_strategy === "text_fast_path" || + args.telemetry.retrieval_strategy === "document_lookup_fast_path" + ) { + failures.push(`forceEmbedding returned lexical strategy ${args.telemetry.retrieval_strategy}`); + } + if (args.telemetry.coverage_gate_decision === "accepted") failures.push("forceEmbedding returned coverage gate"); + if (vectorLayerCount <= 0) failures.push("forceEmbedding found no vector-layer candidates"); + } return { id: args.testCase.id, query: args.testCase.query, + forceEmbedding: args.testCase.forceEmbedding ?? false, expectedQueryClass: args.testCase.expectedQueryClass, actualQueryClass, expectedDocumentSubstrings: args.testCase.expectedDocumentSubstrings, @@ -560,6 +579,7 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] } return counts; }, {}); + const forceEmbeddingResults = results.filter((result) => result.forceEmbedding); return { case_count: results.length, document_recall_at_5: Number( @@ -588,6 +608,8 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] embedding_skip_reason_counts: embeddingSkipReasonCounts, text_fast_path_reason_counts: textFastPathReasonCounts, retrieval_layer_counts: layerCounts, + force_embedding_case_count: forceEmbeddingResults.length, + force_embedding_failure_count: forceEmbeddingResults.filter((result) => result.failures.length > 0).length, median_text_candidate_budget: percentile(textCandidateBudgets, 50), second_stage_rerank_rate: Number( (results.filter((result) => result.secondStageRerankUsed).length / Math.max(results.length, 1)).toFixed(4), @@ -661,6 +683,8 @@ function printHumanSummary(summary: ReturnType, queryClass?: RagQueryClass, queryVariants: string[] = [], @@ -1448,6 +1455,7 @@ export function retrievalPlanCacheQuery( `mode:${modeKey(args)}`, `topK:${args.topK ?? 8}`, `min:${args.minSimilarity ?? 0.15}`, + `forceEmbedding:${args.forceEmbedding ? "1" : "0"}`, `rag:${ragDeepMemoryVersion}`, ].join("|"); return queryCacheKeyForStorage(cacheKey); @@ -1459,7 +1467,6 @@ function scopedSearchCacheKey(args: SearchChunksArgs, queryClass?: RagQueryClass args.ownerId ?? "anonymous", scopeKey(args), retrievalPlanCacheQuery(args, queryClass, queryVariants), - args.forceEmbedding ? "force-embedding" : "", ].join("|"); } @@ -1546,7 +1553,7 @@ type SharedCacheMissReason = function sharedCacheSelector( supabase: ReturnType, kind: SharedCacheKind, - args: Pick, + args: Pick, indexingVersion: string, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), ) { @@ -1711,7 +1718,10 @@ async function getSharedCachedSearch( } async function getSharedCachedAnswer( - args: Pick, + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, startedAt: number, ) { if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return null; @@ -1744,7 +1754,7 @@ async function getSharedCachedAnswer( async function replaceSharedCacheRow( kind: SharedCacheKind, - args: Pick, + args: Pick, payload: unknown, ttlMs: number, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), @@ -1796,7 +1806,10 @@ function setSharedCachedSearch( } function setSharedCachedAnswer( - args: Pick, + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, answer: RagAnswer, ) { if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return; @@ -5370,6 +5383,9 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { // When the provider is source-only (offline mode, or auto mode without a usable key) we must // never call OpenAI for embeddings; retrieval falls back to the lexical text-fast-path only. const sourceOnlyRetrieval = isSourceOnlyMode(); + if (args.forceEmbedding && sourceOnlyRetrieval) { + throw new Error("forceEmbedding requires embedding-capable retrieval; source-only mode cannot exercise vectors."); + } // A3: shared across every withMemoryBoostedCandidates call in this request so the same // owner/query memory cards are fetched at most once per (query, embedding-present, count). const memoryCardCache: MemoryCardCache = new Map(); @@ -5446,7 +5462,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.shared_cache_miss_reason = sharedCached.reason; } - if (shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) { + if (!args.forceEmbedding && shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) { // Item 10 follow-up (RC6): a typo can make an on-topic query ("schizophrenai management") look // unsupported and short-circuit before any layer runs. Before giving up, trigram-correct the // query against the known clinical-term vocabulary; if it changes, re-run the whole retrieval @@ -5704,8 +5720,8 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry, }); const coverageGate = evaluateEvidenceCoverageGate(args.query, coverageGateResults, queryClassification.queryClass); - applyCoverageGateTelemetry(telemetry, coverageGate, coverageGate.accepted); - if (coverageGate.accepted) { + applyCoverageGateTelemetry(telemetry, coverageGate, !args.forceEmbedding && coverageGate.accepted); + if (!args.forceEmbedding && coverageGate.accepted) { telemetry.retrieval_strategy = coverageGate.strategy; recordSearchScoreTelemetry(telemetry, coverageGateResults); setCachedSearch(args, coverageGateResults, telemetry, queryVariants); @@ -5733,7 +5749,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { } catch (error) { // In auto mode a failed embedding call (e.g. quota exhausted) degrades to the lexical // results already gathered rather than failing the whole search. "openai" mode rethrows. - if (!allowsAutoDegrade()) throw error; + if (args.forceEmbedding || !allowsAutoDegrade()) throw error; telemetry.embedding_skipped = true; telemetry.embedding_skip_reason = sourceOnlyReason(error); telemetry.vector_skipped_reason = classifyProviderFailure(error); @@ -5827,10 +5843,14 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { latencyMs: hybridResult.latencyMs, topScore: layerTopScore((hybridData ?? []) as SearchResult[]), }); + const vectorCandidates = mergeSearchResults( + mergeSearchResults((hybridData ?? []) as SearchResult[], embeddingFieldCandidates), + indexUnitCandidates, + ); if (!hybridError) { const rerankStartedAt = Date.now(); - const merged = mergeSearchResults((hybridData ?? []) as SearchResult[], textFastResults); + const merged = args.forceEmbedding ? vectorCandidates : mergeSearchResults(vectorCandidates, textFastResults); const mergedWithMetadata = await attachDocumentRankingMetadata(supabase, merged, args.ownerId); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -5888,7 +5908,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { return (data ?? []) as SearchResult[]; }), ).catch((error) => { - if (textFastResults.length > 0) return [] as SearchResult[][]; + if (!args.forceEmbedding && textFastResults.length > 0) return [] as SearchResult[][]; throw error; }); const fallbackLatencyMs = Date.now() - fallbackRpcStartedAt; @@ -5900,9 +5920,13 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { }); const rerankStartedAt = Date.now(); + const fallbackVectorCandidates = mergeSearchResults( + mergeSearchResults(resultSets.flat(), embeddingFieldCandidates), + indexUnitCandidates, + ); const mergedWithMetadata = await attachDocumentRankingMetadata( supabase, - mergeSearchResults(resultSets.flat(), textFastResults), + args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults), args.ownerId, ); const memoryBoost = await withMemoryBoostedCandidates({ diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts index deffec1cd..b41fd45f2 100644 --- a/tests/eval-quality.test.ts +++ b/tests/eval-quality.test.ts @@ -9,12 +9,13 @@ import { sourceWarningsForRagQualityAnswer, type RagQualityResult, } from "../scripts/eval-quality"; -import type { GoldenRetrievalResult } from "../scripts/eval-retrieval"; +import { evaluateGoldenRetrievalCase, type GoldenRetrievalResult } from "../scripts/eval-retrieval"; function retrievalResult(overrides: Partial = {}): GoldenRetrievalResult { const base: GoldenRetrievalResult = { id: "retrieval-1", query: "What ANC threshold should withhold clozapine?", + forceEmbedding: false, expectedQueryClass: "table_threshold", actualQueryClass: "table_threshold", expectedDocumentSubstrings: ["clozapine.pdf"], @@ -165,6 +166,8 @@ describe("eval quality reporting", () => { structured_threshold_text_match: 1, }); expect(report.retrieval.summary.second_stage_rerank_rate).toBe(0.5); + expect(report.retrieval.summary.force_embedding_case_count).toBe(0); + expect(report.retrieval.summary.force_embedding_failure_count).toBe(0); expect(report.rag.summary.unsupported_correct_rate).toBe(0); expect(report.rag.summary.numeric_grounding_failure_rate).toBeCloseTo(0.3333, 4); expect(report.rag.summary.source_governance_danger_failure_rate).toBeCloseTo(0.3333, 4); @@ -179,6 +182,53 @@ describe("eval quality reporting", () => { ); }); + it("fails forced-embedding retrieval cases that return from cache, coverage, or lexical paths", () => { + const result = evaluateGoldenRetrievalCase({ + testCase: { + id: "vector-regression", + query: "How is panic disorder managed?", + expectedQueryClass: "broad_summary", + expectedDocumentSubstrings: [], + expectedContentTerms: [], + topK: 8, + expectTableEvidence: false, + forceEmbedding: true, + }, + results: [], + telemetry: { + query_class: "broad_summary", + retrieval_strategy: "text_fast_path", + embedding_skipped: true, + embedding_skip_reason: "strong_document_text_score", + text_fast_path_reason: "strong_document_text_score", + text_candidate_budget: 32, + text_candidate_count: 5, + vector_candidate_count: 0, + retrieval_layer_counts: { text_candidates: 5 }, + coverage_gate_decision: "accepted", + coverage_gate_reason: "document_title_evidence_gate", + second_stage_rerank_used: false, + }, + latencyMs: 10, + }); + + expect(result.forceEmbedding).toBe(true); + expect(result.failures).toEqual( + expect.arrayContaining([ + "forceEmbedding expected embedding to run", + "forceEmbedding returned lexical strategy text_fast_path", + "forceEmbedding returned coverage gate", + "forceEmbedding found no vector-layer candidates", + ]), + ); + const report = buildEvalQualityReport({ retrievalResults: [result], ragResults: [] }); + expect(report.retrieval.summary.force_embedding_case_count).toBe(1); + expect(report.retrieval.summary.force_embedding_failure_count).toBe(1); + expect(report.blocking_threshold_failures).toEqual( + expect.arrayContaining([expect.stringContaining("retrieval force_embedding_failure_count 1 above 0")]), + ); + }); + it("derives source governance warnings for direct RAG answers without precomputed warnings", () => { const warnings = sourceWarningsForRagQualityAnswer({ sourceGovernanceWarnings: undefined, diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index ddb62411e..a2f06474e 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -127,6 +127,18 @@ describe("retrieval query variants", () => { expect(textCandidateBudgetForQueryClass("unsupported_or_general", 12)).toBe(24); }); + it("keeps forced-embedding retrieval out of the ordinary search cache key", () => { + const baseArgs = { + query: "How is panic disorder managed?", + topK: 8, + minSimilarity: 0.12, + }; + + expect(retrievalPlanCacheQuery(baseArgs, "broad_summary")).not.toBe( + retrievalPlanCacheQuery({ ...baseArgs, forceEmbedding: true }, "broad_summary"), + ); + }); + it("allows direct document title hits to skip embedding retrieval", () => { expect( decideTextFastPath( From 940084176946fe0e1544f5ec0939df0dd58ecdf4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:19:45 +0800 Subject: [PATCH 03/16] test: add deep public access and production scope checks --- tests/public-access-deep.test.ts | 225 +++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 tests/public-access-deep.test.ts diff --git a/tests/public-access-deep.test.ts b/tests/public-access-deep.test.ts new file mode 100644 index 000000000..0801c4bd9 --- /dev/null +++ b/tests/public-access-deep.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const publicDocumentId = "11111111-1111-4111-8111-111111111111"; + +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); + +describe("public access deep checks", () => { + it("rejects unauthenticated access to operator-only routes", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + const auth = { + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => { + throw new auth.AuthenticationError(); + }), + unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }), + }; + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + + const cases = [ + { + routePath: "../src/app/api/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/jobs"), + }, + { + routePath: "../src/app/api/ingestion/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/jobs"), + }, + { + routePath: "../src/app/api/ingestion/batches/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/batches"), + }, + { + routePath: "../src/app/api/documents/bulk/route", + handler: "POST" as const, + request: new Request("http://localhost/api/documents/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete", documentIds: [publicDocumentId] }), + }), + }, + { + routePath: "../src/app/api/documents/[id]/summarize/route", + handler: "POST" as const, + request: new Request(`http://localhost/api/documents/${publicDocumentId}/summarize`, { method: "POST" }), + params: { id: publicDocumentId }, + }, + { + routePath: "../src/app/api/eval-cases/route", + handler: "POST" as const, + request: new Request("http://localhost/api/eval-cases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "What monitoring is needed?", + rating: "good", + answer: "Monitor FBC.", + queryMode: "auto", + queryClass: "table_threshold", + }), + }), + }, + ] as const; + + for (const testCase of cases) { + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + const mod = await import(testCase.routePath); + const handler = mod[testCase.handler]; + const response = await handler( + testCase.request, + "params" in testCase ? { params: Promise.resolve(testCase.params) } : undefined, + ); + expect(response.status, testCase.routePath).toBe(401); + } + }); + + it("does not expose secret values from the health endpoint", async () => { + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "super-secret-service-role-key", + OPENAI_API_KEY: "sk-super-secret-openai-key", + }, + isDemoMode: () => false, + })); + const { GET } = await import("../src/app/api/health/route"); + const response = await GET(new Request("https://clinical.example/api/health?deep=1")); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(serialized).not.toContain("super-secret-service-role-key"); + expect(serialized).not.toContain("sk-super-secret-openai-key"); + expect(body.checks.supabaseConfig).toBe("ok"); + expect(body.checks.openaiConfig).toBe("ok"); + }); + +}); + +describe("production anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("fails closed in production when anonymous global retrieval omits owner scope", async () => { + vi.doUnmock("@/lib/rag"); + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: "sk-test", + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + + await expect( + searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }), + ).rejects.toThrow(/ownerId|tenant/i); + }); +}); + +describe("test-runtime anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("allows anonymous global retrieval in test runtime where owner scope stays permissive", async () => { + vi.doUnmock("@/lib/rag"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: undefined, + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + in: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + const result = await searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }); + + expect(result.results).toEqual([]); + expect(result.telemetry).toBeDefined(); + }); +}); From a382754cce30010e187558412a4d54e7cd706328 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:26:19 +0800 Subject: [PATCH 04/16] refactor(ui): remove duplicate visual-evidence code from ClinicalDashboard Delete post-extraction dead code left in the monolith and trim unused imports. Also fix minor lint issues in favourites-hub, visual-evidence, and services-navigator. --- src/components/ClinicalDashboard.tsx | 433 +----------------- .../clinical-dashboard/favourites-hub.tsx | 1 - .../clinical-dashboard/visual-evidence.tsx | 1 - .../services/services-navigator-page.tsx | 6 +- 4 files changed, 9 insertions(+), 432 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..647d6e1a8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,18 +1,15 @@ "use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, Bell, BookOpen, - CheckCircle2, ChevronDown, ChevronRight, CircleUserRound, Clock3, - Copy, ExternalLink, FileImage, FileText, @@ -21,7 +18,6 @@ import { HelpCircle, Heart, Keyboard, - Layers, ListChecks, Loader2, LogOut, @@ -29,7 +25,6 @@ import { LockKeyhole, Palette, PanelTop, - Plus, Quote, RefreshCw, Search, @@ -48,66 +43,37 @@ import { import { type CSSProperties, type FormEvent, - type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { AccessibleTable } from "@/components/AccessibleTable"; -import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; -import { DocumentTagCloud } from "@/components/DocumentTagCloud"; -import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; -import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isLocalNoAuthMode } from "@/lib/env"; import { appBackdrop, answerSurface, - chatMicroAction, - clinicalDivider, cn, - EmptyState, - fieldControlPlain, fieldControlWithIcon, fieldIcon, floatingControl, - iconTilePremium, - metadataPill, - panelSubtle, primaryControl, - SourceProvenance, - SourceStatusBadge, - sourceCard, - subtleStatusPill, - tableCard, - tableCardHeader, - tableMicroActionRow, textMuted, - toneDanger, - toneInfo, - toneNeutral, toneSuccess, toneWarning, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; -import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -129,42 +95,22 @@ import { import { GuideDialog, GuideTrigger, - SectionHeading, UtilityDrawer, } from "@/components/clinical-dashboard/dashboard-shell"; import { - cleanDisplayTitle, sanitizeAnswerDisplayText, sanitizeDisplayText, } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, - SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; -import { - AnswerFeedbackPanel, - AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - simpleClinicalTableProps, - evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, - QuoteCards, - SafetyFindingsListContent, -} from "@/components/clinical-dashboard/evidence-panels"; +import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { emptyStates, errorCopy } from "@/lib/ui-copy"; +import { errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { DrawerGroupLabel, @@ -198,7 +144,7 @@ const DocumentDrawer = dynamic( ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; +import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -236,20 +182,15 @@ import { maxStoredAnswerTurns, savePersistedAnswerThread, } from "@/lib/answer-thread-storage"; -import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; +import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; import { - tagSearchText, type SmartDocumentTag, type SmartDocumentTagFacet, - type SmartDocumentTagTier, - type SmartDocumentTagQualityIssueKind, } from "@/lib/document-tags"; import type { ClinicalDocument, @@ -263,16 +204,13 @@ import type { RelatedDocument, SearchResult, SearchScopeSummary, - VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, - DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { createQuoteFollowUp, - type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates, } from "@/lib/ward-output"; @@ -487,367 +425,6 @@ async function readAnswerStream(response: Response, onProgress: (message: string function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } - -function compactClinicalTableCaption(item: VisualEvidenceCard) { - const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; - const cleaned = sourceTextForCompactDisplay(raw) - .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") - .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") - .replace(/\s{2,}/g, " ") - .trim(); - const caption = cleaned || "Clinical table"; - return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; -} - -function visualEvidenceHeader(item: VisualEvidenceCard) { - const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); - const titleText = sourceTextForCompactDisplay(titleSource).trim(); - const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); - const normalizedTitle = titleText.toLowerCase(); - const normalizedCaption = captionText.toLowerCase(); - const isDuplicateCaption = - Boolean(normalizedCaption) && - (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); - return { - title: titleText || captionText || "Visual evidence", - caption: isDuplicateCaption ? null : captionText, - }; -} - -function VisualEvidenceStrip({ - evidence, - collapsed = false, - embedded = false, -}: { - evidence: VisualEvidenceCard[]; - collapsed?: boolean; - embedded?: boolean; -}) { - function looksLikeTableText(value?: string | null) { - return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); - } - - if (collapsed) { - return ( -
- - - -
- ); - } - - const content = ( - <> - - {evidence.length === 0 ? ( - - ) : ( -
- {evidence.map((item) => { - const tableMarkdown = item.accessibleTableMarkdown?.trim() - ? item.accessibleTableMarkdown - : looksLikeTableText(item.tableTextSnippet) - ? item.tableTextSnippet - : null; - const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); - const tableCaption = compactClinicalTableCaption(item); - const sourceHeader = visualEvidenceHeader(item); - const displayLabels = smartEvidenceTags( - item.labels, - [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] - .filter(Boolean) - .join(" "), - ); - return ( -
-
- -
-
- {!hasStructuredTable ?

{sourceHeader.title}

: null} - {!hasStructuredTable && sourceHeader.caption ?

{sourceHeader.caption}

: null} - - {!hasStructuredTable && item.tableTextSnippet ? ( -

- {sourceTextForCompactDisplay(item.tableTextSnippet)} -

- ) : null} - {displayLabels.length ? ( -
- {displayLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
- - {formatCompactCitationLabel(item)} - - - {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"} - - {item.image_type && ( - - {item.image_type.replaceAll("_", " ")} - - )} - {!hasStructuredTable ? : null} - - - Open source - -
-
- ); - })} -
- )} - - ); - - if (embedded) return
{content}
; - - return ( -
- {content} -
- ); -} - -const evidenceTabIconMap: Record = { - Claims: CheckCircle2, - Quotes: Quote, - Tables: ListChecks, - Images: FileImage, - Gaps: AlertCircle, -}; - -function supportDotClass(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) { - return "bg-[color:var(--warning)]"; - } - return "bg-[color:var(--clinical-accent)]"; -} - -function supportLabel(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "Unsupported"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) - return "Partial"; - return "Direct"; -} - -function claimRowsForEvidencePanel(rows: AnswerEvidenceMapRow[], renderModel: AnswerRenderModel) { - if (rows.length) return rows.slice(0, 6); - return renderModel.primarySources.slice(0, 6).map((source, index) => ({ - id: source.id, - section: source.label || cleanDisplayTitle(source.title || source.file_name) || `Source ${index + 1}`, - detail: source.snippet || source.reason || "Open source passage to review the cited evidence.", - supportLevel: source.sourceStrength === "none" ? "partial" : source.sourceStrength, - citationCount: 1, - sourceStatus: - source.sourceStrength === "none" ? "Source requires review" : `${source.sourceStrength} source support`, - bestSourceLabel: source.label, - bestLinkedPassage: source.snippet || source.reason, - href: source.href, - })); -} - -function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[]; renderModel: AnswerRenderModel }) { - const claimRows = claimRowsForEvidencePanel(rows, renderModel); - const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length; - const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length; - - if (!claimRows.length) { - return ; - } - - return ( -
-
-
-

Claims checked

-
- - - Direct - - - - Partial - - - - Unsupported - -
-
-

- {directCount} direct · {partialCount} partial -

-
- -
- {claimRows.map((row, index) => ( - - - - - {row.section} - - {row.detail || row.bestLinkedPassage || row.bestSourceLabel} - - - - - ))} -
-
- ); -} - -function EvidenceGapsPanel({ warnings }: { warnings: string[] }) { - if (!warnings.length) { - return ( - - ); - } - - return ( -
- {warnings.map((warning, index) => ( -
- -
-

Gap {index + 1}

-

{warning}

-
-
- ))} -
- ); -} - -function MobileEvidenceTabPanel({ - tab, - renderModel, - visualEvidence, - answerEvidenceMapRows, - copiedQuotes, - onCopyQuotes, - onFollowUpQuote, - onScopeDocument, -}: { - tab: EvidenceTabName; - renderModel: AnswerRenderModel; - visualEvidence: VisualEvidenceCard[]; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - copiedQuotes: boolean; - onCopyQuotes: () => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - onScopeDocument: (documentId: string) => void; -}) { - if (tab === "Claims") { - return ; - } - - if (tab === "Tables") { - const tableEvidence = visualEvidence.filter((item) => item.accessibleTableMarkdown || item.tableRows?.length); - return tableEvidence.length ? ( -
- {tableEvidence.slice(0, 4).map((item, index) => ( -
- - - -
-

- {compactClinicalTableCaption(item)} -

-

- Table {index + 1} · p.{item.page_number ?? "n/a"} -

-
- - - -
- ))} -
- ) : ( - - ); - } - - if (tab === "Images") { - return visualEvidence.length ? ( - - ) : ( - - ); - } - - if (tab === "Quotes") { - return ( - - ); - } - - return ; -} - /** * A completed Q&A exchange kept on screen after a newer answer arrives, so * Answer mode reads as a conversation thread instead of replacing each result. diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index c1b2835a3..68072f029 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -22,7 +22,6 @@ import { favouriteItems, favouriteSets, favouriteTabs, - favouriteTypeCount, type FavouriteItem, type FavouriteSet, type FavouriteTabId, diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx index 2f84e2134..5dcca9e54 100644 --- a/src/components/clinical-dashboard/visual-evidence.tsx +++ b/src/components/clinical-dashboard/visual-evidence.tsx @@ -29,7 +29,6 @@ import { evidenceTabOrder, QuoteCards, simpleClinicalTableProps, - VerificationWorkspace, } from "@/components/clinical-dashboard/evidence-panels"; import { QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index ec53f934c..d07f9b17f 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -435,8 +435,10 @@ export function ServicesNavigatorPage() { const [localQuery, setLocalQuery] = useState(() => ({ urlQuery, value: initialQuery })); const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery; const registry = useRegistryRecords("service"); - const searchableRecords = - registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords; + const searchableRecords = useMemo( + () => (registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords), + [registry.records, registry.status], + ); const matches = useMemo(() => { const ranked = rankServiceRecords(searchableRecords, query); return ranked.length ? ranked.map((match) => match.service) : query.trim() ? [] : searchableRecords; From 2748e6ef11ea542ff76d9a5b70a06018c364d722 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:41:58 +0800 Subject: [PATCH 05/16] fix: allow anonymous setup-status on production deployments --- src/app/api/setup-status/route.ts | 25 ++++---------------- src/components/ClinicalDashboard.tsx | 27 +++++++++++++--------- tests/setup-status-route.test.ts | 34 ++++++++++++++++++++-------- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index bf0551c94..ba34855b8 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import { env, isDemoMode } from "@/lib/env"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; @@ -409,27 +408,11 @@ async function readSetupStatusPayload() { } } -async function requireProductionSetupStatusAuth(request: Request) { +export async function GET(request: Request) { const identity = localProjectRequestIdentityPayload(request); - if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) { - return identity; + if (!identity.localServer.safeLocalOrigin) { + return unsafeLocalProjectResponse(identity); } - await requireAuthenticatedUser(request, createAdminClient()); - return identity; -} -export async function GET(request: Request) { - try { - const identity = await requireProductionSetupStatusAuth(request); - if (!identity.localServer.safeLocalOrigin) { - return unsafeLocalProjectResponse(identity); - } - - return setupStatusResponse(await readSetupStatusPayload()); - } catch (error) { - if (error instanceof AuthenticationError) { - return unauthorizedResponse(error); - } - throw error; - } + return setupStatusResponse(await readSetupStatusPayload()); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 647d6e1a8..f73ad11e2 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3586,17 +3586,17 @@ export function ClinicalDashboard({ const showDegradedNotice = !isOnline || apiUnavailable; const hasMobileBottomSearch = searchMode !== "answer"; const showDesktopHomeComposer = - !loading && !error && - ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || - (searchMode === "documents" && - activeModeResultKind === "documents" && - documentMatches.length === 0 && - !modeSearchSubmitted) || - (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || - (activeModeResultKind === "differentials" && !modeSearchSubmitted) || + (activeModeResultKind === "tools" || activeModeResultKind === "favourites" || - activeModeResultKind === "tools"); + (!loading && + ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || + (searchMode === "documents" && + activeModeResultKind === "documents" && + documentMatches.length === 0 && + !modeSearchSubmitted) || + (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || + (activeModeResultKind === "differentials" && !modeSearchSubmitted)))); const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; // Favourites and Tools are content-rich hubs: they share the centred hero but // stay top-aligned so their lists start in a stable position. @@ -3614,6 +3614,7 @@ export function ClinicalDashboard({ const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted; const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); + const isDeployedApp = process.env.NODE_ENV === "production"; const renderDegradedNotice = () => (

{!isOnline ? "Reconnect before uploading documents, refreshing source URLs, or generating answers." - : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."} + : isDeployedApp + ? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly." + : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}

); diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts index 170aac7f5..f952b2ef8 100644 --- a/tests/setup-status-route.test.ts +++ b/tests/setup-status-route.test.ts @@ -7,10 +7,13 @@ afterEach(() => { }); describe("/api/setup-status", () => { - it("requires auth for non-local production requests before returning setup posture", async () => { + it("returns setup posture for anonymous production requests without exposing secret values", async () => { vi.stubEnv("NODE_ENV", "production"); - const getUser = vi.fn(); - const createAdminClient = vi.fn(() => ({ auth: { getUser } })); + 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", @@ -24,16 +27,29 @@ describe("/api/setup-status", () => { 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: "ready", detail: "Clinical KB Database target is configured." }), + formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.", + })); const { GET } = await import("../src/app/api/setup-status/route"); const response = await GET(new Request("https://clinical.example/api/setup-status")); const body = await response.json(); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); - expect(JSON.stringify(body)).not.toContain("OPENAI"); - expect(JSON.stringify(body)).not.toContain("Supabase"); - expect(createAdminClient).toHaveBeenCalledTimes(1); - expect(getUser).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(body).toMatchObject({ + demoMode: false, + checks: expect.arrayContaining([ + expect.objectContaining({ id: "env" }), + expect.objectContaining({ id: "openai" }), + ]), + }); + expect(JSON.stringify(body)).not.toContain("service-role-key"); + expect(JSON.stringify(body)).not.toContain("openai-key"); }); }); From a93b5b978f725b9aafd218e13548f15fb10e8801 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:58:57 +0800 Subject: [PATCH 06/16] fix: harden public production access across API, UI, and rate limits --- src/app/api/answer/route.ts | 4 +- src/app/api/answer/stream/route.ts | 4 +- src/app/api/differentials/[slug]/route.ts | 8 +- .../presentations/[slug]/route.ts | 8 +- src/app/api/differentials/route.ts | 8 +- src/app/api/documents/[id]/route.ts | 8 +- src/app/api/documents/[id]/search/route.ts | 8 +- .../api/documents/[id]/signed-url/route.ts | 8 +- src/app/api/documents/route.ts | 58 ++++- src/app/api/images/[id]/signed-url/route.ts | 8 +- src/app/api/medications/[slug]/route.ts | 8 +- src/app/api/medications/route.ts | 8 +- src/app/api/registry/records/[slug]/route.ts | 8 +- src/app/api/registry/records/route.ts | 8 +- src/app/api/search/route.ts | 4 +- src/app/api/upload/route.ts | 29 ++- src/components/ClinicalDashboard.tsx | 45 ++-- src/components/DocumentViewer.tsx | 44 +++- .../DocumentManagerPanel.tsx | 12 +- .../document-search-results.tsx | 9 +- .../medication-prescribing-workspace.tsx | 9 +- src/components/forms/forms-home-page.tsx | 6 +- .../forms/forms-search-results-page.tsx | 4 +- src/components/registry-record-loader.tsx | 6 +- .../services/services-home-page.tsx | 6 +- src/lib/api-rate-limit.ts | 16 +- src/lib/deployed-app.ts | 3 + src/lib/env.ts | 10 + src/lib/public-api-access.ts | 40 ++- src/lib/supabase/auth.ts | 65 +++-- src/lib/use-registry-records.ts | 16 +- ...ten_search_document_chunks_owner_scope.sql | 72 ++++++ tests/api-rate-limit-fallback.test.ts | 23 ++ tests/api-validation-contract.test.ts | 18 +- tests/private-access-routes.test.ts | 232 ++++++++++++++++-- tests/ui-smoke.spec.ts | 31 +++ tests/ui-tools.spec.ts | 16 +- 37 files changed, 704 insertions(+), 166 deletions(-) create mode 100644 src/lib/deployed-app.ts create mode 100644 supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql create mode 100644 tests/api-rate-limit-fallback.test.ts diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 8d4f3b2a9..0084e60cd 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -4,7 +4,7 @@ import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { answerQuestionWithScope } from "@/lib/rag"; import { jsonError, PublicApiError } from "@/lib/http"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { classifyRagQuery } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; @@ -70,7 +70,7 @@ export async function POST(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "answer", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 4741c2287..ef4fb0e54 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; -import { consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag"; import { classifyRagQuery } from "@/lib/clinical-search"; @@ -232,7 +232,7 @@ export async function POST(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "answer", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) return rateLimitStream(rateLimit); diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index eaed5d200..08b67744e 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { deriveGovernanceFromSnapshot, normalizeDifferentialSlug, @@ -14,7 +14,7 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseRequestQuery } from "@/lib/validation/query"; @@ -63,7 +63,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { const snapshot = loadDifferentialSnapshot(); const governance = deriveGovernanceFromSnapshot(snapshot); if (kind === "presentation") { @@ -91,7 +91,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Differential requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts index 239579032..2c991a6c7 100644 --- a/src/app/api/differentials/presentations/[slug]/route.ts +++ b/src/app/api/differentials/presentations/[slug]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { deriveGovernanceFromSnapshot, normalizeDifferentialSlug, @@ -13,7 +13,7 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -53,7 +53,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { const snapshot = loadDifferentialSnapshot(); const workflow = getPresentationWorkflow(normalizedSlug); if (!workflow) return notFoundResponse(normalizedSlug); @@ -78,7 +78,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Differential requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index 260e2a90a..b64d4e395 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { deriveGovernanceFromSnapshot, rowGovernance, @@ -14,7 +14,7 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe import { differentialRecords, searchDifferentialRecords, searchPresentationWorkflows } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -68,7 +68,7 @@ export async function GET(request: Request) { }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { return differentialResponse({ ...publicDifferentialPayload(kind, q, limit), publicAccess: true, @@ -82,7 +82,7 @@ export async function GET(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Differential requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 4f404a004..4ec7010ed 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import type { Json } from "@/lib/supabase/database.types"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { getDemoDocumentPayload } from "@/lib/demo-data"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; @@ -8,7 +9,7 @@ import { invalidateRagCachesForDocumentMutation } from "@/lib/rag"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { writeAuditLog } from "@/lib/audit"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; @@ -281,7 +282,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const access = await publicAccessContext(request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } const { data: document, error } = await withOwnerReadScope( supabase.from("documents").select("*").eq("id", id), access.ownerId, diff --git a/src/app/api/documents/[id]/search/route.ts b/src/app/api/documents/[id]/search/route.ts index d153f9859..80deb18ae 100644 --- a/src/app/api/documents/[id]/search/route.ts +++ b/src/app/api/documents/[id]/search/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { demoChunks, getDemoDocument } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRouteParams } from "@/lib/validation/params"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -182,7 +183,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentSearchParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const access = await publicAccessContext(request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } const { data: document, error: documentError } = await withOwnerReadScope( supabase.from("documents").select("id,metadata").eq("id", id), access.ownerId, diff --git a/src/app/api/documents/[id]/signed-url/route.ts b/src/app/api/documents/[id]/signed-url/route.ts index 5dd6dc8ea..7ecb96f47 100644 --- a/src/app/api/documents/[id]/signed-url/route.ts +++ b/src/app/api/documents/[id]/signed-url/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { getDemoDocument } from "@/lib/demo-data"; import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -32,7 +33,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid document id."); const supabase = createAdminClient(); - const access = await publicAccessContext(_request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(_request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } const { data: document, error } = await withOwnerReadScope( supabase.from("documents").select("storage_path,file_type").eq("id", id), access.ownerId, diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 8fa0958be..a9abec8cf 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,15 +1,33 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { demoDocuments } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRequestQuery, queryBoolean, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; +const PUBLIC_DOCUMENT_LIST_COLUMNS = [ + "id", + "owner_id", + "title", + "description", + "file_name", + "file_type", + "file_size", + "status", + "page_count", + "chunk_count", + "image_count", + "metadata", + "created_at", + "updated_at", +].join(","); + const DOCUMENT_LIST_COLUMNS = [ "id", "owner_id", @@ -32,6 +50,18 @@ const DOCUMENT_LIST_COLUMNS = [ "updated_at", ].join(","); +const PUBLIC_LABEL_LIST_COLUMNS = [ + "id", + "document_id", + "label", + "label_type", + "source", + "confidence", + "metadata", + "created_at", + "updated_at", +].join(","); + const LABEL_LIST_COLUMNS = [ "id", "document_id", @@ -45,6 +75,14 @@ const LABEL_LIST_COLUMNS = [ "updated_at", ].join(","); +const PUBLIC_SUMMARY_LIST_COLUMNS = [ + "id", + "document_id", + "summary", + "clinical_specifics", + "generated_at", +].join(","); + const SUMMARY_LIST_COLUMNS = [ "id", "document_id", @@ -131,9 +169,15 @@ export async function GET(request: Request) { } = parseRequestQuery(request, documentListQuerySchema, "Invalid document list query."); const supabase = createAdminClient(); - const access = await publicAccessContext(request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } + + const effectiveIncludeMeta = access.authenticated ? includeMeta : false; + const listColumns = access.authenticated ? DOCUMENT_LIST_COLUMNS : PUBLIC_DOCUMENT_LIST_COLUMNS; let query = withOwnerReadScope( - supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" }), + supabase.from("documents").select(listColumns, { count: "exact" }), access.ownerId, ) .order("created_at", { ascending: false }) @@ -162,13 +206,15 @@ export async function GET(request: Request) { hasMore: count === null ? documents.length === limit : offset + documents.length < count, }; - if (documentIds.length === 0 || !includeMeta) { + if (documentIds.length === 0 || !effectiveIncludeMeta) { return documentsResponse({ documents, pagination }, indexing); } + const labelColumns = access.authenticated ? LABEL_LIST_COLUMNS : PUBLIC_LABEL_LIST_COLUMNS; + const summaryColumns = access.authenticated ? SUMMARY_LIST_COLUMNS : PUBLIC_SUMMARY_LIST_COLUMNS; const [labelsResult, summariesResult] = await Promise.all([ - supabase.from("document_labels").select(LABEL_LIST_COLUMNS).in("document_id", documentIds), - supabase.from("document_summaries").select(SUMMARY_LIST_COLUMNS).in("document_id", documentIds), + supabase.from("document_labels").select(labelColumns).in("document_id", documentIds), + supabase.from("document_summaries").select(summaryColumns).in("document_id", documentIds), ]); if (labelsResult.error) throw new Error(labelsResult.error.message); diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index 9ca925d49..c5fac4d63 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { getDemoImage } from "@/lib/demo-data"; import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; @@ -7,7 +8,7 @@ import { jsonError, PublicApiError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -32,7 +33,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid image id."); const supabase = createAdminClient(); - const access = await publicAccessContext(_request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(_request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } const { data: image, error } = await supabase .from("document_images") .select("document_id,storage_path,mime_type,caption,metadata") diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index a6853c1a9..80294619b 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { getMedicationRecord } from "@/lib/medication-snapshot"; @@ -12,7 +12,7 @@ import { rowToMedicationRecord, type MedicationRecordRow, } from "@/lib/medication-records"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -56,7 +56,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { const payload = publicMedicationDetailPayload(normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); return medicationResponse({ @@ -72,7 +72,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Medication requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 7fc2cb999..32985604d 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { defaultMedicationRecords, ensureMedicationsSeeded } from "@/lib/medication-seed"; @@ -13,7 +13,7 @@ import { type MedicationRecordRow, } from "@/lib/medication-records"; import { medicationToSearchResult, rankMedicationRecords, type MedicationSearchMatch } from "@/lib/medications"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -76,7 +76,7 @@ export async function GET(request: Request) { }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { return medicationResponse({ ...publicMedicationPayload(q, limit), publicAccess: true, @@ -90,7 +90,7 @@ export async function GET(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Medication requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index a966ab37a..c32ac3684 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { getFormRecord } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -62,7 +62,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { const payload = publicRegistryDetailPayload(kind, normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); return registryResponse({ @@ -78,7 +78,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index ab07badfb..e29d7c15d 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { rankFormRecords, formRecords } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -78,7 +78,7 @@ export async function GET(request: Request) { }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { return registryResponse({ ...publicRegistryPayload(kind, q, limit), publicAccess: true, @@ -92,7 +92,7 @@ export async function GET(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index a0474e14d..ea99d7aa3 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -14,7 +14,7 @@ import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { SOURCE_ONLY_EMBEDDING_SKIP_REASON } from "@/lib/rag-provider"; import { createAdminClient } from "@/lib/supabase/admin"; import * as serverAuth from "@/lib/supabase/auth"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { parseJsonBody } from "@/lib/validation/body"; @@ -893,7 +893,7 @@ export async function POST(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "search", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse( diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 72ddb6023..a2df002f9 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -2,13 +2,14 @@ import { randomUUID } from "node:crypto"; import { createHash } from "node:crypto"; import { NextResponse } from "next/server"; import { z } from "zod"; -import { env } from "@/lib/env"; +import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env"; import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext } from "@/lib/public-api-access"; import { probeSupabaseHealth } from "@/lib/supabase/health"; import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data"; @@ -84,7 +85,11 @@ export async function POST(request: Request) { try { supabase = createAdminClient(); const adminSupabase = supabase; - const user = await requireAuthenticatedUser(request, adminSupabase); + const access = await publicAccessContext(request, adminSupabase); + const uploadOwnerId = access.ownerId ?? (publicUploadsEnabled() ? publicWorkspaceOwnerId() : null); + if (!uploadOwnerId) { + return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 }); + } const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", @@ -107,7 +112,7 @@ export async function POST(request: Request) { const documentId = randomUUID(); const safeName = file.name.replace(/[^\w.\-() ]+/g, "_"); - const storagePath = `${user.id}/documents/${documentId}/${safeName}`; + const storagePath = `${uploadOwnerId}/documents/${documentId}/${safeName}`; const buffer = Buffer.from(await file.arrayBuffer()); // The declared MIME type is client-supplied; verify the real byte signature // before persisting a clinical document. @@ -117,7 +122,7 @@ export async function POST(request: Request) { const { data: duplicate, error: duplicateError } = await adminSupabase .from("documents") .select("id,title,file_name,status,page_count,chunk_count,image_count,created_at") - .eq("owner_id", user.id) + .eq("owner_id", uploadOwnerId) .eq("content_hash", contentHash) .maybeSingle(); @@ -147,7 +152,7 @@ export async function POST(request: Request) { }; const namePlan = await planDocumentName({ supabase: namingSupabase, - ownerId: user.id, + ownerId: uploadOwnerId, fileName: file.name, requestedTitle: uploadMetadata.title, contentHash, @@ -160,7 +165,7 @@ export async function POST(request: Request) { .from("documents") .insert({ id: documentId, - owner_id: user.id, + owner_id: uploadOwnerId, title, description, file_name: file.name, @@ -178,7 +183,7 @@ export async function POST(request: Request) { review_date: null, uploaded_at: uploadedAt, indexed_at: null, - uploaded_by: user.id, + uploaded_by: uploadOwnerId, original_file_name: namePlan.originalFileName, original_title: namePlan.originalTitle, smart_title_base: namePlan.baseTitle, @@ -202,7 +207,7 @@ export async function POST(request: Request) { insertedDocumentOwnerId = null; return duplicateUploadResponse({ supabase, - ownerId: user.id, + ownerId: uploadOwnerId, contentHash, storagePath: uploadedPath, }); @@ -210,7 +215,7 @@ export async function POST(request: Request) { throw new Error(documentError.message); } insertedDocumentId = documentId; - insertedDocumentOwnerId = user.id; + insertedDocumentOwnerId = uploadOwnerId; const { data: job, error: jobError } = await supabase .from("ingestion_jobs") @@ -230,7 +235,7 @@ export async function POST(request: Request) { .from("documents") .delete() .eq("id", documentId) - .eq("owner_id", user.id); + .eq("owner_id", uploadOwnerId); if (rollbackDocumentError) { throw new Error( `Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`, @@ -242,7 +247,7 @@ export async function POST(request: Request) { } await writeAuditLog(supabase, { - ownerId: user.id, + ownerId: uploadOwnerId, action: "document_upload", resourceType: "document", resourceId: documentId, diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index f73ad11e2..aa6c1cb2d 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -53,7 +53,8 @@ import { type DocumentDeleteResult } from "@/components/DocumentManagementAction import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; -import { isLocalNoAuthMode } from "@/lib/env"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; +import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/env"; import { appBackdrop, answerSurface, @@ -1751,7 +1752,11 @@ export function ClinicalDashboard({ process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks); const canUsePrivateApis = localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); - const canRunSearch = explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis; + const canUploadDocuments = + canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis); + const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady; + const canRunSearch = + explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch; const closeDashboardTransientSurfaces = useCallback( (except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => { if (except !== "guide") setGuideOpen(false); @@ -1932,20 +1937,25 @@ export function ClinicalDashboard({ const setupResponse = await fetch("/api/setup-status", { cache: "no-store" }).catch(() => null); if (!setupResponse) { - setApiUnavailable(true); - setSetupWarning("The local API is unavailable."); - return; - } - - if (setupResponse.ok) { + if (isDeployedClinicalKb()) { + setSetupWarning("Setup status could not be loaded. You can still try search."); + } else { + setApiUnavailable(true); + setSetupWarning("The local API is unavailable."); + return; + } + } else if (setupResponse.ok) { const payload = (await setupResponse.json()) as SetupStatusPayload; setSetupChecks(payload.checks ?? fallbackSetupChecks); nextDemoMode = Boolean(payload.demoMode); routeIndexingActive = Boolean(payload.indexingActive); routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.pollAfterMs); if (nextDemoMode) setDemoMode(true); + } else if (isDeployedClinicalKb()) { + setSetupWarning("Setup status could not be loaded. You can still try search."); } else { setApiUnavailable(true); + return; } } @@ -2499,11 +2509,13 @@ export function ClinicalDashboard({ function searchNetworkFailure(label: string) { const offline = typeof navigator !== "undefined" && !navigator.onLine; - const localOrigin = typeof window !== "undefined" ? window.location.origin : "the local Clinical KB server"; + const origin = typeof window !== "undefined" ? window.location.origin : "Clinical KB"; return makeSearchError( offline ? `${label} could not run because the browser is offline.` - : `${label} could not reach Clinical KB at ${localOrigin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`, + : isDeployedClinicalKb() + ? `${label} could not reach Clinical KB at ${origin}. Check your connection and try again shortly.` + : `${label} could not reach Clinical KB at ${origin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`, undefined, true, ); @@ -3582,8 +3594,8 @@ export function ClinicalDashboard({

); - const showAuthPanel = !clientDemoMode && !canUsePrivateApis; - const showDegradedNotice = !isOnline || apiUnavailable; + const showAuthPanel = false; + const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch); const hasMobileBottomSearch = searchMode !== "answer"; const showDesktopHomeComposer = !error && @@ -3614,7 +3626,6 @@ export function ClinicalDashboard({ const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted; const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); - const isDeployedApp = process.env.NODE_ENV === "production"; const renderDegradedNotice = () => ( {!isOnline ? "Reconnect before uploading documents, refreshing source URLs, or generating answers." - : isDeployedApp + : isDeployedClinicalKb() ? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly." : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}

@@ -3661,7 +3672,7 @@ export function ClinicalDashboard({ { id: "upload", label: "Upload", - summary: uploadReadOnlyMode || !canUsePrivateApis ? "Locked" : "Ready", + summary: uploadReadOnlyMode || !canUploadDocuments ? "Locked" : "Ready", panelId: "dashboard-upload-section", icon: UploadCloud, }, @@ -4241,7 +4252,7 @@ export function ClinicalDashboard({ diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 8d875334f..10533849e 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1923,12 +1923,22 @@ export function DocumentViewer({ const [viewerModeInitialized] = useState(true); const generatedSummaryRef = useRef(null); const { status: authStatus, isConfigured, authorizationHeader, markSessionExpired } = useAuthSession(); + const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false); const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true"); const localNoAuthMode = isLocalNoAuthMode(); const clientDemoMode = localNoAuthMode || serverDemoMode; const canViewSourceDocuments = localProjectReady; const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); + useEffect(() => { + if (authStatus !== "loading") { + const resetId = window.setTimeout(() => setAuthLoadingTimedOut(false), 0); + return () => window.clearTimeout(resetId); + } + const timeoutId = window.setTimeout(() => setAuthLoadingTimedOut(true), 4_000); + return () => window.clearTimeout(timeoutId); + }, [authStatus]); + useEffect(() => { if (typeof window === "undefined" || !viewerModeInitialized || hasExplicitPdfViewerMode) return; @@ -2090,9 +2100,17 @@ export function DocumentViewer({ setTableFacts([]); setChunks([]); setIndexHealth(null); - setViewerError( - detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded.", - ); + const message = + detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded."; + if (!canUsePrivateApis && !clientDemoMode && message === "Document not found.") { + setViewerError( + isConfigured + ? "Sign in to open private source documents." + : "Supabase browser authentication is not configured for private source documents.", + ); + } else { + setViewerError(message); + } } if (signedUrlResult.status === "fulfilled") { @@ -2153,7 +2171,7 @@ export function DocumentViewer({ useEffect(() => { const query = sourceSearch.trim(); - if (!canUsePrivateApis || query.length < 2) { + if (!canViewSourceDocuments || query.length < 2) { const reset = window.setTimeout(() => { setDocumentSearchResults([]); setSearchingDocument(false); @@ -2195,7 +2213,7 @@ export function DocumentViewer({ window.clearTimeout(timeout); controller.abort(); }; - }, [authorizationHeader, canUsePrivateApis, clientDemoMode, documentId, markSessionExpired, sourceSearch]); + }, [authorizationHeader, canViewSourceDocuments, clientDemoMode, documentId, markSessionExpired, sourceSearch]); useEffect(() => { const updateOnline = () => setIsOnline(navigator.onLine); @@ -2238,8 +2256,20 @@ export function DocumentViewer({ } } - const authViewerError = null; - const effectiveLoadingDocument = loadingDocument; + const authViewerError = + !canUsePrivateApis && + !clientDemoMode && + !loadingDocument && + !document && + (authStatus !== "loading" || authLoadingTimedOut) && + (viewerError === "Sign in to open private source documents." || + viewerError === "Supabase browser authentication is not configured for private source documents.") + ? viewerError + : null; + const effectiveLoadingDocument = + !canUsePrivateApis && authStatus === "loading" && !authLoadingTimedOut && loadingDocument + ? true + : loadingDocument; const effectiveViewerError = authViewerError ?? viewerError; const viewerState = effectiveLoadingDocument ? "loading" diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index b25fae026..35062b110 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -188,7 +188,11 @@ export function UploadPanel({ return; } if (!canUpload) { - changeStatus("Sign in before uploading private guideline files."); + changeStatus( + demoMode + ? demoUploadReadOnlyMessage + : "Uploads are unavailable until this public workspace is configured.", + ); return; } @@ -202,7 +206,7 @@ export function UploadPanel({ setUploading(true); changeStatus( files.length === 1 - ? "Uploading private document to Supabase Storage..." + ? "Uploading document to Supabase Storage..." : `Uploading 1 of ${files.length}: ${files[0].name}`, ); @@ -226,8 +230,8 @@ export function UploadPanel({ if (failures.length === 0) { changeStatus( files.length === 1 - ? "Successfully uploaded private document to storage queue." - : `Successfully uploaded ${files.length} private documents.`, + ? "Successfully uploaded document to storage queue." + : `Successfully uploaded ${files.length} documents.`, ); if (input) input.value = ""; onUploaded(); diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 5efffa0ad..d14f25ff1 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -24,6 +24,7 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { SafeBoldText } from "@/components/SafeBoldText"; @@ -726,7 +727,7 @@ function RecordRegistryNotice({ status, mode }: { status: RegistryRequestStatus; status === "loading" ? { Icon: Loader2, spin: true, tone: "info" as const, text: `Loading your ${noun} registry...` } : status === "unauthorized" - ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Sign in to search your ${noun} registry.` } + ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Your session expired. Sign in again to search your private ${noun} registry.` } : { Icon: ShieldAlert, spin: false, @@ -834,9 +835,11 @@ export function DocumentSearchResultsPanel({ } const unavailableMessage = apiUnavailable - ? "The local API is unavailable. Check the app server before searching documents." + ? isDeployedClinicalKb() + ? "Clinical KB could not be reached. Check your connection and try again shortly." + : "The local API is unavailable. Check the app server before searching documents." : authUnavailable - ? "Sign in or enable local no-auth mode before listing private indexed documents." + ? "Your session expired. Sign in again to view private indexed documents." : !realDataReady ? setupWarning || "Complete the search setup before using Documents mode." : null; diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 05596db09..597d4d459 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -31,6 +31,7 @@ import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search- import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { medicationMatchesCommandScopes } from "@/lib/search-command-surface"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; type MedicationPrescribingWorkspaceProps = { @@ -414,9 +415,13 @@ function StatusNotice({ }: Pick) { if (realDataReady && !authUnavailable && !apiUnavailable && !setupWarning) return null; const message = authUnavailable - ? "Private medication search is waiting for sign-in." + ? isDeployedClinicalKb() + ? "Sign in to search your private medication library." + : "Private medication search is waiting for sign-in." : apiUnavailable - ? "Medication search is using the local mockup while the API is unavailable." + ? isDeployedClinicalKb() + ? "Medication search is temporarily unavailable. Try again shortly." + : "Medication search is using the local mockup while the API is unavailable." : setupWarning || "Medication search setup is still warming up."; return ( diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index eb3e040de..03a6495c1 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -90,10 +90,10 @@ export function FormsHomePage() { ) : registry.status === "unauthorized" ? ( ) : registry.status === "error" ? ( } - title="Sign in required" - body={`Sign in to view this ${copy.noun}. Registry records are private to your workspace.`} - action={{ href: "/", label: "Go to sign in" }} + title="Session expired" + body={`Your session expired. Sign in again to view your private ${copy.noun}. Public ${copy.noun} records remain available from search.`} + action={{ href: "/", label: "Open account setup" }} /> ); } diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index 132432f57..6624fc2e6 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -92,10 +92,10 @@ export function ServicesHomePage() { ) : registry.status === "unauthorized" ? ( ) : registry.status === "error" ? ( > = { answer: { limit: 6, windowSeconds: 60 }, search: { limit: 60, windowSeconds: 60 }, + document_read: { limit: 45, windowSeconds: 60 }, }; type SupabaseAdmin = ReturnType; diff --git a/src/lib/deployed-app.ts b/src/lib/deployed-app.ts new file mode 100644 index 000000000..82bb8f3a7 --- /dev/null +++ b/src/lib/deployed-app.ts @@ -0,0 +1,3 @@ +export function isDeployedClinicalKb() { + return process.env.NODE_ENV === "production"; +} diff --git a/src/lib/env.ts b/src/lib/env.ts index ac5d8dbf5..f6acba54f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -11,6 +11,8 @@ const envSchema = z.object({ LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"), LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(), LOCAL_NO_AUTH_OWNER_ID: z.string().optional(), + PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(), + NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(), OPENAI_API_KEY: z.string().optional(), OPENAI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"), // Must match the vector(N) dimension in supabase/schema.sql. Changing the embedding @@ -192,3 +194,11 @@ export function isLocalNoAuthMode() { return process.env.NODE_ENV !== "production" && (publicNoAuth || serverNoAuth); } + +export function publicWorkspaceOwnerId() { + return env.PUBLIC_WORKSPACE_OWNER_ID?.trim() || null; +} + +export function publicUploadsEnabled() { + return env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true"; +} diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 8ae3473e1..04a9da0ba 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import type { createAdminClient } from "@/lib/supabase/admin"; +import { consumeSubjectApiRateLimit, allowRateLimitInMemoryFallbackOnUnavailable, type ApiRateLimitResult } from "@/lib/api-rate-limit"; import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth"; type AdminClient = ReturnType; @@ -25,22 +26,35 @@ export function anonymousApiSubjectKey(request: Request) { return `anon:${createHash("sha256").update(source).digest("hex").slice(0, 32)}`; } -export function hasPublicApiAuthSignal(request: Request) { - const authorization = request.headers.get("authorization") ?? ""; - if (/^Bearer\s+\S+/i.test(authorization)) return true; - +export function hasSessionCookieSignal(request: Request) { const cookieHeader = request.headers.get("cookie") ?? ""; return cookieHeader.includes("sb-"); } +export function hasBearerAuthAttempt(request: Request) { + const authorization = request.headers.get("authorization") ?? ""; + return /^Bearer\s+\S+/i.test(authorization); +} + +/** True when the request may carry a durable Supabase session (cookie), not a bare bearer attempt. */ +export function hasPublicApiAuthSignal(request: Request) { + return hasSessionCookieSignal(request); +} + +/** Anonymous callers with no cookie or bearer skip auth resolution and rate limits on curated public catalogs. */ +export function shouldResolvePublicCatalogAccess(request: Request) { + return hasSessionCookieSignal(request) || hasBearerAuthAttempt(request); +} + type OwnerScopedQuery = { eq(column: string, value: unknown): T; is(column: string, value: null): T; + or(filters: string): T; }; -/** Scope document reads to the authenticated owner or public (owner_id IS NULL) rows. */ +/** Scope reads to public rows (owner_id IS NULL) and, when signed in, the caller's owned rows. */ export function withOwnerReadScope>(query: T, ownerId: string | undefined): T { - if (ownerId) return query.eq("owner_id", ownerId); + if (ownerId) return query.or(`owner_id.eq.${ownerId},owner_id.is.null`); return query.is("owner_id", null); } @@ -60,3 +74,17 @@ export async function publicAccessContext(request: Request, supabase: AdminClien rateLimitSubject: { kind: "anonymous", subjectKey: anonymousApiSubjectKey(request) } satisfies RateLimitSubject, }; } + +export async function enforceDocumentReadRateLimit( + request: Request, + supabase: AdminClient, +): Promise<{ access: Awaited>; rateLimit: ApiRateLimitResult }> { + const access = await publicAccessContext(request, supabase); + const rateLimit = await consumeSubjectApiRateLimit({ + supabase, + subject: access.rateLimitSubject, + bucket: "document_read", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + return { access, rateLimit }; +} diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 044384b07..3ecf6122c 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -31,12 +31,14 @@ function readCookies(cookieHeader: string | null): Map { return cookies; } -function extractSessionAccessToken(request: Request): string | null { +function extractBearerAccessToken(request: Request): string | null { const authorization = request.headers.get("authorization") ?? ""; const match = authorization.match(/^Bearer\s+(.+)$/i); const headerToken = match?.[1]?.trim(); - if (headerToken) return headerToken; + return headerToken || null; +} +function extractCookieSessionAccessToken(request: Request): string | null { const cookies = readCookies(request.headers.get("cookie")); const legacyAccessToken = cookies.get("sb-access-token")?.trim(); if (legacyAccessToken) return legacyAccessToken; @@ -57,6 +59,19 @@ function extractSessionAccessToken(request: Request): string | null { return null; } +function extractSessionAccessToken(request: Request): string | null { + return extractBearerAccessToken(request) ?? extractCookieSessionAccessToken(request); +} + +async function getUserFromAccessToken( + supabase: AdminClient, + token: string, +): Promise { + const { data, error } = await supabase.auth.getUser(token); + if (error || !data.user?.id) return null; + return { id: data.user.id }; +} + export class AuthenticationError extends Error { constructor(message = "Authentication required.") { super(message); @@ -72,7 +87,7 @@ export function unauthorizedResponse(error?: AuthenticationError) { /** * Resolve the user from the `@supabase/ssr` cookie session. The * `sb--auth-token` cookie it writes is base64-encoded (and chunked when - * large), which `extractSessionAccessToken`'s plain-JSON parser cannot read, so + * large), which `extractCookieSessionAccessToken`'s plain-JSON parser cannot read, so * this uses the ssr server client to decode + validate it. Returns null when * the public env is absent or no `sb-` cookie is present. */ @@ -102,22 +117,28 @@ async function getUserFromRequestCookies(request: Request): Promise { - // 1. Bearer token / legacy cookie (programmatic callers + current clients). - const token = extractSessionAccessToken(request); - if (token) { - const { data, error } = await supabase.auth.getUser(token); - if (!error && data.user?.id) { - return { id: data.user.id }; - } +async function resolveOptionalAuthenticatedUser( + request: Request, + supabase: AdminClient, +): Promise { + const bearerToken = extractBearerAccessToken(request); + if (bearerToken) { + const bearerUser = await getUserFromAccessToken(supabase, bearerToken); + if (bearerUser) return bearerUser; } - // 2. @supabase/ssr cookie session (persistent cookie logins). - const cookieUser = await getUserFromRequestCookies(request); - if (cookieUser) { - return cookieUser; + const cookieToken = extractCookieSessionAccessToken(request); + if (cookieToken && cookieToken !== bearerToken) { + const cookieTokenUser = await getUserFromAccessToken(supabase, cookieToken); + if (cookieTokenUser) return cookieTokenUser; } + return getUserFromRequestCookies(request); +} + +export async function requireAuthenticatedUser(request: Request, supabase: AdminClient): Promise { + const user = await resolveOptionalAuthenticatedUser(request, supabase); + if (user) return user; throw new AuthenticationError(); } @@ -125,14 +146,8 @@ export async function getOptionalAuthenticatedUser( request: Request, supabase: AdminClient, ): Promise { - const token = extractSessionAccessToken(request); - if (token) { - const { data, error } = await supabase.auth.getUser(token); - if (!error && data.user?.id) { - return { id: data.user.id }; - } - // Invalid or expired Bearer token: fall through to cookie session, then anonymous. - } - - return getUserFromRequestCookies(request); + return resolveOptionalAuthenticatedUser(request, supabase); } + +// Retained for callers that only need a single token string. +export { extractSessionAccessToken }; diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts index 0cf8a09da..79599d5af 100644 --- a/src/lib/use-registry-records.ts +++ b/src/lib/use-registry-records.ts @@ -82,8 +82,12 @@ export function useRegistryRecords( // effect retries with a real header; never expire the session from an // auth-loading 401. Demo/local API responses can still resolve fast. if (authStatus === "loading") return; - if (authStatus === "authenticated") markSessionExpired(); - setState(recordsState("unauthorized", kind)); + if (authStatus === "authenticated") { + markSessionExpired(); + setState(recordsState("unauthorized", kind)); + return; + } + setState(recordsState("error", kind)); return; } if (!response.ok) { @@ -140,8 +144,12 @@ export function useRegistryRecord(kind: RegistryRecordKind, slug: string): Regis if (!active) return; if (response.status === 401) { if (authStatus === "loading") return; - if (authStatus === "authenticated") markSessionExpired(); - setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null }); + if (authStatus === "authenticated") { + markSessionExpired(); + setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null }); + return; + } + setState({ status: "error", record: null, linkedDocuments: [], demoMode: false, governance: null }); return; } if (response.status === 404) { diff --git a/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql new file mode 100644 index 000000000..473fdb90c --- /dev/null +++ b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql @@ -0,0 +1,72 @@ +-- Tighten search_document_chunks owner scoping so null p_owner_id only matches public +-- documents (owner_id IS NULL) and authenticated callers can search both owned and public +-- documents without matching other owners' private rows. + +create or replace function public.search_document_chunks( + p_document_id uuid, + p_query text, + match_count integer default 20, + p_owner_id uuid default null +) +returns table ( + id uuid, + page_number integer, + chunk_index integer, + section_heading text, + content text, + image_ids uuid[], + text_rank real, + trigram_score real +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with normalized as ( + select + websearch_to_tsquery('english', coalesce(p_query, '')) as query_tsv, + lower(trim(coalesce(p_query, ''))) as query_text + ), + tokens as ( + select distinct token + from normalized, + lateral regexp_split_to_table(normalized.query_text, '\s+') as token + where length(token) >= 3 + ) + select + c.id, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.image_ids, + ts_rank_cd(c.search_tsv, normalized.query_tsv)::real as text_rank, + similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text)::real as trigram_score + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join normalized + where c.document_id = p_document_id + and d.status = 'indexed' + and ( + (p_owner_id is null and d.owner_id is null) + or (p_owner_id is not null and (d.owner_id is null or d.owner_id = p_owner_id)) + ) + and ( + c.search_tsv @@ normalized.query_tsv + or lower(coalesce(c.section_heading, '') || ' ' || c.content) % normalized.query_text + or lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || normalized.query_text || '%' + or exists ( + select 1 + from tokens t + where lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || t.token || '%' + or lower(coalesce(c.section_heading, '') || ' ' || c.content) % t.token + ) + ) + order by + ts_rank_cd(c.search_tsv, normalized.query_tsv) desc, + similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text) desc, + c.chunk_index asc + limit least(greatest(match_count, 1), 80); +$$; + +grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role; diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts new file mode 100644 index 000000000..c25bb36c5 --- /dev/null +++ b/tests/api-rate-limit-fallback.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("allowRateLimitInMemoryFallbackOnUnavailable", () => { + it("enables fallback for production deployments", async () => { + vi.stubEnv("NODE_ENV", "production"); + const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit"); + expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true); + }); + + it("enables fallback for local no-auth development", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => true, + })); + const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit"); + expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true); + }); +}); diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts index 5d68487d8..a23c87c0e 100644 --- a/tests/api-validation-contract.test.ts +++ b/tests/api-validation-contract.test.ts @@ -147,7 +147,23 @@ function createSupabaseMock(resolve: QueryResolver = () => ok([])) { calls.push(call); return new QueryBuilder(call, resolve); }), - rpc: vi.fn(async () => ok([])), + rpc: vi.fn(async (name: string) => { + if (name === "consume_api_subject_rate_limit" || name === "consume_api_rate_limit") { + return { + data: [ + { + limited: false, + limit_value: 100, + remaining: 99, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + ], + error: null, + }; + } + return ok([]); + }), storage: { from: storageFrom }, storageMocks: { upload, remove, createSignedUrl, storageFrom }, }; diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 9d91205f7..958a05e92 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -276,7 +276,14 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { function mockRuntime( client: ReturnType, ragMock?: Record, - options: { localNoAuth?: boolean; localOwnerEmail?: string; providerMode?: string; openAiKey?: string } = {}, + options: { + localNoAuth?: boolean; + localOwnerEmail?: string; + providerMode?: string; + openAiKey?: string; + publicUploadsEnabled?: boolean; + publicWorkspaceOwnerId?: string; + } = {}, ) { vi.resetModules(); vi.doUnmock("@/lib/rag"); @@ -298,11 +305,15 @@ function mockRuntime( OPENAI_API_KEY: options.openAiKey ?? "sk-test", RAG_PROVIDER_MODE: options.providerMode ?? "auto", LOCAL_NO_AUTH_OWNER_EMAIL: options.localOwnerEmail, + PUBLIC_WORKSPACE_OWNER_ID: options.publicWorkspaceOwnerId, + NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: options.publicUploadsEnabled ? "true" : undefined, WORKER_STALE_AFTER_MINUTES: 10, WORKER_MAX_ATTEMPTS: 3, }, isDemoMode: () => false, isLocalNoAuthMode: () => Boolean(options.localNoAuth), + publicWorkspaceOwnerId: () => options.publicWorkspaceOwnerId ?? null, + publicUploadsEnabled: () => Boolean(options.publicUploadsEnabled), requireOpenAIEnv: () => undefined, requireServerEnv: () => undefined, })); @@ -322,6 +333,15 @@ function localPortRequest(port: number, path: string, init?: RequestInit) { return new Request(`http://localhost:${port}${path}`, init); } +function matchesOwnerReadScope(call: QueryCall, ownerId?: string | null) { + if (ownerId === undefined || ownerId === null) { + return call.filters.some((filter) => filter.column === "owner_id" && filter.value === null); + } + return call.orFilters.some( + (filter) => filter.includes(`owner_id.eq.${ownerId}`) && filter.includes("owner_id.is.null"), + ); +} + function authenticatedRequest(path: string, init?: RequestInit) { return request(path, { ...init, @@ -386,7 +406,7 @@ describe("private document API access", () => { const body = await payload(response); expect(response.status).toBe(200); - expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); + expect(body.documents).toEqual(documents); expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); expect(client.auth.getUser).not.toHaveBeenCalled(); }); @@ -416,7 +436,8 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(401); + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -438,7 +459,8 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(401); + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -459,7 +481,7 @@ describe("private document API access", () => { expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId }); }); - it("filters authenticated document listing by owner", async () => { + it("filters authenticated document listing by owner and public rows", async () => { const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client); @@ -471,9 +493,8 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); expect(body.pagination).toMatchObject({ limit: 100, offset: 0, nextOffset: 1, hasMore: false }); - expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); - expect(client.calls[0].selected).toContain("id,owner_id,title"); - expect(client.calls[0].selected).not.toBe("*"); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); + expect(client.calls[0].selected).toContain("storage_path"); expect(client.calls[0].range).toEqual({ from: 0, to: 99 }); }); @@ -489,7 +510,7 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(client.auth.getUser).toHaveBeenCalledWith(token); expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); - expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); }); it("accepts Supabase auth token cookies for private document access", async () => { @@ -504,7 +525,124 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(client.auth.getUser).toHaveBeenCalledWith(token); expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); - expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); + }); + + it("allows authenticated users to read public document detail", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { + return ok({ + id: documentId, + owner_id: null, + title: "Public guideline", + file_name: "guideline.pdf", + file_type: "application/pdf", + page_count: 2, + chunk_count: 1, + metadata: { index_generation_id: "generation-a" }, + }); + } + if (call.table === "document_pages") return ok([]); + if (call.table === "document_images") return ok([]); + if (call.table === "document_chunks") return ok([]); + if (call.table === "document_table_facts") return ok([]); + return ok([]); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/route"); + + const response = await GET(authenticatedRequest(`/api/documents/${documentId}`), { + params: Promise.resolve({ id: documentId }), + }); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.document).toMatchObject({ id: documentId, title: "Public guideline", owner_id: null }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); + }); + + it("allows authenticated users to open public document signed URLs", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { + return ok({ storage_path: "public/documents/guideline.pdf", file_type: "application/pdf" }); + } + return ok(null); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/signed-url/route"); + + const response = await GET(authenticatedRequest(`/api/documents/${documentId}/signed-url`), { + params: Promise.resolve({ id: documentId }), + }); + + expect(response.status).toBe(200); + expect((await payload(response)).url).toContain("public/documents/guideline.pdf"); + }); + + it("recovers valid cookie auth when a stale bearer header is also present", async () => { + const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/route"); + + const response = await GET( + request("/api/documents", { + headers: { + authorization: "Bearer expired-token", + cookie: `sb-access-token=${token}`, + }, + }), + ); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); + expect(client.auth.getUser).toHaveBeenCalledWith(token); + }); + + it("omits internal document list fields for anonymous callers", async () => { + const documents = [{ id: documentId, owner_id: null, title: "Public guideline", status: "indexed" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/route"); + + const response = await GET(request("/api/documents?includeMeta=true")); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(client.calls[0].selected).not.toContain("storage_path"); + expect(client.calls[0].selected).not.toContain("content_hash"); + expect(body.documents).toEqual(documents); + }); + + it("rate limits anonymous document read bursts", async () => { + const client = createSupabaseMock((call) => (call.table === "documents" ? ok([]) : ok([]))); + mockRuntime(client); + client.rpc.mockImplementation(async (name: string, args?: Record) => { + if (name === "consume_api_subject_rate_limit" && args?.p_bucket === "document_read") { + return { + data: [rateLimitRow({ limited: true, remaining: 0, retry_after_seconds: 30 })], + error: null, + }; + } + if (name === "consume_api_rate_limit") { + return { data: [rateLimitRow()], error: null }; + } + return ok([]); + }); + const { GET } = await import("../src/app/api/documents/route"); + + const response = await GET(request("/api/documents")); + + expect(response.status).toBe(429); + expect(await payload(response)).toMatchObject({ + error: "Document requests are rate limited. Try again shortly.", + retryAfterSeconds: 30, + }); + expect(client.rpc).toHaveBeenCalledWith( + "consume_api_subject_rate_limit", + expect.objectContaining({ p_bucket: "document_read" }), + ); }); it("does not return raw internal database errors", async () => { @@ -536,7 +674,7 @@ describe("private document API access", () => { it("allows document signed URLs only for owned documents", async () => { const client = createSupabaseMock((call) => { - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ storage_path: `${userId}/documents/${documentId}/source.pdf`, file_type: "application/pdf" }); } return ok(null); @@ -624,7 +762,7 @@ describe("private document API access", () => { metadata: { index_generation_id: "generation-a" }, }); } - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ id: documentId, metadata: { index_generation_id: "generation-a" } }); } return ok(null); @@ -653,7 +791,7 @@ describe("private document API access", () => { metadata: { index_generation_id: "generation-a" }, }); } - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ id: documentId, metadata: {} }); } return ok(null); @@ -682,7 +820,7 @@ describe("private document API access", () => { metadata: { index_generation_id: "generation-new" }, }); } - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ id: documentId, metadata: { index_generation_id: "generation-old" } }); } return ok(null); @@ -722,6 +860,58 @@ describe("private document API access", () => { expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); + it("rejects anonymous upload with setup guidance when public uploads are not configured", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); + + const response = await POST( + request("/api/upload", { + method: "POST", + body: formData, + }), + ); + + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); + expect(client.auth.getUser).not.toHaveBeenCalled(); + expect(client.storageMocks.upload).not.toHaveBeenCalled(); + }); + + it("uploads anonymous documents to the configured public workspace owner", async () => { + const publicOwnerId = "99999999-9999-4999-8999-999999999999"; + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null); + if (call.table === "documents" && call.operation === "insert") { + const inserted = call.insertPayload as { id: string; owner_id: string; storage_path: string }; + return ok({ id: inserted.id, owner_id: inserted.owner_id, storage_path: inserted.storage_path }); + } + if (call.table === "ingestion_jobs" && call.operation === "insert") return ok({ id: "job-1", document_id: documentId }); + return ok([]); + }); + mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId }); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); + + const response = await POST( + request("/api/upload", { + method: "POST", + body: formData, + }), + ); + + expect(response.status).toBe(201); + expect(client.auth.getUser).not.toHaveBeenCalled(); + expect( + client.calls.find((call) => call.table === "documents" && call.operation === "insert")?.insertPayload, + ).toMatchObject({ + owner_id: publicOwnerId, + }); + }); + it("stores uploaded documents with owner_id and a user-scoped storage path", async () => { const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "insert") { @@ -2106,9 +2296,13 @@ describe("private document API access", () => { } return ok([]); }); - client.rpc.mockImplementation(async (name: string) => - name === "search_document_chunks" ? fail("missing rpc") : ok([]), - ); + client.rpc.mockImplementation(async (name: string, args?: Record) => { + if (name === "search_document_chunks") return fail("missing rpc"); + if (name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit") { + return { data: [rateLimitRow()], error: null }; + } + return ok([]); + }); mockRuntime(client); const { GET } = await import("../src/app/api/documents/[id]/search/route"); @@ -3003,7 +3197,9 @@ describe("private document API access", () => { })); const client = createSupabaseMock(); client.rpc.mockImplementation(async (name: string) => - name === "consume_api_rate_limit" ? fail("limiter table unavailable") : ok([]), + name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit" + ? fail("limiter table unavailable") + : ok([]), ); mockRuntime(client, { searchChunksWithTelemetry }); const { POST } = await import("../src/app/api/search/route"); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 3d2bae636..2eb32ebbf 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -143,6 +143,7 @@ async function mockLocalProjectIdentity(page: Page) { } async function mockPrivateUnauthenticatedApi(page: Page) { + await mockLocalProjectIdentity(page); await page.route("**/api/setup-status**", async (route) => { await route.fulfill({ json: { demoMode: false, checks: readySetupChecks }, @@ -706,6 +707,36 @@ test.describe("Clinical KB UI smoke coverage", () => { }); } + test("anonymous user can see enabled live search without a forced sign-in gate", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockPrivateUnauthenticatedApi(page); + await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { + await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } }); + }); + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + + await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0); + await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0); + await expect(page.getByTestId("global-search-input")).toBeEnabled(); + }); + + test("anonymous mobile user can search without a forced sign-in gate", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + await mockPrivateUnauthenticatedApi(page); + await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { + await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } }); + }); + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + + await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0); + await expect(page.getByText("Service unavailable")).toHaveCount(0); + await expect(page.getByText("API unavailable")).toHaveCount(0); + await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0); + await expect(page.getByTestId("global-search-input")).toBeEnabled(); + }); + test("desktop sidebar mode sync and accessibility affordances stay coherent", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index ef9823b7d..3037cfaa4 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -524,12 +524,13 @@ test.describe("Clinical KB applications launcher", () => { test("mode home deep links preserve focus=1 on initial load", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - for (const path of ["/services?focus=1", "/forms?focus=1"]) { - await gotoLauncher(page, path); - const sharedSearch = page.getByTestId("global-search-input"); - await expect(sharedSearch).toBeVisible(); - await expect(sharedSearch).toBeFocused(); - } + await gotoLauncher(page, "/services?focus=1"); + await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeVisible(); + await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeFocused(); + + await gotoLauncher(page, "/forms?focus=1"); + await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeVisible(); + await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeFocused(); }); test("services mode shows source-backed records in search results", async ({ page }) => { @@ -573,11 +574,12 @@ test.describe("Clinical KB applications launcher", () => { test("form detail pages keep the shared forms search wired to form results", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/forms/transport-crisis-form"); + await expect(page.getByTestId("form-detail-page")).toBeVisible(); // Structural coverage — runs on every browser, WebKit included: the form // detail page renders inside the shared shell with the Forms-mode composer // present and no stale results. - await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole("heading", { level: 1, name: "Transport order" })).toBeVisible(); await expect(page.getByTestId("form-search-results")).toHaveCount(0); const formsSearchInput = page.locator('input[placeholder="Search forms..."]:visible').first(); From 90fde2ddd7d73d906f69fa0b68e4a472c6a09587 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:59:38 +0800 Subject: [PATCH 07/16] test: fix unused param lint in document search rate-limit mock --- tests/private-access-routes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 958a05e92..92438c369 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -2296,7 +2296,7 @@ describe("private document API access", () => { } return ok([]); }); - client.rpc.mockImplementation(async (name: string, args?: Record) => { + client.rpc.mockImplementation(async (name: string) => { if (name === "search_document_chunks") return fail("missing rpc"); if (name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit") { return { data: [rateLimitRow()], error: null }; From 650d26c428c02739c56ba14e07f83106be5d8fbe Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:30:52 +0800 Subject: [PATCH 08/16] fix(rag): scope anonymous retrieval to public documents via owner sentinel --- scripts/commit-access-rag-fix.mjs | 209 ++++ src/lib/deep-memory.ts | 12 +- src/lib/document-enrichment.ts | 4 +- src/lib/owner-scope.ts | 31 +- src/lib/rag.ts | 32 +- ...210000_retrieval_owner_filter_sentinel.sql | 1030 +++++++++++++++++ supabase/schema.sql | 44 +- tests/owner-scope.test.ts | 9 + 8 files changed, 1325 insertions(+), 46 deletions(-) create mode 100644 scripts/commit-access-rag-fix.mjs create mode 100644 supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql diff --git a/scripts/commit-access-rag-fix.mjs b/scripts/commit-access-rag-fix.mjs new file mode 100644 index 000000000..72a78c7b0 --- /dev/null +++ b/scripts/commit-access-rag-fix.mjs @@ -0,0 +1,209 @@ +import fs from "node:fs"; +import { execSync } from "node:child_process"; + +const ownerScope = `import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; + +export const PUBLIC_OWNER_FILTER_SENTINEL = "00000000-0000-0000-0000-000000000000"; + +export function requireOwnerScope(ownerId: string | null | undefined): string | undefined { + if (ownerId) return ownerId; + if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") { + return undefined; + } + throw new Error( + "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.", + ); +} + +export function retrievalOwnerFilter(args: { + ownerId?: string | null; + documentIds?: string[]; + allowGlobalSearch?: boolean; +}): string | null | undefined { + if (args.ownerId) return requireOwnerScope(args.ownerId); + if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") { + return undefined; + } + if (args.allowGlobalSearch || args.documentIds?.length) { + return PUBLIC_OWNER_FILTER_SENTINEL; + } + throw new Error( + "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.", + ); +} +`; + +fs.writeFileSync("src/lib/owner-scope.ts", ownerScope); + +let rag = fs.readFileSync("src/lib/rag.ts", "utf8"); +rag = rag.replace( + 'import { requireOwnerScope } from "@/lib/owner-scope";', + 'import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope";', +); +rag = rag.replace( + /function ownerScopeForDocumentFilteredRetrieval\([\s\S]*?\n\}/, + `function ownerScopeForDocumentFilteredRetrieval( + ownerId: string | undefined, + documentIds: string[] | undefined, + allowGlobalSearch?: boolean, +) { + return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch }); +}`, +); +rag = rag.replaceAll( + "ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds)", + "ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch)", +); +rag = rag.replaceAll( + "ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList)", + "ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList, args.allowGlobalSearch)", +); +rag = rag.replace( + `documentFilter ? [documentFilter] : undefined, + ),`, + `documentFilter ? [documentFilter] : undefined, + documentFilter ? undefined : args.allowGlobalSearch, + ),`, +); +rag = rag.replace( + `documentIds: documentFilterList, + matchCount: textCandidateCount,`, + `documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, + matchCount: textCandidateCount,`, +); +rag = rag.replaceAll( + `documentIds: documentFilterList, + matchCount: Math.min(candidateCount, 48),`, + `documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, + matchCount: Math.min(candidateCount, 48),`, +); +rag = rag.replace( + `documentIds: documentFilterList, + matchCount: Math.min(candidateCount, 64),`, + `documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, + matchCount: Math.min(candidateCount, 64),`, +); +rag = rag.replace( + "documentIds?: string[];\n matchCount: number;\n}) {\n const runChunkText", + "documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;\n}) {\n const runChunkText", +); +for (const fn of ["searchTableFactCandidates", "searchEmbeddingFieldCandidates", "searchIndexUnitCandidates"]) { + rag = rag.replace( + new RegExp(`async function ${fn}\\([\\s\\S]*?documentIds\\?: string\\[\\];\\n matchCount: number;`), + (m) => m.replace("documentIds?: string[];\n matchCount: number;", "documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;"), + ); +} +if (!rag.includes('else documentQuery = documentQuery.is("owner_id", null);')) { + rag = rag.replace( + `if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId); + const { data: documents, error: documentsError } = await documentQuery;`, + `if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId); + else documentQuery = documentQuery.is("owner_id", null); + const { data: documents, error: documentsError } = await documentQuery;`, + ); +} +fs.writeFileSync("src/lib/rag.ts", rag); + +let enrichment = fs.readFileSync("src/lib/document-enrichment.ts", "utf8"); +enrichment = enrichment.replace( + 'import { requireOwnerScope } from "@/lib/owner-scope";', + 'import { retrievalOwnerFilter } from "@/lib/owner-scope";', +); +enrichment = enrichment.replace( + "owner_filter: args.ownerId ? requireOwnerScope(args.ownerId) : null,", + "owner_filter: retrievalOwnerFilter({ ownerId: args.ownerId, documentIds: args.documentIds }),", +); +fs.writeFileSync("src/lib/document-enrichment.ts", enrichment); + +let memory = fs.readFileSync("src/lib/deep-memory.ts", "utf8"); +if (!memory.includes("retrievalOwnerFilter")) { + memory = memory.replace( + 'import { requireOwnerScope } from "@/lib/owner-scope";', + 'import { retrievalOwnerFilter } from "@/lib/owner-scope";', + ); + memory = memory.replace( + /owner_filter:[\s\S]*?requireOwnerScope\(args\.ownerId\)[\s\S]*?\),/, + `owner_filter: retrievalOwnerFilter({ + ownerId: args.ownerId, + documentIds: args.documentIds, + allowGlobalSearch: !args.ownerId && !args.documentIds?.length, + }),`, + ); +} +fs.writeFileSync("src/lib/deep-memory.ts", memory); + +let schema = fs.readFileSync("supabase/schema.sql", "utf8"); +if (!schema.includes("create or replace function public.retrieval_owner_matches")) { + schema = schema.replace( + "create or replace function public.match_document_chunks(", + `create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid) +returns boolean +language sql +immutable +parallel safe +set search_path = public, pg_temp +as $$ + select case + when owner_filter is null then true + when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null + else row_owner_id = owner_filter + end; +$$; + +create or replace function public.match_document_chunks(`, + ); +} +schema = schema.replaceAll("(owner_filter is null or d.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, d.owner_id)"); +schema = schema.replaceAll("(owner_filter is null or l.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, l.owner_id)"); +schema = schema.replaceAll("(owner_filter is null or s.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, s.owner_id)"); +schema = schema.replaceAll("(owner_filter is null or f.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, f.owner_id)"); +fs.writeFileSync("supabase/schema.sql", schema); + +const names = [ + "retrieval_owner_matches", + "match_document_chunks", + "match_document_chunks_hybrid", + "match_document_memory_cards_hybrid", + "match_documents_for_query", + "match_document_chunks_text", + "match_document_lookup_chunks_text", + "get_related_document_metadata", + "match_document_table_facts_text", + "match_document_embedding_fields_hybrid", + "match_document_index_units_hybrid", +]; +const chunks = names.map((name) => { + const re = new RegExp(`create or replace function public\\.${name}[\\s\\S]*?\\n\\$\\$;`, "i"); + const match = schema.match(re); + if (!match) throw new Error(`missing ${name}`); + return match[0]; +}); +fs.writeFileSync( + "supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql", + `-- Public-only retrieval owner filter sentinel for hybrid RPCs.\nset search_path = public, extensions, pg_temp;\n\n${chunks.join("\n\n")}\n`, +); + +let ownerTest = fs.readFileSync("tests/owner-scope.test.ts", "utf8"); +if (!ownerTest.includes("retrievalOwnerFilter")) { + ownerTest += `\ndescribe("retrievalOwnerFilter", () => { + it("returns the public sentinel for anonymous production global search", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, isLocalNoAuthMode: () => false })); + vi.stubEnv("NODE_ENV", "production"); + const { retrievalOwnerFilter, PUBLIC_OWNER_FILTER_SENTINEL } = await import("../src/lib/owner-scope"); + expect(retrievalOwnerFilter({ allowGlobalSearch: true })).toBe(PUBLIC_OWNER_FILTER_SENTINEL); + }); +});\n`; + fs.writeFileSync("tests/owner-scope.test.ts", ownerTest); +} + +execSync( + "git add src/lib/owner-scope.ts src/lib/rag.ts src/lib/document-enrichment.ts src/lib/deep-memory.ts supabase/schema.sql supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql tests/owner-scope.test.ts scripts/commit-access-rag-fix.mjs", + { stdio: "inherit" }, +); +execSync('git commit -m "fix(rag): scope anonymous retrieval to public documents via owner sentinel"', { + stdio: "inherit", +}); +console.log("committed"); diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts index fe6d02622..c4b1130a7 100644 --- a/src/lib/deep-memory.ts +++ b/src/lib/deep-memory.ts @@ -1,7 +1,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { buildClinicalTextSearchQuery, classifyRagQuery, normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { logger } from "@/lib/logger"; -import { requireOwnerScope } from "@/lib/owner-scope"; +import { retrievalOwnerFilter } from "@/lib/owner-scope"; import { buildDocumentIndexUnitInputs, countDocumentIndexUnitsByType, @@ -823,11 +823,11 @@ export async function fetchMemoryCardsForQuery(args: { match_count: args.matchCount ?? 32, min_similarity: 0.1, document_filters: args.documentIds?.length ? args.documentIds : null, - owner_filter: args.ownerId - ? requireOwnerScope(args.ownerId) - : args.documentIds?.length - ? null - : (requireOwnerScope(args.ownerId) ?? null), + owner_filter: retrievalOwnerFilter({ + ownerId: args.ownerId, + documentIds: args.documentIds, + allowGlobalSearch: !args.ownerId && !args.documentIds?.length, + }), }); if (error) { diff --git a/src/lib/document-enrichment.ts b/src/lib/document-enrichment.ts index 6aee1d8d0..ec77e4670 100644 --- a/src/lib/document-enrichment.ts +++ b/src/lib/document-enrichment.ts @@ -1,7 +1,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { classifyDocumentOrganization } from "@/lib/document-organization"; import { env } from "@/lib/env"; -import { requireOwnerScope } from "@/lib/owner-scope"; +import { retrievalOwnerFilter } from "@/lib/owner-scope"; import { isClinicalImageEvidence } from "@/lib/image-filtering"; import { buildCoveragePromptNote, @@ -713,7 +713,7 @@ export async function fetchRelatedDocumentMetadata(args: { }) { const { data: rpcData, error: rpcError } = await args.supabase.rpc("get_related_document_metadata", { document_ids: args.documentIds, - owner_filter: args.ownerId ? requireOwnerScope(args.ownerId) : null, + owner_filter: retrievalOwnerFilter({ ownerId: args.ownerId, documentIds: args.documentIds }), }); if (!rpcError) { diff --git a/src/lib/owner-scope.ts b/src/lib/owner-scope.ts index 4bec91eca..4eec27540 100644 --- a/src/lib/owner-scope.ts +++ b/src/lib/owner-scope.ts @@ -1,17 +1,7 @@ import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -/** - * Fail-closed guard for multi-tenant owner scoping. - * - * The hybrid retrieval RPCs treat a null `owner_filter` as "all owners" - * (fail-open), so calling them without an ownerId would silently return another - * tenant's data. Call this at every retrieval RPC boundary that filters by - * owner. In a real (multi-user) deployment a missing ownerId throws instead of - * leaking; in demo / local-no-auth / the test runner there is no multi-tenancy, - * so it stays permissive (returns undefined, preserving the previous behaviour). - * - * See the owner-scoping isolation audit. - */ +export const PUBLIC_OWNER_FILTER_SENTINEL = "00000000-0000-0000-0000-000000000000"; + export function requireOwnerScope(ownerId: string | null | undefined): string | undefined { if (ownerId) return ownerId; if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") { @@ -21,3 +11,20 @@ export function requireOwnerScope(ownerId: string | null | undefined): string | "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.", ); } + +export function retrievalOwnerFilter(args: { + ownerId?: string | null; + documentIds?: string[]; + allowGlobalSearch?: boolean; +}): string | null | undefined { + if (args.ownerId) return requireOwnerScope(args.ownerId); + if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") { + return undefined; + } + if (args.allowGlobalSearch || args.documentIds?.length) { + return PUBLIC_OWNER_FILTER_SENTINEL; + } + throw new Error( + "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.", + ); +} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 7192677a3..d504c10dd 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1,5 +1,5 @@ import { createAdminClient } from "@/lib/supabase/admin"; -import { requireOwnerScope } from "@/lib/owner-scope"; +import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope"; import type { Database, Json } from "@/lib/supabase/database.types"; import { embedTextWithTelemetry, @@ -2069,10 +2069,12 @@ function assertGlobalSearchAllowed(args: SearchChunksArgs) { } } -function ownerScopeForDocumentFilteredRetrieval(ownerId: string | undefined, documentIds: string[] | undefined) { - if (ownerId) return requireOwnerScope(ownerId); - if (documentIds?.length) return undefined; - return requireOwnerScope(ownerId); +function ownerScopeForDocumentFilteredRetrieval( + ownerId: string | undefined, + documentIds: string[] | undefined, + allowGlobalSearch?: boolean, +) { + return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch }); } export function buildRetrievalQueryVariants( @@ -2200,6 +2202,7 @@ async function searchTextChunkCandidates(args: { queryVariants: string[]; ownerId?: string; documentIds?: string[]; + allowGlobalSearch?: boolean; matchCount: number; }) { const runChunkText = async (queryText: string, matchCount: number) => { @@ -2207,7 +2210,7 @@ async function searchTextChunkCandidates(args: { query_text: queryText, match_count: matchCount, document_filters: args.documentIds ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); return error || !data?.length ? ([] as SearchResult[]) : (data as SearchResult[]); }; @@ -2360,7 +2363,7 @@ async function fetchBestDocumentLookupChunks(args: { query_text: args.query, document_filters: args.documentIds ?? undefined, match_count: Math.max(args.limit * 3, 24), - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); if (!rpcError && rpcChunks?.length) { const ranked = (rpcChunks as DocumentLookupChunkRow[]) @@ -2748,6 +2751,7 @@ async function loadChunksForSignalMatches(args: { .in("id", documentIds) .eq("status", "indexed"); if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId); + else documentQuery = documentQuery.is("owner_id", null); const { data: documents, error: documentsError } = await documentQuery; if (documentsError || !documents?.length) return [] as SearchResult[]; const documentById = new Map(documents.map((document) => [document.id, document])); @@ -2807,6 +2811,7 @@ async function searchTableFactCandidates(args: { queryVariants?: string[]; ownerId?: string; documentIds?: string[]; + allowGlobalSearch?: boolean; matchCount: number; }) { const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice( @@ -2819,7 +2824,7 @@ async function searchTableFactCandidates(args: { query_text: variant, match_count: index === 0 ? args.matchCount : Math.min(args.matchCount, 24), document_filters: args.documentIds ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); if (error || !data?.length) return [] as TableFactRpcRow[]; return data as TableFactRpcRow[]; @@ -2858,6 +2863,7 @@ async function searchEmbeddingFieldCandidates(args: { queryEmbedding: number[]; ownerId?: string; documentIds?: string[]; + allowGlobalSearch?: boolean; matchCount: number; telemetry?: SearchTelemetry; }) { @@ -2867,7 +2873,7 @@ async function searchEmbeddingFieldCandidates(args: { match_count: args.matchCount, min_similarity: 0.12, document_filters: args.documentIds ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -2908,6 +2914,7 @@ async function searchIndexUnitCandidates(args: { queryEmbedding: number[]; ownerId?: string; documentIds?: string[]; + allowGlobalSearch?: boolean; matchCount: number; telemetry?: SearchTelemetry; }) { @@ -2917,7 +2924,7 @@ async function searchIndexUnitCandidates(args: { match_count: args.matchCount, min_similarity: 0.1, document_filters: args.documentIds ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -5529,6 +5536,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryVariants, ownerId: args.ownerId, documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, matchCount: textCandidateCount, }); telemetry.text_candidate_count = textData.length; @@ -5627,6 +5635,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryVariants, ownerId: args.ownerId, documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 48), }); const tableFactLatencyMs = Date.now() - tableFactStartedAt; @@ -5833,7 +5842,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { match_count: candidateCount, min_similarity: minSimilarity, document_filters: documentFilterList ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList, args.allowGlobalSearch), }); return { data, error, latencyMs: Date.now() - startedAt }; })(), @@ -5932,6 +5941,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { owner_filter: ownerScopeForDocumentFilteredRetrieval( args.ownerId, documentFilter ? [documentFilter] : undefined, + documentFilter ? undefined : args.allowGlobalSearch, ), }); diff --git a/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql b/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql new file mode 100644 index 000000000..64f42ee56 --- /dev/null +++ b/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql @@ -0,0 +1,1030 @@ +-- Public-only retrieval owner filter sentinel for hybrid RPCs. +set search_path = public, extensions, pg_temp; + +create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid) +returns boolean +language sql +immutable +parallel safe +set search_path = public, pg_temp +as $ + select case + when owner_filter is null then true + when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null + else row_owner_id = owner_filter + end; +$; + +create or replace function public.match_document_chunks( + query_embedding extensions.vector(1536), + match_count integer default 8, + min_similarity double precision default 0.15, + document_filter uuid default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + title text, + file_name text, + page_number integer, + chunk_index integer, + section_heading text, + content text, + retrieval_synopsis text, + image_ids uuid[], + source_metadata jsonb, + document_labels jsonb, + document_summary text, + similarity double precision, + images jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select + c.id, + c.document_id, + d.title, + d.file_name, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + d.metadata as source_metadata, + '[]'::jsonb as document_labels, + null::text as document_summary, + 1 - (c.embedding <=> query_embedding) as similarity, + '[]'::jsonb as images + from public.document_chunks c + join public.documents d on d.id = c.document_id + where (document_filter is null or c.document_id = document_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and 1 - (c.embedding <=> query_embedding) >= min_similarity + order by c.embedding <=> query_embedding + limit match_count; +$$; + +create or replace function public.match_document_chunks( + query_embedding extensions.vector(1536), + match_count integer default 8, + min_similarity double precision default 0.15, + document_filter uuid default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + title text, + file_name text, + page_number integer, + chunk_index integer, + section_heading text, + content text, + retrieval_synopsis text, + image_ids uuid[], + source_metadata jsonb, + document_labels jsonb, + document_summary text, + similarity double precision, + images jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select + c.id, + c.document_id, + d.title, + d.file_name, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + d.metadata as source_metadata, + '[]'::jsonb as document_labels, + null::text as document_summary, + 1 - (c.embedding <=> query_embedding) as similarity, + '[]'::jsonb as images + from public.document_chunks c + join public.documents d on d.id = c.document_id + where (document_filter is null or c.document_id = document_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and 1 - (c.embedding <=> query_embedding) >= min_similarity + order by c.embedding <=> query_embedding + limit match_count; +$$; + +create or replace function public.match_document_chunks_hybrid( + query_embedding extensions.vector(1536), + query_text text, + match_count integer default 12, + min_similarity double precision default 0.12, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + title text, + file_name text, + page_number integer, + chunk_index integer, + section_heading text, + content text, + retrieval_synopsis text, + image_ids uuid[], + source_metadata jsonb, + similarity double precision, + text_rank double precision, + hybrid_score double precision, + rrf_score double precision, + images jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ), + vector_ranked as ( + select + c.id, + c.document_id, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + 1 - (c.embedding <=> query_embedding) as similarity, + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + )::double precision as text_rank, + row_number() over (order by c.embedding <=> query_embedding) as vector_rank, + null::bigint as text_match_rank, + coalesce((d.metadata->'rag_indexing_version') is not null, false) as has_deep_index, + d.updated_at as doc_updated_at, + coalesce( + (select q.quality_score from public.document_index_quality q where q.document_id = c.document_id), + 0.7 + ) as quality_score + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join query + where (document_filters is null or c.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and 1 - (c.embedding <=> query_embedding) >= min_similarity + order by c.embedding <=> query_embedding + limit greatest(match_count * 6, 48) + ), + text_ranked as ( + select + c.id, + c.document_id, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + 1 - (c.embedding <=> query_embedding) as similarity, + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + )::double precision as text_rank, + null::bigint as vector_rank, + row_number() over ( + order by + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + ) desc, + c.embedding <=> query_embedding + ) as text_match_rank, + coalesce((d.metadata->'rag_indexing_version') is not null, false) as has_deep_index, + d.updated_at as doc_updated_at, + coalesce( + (select q.quality_score from public.document_index_quality q where q.document_id = c.document_id), + 0.7 + ) as quality_score + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join query + where (document_filters is null or c.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and c.search_tsv @@ query.tsq + order by ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + ) desc + limit greatest(match_count * 6, 48) + ), + combined as ( + select * from vector_ranked + union all + select * from text_ranked + ), + scored as ( + select + id, + document_id, + page_number, + chunk_index, + section_heading, + content, + retrieval_synopsis, + image_ids, + max(similarity)::double precision as similarity, + max(text_rank)::double precision as text_rank, + min(vector_rank) as vector_rank, + min(text_match_rank) as text_match_rank, + max(quality_score)::double precision as quality_score, + bool_or(has_deep_index) as has_deep_index, + max(doc_updated_at) as doc_updated_at + from combined + group by id, document_id, page_number, chunk_index, section_heading, content, retrieval_synopsis, image_ids + ), + scored_metrics as ( + select + scored.*, + ( + (scored.similarity * 0.62) + + (least(scored.text_rank, 1) * 0.22) + + (scored.quality_score * 0.10) + + (case when scored.doc_updated_at > now() - interval '90 days' then 0.06 else 0 end) + )::double precision as hybrid_score, + ( + coalesce(1.0 / (60 + scored.vector_rank), 0) + + coalesce(1.0 / (60 + scored.text_match_rank), 0) + )::double precision as rrf_score + from scored + ), + hybrid_candidates as ( + select id + from scored_metrics + order by hybrid_score desc, similarity desc, text_rank desc + limit match_count + ), + vector_candidates as ( + select id + from scored_metrics + order by similarity desc, hybrid_score desc + limit match_count + ), + text_candidates as ( + select id + from scored_metrics + order by text_rank desc, hybrid_score desc + limit match_count + ), + rrf_candidates as ( + select id + from scored_metrics + order by rrf_score desc, hybrid_score desc + limit match_count + ), + candidate_ids as ( + select id from hybrid_candidates + union + select id from vector_candidates + union + select id from text_candidates + union + select id from rrf_candidates + ) + select + c.id, + c.document_id, + d.title, + d.file_name, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + d.metadata as source_metadata, + c.similarity, + c.text_rank, + c.hybrid_score, + c.rrf_score, + public.chunk_image_metadata(c.image_ids) as images + from scored_metrics c + join candidate_ids candidates on candidates.id = c.id + join public.documents d on d.id = c.document_id + order by c.hybrid_score desc, c.rrf_score desc, c.similarity desc, c.text_rank desc + limit match_count; +$$; + +create or replace function public.match_document_memory_cards_hybrid_v2( + query_embedding extensions.vector(1536), + query_text text, + match_count integer default 32, + min_similarity double precision default 0.1, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + owner_id uuid, + section_id uuid, + card_type text, + title text, + content text, + normalized_terms text[], + page_number integer, + source_chunk_ids uuid[], + source_image_ids uuid[], + confidence real, + metadata jsonb, + similarity double precision, + text_rank double precision, + hybrid_score double precision, + rrf_score double precision +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ), + vector_ranked as ( + select + m.*, + (1 - (m.embedding <=> query_embedding))::double precision as similarity, + ts_rank_cd(m.search_tsv, query.tsq)::double precision as text_rank, + row_number() over (order by m.embedding <=> query_embedding) as vector_rank, + null::bigint as text_match_rank + from public.document_memory_cards m + join public.documents d on d.id = m.document_id + cross join query + where (document_filters is null or m.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(m.metadata, d.metadata) + and (1 - (m.embedding <=> query_embedding)) >= min_similarity + order by m.embedding <=> query_embedding + limit greatest(match_count * 6, 96) + ), + text_ranked as ( + select + m.*, + (1 - (m.embedding <=> query_embedding))::double precision as similarity, + ts_rank_cd(m.search_tsv, query.tsq)::double precision as text_rank, + null::bigint as vector_rank, + row_number() over ( + order by ts_rank_cd(m.search_tsv, query.tsq) desc, m.embedding <=> query_embedding + ) as text_match_rank + from public.document_memory_cards m + join public.documents d on d.id = m.document_id + cross join query + where (document_filters is null or m.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(m.metadata, d.metadata) + and m.search_tsv @@ query.tsq + order by ts_rank_cd(m.search_tsv, query.tsq) desc + limit greatest(match_count * 6, 96) + ), + combined as ( + select * from vector_ranked + union all + select * from text_ranked + ), + scored as ( + select + id, document_id, owner_id, section_id, card_type, title, content, normalized_terms, + page_number, source_chunk_ids, source_image_ids, confidence, metadata, + max(similarity)::double precision as similarity, + max(text_rank)::double precision as text_rank, + min(vector_rank) as vector_rank, + min(text_match_rank) as text_match_rank + from combined + group by + id, document_id, owner_id, section_id, card_type, title, content, normalized_terms, + page_number, source_chunk_ids, source_image_ids, confidence, metadata + ) + select + id, document_id, owner_id, section_id, card_type, title, content, normalized_terms, + page_number, source_chunk_ids, source_image_ids, confidence, metadata, similarity, text_rank, + ( + (similarity * 0.62) + + (least(text_rank, 1) * 0.24) + + (confidence * 0.10) + + ( + coalesce(1.0 / (60 + vector_rank), 0) + + coalesce(1.0 / (60 + text_match_rank), 0) + ) * 0.04 + )::double precision as hybrid_score, + ( + coalesce(1.0 / (60 + vector_rank), 0) + + coalesce(1.0 / (60 + text_match_rank), 0) + )::double precision as rrf_score + from scored + order by hybrid_score desc, similarity desc, text_rank desc, confidence desc + limit match_count; +$$; + +create or replace function public.match_documents_for_query( + query_text text, + match_count integer default 12, + owner_filter uuid default null +) +returns table ( + id uuid, + owner_id uuid, + title text, + file_name text, + status text, + page_count integer, + chunk_count integer, + image_count integer, + metadata jsonb, + text_rank double precision, + match_reason text +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select + websearch_to_tsquery('english', coalesce(query_text, '')) as tsq, + lower(coalesce(query_text, '')) as normalized + ), + ranked as ( + select + d.id, + d.owner_id, + d.title, + d.file_name, + d.status, + d.page_count, + d.chunk_count, + d.image_count, + d.metadata, + ( + (ts_rank_cd(d.title_search_tsv, query.tsq) * 4.0) + + (ts_rank_cd(d.search_tsv, query.tsq) * 1.5) + + coalesce(max(ts_rank_cd(to_tsvector('english', l.label), query.tsq)) * 1.2, 0) + + coalesce(ts_rank_cd(to_tsvector('english', s.summary), query.tsq), 0) + + (greatest( + similarity(lower(coalesce(d.title, '') || ' ' || coalesce(d.file_name, '')), query.normalized), + coalesce(max(similarity(lower(l.label), query.normalized)), 0), + coalesce(similarity(lower(s.summary), query.normalized), 0) + ) * 1.6) + )::double precision as text_rank, + case + when d.title_search_tsv @@ query.tsq then 'title' + when max(l.label) filter (where to_tsvector('english', l.label) @@ query.tsq) is not null then 'label' + when s.summary is not null and to_tsvector('english', s.summary) @@ query.tsq then 'summary' + when similarity(lower(coalesce(d.title, '') || ' ' || coalesce(d.file_name, '')), query.normalized) >= 0.18 then 'fuzzy_title' + when d.search_tsv @@ query.tsq then 'metadata' + else 'none' + end as match_reason + from public.documents d + left join public.document_labels l + on l.document_id = d.id + and coalesce(l.metadata->>'review_status', 'new') <> 'hidden' + and coalesce(l.metadata->>'hidden', 'false') <> 'true' + left join public.document_summaries s on s.document_id = d.id + cross join query + where public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and ( + d.title_search_tsv @@ query.tsq + or d.search_tsv @@ query.tsq + or to_tsvector('english', coalesce(l.label, '')) @@ query.tsq + or to_tsvector('english', coalesce(s.summary, '')) @@ query.tsq + or similarity(lower(coalesce(d.title, '') || ' ' || coalesce(d.file_name, '')), query.normalized) >= 0.18 + or similarity(lower(coalesce(l.label, '')), query.normalized) >= 0.2 + or similarity(lower(coalesce(s.summary, '')), query.normalized) >= 0.16 + ) + group by d.id, d.owner_id, d.title, d.file_name, d.status, d.page_count, d.chunk_count, d.image_count, d.metadata, s.summary, query.tsq, query.normalized + ) + select * + from ranked + where text_rank > 0 + order by text_rank desc, page_count desc, title asc + limit match_count; +$$; + +create or replace function public.match_document_chunks_text( + query_text text, + match_count integer default 12, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + title text, + file_name text, + page_number integer, + chunk_index integer, + section_heading text, + content text, + retrieval_synopsis text, + image_ids uuid[], + source_metadata jsonb, + document_labels jsonb, + document_summary text, + similarity double precision, + text_rank double precision, + hybrid_score double precision, + lexical_score double precision, + images jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ), + ranked as ( + select + c.id, + c.document_id, + d.title, + d.file_name, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + d.metadata as source_metadata, + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + )::double precision as text_rank + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join query + where (document_filters is null or c.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) + order by ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + ) desc + limit least(greatest(match_count * 2, 24), 96) + ), + -- Batch-fetch label metadata for all distinct document_ids in the result set. + -- One query replaces N per-row calls to document_label_metadata(). + doc_labels as ( + select + l.document_id, + coalesce( + jsonb_agg( + jsonb_build_object( + 'id', l.id, + 'document_id', l.document_id, + 'owner_id', l.owner_id, + 'label', l.label, + 'label_type', l.label_type, + 'source', l.source, + 'confidence', l.confidence, + 'metadata', l.metadata, + 'created_at', l.created_at, + 'updated_at', l.updated_at + ) + order by l.confidence desc, l.label + ), + '[]'::jsonb + ) as labels + from public.document_labels l + where l.document_id in (select distinct ranked.document_id from ranked) + and coalesce(l.metadata->>'review_status', 'new') <> 'hidden' + and coalesce(l.metadata->>'hidden', 'false') <> 'true' + group by l.document_id + ), + -- Batch-fetch summary text for all distinct document_ids in the result set. + -- One query replaces N per-row calls to document_summary_text(). + doc_summaries as ( + select distinct on (s.document_id) + s.document_id, + s.summary + from public.document_summaries s + where s.document_id in (select distinct ranked.document_id from ranked) + order by s.document_id + ) + select + ranked.id, + ranked.document_id, + ranked.title, + ranked.file_name, + ranked.page_number, + ranked.chunk_index, + ranked.section_heading, + ranked.content, + ranked.retrieval_synopsis, + ranked.image_ids, + ranked.source_metadata, + coalesce(doc_labels.labels, '[]'::jsonb) as document_labels, + doc_summaries.summary as document_summary, + -- Text-only fallback has NO vector cosine similarity. Do not fabricate one: + -- a synthetic value here was read downstream as a real semantic score and + -- could label a pure keyword hit as "strong"/"moderate" evidence (>=0.64). + -- Leave similarity at 0; the lexical signal lives in lexical_score. + 0::double precision as similarity, + ranked.text_rank, + -- Cap hybrid_score well below the 0.64 "moderate" threshold so a lexical-only + -- row can order amongst its peers but can never masquerade as a moderate/strong + -- cosine match when merged with vector results. + least(0.5, 0.18 + (least(ranked.text_rank, 1) * 0.3))::double precision as hybrid_score, + least(0.99, 0.4 + (least(ranked.text_rank, 1) * 0.59))::double precision as lexical_score, + public.chunk_image_metadata(ranked.image_ids) as images + from ranked + left join doc_labels on doc_labels.document_id = ranked.document_id + left join doc_summaries on doc_summaries.document_id = ranked.document_id + order by lexical_score desc, text_rank desc + limit match_count; +$$; + +create or replace function public.match_document_lookup_chunks_text( + query_text text, + document_filters uuid[], + match_count integer default 24, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + page_number integer, + chunk_index integer, + section_heading text, + section_path text[], + heading_level integer, + parent_heading text, + anchor_id text, + content text, + retrieval_synopsis text, + image_ids uuid[], + text_rank double precision +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ) + select + c.id, + c.document_id, + c.page_number, + c.chunk_index, + c.section_heading, + c.section_path, + c.heading_level, + c.parent_heading, + c.anchor_id, + c.content, + c.retrieval_synopsis, + c.image_ids, + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (case when c.section_heading is not null then ts_rank_cd(to_tsvector('english', c.section_heading), query.tsq) * 0.35 else 0 end) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 0.25) + )::double precision as text_rank + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join query + where document_filters is not null + and c.document_id = any(document_filters) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) + order by text_rank desc, c.chunk_index asc + limit least(greatest(match_count, 1), 80); +$$; + +create or replace function public.get_related_document_metadata( + document_ids uuid[], + owner_filter uuid default null +) +returns table ( + document_id uuid, + labels jsonb, + summary text +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select + d.id as document_id, + coalesce( + ( + select jsonb_agg( + jsonb_build_object( + 'id', l.id, + 'document_id', l.document_id, + 'owner_id', l.owner_id, + 'label', l.label, + 'label_type', l.label_type, + 'source', l.source, + 'confidence', l.confidence, + 'metadata', l.metadata, + 'created_at', l.created_at, + 'updated_at', l.updated_at + ) + order by l.confidence desc, l.label + ) + from public.document_labels l + where l.document_id = d.id + and public.retrieval_owner_matches(owner_filter, l.owner_id) + and coalesce(l.metadata->>'review_status', 'new') <> 'hidden' + and coalesce(l.metadata->>'hidden', 'false') <> 'true' + ), + '[]'::jsonb + ) as labels, + ( + select s.summary + from public.document_summaries s + where s.document_id = d.id + and public.retrieval_owner_matches(owner_filter, s.owner_id) + order by s.generated_at desc + limit 1 + ) as summary + from public.documents d + where d.id = any(document_ids) + and public.retrieval_owner_matches(owner_filter, d.owner_id); +$$; + +create or replace function public.match_document_table_facts_text( + query_text text, + match_count integer default 16, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + source_chunk_id uuid, + source_image_id uuid, + page_number integer, + table_title text, + row_label text, + clinical_parameter text, + threshold_value text, + action text, + text_rank double precision, + match_reason text, + metadata jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select + websearch_to_tsquery('english', coalesce(query_text, '')) as tsq, + lower(coalesce(query_text, '')) as normalized, + regexp_split_to_array(lower(coalesce(query_text, '')), '[^a-z0-9]+') as terms + ), + ranked as ( + select + f.id, + f.document_id, + f.source_chunk_id, + f.source_image_id, + f.page_number, + f.table_title, + f.row_label, + f.clinical_parameter, + f.threshold_value, + f.action, + ( + ts_rank_cd(f.search_tsv, query.tsq) + + ( + similarity( + lower( + coalesce(f.table_title, '') || ' ' || + coalesce(f.row_label, '') || ' ' || + coalesce(f.clinical_parameter, '') || ' ' || + coalesce(f.threshold_value, '') || ' ' || + coalesce(f.action, '') + ), + query.normalized + ) * 0.8 + ) + + case + when coalesce(f.threshold_value, '') <> '' + and regexp_split_to_array(lower(f.threshold_value), '[^a-z0-9]+') && query.terms then 0.12 + else 0 + end + + case + when coalesce(f.action, '') <> '' + and regexp_split_to_array(lower(f.action), '[^a-z0-9]+') && query.terms then 0.1 + else 0 + end + )::double precision as text_rank, + case + when coalesce(f.threshold_value, '') <> '' then 'table_threshold' + when coalesce(f.action, '') <> '' then 'table_action' + else 'table_row' + end as match_reason, + f.metadata + from public.document_table_facts f + join public.documents d on d.id = f.document_id + cross join query + where (document_filters is null or f.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, f.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(f.metadata, d.metadata) + and ( + f.search_tsv @@ query.tsq + or f.normalized_terms && query.terms + or similarity( + lower( + coalesce(f.table_title, '') || ' ' || + coalesce(f.row_label, '') || ' ' || + coalesce(f.clinical_parameter, '') || ' ' || + coalesce(f.threshold_value, '') || ' ' || + coalesce(f.action, '') + ), + query.normalized + ) >= 0.18 + ) + ) + select * + from ranked + where text_rank > 0 + order by text_rank desc, page_number asc nulls last + limit match_count; +$$; + +create or replace function public.match_document_embedding_fields_hybrid( + query_embedding extensions.vector(1536), + query_text text, + match_count integer default 16, + min_similarity double precision default 0.5, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + source_chunk_id uuid, + field_type text, + content text, + similarity double precision, + text_rank double precision, + hybrid_score double precision +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ), + vector_hits as ( + select f.id + from public.document_embedding_fields f + join public.documents d on d.id = f.document_id + where (document_filters is null or f.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(f.metadata, d.metadata) + and f.source_chunk_id is not null + and 1 - (f.embedding <=> query_embedding) >= min_similarity + order by f.embedding <=> query_embedding + limit greatest(match_count * 3, 32) + ), + text_hits as ( + select f.id + from public.document_embedding_fields f + join public.documents d on d.id = f.document_id + cross join query + where (document_filters is null or f.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(f.metadata, d.metadata) + and f.source_chunk_id is not null + and f.search_tsv @@ query.tsq + order by ts_rank_cd(f.search_tsv, query.tsq) desc + limit greatest(match_count * 3, 32) + ), + candidate_ids as ( + select id from vector_hits + union + select id from text_hits + ), + ranked as ( + select + f.id, f.document_id, f.source_chunk_id, f.field_type, f.content, + (1 - (f.embedding <=> query_embedding))::double precision as similarity, + ts_rank_cd(f.search_tsv, query.tsq)::double precision as text_rank + from public.document_embedding_fields f + join candidate_ids ci on ci.id = f.id + cross join query + ) + select + id, document_id, source_chunk_id, field_type, content, similarity, text_rank, + ((similarity * 0.7) + (least(text_rank, 1) * 0.3))::double precision as hybrid_score + from ranked + order by hybrid_score desc, similarity desc, text_rank desc + limit match_count; +$$; + +create or replace function public.match_document_index_units_hybrid( + query_embedding extensions.vector(1536), + query_text text, + match_count integer default 24, + min_similarity double precision default 0.1, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + source_chunk_id uuid, + source_image_id uuid, + unit_type text, + title text, + content text, + page_start integer, + page_end integer, + heading_path text[], + normalized_terms text[], + source_span jsonb, + quality_score real, + extraction_mode text, + similarity double precision, + text_rank double precision, + hybrid_score double precision, + metadata jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq, + regexp_split_to_array(lower(coalesce(query_text, '')), '\s+') as terms + ), + ranked as ( + select u.id, u.document_id, u.source_chunk_id, u.source_image_id, u.unit_type, u.title, u.content, u.page_start, + u.page_end, u.heading_path, u.normalized_terms, u.source_span, u.quality_score, u.extraction_mode, + (1 - (u.embedding <=> query_embedding))::double precision as similarity, + (ts_rank_cd(u.search_tsv, query.tsq) + + case when u.normalized_terms && query.terms then 0.25 else 0 end + + case when u.unit_type in ('askable_question', 'table_fact', 'clinical_fact', 'threshold', 'workflow_step', 'medication_monitoring', 'alias', 'visual_summary', 'flowchart_step', 'diagram_decision', 'risk_matrix_cell', 'medication_chart_row', 'chart_finding', 'visual_askable_question', 'table_threshold') then 0.06 + when u.unit_type = 'section_summary' then 0.03 + else 0 end + )::double precision as text_rank, + u.metadata + from public.document_index_units u + join public.documents d on d.id = u.document_id + cross join query + where d.status = 'indexed' + and (document_filters is null or u.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and public.is_committed_artifact_generation(u.metadata, d.metadata) + and u.source_chunk_id is not null + and (u.search_tsv @@ query.tsq or u.normalized_terms && query.terms) + order by text_rank desc + limit greatest(match_count * 3, 48) + ) + select id, document_id, source_chunk_id, source_image_id, unit_type, title, content, page_start, page_end, heading_path, + normalized_terms, source_span, quality_score, extraction_mode, similarity, text_rank, + ( + (similarity * 0.52) + + (least(text_rank, 1) * 0.28) + + (quality_score * 0.12) + + (case when extraction_mode in ('model_heavy', 'hybrid') then 0.04 else 0 end) + + (case when unit_type in ('askable_question', 'threshold', 'table_fact', 'table_threshold', 'visual_askable_question') then 0.04 + when unit_type in ('workflow_step', 'medication_monitoring', 'flowchart_step', 'diagram_decision', 'medication_chart_row', 'risk_matrix_cell') then 0.03 + else 0 end) + )::double precision as hybrid_score, + metadata + from ranked + order by hybrid_score desc, similarity desc, text_rank desc + limit match_count; +$$; diff --git a/supabase/schema.sql b/supabase/schema.sql index 16ce2230c..15ce06969 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1903,6 +1903,20 @@ as $$ nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', ''); $$; +create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid) +returns boolean +language sql +immutable +parallel safe +set search_path = public, pg_temp +as $ + select case + when owner_filter is null then true + when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null + else row_owner_id = owner_filter + end; +$; + create or replace function public.match_document_chunks( query_embedding extensions.vector(1536), match_count integer default 8, @@ -1950,7 +1964,7 @@ as $$ from public.document_chunks c join public.documents d on d.id = c.document_id where (document_filter is null or c.document_id = document_filter) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and 1 - (c.embedding <=> query_embedding) >= min_similarity @@ -2018,7 +2032,7 @@ as $$ join public.documents d on d.id = c.document_id cross join query where (document_filters is null or c.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and 1 - (c.embedding <=> query_embedding) >= min_similarity @@ -2059,7 +2073,7 @@ as $$ join public.documents d on d.id = c.document_id cross join query where (document_filters is null or c.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and c.search_tsv @@ query.tsq @@ -2211,7 +2225,7 @@ as $$ join public.documents d on d.id = m.document_id cross join query where (document_filters is null or m.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(m.metadata, d.metadata) and (1 - (m.embedding <=> query_embedding)) >= min_similarity @@ -2231,7 +2245,7 @@ as $$ join public.documents d on d.id = m.document_id cross join query where (document_filters is null or m.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(m.metadata, d.metadata) and m.search_tsv @@ query.tsq @@ -2629,7 +2643,7 @@ as $$ and coalesce(l.metadata->>'hidden', 'false') <> 'true' left join public.document_summaries s on s.document_id = d.id cross join query - where (owner_filter is null or d.owner_id = owner_filter) + where public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and ( d.title_search_tsv @@ query.tsq @@ -2703,7 +2717,7 @@ as $$ join public.documents d on d.id = c.document_id cross join query where (document_filters is null or c.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) @@ -2836,7 +2850,7 @@ as $$ cross join query where document_filters is not null and c.document_id = any(document_filters) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) @@ -2881,7 +2895,7 @@ as $$ ) from public.document_labels l where l.document_id = d.id - and (owner_filter is null or l.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, l.owner_id) and coalesce(l.metadata->>'review_status', 'new') <> 'hidden' and coalesce(l.metadata->>'hidden', 'false') <> 'true' ), @@ -2891,13 +2905,13 @@ as $$ select s.summary from public.document_summaries s where s.document_id = d.id - and (owner_filter is null or s.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, s.owner_id) order by s.generated_at desc limit 1 ) as summary from public.documents d where d.id = any(document_ids) - and (owner_filter is null or d.owner_id = owner_filter); + and public.retrieval_owner_matches(owner_filter, d.owner_id); $$; create or replace function public.match_document_table_facts_text( @@ -2978,7 +2992,7 @@ as $$ join public.documents d on d.id = f.document_id cross join query where (document_filters is null or f.document_id = any(document_filters)) - and (owner_filter is null or f.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, f.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(f.metadata, d.metadata) and ( @@ -3033,7 +3047,7 @@ as $$ from public.document_embedding_fields f join public.documents d on d.id = f.document_id where (document_filters is null or f.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(f.metadata, d.metadata) and f.source_chunk_id is not null @@ -3047,7 +3061,7 @@ as $$ join public.documents d on d.id = f.document_id cross join query where (document_filters is null or f.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(f.metadata, d.metadata) and f.source_chunk_id is not null @@ -3938,7 +3952,7 @@ as $$ cross join query where d.status = 'indexed' and (document_filters is null or u.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and public.is_committed_artifact_generation(u.metadata, d.metadata) and u.source_chunk_id is not null and (u.search_tsv @@ query.tsq or u.normalized_terms && query.terms) diff --git a/tests/owner-scope.test.ts b/tests/owner-scope.test.ts index a47a6bbdc..33030b816 100644 --- a/tests/owner-scope.test.ts +++ b/tests/owner-scope.test.ts @@ -27,3 +27,12 @@ describe("requireOwnerScope (fail-closed owner scoping)", () => { expect(() => requireOwnerScope(null)).toThrow(/tenant/); }); }); + +describe("retrievalOwnerFilter", () => { + it("returns the public sentinel for anonymous production global search", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, isLocalNoAuthMode: () => false })); + vi.stubEnv("NODE_ENV", "production"); + const { retrievalOwnerFilter, PUBLIC_OWNER_FILTER_SENTINEL } = await import("../src/lib/owner-scope"); + expect(retrievalOwnerFilter({ allowGlobalSearch: true })).toBe(PUBLIC_OWNER_FILTER_SENTINEL); + }); +}); From c3318020a97e9d4b391418f794539ee9c9b06fe3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:31:27 +0800 Subject: [PATCH 09/16] fix(access): complete forms fallback, signed-url hardening, upload guard --- src/app/api/upload/route.ts | 20 ++++++++++++++++++++ src/lib/rag.ts | 3 +++ tests/private-access-routes.test.ts | 12 ++++++++---- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index a2df002f9..c6845d7f7 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -4,11 +4,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env"; import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { assertSafeLocalProjectRequest, localProjectOriginErrorResponse, UnsafeLocalProjectOriginError } from "@/lib/local-project-guard"; import { publicAccessContext } from "@/lib/public-api-access"; import { probeSupabaseHealth } from "@/lib/supabase/health"; import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data"; @@ -83,6 +85,7 @@ export async function POST(request: Request) { let insertedDocumentOwnerId: string | null = null; try { + assertSafeLocalProjectRequest(request); supabase = createAdminClient(); const adminSupabase = supabase; const access = await publicAccessContext(request, adminSupabase); @@ -90,6 +93,20 @@ export async function POST(request: Request) { if (!uploadOwnerId) { return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 }); } + + const rateLimit = await consumeSubjectApiRateLimit({ + supabase: adminSupabase, + subject: access.rateLimitSubject, + bucket: "document_upload", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + if (rateLimit.limited) { + return rateLimitJsonResponse( + "Upload is temporarily rate limited because too many requests were received. Retry shortly.", + rateLimit, + ); + } + const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", @@ -303,6 +320,9 @@ export async function POST(request: Request) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); } + if (error instanceof UnsafeLocalProjectOriginError) { + return localProjectOriginErrorResponse(error); + } return jsonError(error); } diff --git a/src/lib/rag.ts b/src/lib/rag.ts index d504c10dd..eb3348003 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -2357,6 +2357,7 @@ async function fetchBestDocumentLookupChunks(args: { query: string; limit: number; ownerId?: string; + allowGlobalSearch?: boolean; }) { const terms = documentLookupChunkTerms(args.query); const { data: rpcChunks, error: rpcError } = await args.supabase.rpc("match_document_lookup_chunks_text", { @@ -5816,6 +5817,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryEmbedding: embedding, ownerId: args.ownerId, documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 48), telemetry, }); @@ -5829,6 +5831,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryEmbedding: embedding, ownerId: args.ownerId, documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 64), telemetry, }); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 92438c369..b6476fc12 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -436,8 +436,10 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(503); - expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); + expect(response.status).toBe(409); + expect(await payload(response)).toMatchObject({ + error: "Use the ensured Clinical KB local URL before calling this API.", + }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -459,8 +461,10 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(503); - expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); + expect(response.status).toBe(409); + expect(await payload(response)).toMatchObject({ + error: "Use the ensured Clinical KB local URL before calling this API.", + }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); From 89817617b9f478a6c2b3841146cccf946a445e99 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:28:06 +0800 Subject: [PATCH 10/16] feat(db): promote locally reviewed documents to public corpus for anonymous RAG --- package.json | 1 + scripts/promote-public-documents.ts | 80 +++++++++++++++++++ ...mote_locally_reviewed_documents_public.sql | 65 +++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 scripts/promote-public-documents.ts create mode 100644 supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql diff --git a/package.json b/package.json index c38c76383..0f96c119f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts", "supabase:recovery-status": "tsx scripts/supabase-recovery-status.ts", "promote:query-misses": "tsx scripts/promote-query-misses.ts", + "promote:public-documents": "tsx scripts/promote-public-documents.ts", "eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts", "eval:answer-quality": "node scripts/run-eval-safe.mjs scripts/eval-answer-quality.ts", "eval:quality": "node scripts/run-eval-safe.mjs scripts/eval-quality.ts", diff --git a/scripts/promote-public-documents.ts b/scripts/promote-public-documents.ts new file mode 100644 index 000000000..e7b1b606d --- /dev/null +++ b/scripts/promote-public-documents.ts @@ -0,0 +1,80 @@ +import { loadEnvConfig } from "@next/env"; + +loadEnvConfig(process.cwd()); + +type PromoteArgs = { + ownerId?: string; +}; + +async function loadAdminClient() { + const { createAdminClient } = await import("@/lib/supabase/admin"); + return createAdminClient(); +} + +function parseArgs(argv: string[]): PromoteArgs { + const args: PromoteArgs = { + ownerId: process.env.PUBLIC_WORKSPACE_OWNER_ID ?? process.env.LOCAL_NO_AUTH_OWNER_ID, + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--owner-id") { + args.ownerId = argv[index + 1]; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + + return args; +} + +async function countCandidates(supabase: Awaited>, ownerId?: string) { + let query = supabase + .from("documents") + .select("id", { count: "exact", head: true }) + .eq("status", "indexed") + .not("owner_id", "is", null) + .in("metadata->>clinical_validation_status", ["locally_reviewed", "approved"]); + + if (ownerId) query = query.eq("owner_id", ownerId); + + const { count, error } = await query; + if (error) throw new Error(error.message); + return count ?? 0; +} + +async function countPublic(supabase: Awaited>) { + const { count, error } = await supabase + .from("documents") + .select("id", { count: "exact", head: true }) + .eq("status", "indexed") + .is("owner_id", null); + + if (error) throw new Error(error.message); + return count ?? 0; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const supabase = await loadAdminClient(); + + const [candidateCount, publicCount] = await Promise.all([ + countCandidates(supabase, args.ownerId), + countPublic(supabase), + ]); + + console.log("[public-documents:promote] indexed public documents:", publicCount); + console.log( + `[public-documents:promote] pending promotion${args.ownerId ? ` for owner ${args.ownerId}` : ""}:`, + candidateCount, + ); + console.log( + "Apply migration 20260705220000_promote_locally_reviewed_documents_public.sql with `npx supabase db push --linked`.", + ); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql b/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql new file mode 100644 index 000000000..76c9a5560 --- /dev/null +++ b/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql @@ -0,0 +1,65 @@ +-- Promote locally reviewed indexed documents to the public corpus (owner_id IS NULL) +-- so anonymous callers can list, preview, search, and use them in RAG. + +begin; + +create temporary table promoted_public_documents on commit drop as +with promoted as ( + update public.documents d + set + owner_id = null, + metadata = jsonb_set( + coalesce(d.metadata, '{}'::jsonb), + '{public_corpus}', + 'true'::jsonb, + true + ), + updated_at = now() + where d.status = 'indexed' + and d.owner_id is not null + and coalesce(d.metadata->>'clinical_validation_status', 'unverified') in ('locally_reviewed', 'approved') + returning d.id, d.owner_id as previous_owner_id +) +select id, previous_owner_id from promoted; + +update public.document_labels dl +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where dl.document_id = pd.id; + +update public.document_summaries ds +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where ds.document_id = pd.id; + +update public.document_sections ds +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where ds.document_id = pd.id; + +update public.document_memory_cards dmc +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where dmc.document_id = pd.id; + +update public.document_table_facts dtf +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where dtf.document_id = pd.id; + +update public.document_embedding_fields def +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where def.document_id = pd.id; + +update public.document_index_quality diq +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where diq.document_id = pd.id; + +update public.document_index_units diu +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where diu.document_id = pd.id; + +commit; From 85f247ad0e0412fd1d3b19bbfa7562edb8b62fe5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:36:09 +0800 Subject: [PATCH 11/16] test: stabilize scope-sources stress flow via answer options menu --- tests/ui-stress.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 89a8aa2a4..1cffeb387 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -278,12 +278,14 @@ test.describe("Clinical KB long-content stress coverage", () => { await expect(page.getByLabel("Source-backed answer")).toBeVisible(); await expect(page.getByTestId("plain-answer-response")).toBeVisible(); - const scopeTrigger = page.locator('[data-testid="scope-trigger"]:visible'); + const actionMenu = page.getByRole("button", { name: "Open answer options" }); await page.keyboard.press("Escape"); - await scopeTrigger.click(); + await actionMenu.click(); + const actionsMenu = page.getByTestId("daily-actions-menu"); + await expect(actionsMenu).toBeVisible(); + await actionsMenu.getByRole("menuitem", { name: "Scope sources" }).click(); const scopeContainer = page.getByTestId("scope-command-popover"); await expect(scopeContainer).toBeVisible(); - await expect(scopeContainer).toBeVisible(); await expect( scopeContainer.getByText(/Type to filter 24 (loaded )?documents\. Selected documents stay pinned here\./), ).toBeVisible(); From e474d037d30163a8ff211effd27838247b770a42 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:49:00 +0800 Subject: [PATCH 12/16] fix: allow anonymous setup-status on production for mobile access --- src/app/api/setup-status/route.ts | 25 ++++------------------- tests/setup-status-route.test.ts | 34 +++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index bf0551c94..ba34855b8 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import { env, isDemoMode } from "@/lib/env"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; @@ -409,27 +408,11 @@ async function readSetupStatusPayload() { } } -async function requireProductionSetupStatusAuth(request: Request) { +export async function GET(request: Request) { const identity = localProjectRequestIdentityPayload(request); - if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) { - return identity; + if (!identity.localServer.safeLocalOrigin) { + return unsafeLocalProjectResponse(identity); } - await requireAuthenticatedUser(request, createAdminClient()); - return identity; -} -export async function GET(request: Request) { - try { - const identity = await requireProductionSetupStatusAuth(request); - if (!identity.localServer.safeLocalOrigin) { - return unsafeLocalProjectResponse(identity); - } - - return setupStatusResponse(await readSetupStatusPayload()); - } catch (error) { - if (error instanceof AuthenticationError) { - return unauthorizedResponse(error); - } - throw error; - } + return setupStatusResponse(await readSetupStatusPayload()); } diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts index 170aac7f5..f952b2ef8 100644 --- a/tests/setup-status-route.test.ts +++ b/tests/setup-status-route.test.ts @@ -7,10 +7,13 @@ afterEach(() => { }); describe("/api/setup-status", () => { - it("requires auth for non-local production requests before returning setup posture", async () => { + it("returns setup posture for anonymous production requests without exposing secret values", async () => { vi.stubEnv("NODE_ENV", "production"); - const getUser = vi.fn(); - const createAdminClient = vi.fn(() => ({ auth: { getUser } })); + 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", @@ -24,16 +27,29 @@ describe("/api/setup-status", () => { 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: "ready", detail: "Clinical KB Database target is configured." }), + formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.", + })); const { GET } = await import("../src/app/api/setup-status/route"); const response = await GET(new Request("https://clinical.example/api/setup-status")); const body = await response.json(); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); - expect(JSON.stringify(body)).not.toContain("OPENAI"); - expect(JSON.stringify(body)).not.toContain("Supabase"); - expect(createAdminClient).toHaveBeenCalledTimes(1); - expect(getUser).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(body).toMatchObject({ + demoMode: false, + checks: expect.arrayContaining([ + expect.objectContaining({ id: "env" }), + expect.objectContaining({ id: "openai" }), + ]), + }); + expect(JSON.stringify(body)).not.toContain("service-role-key"); + expect(JSON.stringify(body)).not.toContain("openai-key"); }); }); From 3a1cf9c194e0eb631704c497059ad5560de04ee0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:31:16 +0800 Subject: [PATCH 13/16] Auto-hide answer support chips when content sits below on mobile --- src/components/ClinicalDashboard.tsx | 1 + .../answer-result-surface.tsx | 23 +++- .../clinical-dashboard/evidence-panels.tsx | 100 +++++++++++------- .../use-collapse-when-content-below.ts | 96 +++++++++++++++++ 4 files changed, 180 insertions(+), 40 deletions(-) create mode 100644 src/components/clinical-dashboard/use-collapse-when-content-below.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index aa6c1cb2d..ddc94bf1f 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -4082,6 +4082,7 @@ export function ClinicalDashboard({ followUpSuggestions={answerFollowUpSuggestions} onPickFollowUpSuggestion={handlePickFollowUpSuggestion} followUpSuggestionsDisabled={loading} + scrollContainerRef={mainRef} /> ) : null diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 581e10c6d..f7d59ca68 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -6,6 +6,7 @@ import { ClipboardCheck, ExternalLink, Layers, ShieldAlert } from "lucide-react" import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; +import { useCollapseWhenContentBelow } from "@/components/clinical-dashboard/use-collapse-when-content-below"; import { NaturalLanguageAnswer, UserQuestionBubble } from "@/components/clinical-dashboard/answer-content"; import { AnswerSupportSummaryCard, @@ -61,6 +62,7 @@ export function StagedAnswerResultSurface({ followUpSuggestions, onPickFollowUpSuggestion, followUpSuggestionsDisabled = false, + scrollContainerRef, }: { answer: RagAnswer; query: string; @@ -86,6 +88,7 @@ export function StagedAnswerResultSurface({ followUpSuggestions?: string[]; onPickFollowUpSuggestion?: (suggestion: string) => void; followUpSuggestionsDisabled?: boolean; + scrollContainerRef?: RefObject; }) { const noteCount = clinicalNotesCount(answer); const showClinicalNotes = @@ -116,6 +119,8 @@ export function StagedAnswerResultSurface({ const clinicalNotesTriggerRef = useRef(null); const evidenceTriggerRef = useRef(null); const safetyTriggerRef = useRef(null); + const supportActionRowRef = useRef(null); + const belowContentSentinelRef = useRef(null); const copyQuotesTimerRef = useRef(null); useEffect(() => { return () => { @@ -182,6 +187,13 @@ export function StagedAnswerResultSurface({ const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); + const showSupportActionRow = showClinicalNotes || showEvidenceDrawer; + const collapseActionRow = useCollapseWhenContentBelow({ + containerRef: scrollContainerRef, + anchorRef: supportActionRowRef, + belowSentinelRef: belowContentSentinelRef, + disabled: !showSupportActionRow || !scrollContainerRef, + }); const showLayoutAside = Boolean(centralTable); return ( @@ -222,6 +234,8 @@ export function StagedAnswerResultSurface({ clinicalTriggerRef={clinicalNotesTriggerRef} evidenceTriggerRef={evidenceTriggerRef} safetyTriggerRef={safetyTriggerRef} + actionRowRef={supportActionRowRef} + collapseActionRow={collapseActionRow} safetyFindingsCount={safetyFindings.length} onOpenClinicalNotes={openClinicalNotes} onOpenEvidence={() => openEvidence(null)} @@ -236,6 +250,12 @@ export function StagedAnswerResultSurface({ disabled={followUpSuggestionsDisabled} /> ) : null} + {centralTable ? ( @@ -309,8 +329,9 @@ export function StagedAnswerResultSurface({ } - contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl" + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-3xl" bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + desktopBackdropClassName="sm:bg-black/50" returnFocusRef={evidenceTriggerRef} portal > diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index f15cad21f..cd49bab95 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -151,6 +151,8 @@ export function AnswerSupportSummaryCard({ clinicalTriggerRef, evidenceTriggerRef, safetyTriggerRef, + actionRowRef, + collapseActionRow = false, safetyFindingsCount = 0, onOpenClinicalNotes, onOpenEvidence, @@ -164,6 +166,8 @@ export function AnswerSupportSummaryCard({ clinicalTriggerRef?: RefObject; evidenceTriggerRef?: RefObject; safetyTriggerRef?: RefObject; + actionRowRef?: RefObject; + collapseActionRow?: boolean; safetyFindingsCount?: number; onOpenClinicalNotes: () => void; onOpenEvidence: () => void; @@ -238,48 +242,66 @@ export function AnswerSupportSummaryCard({ {supportRowCount > 0 ? (
- {clinicalAvailable ? ( - - ) : null} - {evidenceAvailable ? ( - - ) : null} + {clinicalAvailable ? ( + + ) : null} + {evidenceAvailable ? ( + + ) : null} +
+ ) : null} diff --git a/src/components/clinical-dashboard/use-collapse-when-content-below.ts b/src/components/clinical-dashboard/use-collapse-when-content-below.ts new file mode 100644 index 000000000..9c3dda334 --- /dev/null +++ b/src/components/clinical-dashboard/use-collapse-when-content-below.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore, type RefObject } from "react"; + +// Matches phoneSearchLayoutMediaQuery in master-search-header.tsx — the repo's +// phone/tablet seam. Collapse only ever runs below the sm breakpoint. +const phoneMediaQuery = "(max-width: 639px)"; + +// Reserved height for the fixed answer footer composer (pill + chips + safe area). +const defaultComposerInsetPx = 132; +// Small tolerance so the row hides just before it visually overlaps the composer. +const dockBandThresholdPx = 12; + +function subscribeToPhoneMedia(onChange: () => void) { + const media = window.matchMedia(phoneMediaQuery); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); +} + +function readPhoneMedia() { + return window.matchMedia(phoneMediaQuery).matches; +} + +function readPhoneMediaServer() { + return false; +} + +interface UseCollapseWhenContentBelowOptions { + /** Scroll container (`#main-content`). */ + containerRef?: RefObject; + /** The Clinical notes / Evidence row wrapper. */ + anchorRef?: RefObject; + /** Sentinel placed after all content below the action row. */ + belowSentinelRef?: RefObject; + /** Viewport inset reserved for the fixed bottom composer. */ + composerInsetPx?: number; + /** Disables the behavior entirely (state resets to expanded). */ + disabled?: boolean; +} + +/** + * On phones, collapses the answer support action row when it sits in the + * composer dock band while unscrolled content still lives below it (e.g. + * follow-up suggestions). Expands again at the true scroll bottom or when + * the row leaves the dock band. + */ +export function useCollapseWhenContentBelow({ + containerRef, + anchorRef, + belowSentinelRef, + composerInsetPx = defaultComposerInsetPx, + disabled = false, +}: UseCollapseWhenContentBelowOptions): boolean { + const [collapsed, setCollapsed] = useState(false); + const isPhone = useSyncExternalStore(subscribeToPhoneMedia, readPhoneMedia, readPhoneMediaServer); + const active = isPhone && !disabled; + + useEffect(() => { + if (!active) return; + + const container = containerRef?.current ?? null; + const anchor = anchorRef?.current ?? null; + const sentinel = belowSentinelRef?.current ?? null; + if (!container || !anchor || !sentinel) return; + + let frame = 0; + + const evaluate = () => { + frame = 0; + const dockLine = window.innerHeight - composerInsetPx; + const anchorRect = anchor.getBoundingClientRect(); + const sentinelRect = sentinel.getBoundingClientRect(); + const inDockBand = anchorRect.bottom >= dockLine - dockBandThresholdPx; + const contentBelow = sentinelRect.top > dockLine; + setCollapsed(inDockBand && contentBelow); + }; + + const schedule = () => { + if (frame) return; + frame = window.requestAnimationFrame(evaluate); + }; + + container.addEventListener("scroll", schedule, { passive: true }); + window.addEventListener("resize", schedule, { passive: true }); + schedule(); + + return () => { + container.removeEventListener("scroll", schedule); + window.removeEventListener("resize", schedule); + if (frame) window.cancelAnimationFrame(frame); + setCollapsed(false); + }; + }, [active, containerRef, anchorRef, belowSentinelRef, composerInsetPx]); + + return active && collapsed; +} From 8eccda83af5955603dc4f8a22640953739752378 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:31:19 +0800 Subject: [PATCH 14/16] Remove Evidence-based and All sources chips from answer footer --- scripts/capture-chrome-parity.ts | 4 ---- .../clinical-dashboard/master-search-header.tsx | 3 ++- tests/ui-accessibility.spec.ts | 17 +++++++++++------ tests/ui-smoke.spec.ts | 15 +++++++-------- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/scripts/capture-chrome-parity.ts b/scripts/capture-chrome-parity.ts index 98e0a00c3..567a93717 100644 --- a/scripts/capture-chrome-parity.ts +++ b/scripts/capture-chrome-parity.ts @@ -82,10 +82,6 @@ const selectorGroups: Array<{ key: string; selector: string; pseudo?: string }> { key: "composer-pill-children", selector: 'form:has([data-testid="global-search-input"]) > div > *' }, { key: "composer-input", selector: '[data-testid="global-search-input"]', pseudo: "::placeholder" }, { key: "composer-buttons", selector: 'form:has([data-testid="global-search-input"]) button' }, - { key: "evidence-chip", selector: 'button[aria-label="Open evidence-backed answer sources"]' }, - { key: "evidence-chip-icon", selector: 'button[aria-label="Open evidence-backed answer sources"] svg' }, - { key: "scope-chip", selector: 'button[aria-label="Open source scope"]' }, - { key: "scope-chip-icon", selector: 'button[aria-label="Open source scope"] svg' }, { key: "viewer-header", selector: "main header, body > div > header", pseudo: "::after" }, { key: "viewer-composer", selector: 'form:has(input[placeholder^="Search or answer"])' }, { key: "viewer-composer-children", selector: 'form:has(input[placeholder^="Search or answer"]) > *' }, diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 58761d61a..44238b994 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1165,7 +1165,8 @@ export function MasterSearchHeader({ // Compact search views drop the chip row on phones so the pill can sit // flush with the bottom edge; the same actions stay reachable via the // integrated "+" menu. - const showFooterSearchChips = usesFooterChipLayout && !usesCompactMobileBottomStyle; + const showFooterSearchChips = + usesFooterChipLayout && !usesCompactMobileBottomStyle && searchMode !== "answer"; // The visible footer/hero composer chrome is universal; submit semantics still // come from the active mode. const usesSendAffordance = searchMode === "answer" || usesFooterChipLayout; diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 4397cfb8a..a13bc0fdd 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -50,19 +50,24 @@ async function expectDashboardUsable(page: Page) { await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toHaveCount(1); await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible(); await expect(page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible')).toBeVisible(); - await expect(page.locator('[data-testid="scope-trigger"]:visible')).toBeVisible(); + await expect(page.getByRole("button", { name: "Open answer options" })).toBeVisible(); await expectNoPageHorizontalOverflow(page); } async function openScopeControl(page: Page) { - const scopeTrigger = page.locator('[data-testid="scope-trigger"]:visible'); + const trigger = page.getByRole("button", { name: "Open answer options" }); + const menu = page.getByTestId("daily-actions-menu"); await expect(async () => { - await scopeTrigger.click(); - await expect(page.locator('[data-testid="scope-command-popover"]:visible')).toBeVisible({ - timeout: uiAssertionTimeoutMs, - }); + if (await menu.isVisible().catch(() => false)) return; + await trigger.click(); + await expect(menu).toBeVisible({ timeout: uiAssertionTimeoutMs }); }).toPass({ timeout: 10_000 }); + + await menu.getByRole("menuitem", { name: "Scope sources" }).click(); + await expect(page.locator('[data-testid="scope-command-popover"]:visible')).toBeVisible({ + timeout: uiAssertionTimeoutMs, + }); } test.describe("Clinical KB accessibility media smoke", () => { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 2eb32ebbf..0ab4f7301 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -507,12 +507,6 @@ async function expectDomIntegrity(page: Page, options: { mobileNav?: boolean; mo } } -// The document-scope control lives in the footer composer. It opens a desktop -// popover or mobile sheet depending on the current viewport. -function scopeTrigger(page: Page) { - return page.locator('[data-testid="scope-trigger"]:visible'); -} - async function expectMinTouchTarget(locator: Locator, minSize = 44) { const box = await locator.boundingBox(); expect(box).not.toBeNull(); @@ -1233,7 +1227,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(evidenceSheet).toHaveCount(0); await expect(evidenceDrawer).toBeFocused(); - await scopeTrigger(page).click(); + const dailyActions = await openDailyActions(page); + await dailyActions.getByRole("menuitem", { name: "Scope sources" }).click(); const scopePopover = page.locator('[data-testid="scope-command-popover"]:visible'); await expect(scopePopover).toBeVisible(); const scopeFilter = scopePopover.locator('[data-testid="document-scope-filter"]'); @@ -1256,7 +1251,6 @@ test.describe("Clinical KB UI smoke coverage", () => { expect(popoverMetrics.height).toBeLessThanOrEqual(Math.ceil(popoverMetrics.viewportHeight * 0.72)); await page.keyboard.press("Escape"); await expect(scopePopover).toBeHidden(); - await expect(scopeTrigger(page)).toBeFocused(); await expectNoPageHorizontalOverflow(page); }); @@ -1450,6 +1444,11 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(sourceSurface).toHaveCount(0); await expect(sourceCapsule).toBeFocused(); } else { + await page.keyboard.press("Escape"); + await expect(sourceSurface).toHaveCount(0); + await expect(sourceCapsule).toBeFocused(); + await sourceCapsule.click(); + await expect(sourceSurface).toBeVisible(); await sourceCapsule.click(); await expect(sourceSurface).toHaveCount(0); } From 5d86c8fa412c6ff84b363c1176bd5e8212b69295 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:40:11 +0800 Subject: [PATCH 15/16] test: open answer scope via + menu using Scope label --- tests/ui-accessibility.spec.ts | 2 +- tests/ui-smoke.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index a13bc0fdd..cab9cb238 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -64,7 +64,7 @@ async function openScopeControl(page: Page) { await expect(menu).toBeVisible({ timeout: uiAssertionTimeoutMs }); }).toPass({ timeout: 10_000 }); - await menu.getByRole("menuitem", { name: "Scope sources" }).click(); + await menu.getByRole("menuitem", { name: "Scope", exact: true }).click(); await expect(page.locator('[data-testid="scope-command-popover"]:visible')).toBeVisible({ timeout: uiAssertionTimeoutMs, }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 0ab4f7301..196e2e7a0 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1228,7 +1228,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(evidenceDrawer).toBeFocused(); const dailyActions = await openDailyActions(page); - await dailyActions.getByRole("menuitem", { name: "Scope sources" }).click(); + await dailyActions.getByRole("menuitem", { name: "Scope", exact: true }).click(); const scopePopover = page.locator('[data-testid="scope-command-popover"]:visible'); await expect(scopePopover).toBeVisible(); const scopeFilter = scopePopover.locator('[data-testid="document-scope-filter"]'); From 7a0166ddf15d921ef70b8b6633ebe8023b3b3865 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:48:59 +0800 Subject: [PATCH 16/16] chore: add answer bar screenshot capture script for chip removal QA --- scripts/capture-answer-bar-screenshot.ts | 90 ++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 scripts/capture-answer-bar-screenshot.ts diff --git a/scripts/capture-answer-bar-screenshot.ts b/scripts/capture-answer-bar-screenshot.ts new file mode 100644 index 000000000..bfa111cc4 --- /dev/null +++ b/scripts/capture-answer-bar-screenshot.ts @@ -0,0 +1,90 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { chromium } from "playwright"; +import { demoAnswer, demoDocuments } from "../src/lib/demo-data"; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:4298"; +const outDir = join(process.cwd(), "scratch", "screenshots"); +const outPath = join(outDir, "answer-bar-after-chip-removal.png"); +const outBottomPath = join(outDir, "answer-bar-bottom-after-chip-removal.png"); + +const readySetupChecks = [ + { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." }, + { id: "project", label: "Clinical KB Database target", status: "ready", detail: "Test Supabase project ready." }, + { id: "schema", label: "supabase/schema.sql applied", status: "ready", detail: "Test schema ready." }, + { id: "search", label: "Search RPC and vector indexes", status: "ready", detail: "Test search schema ready." }, + { id: "openai", label: "OpenAI API key available", status: "ready", detail: "Test OpenAI ready." }, + { id: "worker", label: "npm run worker running", status: "unknown", detail: "Worker not required." }, +]; + +async function mockDemoApi(page) { + await page.route("**/api/setup-status**", async (route) => { + await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } }); + }); + await page.route(/\/api\/documents(?:\?.*)?$/, async (route) => { + await route.fulfill({ + json: { + documents: demoDocuments, + demoMode: true, + pagination: { + limit: 150, + offset: 0, + total: demoDocuments.length, + nextOffset: demoDocuments.length, + hasMore: false, + }, + }, + }); + }); + await page.route(/\/api\/ingestion\/(jobs|batches|quality)(?:\?.*)?$/, async (route) => { + await route.fulfill({ json: { jobs: [], batches: [], items: [], demoMode: true } }); + }); + await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => { + const body = route.request().postDataJSON(); + const payload = { ...demoAnswer(body?.query ?? "clozapine monitoring"), demoMode: true }; + if (route.request().url().includes("/stream")) { + await route.fulfill({ + body: [ + `event: progress\ndata: ${JSON.stringify({ stage: "retrieving", message: "Searching indexed documents." })}`, + `event: final\ndata: ${JSON.stringify(payload)}`, + "", + ].join("\n\n"), + contentType: "text/event-stream; charset=utf-8", + }); + return; + } + await route.fulfill({ json: payload }); + }); +} + +async function main() { + mkdirSync(outDir, { recursive: true }); + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 390, height: 820 } }); + await mockDemoApi(page); + await page.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => undefined); + + const input = page.locator('[data-testid="global-search-input"]:visible').first(); + await input.waitFor({ state: "visible", timeout: 30000 }); + await input.fill("clozapine monitoring"); + await page.locator('[aria-label="Generate source-backed answer"]:visible').first().click(); + await page.getByTestId("plain-answer-response").waitFor({ timeout: 30000 }); + await page.waitForTimeout(600); + + const chipCount = await page.locator(".answer-footer-search-chip:visible").count(); + if (chipCount > 0) { + throw new Error(`Expected 0 footer chips in answer mode, found ${chipCount}.`); + } + + await page.screenshot({ path: outPath, fullPage: false }); + await page.locator("form.answer-footer-search-edge").first().screenshot({ path: outBottomPath }); + console.log(outPath); + console.log(outBottomPath); + await browser.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});