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 1/5] 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 2/5] 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 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 3/5] 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 4/5] 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 5/5] 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 };