diff --git a/.gitleaksignore b/.gitleaksignore index 8b3bd8187..16fe1ab80 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -8,3 +8,5 @@ # generic-api-key rule on historical commit content scanned in PR history. 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:22253 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:47690 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:21520 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:46330 diff --git a/package.json b/package.json index c38c76383..0f96c119f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts", "supabase:recovery-status": "tsx scripts/supabase-recovery-status.ts", "promote:query-misses": "tsx scripts/promote-query-misses.ts", + "promote:public-documents": "tsx scripts/promote-public-documents.ts", "eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts", "eval:answer-quality": "node scripts/run-eval-safe.mjs scripts/eval-answer-quality.ts", "eval:quality": "node scripts/run-eval-safe.mjs scripts/eval-quality.ts", diff --git a/scripts/commit-access-rag-fix.mjs b/scripts/commit-access-rag-fix.mjs new file mode 100644 index 000000000..72a78c7b0 --- /dev/null +++ b/scripts/commit-access-rag-fix.mjs @@ -0,0 +1,209 @@ +import fs from "node:fs"; +import { execSync } from "node:child_process"; + +const ownerScope = `import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; + +export const PUBLIC_OWNER_FILTER_SENTINEL = "00000000-0000-0000-0000-000000000000"; + +export function requireOwnerScope(ownerId: string | null | undefined): string | undefined { + if (ownerId) return ownerId; + if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") { + return undefined; + } + throw new Error( + "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.", + ); +} + +export function retrievalOwnerFilter(args: { + ownerId?: string | null; + documentIds?: string[]; + allowGlobalSearch?: boolean; +}): string | null | undefined { + if (args.ownerId) return requireOwnerScope(args.ownerId); + if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") { + return undefined; + } + if (args.allowGlobalSearch || args.documentIds?.length) { + return PUBLIC_OWNER_FILTER_SENTINEL; + } + throw new Error( + "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.", + ); +} +`; + +fs.writeFileSync("src/lib/owner-scope.ts", ownerScope); + +let rag = fs.readFileSync("src/lib/rag.ts", "utf8"); +rag = rag.replace( + 'import { requireOwnerScope } from "@/lib/owner-scope";', + 'import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope";', +); +rag = rag.replace( + /function ownerScopeForDocumentFilteredRetrieval\([\s\S]*?\n\}/, + `function ownerScopeForDocumentFilteredRetrieval( + ownerId: string | undefined, + documentIds: string[] | undefined, + allowGlobalSearch?: boolean, +) { + return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch }); +}`, +); +rag = rag.replaceAll( + "ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds)", + "ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch)", +); +rag = rag.replaceAll( + "ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList)", + "ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList, args.allowGlobalSearch)", +); +rag = rag.replace( + `documentFilter ? [documentFilter] : undefined, + ),`, + `documentFilter ? [documentFilter] : undefined, + documentFilter ? undefined : args.allowGlobalSearch, + ),`, +); +rag = rag.replace( + `documentIds: documentFilterList, + matchCount: textCandidateCount,`, + `documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, + matchCount: textCandidateCount,`, +); +rag = rag.replaceAll( + `documentIds: documentFilterList, + matchCount: Math.min(candidateCount, 48),`, + `documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, + matchCount: Math.min(candidateCount, 48),`, +); +rag = rag.replace( + `documentIds: documentFilterList, + matchCount: Math.min(candidateCount, 64),`, + `documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, + matchCount: Math.min(candidateCount, 64),`, +); +rag = rag.replace( + "documentIds?: string[];\n matchCount: number;\n}) {\n const runChunkText", + "documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;\n}) {\n const runChunkText", +); +for (const fn of ["searchTableFactCandidates", "searchEmbeddingFieldCandidates", "searchIndexUnitCandidates"]) { + rag = rag.replace( + new RegExp(`async function ${fn}\\([\\s\\S]*?documentIds\\?: string\\[\\];\\n matchCount: number;`), + (m) => m.replace("documentIds?: string[];\n matchCount: number;", "documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;"), + ); +} +if (!rag.includes('else documentQuery = documentQuery.is("owner_id", null);')) { + rag = rag.replace( + `if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId); + const { data: documents, error: documentsError } = await documentQuery;`, + `if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId); + else documentQuery = documentQuery.is("owner_id", null); + const { data: documents, error: documentsError } = await documentQuery;`, + ); +} +fs.writeFileSync("src/lib/rag.ts", rag); + +let enrichment = fs.readFileSync("src/lib/document-enrichment.ts", "utf8"); +enrichment = enrichment.replace( + 'import { requireOwnerScope } from "@/lib/owner-scope";', + 'import { retrievalOwnerFilter } from "@/lib/owner-scope";', +); +enrichment = enrichment.replace( + "owner_filter: args.ownerId ? requireOwnerScope(args.ownerId) : null,", + "owner_filter: retrievalOwnerFilter({ ownerId: args.ownerId, documentIds: args.documentIds }),", +); +fs.writeFileSync("src/lib/document-enrichment.ts", enrichment); + +let memory = fs.readFileSync("src/lib/deep-memory.ts", "utf8"); +if (!memory.includes("retrievalOwnerFilter")) { + memory = memory.replace( + 'import { requireOwnerScope } from "@/lib/owner-scope";', + 'import { retrievalOwnerFilter } from "@/lib/owner-scope";', + ); + memory = memory.replace( + /owner_filter:[\s\S]*?requireOwnerScope\(args\.ownerId\)[\s\S]*?\),/, + `owner_filter: retrievalOwnerFilter({ + ownerId: args.ownerId, + documentIds: args.documentIds, + allowGlobalSearch: !args.ownerId && !args.documentIds?.length, + }),`, + ); +} +fs.writeFileSync("src/lib/deep-memory.ts", memory); + +let schema = fs.readFileSync("supabase/schema.sql", "utf8"); +if (!schema.includes("create or replace function public.retrieval_owner_matches")) { + schema = schema.replace( + "create or replace function public.match_document_chunks(", + `create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid) +returns boolean +language sql +immutable +parallel safe +set search_path = public, pg_temp +as $$ + select case + when owner_filter is null then true + when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null + else row_owner_id = owner_filter + end; +$$; + +create or replace function public.match_document_chunks(`, + ); +} +schema = schema.replaceAll("(owner_filter is null or d.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, d.owner_id)"); +schema = schema.replaceAll("(owner_filter is null or l.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, l.owner_id)"); +schema = schema.replaceAll("(owner_filter is null or s.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, s.owner_id)"); +schema = schema.replaceAll("(owner_filter is null or f.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, f.owner_id)"); +fs.writeFileSync("supabase/schema.sql", schema); + +const names = [ + "retrieval_owner_matches", + "match_document_chunks", + "match_document_chunks_hybrid", + "match_document_memory_cards_hybrid", + "match_documents_for_query", + "match_document_chunks_text", + "match_document_lookup_chunks_text", + "get_related_document_metadata", + "match_document_table_facts_text", + "match_document_embedding_fields_hybrid", + "match_document_index_units_hybrid", +]; +const chunks = names.map((name) => { + const re = new RegExp(`create or replace function public\\.${name}[\\s\\S]*?\\n\\$\\$;`, "i"); + const match = schema.match(re); + if (!match) throw new Error(`missing ${name}`); + return match[0]; +}); +fs.writeFileSync( + "supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql", + `-- Public-only retrieval owner filter sentinel for hybrid RPCs.\nset search_path = public, extensions, pg_temp;\n\n${chunks.join("\n\n")}\n`, +); + +let ownerTest = fs.readFileSync("tests/owner-scope.test.ts", "utf8"); +if (!ownerTest.includes("retrievalOwnerFilter")) { + ownerTest += `\ndescribe("retrievalOwnerFilter", () => { + it("returns the public sentinel for anonymous production global search", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, isLocalNoAuthMode: () => false })); + vi.stubEnv("NODE_ENV", "production"); + const { retrievalOwnerFilter, PUBLIC_OWNER_FILTER_SENTINEL } = await import("../src/lib/owner-scope"); + expect(retrievalOwnerFilter({ allowGlobalSearch: true })).toBe(PUBLIC_OWNER_FILTER_SENTINEL); + }); +});\n`; + fs.writeFileSync("tests/owner-scope.test.ts", ownerTest); +} + +execSync( + "git add src/lib/owner-scope.ts src/lib/rag.ts src/lib/document-enrichment.ts src/lib/deep-memory.ts supabase/schema.sql supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql tests/owner-scope.test.ts scripts/commit-access-rag-fix.mjs", + { stdio: "inherit" }, +); +execSync('git commit -m "fix(rag): scope anonymous retrieval to public documents via owner sentinel"', { + stdio: "inherit", +}); +console.log("committed"); diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 0bde64faf..0c1749ceb 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -45,6 +45,7 @@ type EvalQualityArgs = { retrievalOnly: boolean; ragOnly: boolean; skipPreflight: boolean; + forceEmbedding: boolean; }; export type RagQualityResult = { @@ -150,6 +151,7 @@ function parseArgs(argv: string[]): EvalQualityArgs { retrievalOnly: false, ragOnly: false, skipPreflight: false, + forceEmbedding: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -176,6 +178,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}`); @@ -476,6 +482,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}`, @@ -664,6 +675,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], @@ -728,6 +741,7 @@ async function runRetrievalQualityCases(args: { ownerId?: string; limit?: number; query?: string; + forceEmbedding?: boolean; supabase: Awaited>; }) { const [{ searchChunksWithTelemetry }, capturedCases] = await Promise.all([ @@ -756,7 +770,7 @@ async function runRetrievalQualityCases(args: { topK: retrievalLimitForGoldenCase(testCase), minSimilarity: 0.12, skipCache: true, - forceEmbedding: testCase.forceEmbedding, + forceEmbedding: testCase.forceEmbedding || args.forceEmbedding, }), ); const latencyMs = diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index edeb89564..2a46d96f2 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[]; @@ -516,6 +517,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 && @@ -533,10 +539,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, @@ -612,6 +631,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( @@ -647,6 +667,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), @@ -723,6 +745,8 @@ function printHumanSummary(summary: ReturnType>, ownerId?: string) { + let query = supabase + .from("documents") + .select("id", { count: "exact", head: true }) + .eq("status", "indexed") + .not("owner_id", "is", null) + .in("metadata->>clinical_validation_status", ["locally_reviewed", "approved"]); + + if (ownerId) query = query.eq("owner_id", ownerId); + + const { count, error } = await query; + if (error) throw new Error(error.message); + return count ?? 0; +} + +async function countPublic(supabase: Awaited>) { + const { count, error } = await supabase + .from("documents") + .select("id", { count: "exact", head: true }) + .eq("status", "indexed") + .is("owner_id", null); + + if (error) throw new Error(error.message); + return count ?? 0; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const supabase = await loadAdminClient(); + + const [candidateCount, publicCount] = await Promise.all([ + countCandidates(supabase, args.ownerId), + countPublic(supabase), + ]); + + console.log("[public-documents:promote] indexed public documents:", publicCount); + console.log( + `[public-documents:promote] pending promotion${args.ownerId ? ` for owner ${args.ownerId}` : ""}:`, + candidateCount, + ); + console.log( + "Apply migration 20260705220000_promote_locally_reviewed_documents_public.sql with `npx supabase db push --linked`.", + ); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/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 55d6a1558..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,6 +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 { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { writeAuditLog } from "@/lib/audit"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; @@ -280,13 +282,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - const { data: document, error } = await supabase - .from("documents") - .select("*") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + 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, + ).maybeSingle(); if (error) throw new Error(error.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); diff --git a/src/app/api/documents/[id]/search/route.ts b/src/app/api/documents/[id]/search/route.ts index 4ab60b1ed..80deb18ae 100644 --- a/src/app/api/documents/[id]/search/route.ts +++ b/src/app/api/documents/[id]/search/route.ts @@ -1,11 +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, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRouteParams } from "@/lib/validation/params"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -181,13 +183,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentSearchParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - const { data: document, error: documentError } = await supabase - .from("documents") - .select("id,metadata") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + 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, + ).maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); @@ -197,7 +200,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: p_document_id: id, p_query: query, match_count: limit, - p_owner_id: user.id, + p_owner_id: access.ownerId, }); if (!rpcError) { diff --git a/src/app/api/documents/[id]/signed-url/route.ts b/src/app/api/documents/[id]/signed-url/route.ts index 043d01118..7ecb96f47 100644 --- a/src/app/api/documents/[id]/signed-url/route.ts +++ b/src/app/api/documents/[id]/signed-url/route.ts @@ -1,11 +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, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -31,13 +33,14 @@ 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 user = await requireAuthenticatedUser(_request, supabase); - const { data: document, error } = await supabase - .from("documents") - .select("storage_path,file_type") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + 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, + ).maybeSingle(); if (error) throw new Error(error.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 52814c3a6..a9abec8cf 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,14 +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, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +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", @@ -31,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", @@ -44,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", @@ -130,11 +169,17 @@ export async function GET(request: Request) { } = parseRequestQuery(request, documentListQuerySchema, "Invalid document list query."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - let query = supabase - .from("documents") - .select(DOCUMENT_LIST_COLUMNS, { count: "exact" }) - .eq("owner_id", user.id) + 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(listColumns, { count: "exact" }), + access.ownerId, + ) .order("created_at", { ascending: false }) .range(offset, offset + limit - 1); @@ -161,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 b061aaf8a..c5fac4d63 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -1,12 +1,14 @@ 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"; import { jsonError, PublicApiError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -31,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 user = await requireAuthenticatedUser(_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") @@ -41,12 +46,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (error) throw new Error(error.message); if (!image) return NextResponse.json({ error: "Image not found." }, { status: 404 }); - const { data: document, error: documentError } = await supabase - .from("documents") - .select("id,metadata") - .eq("id", image.document_id) - .eq("owner_id", user.id) - .maybeSingle(); + const { data: document, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").eq("id", image.document_id), + access.ownerId, + ).maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Image not found." }, { status: 404 }); 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 bfa21c873..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 { publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { getFormRecord } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -62,6 +62,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } + if (!shouldResolvePublicCatalogAccess(request)) { + const payload = publicRegistryDetailPayload(kind, normalizedSlug); + if (!payload) return notFoundResponse(normalizedSlug); + return registryResponse({ + ...payload, + publicAccess: true, + }); + } + const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); @@ -69,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 5897a1525..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 { publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { rankFormRecords, formRecords } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -78,6 +78,13 @@ export async function GET(request: Request) { }); } + if (!shouldResolvePublicCatalogAccess(request)) { + return registryResponse({ + ...publicRegistryPayload(kind, q, limit), + publicAccess: true, + }); + } + const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); @@ -85,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/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/app/api/upload/route.ts b/src/app/api/upload/route.ts index 72ddb6023..c6845d7f7 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -2,13 +2,16 @@ 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 { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { assertSafeLocalProjectRequest, localProjectOriginErrorResponse, UnsafeLocalProjectOriginError } from "@/lib/local-project-guard"; +import { publicAccessContext } from "@/lib/public-api-access"; import { probeSupabaseHealth } from "@/lib/supabase/health"; import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data"; @@ -82,9 +85,28 @@ export async function POST(request: Request) { let insertedDocumentOwnerId: string | null = null; try { + assertSafeLocalProjectRequest(request); 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 rateLimit = await consumeSubjectApiRateLimit({ + supabase: adminSupabase, + subject: access.rateLimitSubject, + bucket: "document_upload", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + if (rateLimit.limited) { + return rateLimitJsonResponse( + "Upload is temporarily rate limited because too many requests were received. Retry shortly.", + rateLimit, + ); + } + const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", @@ -107,7 +129,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 +139,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 +169,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 +182,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 +200,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 +224,7 @@ export async function POST(request: Request) { insertedDocumentOwnerId = null; return duplicateUploadResponse({ supabase, - ownerId: user.id, + ownerId: uploadOwnerId, contentHash, storagePath: uploadedPath, }); @@ -210,7 +232,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 +252,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 +264,7 @@ export async function POST(request: Request) { } await writeAuditLog(supabase, { - ownerId: user.id, + ownerId: uploadOwnerId, action: "document_upload", resourceType: "document", resourceId: documentId, @@ -298,6 +320,9 @@ export async function POST(request: Request) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); } + if (error instanceof UnsafeLocalProjectOriginError) { + return localProjectOriginErrorResponse(error); + } return jsonError(error); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..aa6c1cb2d 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,18 +1,15 @@ "use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, Bell, BookOpen, - CheckCircle2, ChevronDown, ChevronRight, CircleUserRound, Clock3, - Copy, ExternalLink, FileImage, FileText, @@ -21,7 +18,6 @@ import { HelpCircle, Heart, Keyboard, - Layers, ListChecks, Loader2, LogOut, @@ -29,7 +25,6 @@ import { LockKeyhole, Palette, PanelTop, - Plus, Quote, RefreshCw, Search, @@ -48,66 +43,38 @@ import { import { type CSSProperties, type FormEvent, - type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { AccessibleTable } from "@/components/AccessibleTable"; -import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; -import { DocumentTagCloud } from "@/components/DocumentTagCloud"; -import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; -import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; -import { isLocalNoAuthMode } from "@/lib/env"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; +import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/env"; import { appBackdrop, answerSurface, - chatMicroAction, - clinicalDivider, cn, - EmptyState, - fieldControlPlain, fieldControlWithIcon, fieldIcon, floatingControl, - iconTilePremium, - metadataPill, - panelSubtle, primaryControl, - SourceProvenance, - SourceStatusBadge, - sourceCard, - subtleStatusPill, - tableCard, - tableCardHeader, - tableMicroActionRow, textMuted, - toneDanger, - toneInfo, - toneNeutral, toneSuccess, toneWarning, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; -import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -129,42 +96,22 @@ import { import { GuideDialog, GuideTrigger, - SectionHeading, UtilityDrawer, } from "@/components/clinical-dashboard/dashboard-shell"; import { - cleanDisplayTitle, sanitizeAnswerDisplayText, sanitizeDisplayText, } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, - SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; -import { - AnswerFeedbackPanel, - AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - simpleClinicalTableProps, - evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, - QuoteCards, - SafetyFindingsListContent, -} from "@/components/clinical-dashboard/evidence-panels"; +import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { emptyStates, errorCopy } from "@/lib/ui-copy"; +import { errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { DrawerGroupLabel, @@ -198,7 +145,7 @@ const DocumentDrawer = dynamic( ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; +import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -236,20 +183,15 @@ import { maxStoredAnswerTurns, savePersistedAnswerThread, } from "@/lib/answer-thread-storage"; -import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; +import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; import { - tagSearchText, type SmartDocumentTag, type SmartDocumentTagFacet, - type SmartDocumentTagTier, - type SmartDocumentTagQualityIssueKind, } from "@/lib/document-tags"; import type { ClinicalDocument, @@ -263,16 +205,13 @@ import type { RelatedDocument, SearchResult, SearchScopeSummary, - VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, - DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { createQuoteFollowUp, - type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates, } from "@/lib/ward-output"; @@ -487,367 +426,6 @@ async function readAnswerStream(response: Response, onProgress: (message: string function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } - -function compactClinicalTableCaption(item: VisualEvidenceCard) { - const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; - const cleaned = sourceTextForCompactDisplay(raw) - .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") - .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") - .replace(/\s{2,}/g, " ") - .trim(); - const caption = cleaned || "Clinical table"; - return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; -} - -function visualEvidenceHeader(item: VisualEvidenceCard) { - const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); - const titleText = sourceTextForCompactDisplay(titleSource).trim(); - const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); - const normalizedTitle = titleText.toLowerCase(); - const normalizedCaption = captionText.toLowerCase(); - const isDuplicateCaption = - Boolean(normalizedCaption) && - (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); - return { - title: titleText || captionText || "Visual evidence", - caption: isDuplicateCaption ? null : captionText, - }; -} - -function VisualEvidenceStrip({ - evidence, - collapsed = false, - embedded = false, -}: { - evidence: VisualEvidenceCard[]; - collapsed?: boolean; - embedded?: boolean; -}) { - function looksLikeTableText(value?: string | null) { - return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); - } - - if (collapsed) { - return ( -
- - - -
- ); - } - - const content = ( - <> - - {evidence.length === 0 ? ( - - ) : ( -
- {evidence.map((item) => { - const tableMarkdown = item.accessibleTableMarkdown?.trim() - ? item.accessibleTableMarkdown - : looksLikeTableText(item.tableTextSnippet) - ? item.tableTextSnippet - : null; - const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); - const tableCaption = compactClinicalTableCaption(item); - const sourceHeader = visualEvidenceHeader(item); - const displayLabels = smartEvidenceTags( - item.labels, - [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] - .filter(Boolean) - .join(" "), - ); - return ( -
-
- -
-
- {!hasStructuredTable ?

{sourceHeader.title}

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

{sourceHeader.caption}

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

- {sourceTextForCompactDisplay(item.tableTextSnippet)} -

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

Claims checked

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

- {directCount} direct · {partialCount} partial -

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

Gap {index + 1}

-

{warning}

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

- {compactClinicalTableCaption(item)} -

-

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

-
- - - -
- ))} -
- ) : ( - - ); - } - - if (tab === "Images") { - return visualEvidence.length ? ( - - ) : ( - - ); - } - - if (tab === "Quotes") { - return ( - - ); - } - - return ; -} - /** * A completed Q&A exchange kept on screen after a newer answer arrives, so * Answer mode reads as a conversation thread instead of replacing each result. @@ -2174,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); @@ -2355,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; } } @@ -2922,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, ); @@ -4005,21 +3594,21 @@ 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 = - !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. @@ -4044,14 +3633,18 @@ export function ClinicalDashboard({ summary={ !isOnline ? "Your browser is offline. Existing content may remain visible, but private search and uploads need network access." - : "The local API did not respond. Check the app server and setup status before retrying." + : isDeployedClinicalKb() + ? "The app could not reach its API. Try again in a moment." + : "The local API did not respond. Check the app server and setup status before retrying." } mobileSummary={!isOnline ? "Offline" : "API unavailable"} >

{!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."} + : 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."}

); @@ -4079,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, }, @@ -4659,7 +4252,7 @@ export function ClinicalDashboard({ diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index bd4c4d222..10533849e 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1914,7 +1914,6 @@ export function DocumentViewer({ const [documentSearchError, setDocumentSearchError] = useState(null); const [reviewingTableFactId, setReviewingTableFactId] = useState(null); const [isOnline, setIsOnline] = useState(true); - const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false); const [localProjectReady, setLocalProjectReady] = useState(true); const [mobileActionsOpen, setMobileActionsOpen] = useState(false); const [useNativePdfViewer, setUseNativePdfViewer] = useState(() => getInitialPdfViewerMode().useNativePdfViewer); @@ -1924,11 +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; @@ -2002,10 +2012,10 @@ export function DocumentViewer({ }, [isConfigured]); useEffect(() => { - if (!canUsePrivateApis && authStatus === "loading") { + if (!canViewSourceDocuments && authStatus === "loading") { return () => undefined; } - if (!canUsePrivateApis) { + if (!canViewSourceDocuments) { return () => undefined; } @@ -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") { @@ -2141,7 +2159,7 @@ export function DocumentViewer({ }, [ authStatus, authorizationHeader, - canUsePrivateApis, + canViewSourceDocuments, clientDemoMode, documentId, chunkId, @@ -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); @@ -2208,15 +2226,6 @@ export function DocumentViewer({ }; }, []); - useEffect(() => { - if (canUsePrivateApis || authStatus !== "loading") { - return () => undefined; - } - - const timeout = window.setTimeout(() => setAuthLoadingTimedOut(true), 3000); - return () => window.clearTimeout(timeout); - }, [authStatus, canUsePrivateApis]); - async function summarize() { if (!canSummarizeDocument) { setSummaryError("Load a source document before summarising."); @@ -2248,14 +2257,19 @@ export function DocumentViewer({ } const authViewerError = - !canUsePrivateApis && (authStatus !== "loading" || authLoadingTimedOut) - ? isConfigured - ? "Sign in to open private source documents." - : "Supabase browser authentication is not configured for private source documents." + !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 - : loadingDocument; + const effectiveLoadingDocument = + !canUsePrivateApis && authStatus === "loading" && !authLoadingTimedOut && loadingDocument + ? true + : loadingDocument; const effectiveViewerError = authViewerError ?? viewerError; const viewerState = effectiveLoadingDocument ? "loading" diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index b47b1267a..a362bc811 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -906,12 +906,17 @@ export function ApplicationsLauncherWorkspace({ }: ApplicationsLauncherWorkspaceProps) { const [uncontrolledQuery, setUncontrolledQuery] = useState(""); const [activeFilter, setActiveFilter] = useState("all"); - const [selectedId, setSelectedId] = useState(() => initialToolId(controlledQuery)); const isDashboardTools = variant === "dashboard-tools"; const [detailOpen, setDetailOpen] = useState(!isDashboardTools && showDetailPanel === true); const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; const query = controlledQuery ?? uncontrolledQuery; const normalizedQuery = query.trim().toLowerCase(); + const queryDerivedId = useMemo(() => initialToolId(query), [query]); + const [selection, setSelection] = useState(() => ({ + queryKey: (controlledQuery ?? "").trim().toLowerCase(), + id: initialToolId(controlledQuery), + })); + const selectedId = selection.queryKey === normalizedQuery ? selection.id : queryDerivedId; const filteredApps = useMemo(() => { return launcherApps.filter((app) => { @@ -941,7 +946,7 @@ export function ApplicationsLauncherWorkspace({ } function openTool(id: string) { - setSelectedId(id); + setSelection({ queryKey: normalizedQuery, id }); setDetailOpen(true); } 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/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index c1b2835a3..68072f029 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -22,7 +22,6 @@ import { favouriteItems, favouriteSets, favouriteTabs, - favouriteTypeCount, type FavouriteItem, type FavouriteSet, type FavouriteTabId, diff --git a/src/components/clinical-dashboard/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/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx index 2f84e2134..5dcca9e54 100644 --- a/src/components/clinical-dashboard/visual-evidence.tsx +++ b/src/components/clinical-dashboard/visual-evidence.tsx @@ -29,7 +29,6 @@ import { evidenceTabOrder, QuoteCards, simpleClinicalTableProps, - VerificationWorkspace, } from "@/components/clinical-dashboard/evidence-panels"; import { QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { diff --git a/src/components/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" ? ( ({ urlQuery, value: initialQuery })); const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery; const registry = useRegistryRecords("service"); - const searchableRecords = - registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords; + const searchableRecords = useMemo( + () => (registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords), + [registry.records, registry.status], + ); const matches = useMemo(() => { const ranked = rankServiceRecords(searchableRecords, query); return ranked.length ? ranked.map((match) => match.service) : query.trim() ? [] : searchableRecords; diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 2a19a21ba..a71465d44 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -1,10 +1,22 @@ import { NextResponse } from "next/server"; +import { isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError } from "@/lib/http"; import type { RateLimitSubject } from "@/lib/public-api-access"; import type { createAdminClient } from "@/lib/supabase/admin"; +/** Prefer durable RPC rate limits; fall back to per-instance memory when the DB function is unavailable. */ +export function allowRateLimitInMemoryFallbackOnUnavailable() { + return isLocalNoAuthMode() || process.env.NODE_ENV === "production"; +} + export type ApiRateLimitBucket = - "answer" | "search" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry"; + | "answer" + | "search" + | "document_read" + | "document_summarize" + | "document_reindex" + | "bulk_reindex" + | "registry"; export type ApiRateLimitResult = { limited: boolean; @@ -17,6 +29,7 @@ export type ApiRateLimitResult = { const apiRateLimitDefaults = { answer: { limit: 30, windowSeconds: 60 }, search: { limit: 240, windowSeconds: 60 }, + document_read: { limit: 180, windowSeconds: 60 }, document_summarize: { limit: 12, windowSeconds: 60 }, document_reindex: { limit: 6, windowSeconds: 60 }, bulk_reindex: { limit: 2, windowSeconds: 60 }, @@ -26,6 +39,7 @@ const apiRateLimitDefaults = { const anonymousApiRateLimitDefaults: Partial> = { answer: { limit: 6, windowSeconds: 60 }, search: { limit: 60, windowSeconds: 60 }, + document_read: { limit: 45, windowSeconds: 60 }, }; type SupabaseAdmin = ReturnType; diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts index fe6d02622..c4b1130a7 100644 --- a/src/lib/deep-memory.ts +++ b/src/lib/deep-memory.ts @@ -1,7 +1,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { buildClinicalTextSearchQuery, classifyRagQuery, normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { logger } from "@/lib/logger"; -import { requireOwnerScope } from "@/lib/owner-scope"; +import { retrievalOwnerFilter } from "@/lib/owner-scope"; import { buildDocumentIndexUnitInputs, countDocumentIndexUnitsByType, @@ -823,11 +823,11 @@ export async function fetchMemoryCardsForQuery(args: { match_count: args.matchCount ?? 32, min_similarity: 0.1, document_filters: args.documentIds?.length ? args.documentIds : null, - owner_filter: args.ownerId - ? requireOwnerScope(args.ownerId) - : args.documentIds?.length - ? null - : (requireOwnerScope(args.ownerId) ?? null), + owner_filter: retrievalOwnerFilter({ + ownerId: args.ownerId, + documentIds: args.documentIds, + allowGlobalSearch: !args.ownerId && !args.documentIds?.length, + }), }); if (error) { diff --git a/src/lib/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/document-enrichment.ts b/src/lib/document-enrichment.ts index 6aee1d8d0..ec77e4670 100644 --- a/src/lib/document-enrichment.ts +++ b/src/lib/document-enrichment.ts @@ -1,7 +1,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { classifyDocumentOrganization } from "@/lib/document-organization"; import { env } from "@/lib/env"; -import { requireOwnerScope } from "@/lib/owner-scope"; +import { retrievalOwnerFilter } from "@/lib/owner-scope"; import { isClinicalImageEvidence } from "@/lib/image-filtering"; import { buildCoveragePromptNote, @@ -713,7 +713,7 @@ export async function fetchRelatedDocumentMetadata(args: { }) { const { data: rpcData, error: rpcError } = await args.supabase.rpc("get_related_document_metadata", { document_ids: args.documentIds, - owner_filter: args.ownerId ? requireOwnerScope(args.ownerId) : null, + owner_filter: retrievalOwnerFilter({ ownerId: args.ownerId, documentIds: args.documentIds }), }); if (!rpcError) { diff --git a/src/lib/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/owner-scope.ts b/src/lib/owner-scope.ts index 4bec91eca..4eec27540 100644 --- a/src/lib/owner-scope.ts +++ b/src/lib/owner-scope.ts @@ -1,17 +1,7 @@ import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -/** - * Fail-closed guard for multi-tenant owner scoping. - * - * The hybrid retrieval RPCs treat a null `owner_filter` as "all owners" - * (fail-open), so calling them without an ownerId would silently return another - * tenant's data. Call this at every retrieval RPC boundary that filters by - * owner. In a real (multi-user) deployment a missing ownerId throws instead of - * leaking; in demo / local-no-auth / the test runner there is no multi-tenancy, - * so it stays permissive (returns undefined, preserving the previous behaviour). - * - * See the owner-scoping isolation audit. - */ +export const PUBLIC_OWNER_FILTER_SENTINEL = "00000000-0000-0000-0000-000000000000"; + export function requireOwnerScope(ownerId: string | null | undefined): string | undefined { if (ownerId) return ownerId; if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") { @@ -21,3 +11,20 @@ export function requireOwnerScope(ownerId: string | null | undefined): string | "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.", ); } + +export function retrievalOwnerFilter(args: { + ownerId?: string | null; + documentIds?: string[]; + allowGlobalSearch?: boolean; +}): string | null | undefined { + if (args.ownerId) return requireOwnerScope(args.ownerId); + if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") { + return undefined; + } + if (args.allowGlobalSearch || args.documentIds?.length) { + return PUBLIC_OWNER_FILTER_SENTINEL; + } + throw new Error( + "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.", + ); +} diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 26b80f3fc..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,14 +26,38 @@ 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 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.or(`owner_id.eq.${ownerId},owner_id.is.null`); + return query.is("owner_id", null); +} + export async function publicAccessContext(request: Request, supabase: AdminClient) { const user = await getOptionalAuthenticatedUser(request, supabase); if (user) { @@ -49,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/rag.ts b/src/lib/rag.ts index 6adea390f..eb3348003 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1,5 +1,5 @@ import { createAdminClient } from "@/lib/supabase/admin"; -import { requireOwnerScope } from "@/lib/owner-scope"; +import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope"; import type { Database, Json } from "@/lib/supabase/database.types"; import { embedTextWithTelemetry, @@ -1441,7 +1441,14 @@ function stableHash(value: string) { export function retrievalPlanCacheQuery( args: Pick< SearchChunksArgs, - "query" | "documentId" | "documentIds" | "ownerId" | "queryMode" | "topK" | "minSimilarity" | "forceEmbedding" + | "query" + | "documentId" + | "documentIds" + | "ownerId" + | "queryMode" + | "topK" + | "minSimilarity" + | "forceEmbedding" >, queryClass?: RagQueryClass, queryVariants: string[] = [], @@ -1456,6 +1463,7 @@ export function retrievalPlanCacheQuery( `mode:${modeKey(args)}`, `topK:${args.topK ?? 8}`, `min:${args.minSimilarity ?? 0.15}`, + `forceEmbedding:${args.forceEmbedding ? "1" : "0"}`, `rag:${ragDeepMemoryVersion}`, `force:${args.forceEmbedding ? 1 : 0}`, ].join("|"); @@ -1554,7 +1562,7 @@ type SharedCacheMissReason = function sharedCacheSelector( supabase: ReturnType, kind: SharedCacheKind, - args: Pick, + args: Pick, indexingVersion: string, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), ) { @@ -1719,7 +1727,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; @@ -1752,7 +1763,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}`)), @@ -1804,7 +1815,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; @@ -2055,10 +2069,12 @@ function assertGlobalSearchAllowed(args: SearchChunksArgs) { } } -function ownerScopeForDocumentFilteredRetrieval(ownerId: string | undefined, documentIds: string[] | undefined) { - if (ownerId) return requireOwnerScope(ownerId); - if (documentIds?.length) return undefined; - return requireOwnerScope(ownerId); +function ownerScopeForDocumentFilteredRetrieval( + ownerId: string | undefined, + documentIds: string[] | undefined, + allowGlobalSearch?: boolean, +) { + return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch }); } export function buildRetrievalQueryVariants( @@ -2186,6 +2202,7 @@ async function searchTextChunkCandidates(args: { queryVariants: string[]; ownerId?: string; documentIds?: string[]; + allowGlobalSearch?: boolean; matchCount: number; }) { const runChunkText = async (queryText: string, matchCount: number) => { @@ -2193,7 +2210,7 @@ async function searchTextChunkCandidates(args: { query_text: queryText, match_count: matchCount, document_filters: args.documentIds ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); return error || !data?.length ? ([] as SearchResult[]) : (data as SearchResult[]); }; @@ -2340,13 +2357,14 @@ async function fetchBestDocumentLookupChunks(args: { query: string; limit: number; ownerId?: string; + allowGlobalSearch?: boolean; }) { const terms = documentLookupChunkTerms(args.query); const { data: rpcChunks, error: rpcError } = await args.supabase.rpc("match_document_lookup_chunks_text", { query_text: args.query, document_filters: args.documentIds ?? undefined, match_count: Math.max(args.limit * 3, 24), - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); if (!rpcError && rpcChunks?.length) { const ranked = (rpcChunks as DocumentLookupChunkRow[]) @@ -2734,6 +2752,7 @@ async function loadChunksForSignalMatches(args: { .in("id", documentIds) .eq("status", "indexed"); if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId); + else documentQuery = documentQuery.is("owner_id", null); const { data: documents, error: documentsError } = await documentQuery; if (documentsError || !documents?.length) return [] as SearchResult[]; const documentById = new Map(documents.map((document) => [document.id, document])); @@ -2793,6 +2812,7 @@ async function searchTableFactCandidates(args: { queryVariants?: string[]; ownerId?: string; documentIds?: string[]; + allowGlobalSearch?: boolean; matchCount: number; }) { const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice( @@ -2805,7 +2825,7 @@ async function searchTableFactCandidates(args: { query_text: variant, match_count: index === 0 ? args.matchCount : Math.min(args.matchCount, 24), document_filters: args.documentIds ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); if (error || !data?.length) return [] as TableFactRpcRow[]; return data as TableFactRpcRow[]; @@ -2844,6 +2864,7 @@ async function searchEmbeddingFieldCandidates(args: { queryEmbedding: number[]; ownerId?: string; documentIds?: string[]; + allowGlobalSearch?: boolean; matchCount: number; telemetry?: SearchTelemetry; }) { @@ -2853,7 +2874,7 @@ async function searchEmbeddingFieldCandidates(args: { match_count: args.matchCount, min_similarity: 0.12, document_filters: args.documentIds ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -2894,6 +2915,7 @@ async function searchIndexUnitCandidates(args: { queryEmbedding: number[]; ownerId?: string; documentIds?: string[]; + allowGlobalSearch?: boolean; matchCount: number; telemetry?: SearchTelemetry; }) { @@ -2903,7 +2925,7 @@ async function searchIndexUnitCandidates(args: { match_count: args.matchCount, min_similarity: 0.1, document_filters: args.documentIds ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -5386,6 +5408,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(); @@ -5463,7 +5488,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 @@ -5512,6 +5537,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryVariants, ownerId: args.ownerId, documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, matchCount: textCandidateCount, }); telemetry.text_candidate_count = textData.length; @@ -5610,6 +5636,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryVariants, ownerId: args.ownerId, documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 48), }); const tableFactLatencyMs = Date.now() - tableFactStartedAt; @@ -5722,7 +5749,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry, }); const coverageGate = evaluateEvidenceCoverageGate(args.query, coverageGateResults, queryClassification.queryClass); - applyCoverageGateTelemetry(telemetry, coverageGate, coverageGate.accepted); + applyCoverageGateTelemetry(telemetry, coverageGate, !args.forceEmbedding && coverageGate.accepted); if (!args.forceEmbedding && coverageGate.accepted) { telemetry.retrieval_strategy = coverageGate.strategy; recordSearchScoreTelemetry(telemetry, coverageGateResults); @@ -5752,7 +5779,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); @@ -5790,6 +5817,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryEmbedding: embedding, ownerId: args.ownerId, documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 48), telemetry, }); @@ -5803,6 +5831,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryEmbedding: embedding, ownerId: args.ownerId, documentIds: documentFilterList, + allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 64), telemetry, }); @@ -5816,7 +5845,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { match_count: candidateCount, min_similarity: minSimilarity, document_filters: documentFilterList ?? undefined, - owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList), + owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList, args.allowGlobalSearch), }); return { data, error, latencyMs: Date.now() - startedAt }; })(), @@ -5854,10 +5883,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, @@ -5911,6 +5944,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { owner_filter: ownerScopeForDocumentFilteredRetrieval( args.ownerId, documentFilter ? [documentFilter] : undefined, + documentFilter ? undefined : args.allowGlobalSearch, ), }); @@ -5918,7 +5952,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; @@ -5930,9 +5964,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/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index db67f2cbe..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) { - throw new AuthenticationError(); - } - return { id: data.user.id }; - } - - 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/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql new file mode 100644 index 000000000..21e0c281d --- /dev/null +++ b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql @@ -0,0 +1,202 @@ +-- Reconcile live index drift reported by search_schema_health() on 2026-07-05. +-- Creates canonical retrieval-support indexes that are missing on live (or only +-- present under legacy names) and teaches search_schema_health() to accept +-- verified functional equivalents so replays do not false-fail mid-rollout. + +set search_path = public, extensions, pg_catalog; + +create index if not exists documents_title_trgm_idx + on public.documents using gin (lower(coalesce(title, '') || ' ' || coalesce(file_name, '')) gin_trgm_ops); + +create index if not exists document_chunks_content_trgm_idx + on public.document_chunks using gin (lower(coalesce(section_heading, '') || ' ' || coalesce(content, '')) gin_trgm_ops); + +create index if not exists document_labels_label_trgm_idx + on public.document_labels using gin (lower(label) gin_trgm_ops); + +create index if not exists document_summaries_summary_trgm_idx + on public.document_summaries using gin (lower(summary) gin_trgm_ops); + +create index if not exists document_table_facts_owner_document_page_idx + on public.document_table_facts(owner_id, document_id, page_number); + +create index if not exists document_index_units_owner_chunk_type_idx + on public.document_index_units(owner_id, source_chunk_id, unit_type) + where source_chunk_id is not null; + +create index if not exists document_pages_document_idx + on public.document_pages(document_id, page_number); + +create index if not exists document_sections_document_idx + on public.document_sections(document_id, section_index); + +create index if not exists rag_retrieval_logs_owner_created_idx + on public.rag_retrieval_logs(owner_id, created_at desc); + +create index if not exists rag_retrieval_logs_miss_idx + on public.rag_retrieval_logs(is_miss, created_at desc) + where is_miss = true; + +create or replace function public.search_schema_health() +returns jsonb +language plpgsql +stable +security definer +set search_path = public, extensions, pg_catalog, pg_temp +as $$ +declare + missing text[] := array[]::text[]; + vector_type_oid oid; + vector_schema text; + index_name text; + legacy_ivfflat_indexes text[]; + zero_vec extensions.vector(1536); + probe_text text := 'schema health probe zzznomatch'; + hybrid_rpcs text[] := array[ + 'match_document_chunks_hybrid', + 'match_document_index_units_hybrid', + 'match_document_embedding_fields_hybrid', + 'match_document_memory_cards_hybrid' + ]; + rpc_name text; + required_indexes constant text[] := array[ + 'documents_title_trgm_idx', + 'document_chunks_content_trgm_idx', + 'document_labels_label_trgm_idx', + 'document_summaries_summary_trgm_idx', + 'document_chunks_embedding_hnsw_idx', + 'document_embedding_fields_embedding_hnsw_idx', + 'document_memory_cards_embedding_hnsw_idx', + 'documents_indexed_owner_title_idx', + 'document_table_facts_owner_document_page_idx', + 'document_embedding_fields_owner_chunk_idx', + 'document_index_units_owner_chunk_type_idx', + 'document_table_facts_source_image_idx', + 'document_pages_document_idx', + 'document_sections_document_idx', + 'document_chunks_document_idx', + 'document_memory_cards_document_idx', + 'document_embedding_fields_document_idx', + 'document_table_facts_document_idx', + 'document_index_units_document_idx', + 'rag_retrieval_logs_owner_created_idx', + 'rag_retrieval_logs_miss_idx', + 'rag_retrieval_logs_strategy_idx' + ]; + -- Verified live equivalents: same table/column intent, different migration-era name. + index_aliases constant jsonb := jsonb_build_object( + 'documents_title_trgm_idx', jsonb_build_array('documents_title_search_tsv_idx', 'documents_title_search_idx'), + 'document_chunks_content_trgm_idx', jsonb_build_array('document_chunks_search_tsv_idx', 'document_chunks_search_idx'), + 'document_table_facts_owner_document_page_idx', jsonb_build_array('document_table_facts_owner_idx'), + 'document_pages_document_idx', jsonb_build_array('document_pages_document_id_page_number_key'), + 'document_sections_document_idx', jsonb_build_array('document_sections_document_id_idx'), + 'rag_retrieval_logs_owner_created_idx', jsonb_build_array('rag_retrieval_logs_owner_id_idx') + ); +begin + select t.oid, n.nspname + into vector_type_oid, vector_schema + from pg_type t + join pg_namespace n on n.oid = t.typnamespace + where t.typname = 'vector' + and n.nspname = 'extensions' + limit 1; + + if vector_type_oid is null then + missing := array_append(missing, 'extensions.vector_type'); + end if; + + if to_regprocedure('public.match_document_chunks(extensions.vector, integer, double precision, uuid, uuid)') is null then + missing := array_append(missing, 'match_document_chunks.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_chunks_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_chunks_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_chunks_text(text, integer, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_chunks_text.signature'); + end if; + if to_regprocedure('public.match_document_lookup_chunks_text(text, uuid[], integer, uuid)') is null then + missing := array_append(missing, 'match_document_lookup_chunks_text.signature'); + end if; + if to_regprocedure('public.match_document_memory_cards_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_memory_cards_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_memory_cards_hybrid_v2(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_memory_cards_hybrid_v2.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_index_units_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_embedding_fields_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_embedding_fields_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_documents_for_query(text, integer, uuid)') is null then + missing := array_append(missing, 'match_documents_for_query.signature'); + end if; + if to_regprocedure('public.match_document_table_facts_text(text, integer, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_table_facts_text.signature'); + end if; + if to_regprocedure('public.explain_retrieval_rpc(text, text, integer, uuid, uuid[], boolean)') is null then + missing := array_append(missing, 'explain_retrieval_rpc.signature'); + end if; + if to_regclass('public.rag_retrieval_logs') is null then + missing := array_append(missing, 'rag_retrieval_logs.table'); + end if; + + foreach index_name in array required_indexes loop + if not exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relname = index_name + and c.relkind = 'i' + ) + and not ( + index_aliases ? index_name + and exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relkind = 'i' + and c.relname in ( + select jsonb_array_elements_text(index_aliases -> index_name) + ) + ) + ) then + missing := array_append(missing, index_name); + end if; + end loop; + + if vector_type_oid is not null then + zero_vec := (select ('[' || string_agg('0', ',') || ']') from generate_series(1, 1536))::extensions.vector(1536); + foreach rpc_name in array hybrid_rpcs loop + begin + execute format( + 'select 1 from public.%I($1, $2, 1, 0.1, null::uuid[], null::uuid) limit 1', + rpc_name + ) using zero_vec, probe_text; + exception + when undefined_function then + missing := array_append(missing, rpc_name || '.execution_signature'); + when others then + missing := array_append(missing, rpc_name || '.execution:' || SQLSTATE); + end; + end loop; + end if; + + select public.detect_legacy_ivfflat_indexes() into legacy_ivfflat_indexes; + + return jsonb_build_object( + 'ok', cardinality(missing) = 0, + 'missing', missing, + 'vector_extension_schema', vector_schema, + 'legacy_ivfflat_indexes', coalesce(legacy_ivfflat_indexes, array[]::text[]), + 'deferred_hnsw_indexes', array[]::text[], + 'checked_at', now() + ); +end; +$$; + +revoke execute on function public.search_schema_health() from public, anon, authenticated; +grant execute on function public.search_schema_health() to service_role; diff --git a/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql b/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql new file mode 100644 index 000000000..64f42ee56 --- /dev/null +++ b/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql @@ -0,0 +1,1030 @@ +-- Public-only retrieval owner filter sentinel for hybrid RPCs. +set search_path = public, extensions, pg_temp; + +create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid) +returns boolean +language sql +immutable +parallel safe +set search_path = public, pg_temp +as $ + select case + when owner_filter is null then true + when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null + else row_owner_id = owner_filter + end; +$; + +create or replace function public.match_document_chunks( + query_embedding extensions.vector(1536), + match_count integer default 8, + min_similarity double precision default 0.15, + document_filter uuid default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + title text, + file_name text, + page_number integer, + chunk_index integer, + section_heading text, + content text, + retrieval_synopsis text, + image_ids uuid[], + source_metadata jsonb, + document_labels jsonb, + document_summary text, + similarity double precision, + images jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select + c.id, + c.document_id, + d.title, + d.file_name, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + d.metadata as source_metadata, + '[]'::jsonb as document_labels, + null::text as document_summary, + 1 - (c.embedding <=> query_embedding) as similarity, + '[]'::jsonb as images + from public.document_chunks c + join public.documents d on d.id = c.document_id + where (document_filter is null or c.document_id = document_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and 1 - (c.embedding <=> query_embedding) >= min_similarity + order by c.embedding <=> query_embedding + limit match_count; +$$; + +create or replace function public.match_document_chunks( + query_embedding extensions.vector(1536), + match_count integer default 8, + min_similarity double precision default 0.15, + document_filter uuid default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + title text, + file_name text, + page_number integer, + chunk_index integer, + section_heading text, + content text, + retrieval_synopsis text, + image_ids uuid[], + source_metadata jsonb, + document_labels jsonb, + document_summary text, + similarity double precision, + images jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select + c.id, + c.document_id, + d.title, + d.file_name, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + d.metadata as source_metadata, + '[]'::jsonb as document_labels, + null::text as document_summary, + 1 - (c.embedding <=> query_embedding) as similarity, + '[]'::jsonb as images + from public.document_chunks c + join public.documents d on d.id = c.document_id + where (document_filter is null or c.document_id = document_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and 1 - (c.embedding <=> query_embedding) >= min_similarity + order by c.embedding <=> query_embedding + limit match_count; +$$; + +create or replace function public.match_document_chunks_hybrid( + query_embedding extensions.vector(1536), + query_text text, + match_count integer default 12, + min_similarity double precision default 0.12, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + title text, + file_name text, + page_number integer, + chunk_index integer, + section_heading text, + content text, + retrieval_synopsis text, + image_ids uuid[], + source_metadata jsonb, + similarity double precision, + text_rank double precision, + hybrid_score double precision, + rrf_score double precision, + images jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ), + vector_ranked as ( + select + c.id, + c.document_id, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + 1 - (c.embedding <=> query_embedding) as similarity, + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + )::double precision as text_rank, + row_number() over (order by c.embedding <=> query_embedding) as vector_rank, + null::bigint as text_match_rank, + coalesce((d.metadata->'rag_indexing_version') is not null, false) as has_deep_index, + d.updated_at as doc_updated_at, + coalesce( + (select q.quality_score from public.document_index_quality q where q.document_id = c.document_id), + 0.7 + ) as quality_score + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join query + where (document_filters is null or c.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and 1 - (c.embedding <=> query_embedding) >= min_similarity + order by c.embedding <=> query_embedding + limit greatest(match_count * 6, 48) + ), + text_ranked as ( + select + c.id, + c.document_id, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + 1 - (c.embedding <=> query_embedding) as similarity, + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + )::double precision as text_rank, + null::bigint as vector_rank, + row_number() over ( + order by + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + ) desc, + c.embedding <=> query_embedding + ) as text_match_rank, + coalesce((d.metadata->'rag_indexing_version') is not null, false) as has_deep_index, + d.updated_at as doc_updated_at, + coalesce( + (select q.quality_score from public.document_index_quality q where q.document_id = c.document_id), + 0.7 + ) as quality_score + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join query + where (document_filters is null or c.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and c.search_tsv @@ query.tsq + order by ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + ) desc + limit greatest(match_count * 6, 48) + ), + combined as ( + select * from vector_ranked + union all + select * from text_ranked + ), + scored as ( + select + id, + document_id, + page_number, + chunk_index, + section_heading, + content, + retrieval_synopsis, + image_ids, + max(similarity)::double precision as similarity, + max(text_rank)::double precision as text_rank, + min(vector_rank) as vector_rank, + min(text_match_rank) as text_match_rank, + max(quality_score)::double precision as quality_score, + bool_or(has_deep_index) as has_deep_index, + max(doc_updated_at) as doc_updated_at + from combined + group by id, document_id, page_number, chunk_index, section_heading, content, retrieval_synopsis, image_ids + ), + scored_metrics as ( + select + scored.*, + ( + (scored.similarity * 0.62) + + (least(scored.text_rank, 1) * 0.22) + + (scored.quality_score * 0.10) + + (case when scored.doc_updated_at > now() - interval '90 days' then 0.06 else 0 end) + )::double precision as hybrid_score, + ( + coalesce(1.0 / (60 + scored.vector_rank), 0) + + coalesce(1.0 / (60 + scored.text_match_rank), 0) + )::double precision as rrf_score + from scored + ), + hybrid_candidates as ( + select id + from scored_metrics + order by hybrid_score desc, similarity desc, text_rank desc + limit match_count + ), + vector_candidates as ( + select id + from scored_metrics + order by similarity desc, hybrid_score desc + limit match_count + ), + text_candidates as ( + select id + from scored_metrics + order by text_rank desc, hybrid_score desc + limit match_count + ), + rrf_candidates as ( + select id + from scored_metrics + order by rrf_score desc, hybrid_score desc + limit match_count + ), + candidate_ids as ( + select id from hybrid_candidates + union + select id from vector_candidates + union + select id from text_candidates + union + select id from rrf_candidates + ) + select + c.id, + c.document_id, + d.title, + d.file_name, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + d.metadata as source_metadata, + c.similarity, + c.text_rank, + c.hybrid_score, + c.rrf_score, + public.chunk_image_metadata(c.image_ids) as images + from scored_metrics c + join candidate_ids candidates on candidates.id = c.id + join public.documents d on d.id = c.document_id + order by c.hybrid_score desc, c.rrf_score desc, c.similarity desc, c.text_rank desc + limit match_count; +$$; + +create or replace function public.match_document_memory_cards_hybrid_v2( + query_embedding extensions.vector(1536), + query_text text, + match_count integer default 32, + min_similarity double precision default 0.1, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + owner_id uuid, + section_id uuid, + card_type text, + title text, + content text, + normalized_terms text[], + page_number integer, + source_chunk_ids uuid[], + source_image_ids uuid[], + confidence real, + metadata jsonb, + similarity double precision, + text_rank double precision, + hybrid_score double precision, + rrf_score double precision +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ), + vector_ranked as ( + select + m.*, + (1 - (m.embedding <=> query_embedding))::double precision as similarity, + ts_rank_cd(m.search_tsv, query.tsq)::double precision as text_rank, + row_number() over (order by m.embedding <=> query_embedding) as vector_rank, + null::bigint as text_match_rank + from public.document_memory_cards m + join public.documents d on d.id = m.document_id + cross join query + where (document_filters is null or m.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(m.metadata, d.metadata) + and (1 - (m.embedding <=> query_embedding)) >= min_similarity + order by m.embedding <=> query_embedding + limit greatest(match_count * 6, 96) + ), + text_ranked as ( + select + m.*, + (1 - (m.embedding <=> query_embedding))::double precision as similarity, + ts_rank_cd(m.search_tsv, query.tsq)::double precision as text_rank, + null::bigint as vector_rank, + row_number() over ( + order by ts_rank_cd(m.search_tsv, query.tsq) desc, m.embedding <=> query_embedding + ) as text_match_rank + from public.document_memory_cards m + join public.documents d on d.id = m.document_id + cross join query + where (document_filters is null or m.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(m.metadata, d.metadata) + and m.search_tsv @@ query.tsq + order by ts_rank_cd(m.search_tsv, query.tsq) desc + limit greatest(match_count * 6, 96) + ), + combined as ( + select * from vector_ranked + union all + select * from text_ranked + ), + scored as ( + select + id, document_id, owner_id, section_id, card_type, title, content, normalized_terms, + page_number, source_chunk_ids, source_image_ids, confidence, metadata, + max(similarity)::double precision as similarity, + max(text_rank)::double precision as text_rank, + min(vector_rank) as vector_rank, + min(text_match_rank) as text_match_rank + from combined + group by + id, document_id, owner_id, section_id, card_type, title, content, normalized_terms, + page_number, source_chunk_ids, source_image_ids, confidence, metadata + ) + select + id, document_id, owner_id, section_id, card_type, title, content, normalized_terms, + page_number, source_chunk_ids, source_image_ids, confidence, metadata, similarity, text_rank, + ( + (similarity * 0.62) + + (least(text_rank, 1) * 0.24) + + (confidence * 0.10) + + ( + coalesce(1.0 / (60 + vector_rank), 0) + + coalesce(1.0 / (60 + text_match_rank), 0) + ) * 0.04 + )::double precision as hybrid_score, + ( + coalesce(1.0 / (60 + vector_rank), 0) + + coalesce(1.0 / (60 + text_match_rank), 0) + )::double precision as rrf_score + from scored + order by hybrid_score desc, similarity desc, text_rank desc, confidence desc + limit match_count; +$$; + +create or replace function public.match_documents_for_query( + query_text text, + match_count integer default 12, + owner_filter uuid default null +) +returns table ( + id uuid, + owner_id uuid, + title text, + file_name text, + status text, + page_count integer, + chunk_count integer, + image_count integer, + metadata jsonb, + text_rank double precision, + match_reason text +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select + websearch_to_tsquery('english', coalesce(query_text, '')) as tsq, + lower(coalesce(query_text, '')) as normalized + ), + ranked as ( + select + d.id, + d.owner_id, + d.title, + d.file_name, + d.status, + d.page_count, + d.chunk_count, + d.image_count, + d.metadata, + ( + (ts_rank_cd(d.title_search_tsv, query.tsq) * 4.0) + + (ts_rank_cd(d.search_tsv, query.tsq) * 1.5) + + coalesce(max(ts_rank_cd(to_tsvector('english', l.label), query.tsq)) * 1.2, 0) + + coalesce(ts_rank_cd(to_tsvector('english', s.summary), query.tsq), 0) + + (greatest( + similarity(lower(coalesce(d.title, '') || ' ' || coalesce(d.file_name, '')), query.normalized), + coalesce(max(similarity(lower(l.label), query.normalized)), 0), + coalesce(similarity(lower(s.summary), query.normalized), 0) + ) * 1.6) + )::double precision as text_rank, + case + when d.title_search_tsv @@ query.tsq then 'title' + when max(l.label) filter (where to_tsvector('english', l.label) @@ query.tsq) is not null then 'label' + when s.summary is not null and to_tsvector('english', s.summary) @@ query.tsq then 'summary' + when similarity(lower(coalesce(d.title, '') || ' ' || coalesce(d.file_name, '')), query.normalized) >= 0.18 then 'fuzzy_title' + when d.search_tsv @@ query.tsq then 'metadata' + else 'none' + end as match_reason + from public.documents d + left join public.document_labels l + on l.document_id = d.id + and coalesce(l.metadata->>'review_status', 'new') <> 'hidden' + and coalesce(l.metadata->>'hidden', 'false') <> 'true' + left join public.document_summaries s on s.document_id = d.id + cross join query + where public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and ( + d.title_search_tsv @@ query.tsq + or d.search_tsv @@ query.tsq + or to_tsvector('english', coalesce(l.label, '')) @@ query.tsq + or to_tsvector('english', coalesce(s.summary, '')) @@ query.tsq + or similarity(lower(coalesce(d.title, '') || ' ' || coalesce(d.file_name, '')), query.normalized) >= 0.18 + or similarity(lower(coalesce(l.label, '')), query.normalized) >= 0.2 + or similarity(lower(coalesce(s.summary, '')), query.normalized) >= 0.16 + ) + group by d.id, d.owner_id, d.title, d.file_name, d.status, d.page_count, d.chunk_count, d.image_count, d.metadata, s.summary, query.tsq, query.normalized + ) + select * + from ranked + where text_rank > 0 + order by text_rank desc, page_count desc, title asc + limit match_count; +$$; + +create or replace function public.match_document_chunks_text( + query_text text, + match_count integer default 12, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + title text, + file_name text, + page_number integer, + chunk_index integer, + section_heading text, + content text, + retrieval_synopsis text, + image_ids uuid[], + source_metadata jsonb, + document_labels jsonb, + document_summary text, + similarity double precision, + text_rank double precision, + hybrid_score double precision, + lexical_score double precision, + images jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ), + ranked as ( + select + c.id, + c.document_id, + d.title, + d.file_name, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.retrieval_synopsis, + c.image_ids, + d.metadata as source_metadata, + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + )::double precision as text_rank + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join query + where (document_filters is null or c.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) + order by ( + ts_rank_cd(c.search_tsv, query.tsq) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) + ) desc + limit least(greatest(match_count * 2, 24), 96) + ), + -- Batch-fetch label metadata for all distinct document_ids in the result set. + -- One query replaces N per-row calls to document_label_metadata(). + doc_labels as ( + select + l.document_id, + coalesce( + jsonb_agg( + jsonb_build_object( + 'id', l.id, + 'document_id', l.document_id, + 'owner_id', l.owner_id, + 'label', l.label, + 'label_type', l.label_type, + 'source', l.source, + 'confidence', l.confidence, + 'metadata', l.metadata, + 'created_at', l.created_at, + 'updated_at', l.updated_at + ) + order by l.confidence desc, l.label + ), + '[]'::jsonb + ) as labels + from public.document_labels l + where l.document_id in (select distinct ranked.document_id from ranked) + and coalesce(l.metadata->>'review_status', 'new') <> 'hidden' + and coalesce(l.metadata->>'hidden', 'false') <> 'true' + group by l.document_id + ), + -- Batch-fetch summary text for all distinct document_ids in the result set. + -- One query replaces N per-row calls to document_summary_text(). + doc_summaries as ( + select distinct on (s.document_id) + s.document_id, + s.summary + from public.document_summaries s + where s.document_id in (select distinct ranked.document_id from ranked) + order by s.document_id + ) + select + ranked.id, + ranked.document_id, + ranked.title, + ranked.file_name, + ranked.page_number, + ranked.chunk_index, + ranked.section_heading, + ranked.content, + ranked.retrieval_synopsis, + ranked.image_ids, + ranked.source_metadata, + coalesce(doc_labels.labels, '[]'::jsonb) as document_labels, + doc_summaries.summary as document_summary, + -- Text-only fallback has NO vector cosine similarity. Do not fabricate one: + -- a synthetic value here was read downstream as a real semantic score and + -- could label a pure keyword hit as "strong"/"moderate" evidence (>=0.64). + -- Leave similarity at 0; the lexical signal lives in lexical_score. + 0::double precision as similarity, + ranked.text_rank, + -- Cap hybrid_score well below the 0.64 "moderate" threshold so a lexical-only + -- row can order amongst its peers but can never masquerade as a moderate/strong + -- cosine match when merged with vector results. + least(0.5, 0.18 + (least(ranked.text_rank, 1) * 0.3))::double precision as hybrid_score, + least(0.99, 0.4 + (least(ranked.text_rank, 1) * 0.59))::double precision as lexical_score, + public.chunk_image_metadata(ranked.image_ids) as images + from ranked + left join doc_labels on doc_labels.document_id = ranked.document_id + left join doc_summaries on doc_summaries.document_id = ranked.document_id + order by lexical_score desc, text_rank desc + limit match_count; +$$; + +create or replace function public.match_document_lookup_chunks_text( + query_text text, + document_filters uuid[], + match_count integer default 24, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + page_number integer, + chunk_index integer, + section_heading text, + section_path text[], + heading_level integer, + parent_heading text, + anchor_id text, + content text, + retrieval_synopsis text, + image_ids uuid[], + text_rank double precision +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ) + select + c.id, + c.document_id, + c.page_number, + c.chunk_index, + c.section_heading, + c.section_path, + c.heading_level, + c.parent_heading, + c.anchor_id, + c.content, + c.retrieval_synopsis, + c.image_ids, + ( + ts_rank_cd(c.search_tsv, query.tsq) + + (case when c.section_heading is not null then ts_rank_cd(to_tsvector('english', c.section_heading), query.tsq) * 0.35 else 0 end) + + (ts_rank_cd(d.title_search_tsv, query.tsq) * 0.25) + )::double precision as text_rank + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join query + where document_filters is not null + and c.document_id = any(document_filters) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) + and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) + order by text_rank desc, c.chunk_index asc + limit least(greatest(match_count, 1), 80); +$$; + +create or replace function public.get_related_document_metadata( + document_ids uuid[], + owner_filter uuid default null +) +returns table ( + document_id uuid, + labels jsonb, + summary text +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select + d.id as document_id, + coalesce( + ( + select jsonb_agg( + jsonb_build_object( + 'id', l.id, + 'document_id', l.document_id, + 'owner_id', l.owner_id, + 'label', l.label, + 'label_type', l.label_type, + 'source', l.source, + 'confidence', l.confidence, + 'metadata', l.metadata, + 'created_at', l.created_at, + 'updated_at', l.updated_at + ) + order by l.confidence desc, l.label + ) + from public.document_labels l + where l.document_id = d.id + and public.retrieval_owner_matches(owner_filter, l.owner_id) + and coalesce(l.metadata->>'review_status', 'new') <> 'hidden' + and coalesce(l.metadata->>'hidden', 'false') <> 'true' + ), + '[]'::jsonb + ) as labels, + ( + select s.summary + from public.document_summaries s + where s.document_id = d.id + and public.retrieval_owner_matches(owner_filter, s.owner_id) + order by s.generated_at desc + limit 1 + ) as summary + from public.documents d + where d.id = any(document_ids) + and public.retrieval_owner_matches(owner_filter, d.owner_id); +$$; + +create or replace function public.match_document_table_facts_text( + query_text text, + match_count integer default 16, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + source_chunk_id uuid, + source_image_id uuid, + page_number integer, + table_title text, + row_label text, + clinical_parameter text, + threshold_value text, + action text, + text_rank double precision, + match_reason text, + metadata jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select + websearch_to_tsquery('english', coalesce(query_text, '')) as tsq, + lower(coalesce(query_text, '')) as normalized, + regexp_split_to_array(lower(coalesce(query_text, '')), '[^a-z0-9]+') as terms + ), + ranked as ( + select + f.id, + f.document_id, + f.source_chunk_id, + f.source_image_id, + f.page_number, + f.table_title, + f.row_label, + f.clinical_parameter, + f.threshold_value, + f.action, + ( + ts_rank_cd(f.search_tsv, query.tsq) + + ( + similarity( + lower( + coalesce(f.table_title, '') || ' ' || + coalesce(f.row_label, '') || ' ' || + coalesce(f.clinical_parameter, '') || ' ' || + coalesce(f.threshold_value, '') || ' ' || + coalesce(f.action, '') + ), + query.normalized + ) * 0.8 + ) + + case + when coalesce(f.threshold_value, '') <> '' + and regexp_split_to_array(lower(f.threshold_value), '[^a-z0-9]+') && query.terms then 0.12 + else 0 + end + + case + when coalesce(f.action, '') <> '' + and regexp_split_to_array(lower(f.action), '[^a-z0-9]+') && query.terms then 0.1 + else 0 + end + )::double precision as text_rank, + case + when coalesce(f.threshold_value, '') <> '' then 'table_threshold' + when coalesce(f.action, '') <> '' then 'table_action' + else 'table_row' + end as match_reason, + f.metadata + from public.document_table_facts f + join public.documents d on d.id = f.document_id + cross join query + where (document_filters is null or f.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, f.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(f.metadata, d.metadata) + and ( + f.search_tsv @@ query.tsq + or f.normalized_terms && query.terms + or similarity( + lower( + coalesce(f.table_title, '') || ' ' || + coalesce(f.row_label, '') || ' ' || + coalesce(f.clinical_parameter, '') || ' ' || + coalesce(f.threshold_value, '') || ' ' || + coalesce(f.action, '') + ), + query.normalized + ) >= 0.18 + ) + ) + select * + from ranked + where text_rank > 0 + order by text_rank desc, page_number asc nulls last + limit match_count; +$$; + +create or replace function public.match_document_embedding_fields_hybrid( + query_embedding extensions.vector(1536), + query_text text, + match_count integer default 16, + min_similarity double precision default 0.5, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + source_chunk_id uuid, + field_type text, + content text, + similarity double precision, + text_rank double precision, + hybrid_score double precision +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq + ), + vector_hits as ( + select f.id + from public.document_embedding_fields f + join public.documents d on d.id = f.document_id + where (document_filters is null or f.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(f.metadata, d.metadata) + and f.source_chunk_id is not null + and 1 - (f.embedding <=> query_embedding) >= min_similarity + order by f.embedding <=> query_embedding + limit greatest(match_count * 3, 32) + ), + text_hits as ( + select f.id + from public.document_embedding_fields f + join public.documents d on d.id = f.document_id + cross join query + where (document_filters is null or f.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and d.status = 'indexed' + and public.is_committed_artifact_generation(f.metadata, d.metadata) + and f.source_chunk_id is not null + and f.search_tsv @@ query.tsq + order by ts_rank_cd(f.search_tsv, query.tsq) desc + limit greatest(match_count * 3, 32) + ), + candidate_ids as ( + select id from vector_hits + union + select id from text_hits + ), + ranked as ( + select + f.id, f.document_id, f.source_chunk_id, f.field_type, f.content, + (1 - (f.embedding <=> query_embedding))::double precision as similarity, + ts_rank_cd(f.search_tsv, query.tsq)::double precision as text_rank + from public.document_embedding_fields f + join candidate_ids ci on ci.id = f.id + cross join query + ) + select + id, document_id, source_chunk_id, field_type, content, similarity, text_rank, + ((similarity * 0.7) + (least(text_rank, 1) * 0.3))::double precision as hybrid_score + from ranked + order by hybrid_score desc, similarity desc, text_rank desc + limit match_count; +$$; + +create or replace function public.match_document_index_units_hybrid( + query_embedding extensions.vector(1536), + query_text text, + match_count integer default 24, + min_similarity double precision default 0.1, + document_filters uuid[] default null, + owner_filter uuid default null +) +returns table ( + id uuid, + document_id uuid, + source_chunk_id uuid, + source_image_id uuid, + unit_type text, + title text, + content text, + page_start integer, + page_end integer, + heading_path text[], + normalized_terms text[], + source_span jsonb, + quality_score real, + extraction_mode text, + similarity double precision, + text_rank double precision, + hybrid_score double precision, + metadata jsonb +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with query as ( + select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq, + regexp_split_to_array(lower(coalesce(query_text, '')), '\s+') as terms + ), + ranked as ( + select u.id, u.document_id, u.source_chunk_id, u.source_image_id, u.unit_type, u.title, u.content, u.page_start, + u.page_end, u.heading_path, u.normalized_terms, u.source_span, u.quality_score, u.extraction_mode, + (1 - (u.embedding <=> query_embedding))::double precision as similarity, + (ts_rank_cd(u.search_tsv, query.tsq) + + case when u.normalized_terms && query.terms then 0.25 else 0 end + + case when u.unit_type in ('askable_question', 'table_fact', 'clinical_fact', 'threshold', 'workflow_step', 'medication_monitoring', 'alias', 'visual_summary', 'flowchart_step', 'diagram_decision', 'risk_matrix_cell', 'medication_chart_row', 'chart_finding', 'visual_askable_question', 'table_threshold') then 0.06 + when u.unit_type = 'section_summary' then 0.03 + else 0 end + )::double precision as text_rank, + u.metadata + from public.document_index_units u + join public.documents d on d.id = u.document_id + cross join query + where d.status = 'indexed' + and (document_filters is null or u.document_id = any(document_filters)) + and public.retrieval_owner_matches(owner_filter, d.owner_id) + and public.is_committed_artifact_generation(u.metadata, d.metadata) + and u.source_chunk_id is not null + and (u.search_tsv @@ query.tsq or u.normalized_terms && query.terms) + order by text_rank desc + limit greatest(match_count * 3, 48) + ) + select id, document_id, source_chunk_id, source_image_id, unit_type, title, content, page_start, page_end, heading_path, + normalized_terms, source_span, quality_score, extraction_mode, similarity, text_rank, + ( + (similarity * 0.52) + + (least(text_rank, 1) * 0.28) + + (quality_score * 0.12) + + (case when extraction_mode in ('model_heavy', 'hybrid') then 0.04 else 0 end) + + (case when unit_type in ('askable_question', 'threshold', 'table_fact', 'table_threshold', 'visual_askable_question') then 0.04 + when unit_type in ('workflow_step', 'medication_monitoring', 'flowchart_step', 'diagram_decision', 'medication_chart_row', 'risk_matrix_cell') then 0.03 + else 0 end) + )::double precision as hybrid_score, + metadata + from ranked + order by hybrid_score desc, similarity desc, text_rank desc + limit match_count; +$$; diff --git a/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql b/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql new file mode 100644 index 000000000..76c9a5560 --- /dev/null +++ b/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql @@ -0,0 +1,65 @@ +-- Promote locally reviewed indexed documents to the public corpus (owner_id IS NULL) +-- so anonymous callers can list, preview, search, and use them in RAG. + +begin; + +create temporary table promoted_public_documents on commit drop as +with promoted as ( + update public.documents d + set + owner_id = null, + metadata = jsonb_set( + coalesce(d.metadata, '{}'::jsonb), + '{public_corpus}', + 'true'::jsonb, + true + ), + updated_at = now() + where d.status = 'indexed' + and d.owner_id is not null + and coalesce(d.metadata->>'clinical_validation_status', 'unverified') in ('locally_reviewed', 'approved') + returning d.id, d.owner_id as previous_owner_id +) +select id, previous_owner_id from promoted; + +update public.document_labels dl +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where dl.document_id = pd.id; + +update public.document_summaries ds +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where ds.document_id = pd.id; + +update public.document_sections ds +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where ds.document_id = pd.id; + +update public.document_memory_cards dmc +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where dmc.document_id = pd.id; + +update public.document_table_facts dtf +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where dtf.document_id = pd.id; + +update public.document_embedding_fields def +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where def.document_id = pd.id; + +update public.document_index_quality diq +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where diq.document_id = pd.id; + +update public.document_index_units diu +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where diu.document_id = pd.id; + +commit; diff --git a/supabase/schema.sql b/supabase/schema.sql index 63657b132..15ce06969 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1903,6 +1903,20 @@ as $$ nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', ''); $$; +create or replace function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid) +returns boolean +language sql +immutable +parallel safe +set search_path = public, pg_temp +as $ + select case + when owner_filter is null then true + when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null + else row_owner_id = owner_filter + end; +$; + create or replace function public.match_document_chunks( query_embedding extensions.vector(1536), match_count integer default 8, @@ -1950,7 +1964,7 @@ as $$ from public.document_chunks c join public.documents d on d.id = c.document_id where (document_filter is null or c.document_id = document_filter) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and 1 - (c.embedding <=> query_embedding) >= min_similarity @@ -2018,7 +2032,7 @@ as $$ join public.documents d on d.id = c.document_id cross join query where (document_filters is null or c.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and 1 - (c.embedding <=> query_embedding) >= min_similarity @@ -2059,7 +2073,7 @@ as $$ join public.documents d on d.id = c.document_id cross join query where (document_filters is null or c.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and c.search_tsv @@ query.tsq @@ -2211,7 +2225,7 @@ as $$ join public.documents d on d.id = m.document_id cross join query where (document_filters is null or m.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(m.metadata, d.metadata) and (1 - (m.embedding <=> query_embedding)) >= min_similarity @@ -2231,7 +2245,7 @@ as $$ join public.documents d on d.id = m.document_id cross join query where (document_filters is null or m.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(m.metadata, d.metadata) and m.search_tsv @@ query.tsq @@ -2396,6 +2410,15 @@ declare 'rag_retrieval_logs_miss_idx', 'rag_retrieval_logs_strategy_idx' ]; + -- Verified live equivalents: same table/column intent, different migration-era name. + index_aliases constant jsonb := jsonb_build_object( + 'documents_title_trgm_idx', jsonb_build_array('documents_title_search_tsv_idx', 'documents_title_search_idx'), + 'document_chunks_content_trgm_idx', jsonb_build_array('document_chunks_search_tsv_idx', 'document_chunks_search_idx'), + 'document_table_facts_owner_document_page_idx', jsonb_build_array('document_table_facts_owner_idx'), + 'document_pages_document_idx', jsonb_build_array('document_pages_document_id_page_number_key'), + 'document_sections_document_idx', jsonb_build_array('document_sections_document_id_idx'), + 'rag_retrieval_logs_owner_created_idx', jsonb_build_array('rag_retrieval_logs_owner_id_idx') + ); begin select t.oid, n.nspname into vector_type_oid, vector_schema @@ -2454,6 +2477,19 @@ begin where ns.nspname = 'public' and c.relname = index_name and c.relkind = 'i' + ) + and not ( + index_aliases ? index_name + and exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relkind = 'i' + and c.relname in ( + select jsonb_array_elements_text(index_aliases -> index_name) + ) + ) ) then missing := array_append(missing, index_name); end if; @@ -2607,7 +2643,7 @@ as $$ and coalesce(l.metadata->>'hidden', 'false') <> 'true' left join public.document_summaries s on s.document_id = d.id cross join query - where (owner_filter is null or d.owner_id = owner_filter) + where public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and ( d.title_search_tsv @@ query.tsq @@ -2681,7 +2717,7 @@ as $$ join public.documents d on d.id = c.document_id cross join query where (document_filters is null or c.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) @@ -2814,7 +2850,7 @@ as $$ cross join query where document_filters is not null and c.document_id = any(document_filters) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_document_generation(c.index_generation_id, d.metadata) and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) @@ -2859,7 +2895,7 @@ as $$ ) from public.document_labels l where l.document_id = d.id - and (owner_filter is null or l.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, l.owner_id) and coalesce(l.metadata->>'review_status', 'new') <> 'hidden' and coalesce(l.metadata->>'hidden', 'false') <> 'true' ), @@ -2869,13 +2905,13 @@ as $$ select s.summary from public.document_summaries s where s.document_id = d.id - and (owner_filter is null or s.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, s.owner_id) order by s.generated_at desc limit 1 ) as summary from public.documents d where d.id = any(document_ids) - and (owner_filter is null or d.owner_id = owner_filter); + and public.retrieval_owner_matches(owner_filter, d.owner_id); $$; create or replace function public.match_document_table_facts_text( @@ -2956,7 +2992,7 @@ as $$ join public.documents d on d.id = f.document_id cross join query where (document_filters is null or f.document_id = any(document_filters)) - and (owner_filter is null or f.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, f.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(f.metadata, d.metadata) and ( @@ -3011,7 +3047,7 @@ as $$ from public.document_embedding_fields f join public.documents d on d.id = f.document_id where (document_filters is null or f.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(f.metadata, d.metadata) and f.source_chunk_id is not null @@ -3025,7 +3061,7 @@ as $$ join public.documents d on d.id = f.document_id cross join query where (document_filters is null or f.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and public.is_committed_artifact_generation(f.metadata, d.metadata) and f.source_chunk_id is not null @@ -3916,7 +3952,7 @@ as $$ cross join query where d.status = 'indexed' and (document_filters is null or u.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) + and public.retrieval_owner_matches(owner_filter, d.owner_id) and public.is_committed_artifact_generation(u.metadata, d.metadata) and u.source_chunk_id is not null and (u.search_tsv @@ query.tsq or u.normalized_terms && query.terms) diff --git a/tests/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 4bdbf8834..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 }, }; @@ -180,6 +196,13 @@ function mockRuntime(client: ReturnType) { vi.doMock("@/lib/supabase/auth", () => ({ AuthenticationError: class AuthenticationError extends Error {}, requireAuthenticatedUser, + getOptionalAuthenticatedUser: vi.fn(async (request: Request) => { + const authorization = request.headers.get("authorization") ?? ""; + if (/^Bearer\s+\S+/i.test(authorization)) return { id: userId }; + const cookieHeader = request.headers.get("cookie") ?? ""; + if (cookieHeader.includes("sb-")) return { id: userId }; + return null; + }), unauthorizedResponse: () => new Response(JSON.stringify({ error: "Authentication required." }), { status: 401, diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts index d4888a935..fa4c7e48c 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"], @@ -166,6 +167,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); @@ -180,6 +183,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/owner-scope.test.ts b/tests/owner-scope.test.ts index a47a6bbdc..33030b816 100644 --- a/tests/owner-scope.test.ts +++ b/tests/owner-scope.test.ts @@ -27,3 +27,12 @@ describe("requireOwnerScope (fail-closed owner scoping)", () => { expect(() => requireOwnerScope(null)).toThrow(/tenant/); }); }); + +describe("retrievalOwnerFilter", () => { + it("returns the public sentinel for anonymous production global search", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, isLocalNoAuthMode: () => false })); + vi.stubEnv("NODE_ENV", "production"); + const { retrievalOwnerFilter, PUBLIC_OWNER_FILTER_SENTINEL } = await import("../src/lib/owner-scope"); + expect(retrievalOwnerFilter({ allowGlobalSearch: true })).toBe(PUBLIC_OWNER_FILTER_SENTINEL); + }); +}); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 45b5b1e61..b6476fc12 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, @@ -376,14 +396,18 @@ afterEach(() => { }); describe("private document API access", () => { - it("still requires a valid token even when local no-auth mode is enabled", async () => { - const client = createSupabaseMock(); + it("lists public documents without auth in local no-auth mode", async () => { + const documents = [{ id: documentId, owner_id: null, title: "Public guideline" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client, undefined, { localNoAuth: true }); const { GET } = await import("../src/app/api/documents/route"); const response = await GET(localPortRequest(4298, "/api/documents")); + const body = await payload(response); - expect(response.status).toBe(401); + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents); + expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); expect(client.auth.getUser).not.toHaveBeenCalled(); }); @@ -398,144 +422,231 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(client.auth.getUser).toHaveBeenCalledTimes(1); }); - it("rejects local no-auth private calls from unmanaged localhost ports before Supabase access", async () => { + it("rejects local no-auth upload calls from unmanaged localhost ports before Supabase access", async () => { const client = createSupabaseMock(); mockRuntime(client, undefined, { localNoAuth: true }); - const { GET } = await import("../src/app/api/documents/route"); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" })); - const response = await GET(localPortRequest(3000, "/api/documents")); + const response = await POST( + localPortRequest(3000, "/api/upload", { + method: "POST", + body: formData, + }), + ); - expect(response.status).toBe(401); + expect(response.status).toBe(409); + expect(await payload(response)).toMatchObject({ + error: "Use the ensured Clinical KB local URL before calling this API.", + }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); - it("rejects managed-port private calls with stale localhost referers before Supabase access", async () => { + it("rejects managed-port upload calls with stale localhost referers before Supabase access", async () => { const client = createSupabaseMock(); mockRuntime(client, undefined, { localNoAuth: true }); - const { GET } = await import("../src/app/api/documents/route"); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" })); - const response = await GET( - localPortRequest(4298, "/api/documents", { + const response = await POST( + localPortRequest(4298, "/api/upload", { + method: "POST", headers: { referer: "http://localhost:3000/", }, + body: formData, }), ); - expect(response.status).toBe(401); + expect(response.status).toBe(409); + expect(await payload(response)).toMatchObject({ + error: "Use the ensured Clinical KB local URL before calling this API.", + }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); - it("resolves local no-auth owner from documents before listing auth users", async () => { - const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; - const client = createSupabaseMock((call) => { - if (call.table === "documents" && call.selected === "owner_id") { - return ok({ owner_id: userId }); - } - if (call.table === "documents") { - return ok(documents); - } - return ok([]); - }); - mockRuntime(client, undefined, { localNoAuth: true }); + it("lists public documents for unauthenticated 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(localPortRequest(4298, "/api/documents")); + const response = await GET(request("/api/documents?includeMeta=false")); const body = await payload(response); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents); expect(client.auth.getUser).not.toHaveBeenCalled(); - expect(client.from).not.toHaveBeenCalled(); + expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); + expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId }); }); - it("resolves configured local no-auth owner email before document fallback", async () => { - const documents = [{ id: documentId, owner_id: userId, title: "Configured owner document" }]; - const client = createSupabaseMock((call) => { - if (call.table === "documents" && call.selected === "owner_id") { - return ok({ owner_id: otherUserId }); - } - if (call.table === "documents") { - return ok(documents); - } - return ok([]); - }); - client.auth.admin.listUsers.mockResolvedValueOnce({ - data: { users: [{ id: userId, email: "clinician@example.test" }], nextPage: 0 }, - error: null, - }); - mockRuntime(client, undefined, { localNoAuth: true, localOwnerEmail: "clinician@example.test" }); + 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); const { GET } = await import("../src/app/api/documents/route"); - const response = await GET(localPortRequest(4298, "/api/documents")); + const response = await GET(authenticatedRequest("/api/documents")); const body = await payload(response); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); - expect(client.auth.admin.listUsers).not.toHaveBeenCalled(); - expect(client.from).not.toHaveBeenCalled(); + 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].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 }); }); - it("rejects unauthenticated document listing", async () => { - const client = createSupabaseMock(); + it("accepts legacy Supabase auth cookies for private document access", 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")); + const response = await GET(authenticatedCookieRequest("/api/documents")); + const body = await payload(response); - expect(response.status).toBe(401); - expect(await payload(response)).toEqual({ error: "Authentication required." }); - expect(client.from).not.toHaveBeenCalled(); + 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].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); }); - it("filters authenticated document listing by owner", async () => { + it("accepts Supabase auth token cookies for private document access", 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(authenticatedRequest("/api/documents")); + const response = await GET(authenticatedAuthTokenCookieRequest("/api/documents")); const body = await payload(response); expect(response.status).toBe(200); + expect(client.auth.getUser).toHaveBeenCalledWith(token); 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].range).toEqual({ from: 0, to: 99 }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); }); - it("accepts legacy Supabase auth cookies for private document access", async () => { + 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(authenticatedCookieRequest("/api/documents")); + 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(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.auth.getUser).toHaveBeenCalledWith(token); }); - it("accepts Supabase auth token cookies for private document access", async () => { - const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; + 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(authenticatedAuthTokenCookieRequest("/api/documents")); + const response = await GET(request("/api/documents?includeMeta=true")); const body = await payload(response); 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].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 () => { @@ -567,7 +678,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); @@ -602,6 +713,48 @@ describe("private document API access", () => { expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); + it("allows anonymous signed URLs for public documents", async () => { + const client = createSupabaseMock((call) => { + if ( + call.table === "documents" && + call.filters.some((filter) => filter.column === "owner_id" && filter.value === null) + ) { + 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(request(`/api/documents/${documentId}/signed-url`), { + params: Promise.resolve({ id: documentId }), + }); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.url).toContain("public/documents/guideline.pdf"); + expect(client.auth.getUser).not.toHaveBeenCalled(); + }); + + it("rejects anonymous signed URLs for private documents", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.filters.some((filter) => filter.column === "owner_id")) { + return ok(null); + } + return ok(null); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/signed-url/route"); + + const response = await GET(request(`/api/documents/${otherDocumentId}/signed-url`), { + params: Promise.resolve({ id: otherDocumentId }), + }); + + expect(response.status).toBe(404); + expect(await payload(response)).toEqual({ error: "Document not found." }); + expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); + }); + it("allows image signed URLs only when the parent document is owned", async () => { const client = createSupabaseMock((call) => { if (call.table === "document_images") { @@ -613,7 +766,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); @@ -642,7 +795,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); @@ -671,7 +824,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); @@ -711,6 +864,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") { @@ -2095,9 +2300,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) => { + 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"); @@ -2788,6 +2997,41 @@ describe("private document API access", () => { ); }); + it("degrades invalid bearer tokens to anonymous search scope", async () => { + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [], + telemetry: { + search_cache_hit: false, + text_fast_path_latency_ms: 0, + embedding_skipped: true, + embedding_latency_ms: 0, + embedding_cache_hit: false, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, + retrieval_strategy: "text_fast_path", + }, + })); + const client = createSupabaseMock(); + mockRuntime(client, { searchChunksWithTelemetry }); + const searchRoute = await import("../src/app/api/search/route"); + + const response = await searchRoute.POST( + request("/api/search", { + method: "POST", + headers: { + authorization: "Bearer expired-token", + "content-type": "application/json", + }, + body: JSON.stringify({ query: "monitoring" }), + }), + ); + + expect(response.status).toBe(200); + expect(searchChunksWithTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }), + ); + }); + it("rate limits anonymous answer bursts before generation", async () => { const answerQuestionWithScope = vi.fn(async () => ({ answer: "Public evidence.", @@ -2957,7 +3201,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/public-access-deep.test.ts b/tests/public-access-deep.test.ts new file mode 100644 index 000000000..0801c4bd9 --- /dev/null +++ b/tests/public-access-deep.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const publicDocumentId = "11111111-1111-4111-8111-111111111111"; + +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); + +describe("public access deep checks", () => { + it("rejects unauthenticated access to operator-only routes", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + const auth = { + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => { + throw new auth.AuthenticationError(); + }), + unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }), + }; + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + + const cases = [ + { + routePath: "../src/app/api/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/jobs"), + }, + { + routePath: "../src/app/api/ingestion/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/jobs"), + }, + { + routePath: "../src/app/api/ingestion/batches/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/batches"), + }, + { + routePath: "../src/app/api/documents/bulk/route", + handler: "POST" as const, + request: new Request("http://localhost/api/documents/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete", documentIds: [publicDocumentId] }), + }), + }, + { + routePath: "../src/app/api/documents/[id]/summarize/route", + handler: "POST" as const, + request: new Request(`http://localhost/api/documents/${publicDocumentId}/summarize`, { method: "POST" }), + params: { id: publicDocumentId }, + }, + { + routePath: "../src/app/api/eval-cases/route", + handler: "POST" as const, + request: new Request("http://localhost/api/eval-cases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "What monitoring is needed?", + rating: "good", + answer: "Monitor FBC.", + queryMode: "auto", + queryClass: "table_threshold", + }), + }), + }, + ] as const; + + for (const testCase of cases) { + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + const mod = await import(testCase.routePath); + const handler = mod[testCase.handler]; + const response = await handler( + testCase.request, + "params" in testCase ? { params: Promise.resolve(testCase.params) } : undefined, + ); + expect(response.status, testCase.routePath).toBe(401); + } + }); + + it("does not expose secret values from the health endpoint", async () => { + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "super-secret-service-role-key", + OPENAI_API_KEY: "sk-super-secret-openai-key", + }, + isDemoMode: () => false, + })); + const { GET } = await import("../src/app/api/health/route"); + const response = await GET(new Request("https://clinical.example/api/health?deep=1")); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(serialized).not.toContain("super-secret-service-role-key"); + expect(serialized).not.toContain("sk-super-secret-openai-key"); + expect(body.checks.supabaseConfig).toBe("ok"); + expect(body.checks.openaiConfig).toBe("ok"); + }); + +}); + +describe("production anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("fails closed in production when anonymous global retrieval omits owner scope", async () => { + vi.doUnmock("@/lib/rag"); + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: "sk-test", + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + + await expect( + searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }), + ).rejects.toThrow(/ownerId|tenant/i); + }); +}); + +describe("test-runtime anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("allows anonymous global retrieval in test runtime where owner scope stays permissive", async () => { + vi.doUnmock("@/lib/rag"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: undefined, + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + in: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + const result = await searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }); + + expect(result.results).toEqual([]); + expect(result.telemetry).toBeDefined(); + }); +}); diff --git a/tests/registry-records-route.test.ts b/tests/registry-records-route.test.ts index 388bd52e5..5571c270c 100644 --- a/tests/registry-records-route.test.ts +++ b/tests/registry-records-route.test.ts @@ -198,11 +198,8 @@ describe("registry records API", () => { expect(payload.records.some((record) => record.slug === "13yarn")).toBe(true); expect(payload.matches?.[0]?.record.slug).toBe("13yarn"); expect(client.from).not.toHaveBeenCalled(); - expect(client.rpc).toHaveBeenCalledWith( - "consume_api_subject_rate_limit", - expect.objectContaining({ p_bucket: "registry" }), - ); - expect(client.rpc).not.toHaveBeenCalledWith("consume_api_rate_limit", expect.anything()); + expect(client.rpc).not.toHaveBeenCalled(); + expect(client.auth.getUser).not.toHaveBeenCalled(); }); it("rejects an invalid kind with a validation error", async () => { @@ -311,11 +308,8 @@ describe("registry records API", () => { expect(payload.record.slug).toBe("13yarn"); expect(payload.linkedDocuments).toEqual([]); expect(client.from).not.toHaveBeenCalled(); - expect(client.rpc).toHaveBeenCalledWith( - "consume_api_subject_rate_limit", - expect.objectContaining({ p_bucket: "registry" }), - ); - expect(client.rpc).not.toHaveBeenCalledWith("consume_api_rate_limit", expect.anything()); + expect(client.rpc).not.toHaveBeenCalled(); + expect(client.auth.getUser).not.toHaveBeenCalled(); }); it("returns 404 for an unknown slug", async () => { 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( 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"); }); }); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 68cd98773..6dd16d6c9 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -80,6 +80,10 @@ const registryCatalogPayloadMigration = readFileSync( new URL("../supabase/migrations/20260705030000_registry_catalog_payload.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const searchHealthIndexesMigration = readFileSync( + new URL("../supabase/migrations/20260705180000_reconcile_search_health_indexes.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); const ragQueriesRetentionMigration = readFileSync( new URL("../supabase/migrations/20260629060603_rag_queries_retention.sql", import.meta.url), "utf8", @@ -739,6 +743,17 @@ describe("Supabase schema Data API grants", () => { "add column if not exists catalog_payload jsonb not null default '{}'::jsonb", ); }); + + it("reconciles search_schema_health index drift with canonical creates and live aliases", () => { + expect(searchHealthIndexesMigration).toContain("create index if not exists documents_title_trgm_idx"); + expect(searchHealthIndexesMigration).toContain("create index if not exists document_labels_label_trgm_idx"); + expect(searchHealthIndexesMigration).toContain("create index if not exists rag_retrieval_logs_miss_idx"); + expect(searchHealthIndexesMigration).toContain("index_aliases constant jsonb := jsonb_build_object("); + expect(searchHealthIndexesMigration).toContain("'documents_title_search_tsv_idx'"); + expect(searchHealthIndexesMigration).toContain("'document_pages_document_id_page_number_key'"); + expect(schema).toContain("index_aliases constant jsonb := jsonb_build_object("); + expect(schema).toContain("jsonb_array_elements_text(index_aliases -> index_name)"); + }); }); describe("RC9 — lexical text path must not fabricate a cosine similarity", () => { 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-stress.spec.ts b/tests/ui-stress.spec.ts index 89a8aa2a4..1cffeb387 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -278,12 +278,14 @@ test.describe("Clinical KB long-content stress coverage", () => { await expect(page.getByLabel("Source-backed answer")).toBeVisible(); await expect(page.getByTestId("plain-answer-response")).toBeVisible(); - const scopeTrigger = page.locator('[data-testid="scope-trigger"]:visible'); + const actionMenu = page.getByRole("button", { name: "Open answer options" }); await page.keyboard.press("Escape"); - await scopeTrigger.click(); + await actionMenu.click(); + const actionsMenu = page.getByTestId("daily-actions-menu"); + await expect(actionsMenu).toBeVisible(); + await actionsMenu.getByRole("menuitem", { name: "Scope sources" }).click(); const scopeContainer = page.getByTestId("scope-command-popover"); await expect(scopeContainer).toBeVisible(); - await expect(scopeContainer).toBeVisible(); await expect( scopeContainer.getByText(/Type to filter 24 (loaded )?documents\. Selected documents stay pinned here\./), ).toBeVisible(); 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();