diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 55baf040b6..805323713e 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -21,6 +21,7 @@ import { } from "@/lib/source-governance"; import { parseJsonBody } from "@/lib/validation/body"; import { createAdminClient } from "@/lib/supabase/admin"; +import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; import * as serverAuth from "@/lib/supabase/auth"; import type { RagAnswer } from "@/lib/types"; @@ -35,6 +36,8 @@ const answerSchema = z.object({ skipCache: z.boolean().optional().default(false), }); +type AnswerRequestBody = z.infer; + function answerDegradedModeSignal(answer?: Pick) { if (answer?.degradedMode) return answer.degradedMode; const active = answer?.answerQualityTier === "source_only"; @@ -44,26 +47,33 @@ function answerDegradedModeSignal(answer?: Pick, limit = 12) { const counts = new Map(); for (const raw of values) { @@ -845,47 +883,13 @@ function extractSqlState(error: unknown) { export async function POST(request: Request) { let supabase: ReturnType | null = null; let ownerId: string | null = null; + let body: SearchRequestBody | null = null; try { - const body = await parseJsonBody(request, searchSchema, "Invalid search request."); + const searchBody = await parseJsonBody(request, searchSchema, "Invalid search request."); + body = searchBody; if (isDemoMode()) { - const searchFocusQuery = queryForClinicalMode(body.query, body.queryMode); - const queryClass = queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(searchFocusQuery).queryClass; - const results = annotateSearchResults( - searchFocusQuery, - demoSearch(body.query, body.topK ?? 8, body.documentId, body.documentIds), - ); - const relevance = buildEvidenceRelevance(searchFocusQuery, results); - const documentMatches = isSourceLibrarySearchMode(body.mode) - ? annotateDocumentMatches( - searchFocusQuery, - buildDocumentMatchesFromResults(results, body.documentLimit), - results, - ) - : []; - const cachedVisualEvidence = buildVisualEvidence(results); - return NextResponse.json({ - results: compactSearchResults(searchFocusQuery, results), - facets: buildSearchFacets(results), - visualEvidence: cachedVisualEvidence, - relevance, - smartPanel: { - ...buildSmartPanel(searchFocusQuery, results, { relevance, visualEvidence: cachedVisualEvidence }), - relevance, - }, - smartApiPlan: buildSmartRagApiPlan({ - query: searchFocusQuery, - queryClass, - results, - retrievalStrategy: "hybrid", - routeMode: isSourceLibrarySearchMode(body.mode) ? undefined : "fast", - preferredResponseMode: isSourceLibrarySearchMode(body.mode) ? "document_lookup" : undefined, - }), - relatedDocuments: [], - documentMatches, - demoMode: true, - degradedMode: searchDegradedModeSignal(), - }); + return NextResponse.json(buildDemoSearchPayload(searchBody)); } supabase = createAdminClient(); @@ -906,9 +910,9 @@ export async function POST(request: Request) { ); } - const key = scopedSearchKey(body, ownerId, publicOnly); + const key = scopedSearchKey(searchBody, ownerId, publicOnly); const { payload, coalesced } = await coalesceScopedSearch(key, () => - buildScopedSearchPayload(body, supabase!, ownerId, publicOnly), + buildScopedSearchPayload(searchBody, supabase!, ownerId, publicOnly), ); return NextResponse.json({ ...payload, @@ -929,6 +933,13 @@ export async function POST(request: Request) { } if (error instanceof Error && error.message.trim()) { const code = classifySearchFailure(error); + const fallbackBody = body; + const fallbackReason = fallbackBody ? nonProductionSupabaseDemoFallbackReason(error) : null; + if (fallbackBody && fallbackReason) { + return NextResponse.json(buildDemoSearchPayload(fallbackBody, fallbackReason), { + headers: { "X-Clinical-KB-Fallback": fallbackReason }, + }); + } const failurePayload = { results: [], telemetry: { diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 593cd9cfbf..ed5e4af39e 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -9,6 +9,14 @@ export function allowRateLimitInMemoryFallbackOnUnavailable() { return isLocalNoAuthMode() || process.env.NODE_ENV === "production"; } +function allowAnonymousRateLimitFallback(bucket: ApiRateLimitBucket, allowInMemoryFallbackOnUnavailable?: boolean) { + if (allowInMemoryFallbackOnUnavailable) return true; + + // Anonymous public read/search paths must stay reachable if the durable limiter + // migration is temporarily unavailable; the per-instance limiter still applies. + return bucket === "answer" || bucket === "search" || bucket === "document_read" || bucket === "registry"; +} + export type ApiRateLimitBucket = | "answer" | "search" @@ -159,7 +167,7 @@ export async function consumeSubjectApiRateLimit(args: { }); if (error) { - if (args.allowInMemoryFallbackOnUnavailable) { + if (allowAnonymousRateLimitFallback(args.bucket, args.allowInMemoryFallbackOnUnavailable)) { console.warn("Durable anonymous API rate limit check unavailable; using local in-memory fallback.", { bucket: args.bucket, code: error.code, @@ -177,7 +185,7 @@ export async function consumeSubjectApiRateLimit(args: { const row = parseRateLimitRow(data); if (!row || typeof row.limited !== "boolean") { - if (args.allowInMemoryFallbackOnUnavailable) { + if (allowAnonymousRateLimitFallback(args.bucket, args.allowInMemoryFallbackOnUnavailable)) { return consumeInMemoryApiRateLimit({ ownerId: args.subject.subjectKey, bucket: args.bucket, diff --git a/src/lib/supabase/errors.ts b/src/lib/supabase/errors.ts new file mode 100644 index 0000000000..b69557a159 --- /dev/null +++ b/src/lib/supabase/errors.ts @@ -0,0 +1,18 @@ +function errorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + if (error && typeof error === "object" && "message" in error) { + return String((error as { message?: unknown }).message ?? ""); + } + return String(error ?? ""); +} + +export function isSupabaseApiKeyConfigurationError(error: unknown) { + return /\b(?:unregistered|invalid)\s+api\s+key\b/i.test(errorMessage(error)); +} + +export function nonProductionSupabaseDemoFallbackReason(error: unknown) { + if (process.env.NODE_ENV === "production") return null; + if (!isSupabaseApiKeyConfigurationError(error)) return null; + return "supabase_api_key_configuration_unavailable"; +} diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index d7ae5da91c..b4c6b3d101 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -391,6 +391,7 @@ function ssePayload(body: string, eventName: string) { } afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); vi.resetModules(); }); @@ -913,6 +914,29 @@ describe("private document API access", () => { }); }); + it("fails closed for anonymous uploads when the durable anonymous limiter is unavailable", async () => { + const publicOwnerId = "99999999-9999-4999-8999-999999999999"; + const client = createSupabaseMock(); + client.rpc.mockImplementation(async (name: string) => + name === "consume_api_subject_rate_limit" ? fail("anonymous limiter table unavailable") : 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(503); + expect(await payload(response)).toEqual({ error: "Rate limit check is temporarily unavailable." }); + expect(client.storageMocks.upload).not.toHaveBeenCalled(); + }); + 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") { @@ -3217,6 +3241,132 @@ describe("private document API access", () => { expect(searchChunksWithTelemetry).not.toHaveBeenCalled(); }); + it("uses an anonymous in-memory limiter for public search when the durable anonymous limiter is unavailable", 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(); + client.rpc.mockImplementation(async (name: string) => + name === "consume_api_subject_rate_limit" ? fail("anonymous limiter table unavailable") : ok([]), + ); + mockRuntime(client, { searchChunksWithTelemetry }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + request("/api/search", { + method: "POST", + headers: { "x-forwarded-for": "203.0.113.21" }, + body: JSON.stringify({ query: "monitoring", includeRelatedDocuments: false }), + }), + ); + + expect(response.status).toBe(200); + expect(searchChunksWithTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }), + ); + expect(client.auth.getUser).not.toHaveBeenCalled(); + }); + + it("falls back to visible demo search only outside production when Supabase rejects the API key", async () => { + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [], + telemetry: { retrieval_strategy: "text_fast_path" }, + })); + const client = createSupabaseMock((call) => + call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]), + ); + mockRuntime(client, { searchChunksWithTelemetry }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + request("/api/search", { + method: "POST", + body: JSON.stringify({ query: "clozapine monitoring", includeRelatedDocuments: false }), + }), + ); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(response.headers.get("X-Clinical-KB-Fallback")).toBe("supabase_api_key_configuration_unavailable"); + expect(body).toMatchObject({ + demoMode: true, + fallbackMode: "non_production_demo", + fallbackReason: "supabase_api_key_configuration_unavailable", + degradedMode: { active: true, reason: "supabase_api_key_configuration_unavailable" }, + }); + expect(Array.isArray(body.results) ? body.results.length : 0).toBeGreaterThan(0); + expect(searchChunksWithTelemetry).not.toHaveBeenCalled(); + }); + + it("does not fall back to demo search in production when Supabase rejects the API key", async () => { + vi.stubEnv("NODE_ENV", "production"); + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [], + telemetry: { retrieval_strategy: "text_fast_path" }, + })); + const client = createSupabaseMock((call) => + call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]), + ); + mockRuntime(client, { searchChunksWithTelemetry }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + request("/api/search", { + method: "POST", + body: JSON.stringify({ query: "clozapine monitoring", includeRelatedDocuments: false }), + }), + ); + + expect(response.status).toBe(500); + expect(response.headers.get("X-Clinical-KB-Fallback")).toBeNull(); + expect(await payload(response)).toEqual({ error: "Search failed. Retry with a narrower question." }); + expect(searchChunksWithTelemetry).not.toHaveBeenCalled(); + }); + + it("falls back to visible demo answers only outside production when Supabase rejects the API key", async () => { + const answerQuestionWithScope = vi.fn(async () => ({ + answer: "Live answer", + grounded: true, + confidence: "medium", + citations: [], + sources: [], + })); + const client = createSupabaseMock((call) => + call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]), + ); + mockRuntime(client, { answerQuestionWithScope }); + const { POST } = await import("../src/app/api/answer/route"); + + const response = await POST( + request("/api/answer", { + method: "POST", + body: JSON.stringify({ query: "clozapine monitoring" }), + }), + ); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(response.headers.get("X-Clinical-KB-Fallback")).toBe("supabase_api_key_configuration_unavailable"); + expect(body).toMatchObject({ + demoMode: true, + fallbackMode: "non_production_demo", + fallbackReason: "supabase_api_key_configuration_unavailable", + degradedMode: { active: true, reason: "supabase_api_key_configuration_unavailable" }, + }); + expect(String(body.answer)).toContain("Synthetic"); + expect(answerQuestionWithScope).not.toHaveBeenCalled(); + }); + it("uses an anonymous in-memory limiter for managed local no-auth search", async () => { const searchChunksWithTelemetry = vi.fn(async () => ({ results: [],