From a7c45eb4666933c43aaeba834a39cef7108df530 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:13:21 +0800 Subject: [PATCH 1/7] fix: implement first planned fix batch From 870b0ca1b9f60a51916ae9be0296db0970b6f979 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:14:30 +0800 Subject: [PATCH 2/7] fix: implement first planned fix batch --- src/app/api/answer/route.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 736ab1a1d..95811ff3d 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -7,6 +7,8 @@ import { jsonError, PublicApiError } from "@/lib/http"; import { consumePublicAnswerRateLimit } from "@/lib/public-rate-limit"; import { classifyRagQuery } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; +import { createAdminClient } from "@/lib/supabase/admin"; +import * as serverAuth from "@/lib/supabase/auth"; export const runtime = "nodejs"; @@ -36,6 +38,9 @@ export async function POST(request: Request) { }); } + const supabase = createAdminClient(); + const user = await serverAuth.requireAuthenticatedUser(request, supabase); + const rateLimit = consumePublicAnswerRateLimit(request.headers); if (rateLimit.limited) { return NextResponse.json( @@ -48,10 +53,13 @@ export async function POST(request: Request) { query: body.query, documentId: body.documentId, documentIds: body.documentIds, - ownerId: undefined, + ownerId: user.id, }); return NextResponse.json(answer); } catch (error) { + if (error instanceof serverAuth.AuthenticationError) { + return serverAuth.unauthorizedResponse(error); + } if (error instanceof z.ZodError) { return jsonError(error, 400); } From 8ae342ef8b43ebdd3c4075b0908fc0a0979328f5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:16:35 +0800 Subject: [PATCH 3/7] fix: implement first planned fix batch From 5952f1a34642e7d2bda05462b237eca01ab7e943 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:18:15 +0800 Subject: [PATCH 4/7] fix: implement first planned fix batch --- src/app/api/answer/stream/route.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 9b8ea37a5..d20f6f718 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -7,6 +7,8 @@ import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag"; import { classifyRagQuery } from "@/lib/clinical-search"; import { annotateSearchResults, buildEvidenceRelevance } from "@/lib/evidence-relevance"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { requireAuthenticatedUser } from "@/lib/supabase/auth"; export const runtime = "nodejs"; @@ -132,11 +134,17 @@ export async function POST(request: Request) { const body = answerSchema.parse(await request.json()); if (isDemoMode()) return streamAnswer(body); + const supabase = createAdminClient(); + const user = await requireAuthenticatedUser(request, supabase); + const rateLimit = consumePublicAnswerRateLimit(request.headers); if (rateLimit.limited) return rateLimitStream(rateLimit); - return streamAnswer(body); + return streamAnswer(body, user.id); } catch (error) { + if (error instanceof Error && error.name === "AuthenticationError") { + return Response.json({ error: "Authentication required." }, { status: 401 }); + } if (error instanceof z.ZodError) { return jsonError(error, 400); } From a732265caba81708c5002eb474efe55ba7e0de0a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:23:57 +0800 Subject: [PATCH 5/7] fix: implement first planned fix batch --- src/app/api/search/route.ts | 131 +++++++++++++++++++++--------------- 1 file changed, 77 insertions(+), 54 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index d37dadd12..b408975ba 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -11,6 +11,7 @@ import { searchChunksWithTelemetry } from "@/lib/rag"; import { classifyRagQuery, normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { createAdminClient } from "@/lib/supabase/admin"; +import * as serverAuth from "@/lib/supabase/auth"; import { consumePublicSearchRateLimit } from "@/lib/public-rate-limit"; import type { ChunkImage, ClinicalSourceMetadata, SearchResult } from "@/lib/types"; @@ -28,10 +29,11 @@ const searchSchema = z.object({ type SearchRequestBody = z.infer; -const publicSearchInflight = new Map>(); +const scopedSearchInflight = new Map>(); -function publicSearchKey(body: SearchRequestBody) { +function scopedSearchKey(body: SearchRequestBody, ownerId: string) { return JSON.stringify({ + ownerId, query: body.query.toLowerCase().replace(/\s+/g, " ").trim(), topK: body.topK ?? null, documentId: body.documentId ?? null, @@ -42,14 +44,14 @@ function publicSearchKey(body: SearchRequestBody) { }); } -async function coalescePublicSearch>(key: string, producer: () => Promise) { - const existing = publicSearchInflight.get(key) as Promise | undefined; +async function coalesceScopedSearch>(key: string, producer: () => Promise) { + const existing = scopedSearchInflight.get(key) as Promise | undefined; if (existing) return { payload: await existing, coalesced: true }; const pending = producer().finally(() => { - publicSearchInflight.delete(key); + scopedSearchInflight.delete(key); }); - publicSearchInflight.set(key, pending); + scopedSearchInflight.set(key, pending); return { payload: await pending, coalesced: false }; } @@ -294,6 +296,7 @@ function candidatePromotions(query: string, results: SearchResult[]) { function logWeakSearch(args: { supabase: ReturnType; + ownerId: string; query: string; queryClass: string; route?: string | null; @@ -317,7 +320,7 @@ function logWeakSearch(args: { void args.supabase .from("rag_query_misses") .insert({ - owner_id: null, + owner_id: args.ownerId, query: args.query, normalized_query: args.query.toLowerCase().replace(/\s+/g, " ").trim(), query_class: args.queryClass, @@ -356,7 +359,9 @@ function latencyBucket(ms: number) { return "gte_5000ms"; } -function logPublicSearchObservation(args: { +function logSearchObservation(args: { + supabase: ReturnType; + ownerId: string; query: string; results: SearchResult[]; payload: Record; @@ -371,48 +376,49 @@ function logPublicSearchObservation(args: { const payloadBytes = Buffer.byteLength(JSON.stringify(args.payload), "utf8"); const topResult = args.results[0] ?? null; const latencyMs = telemetryLatencyMs(telemetry); - await createAdminClient() - .from("rag_queries") - .insert({ - owner_id: null, - query: args.query, - answer: null, - source_chunk_ids: args.results.map((result) => result.id), - model: "search", - metadata: { - event_type: args.failure ? "public_search_failure" : "public_search", - failure_code: args.failure?.code ?? null, - failure_cause_name: args.failure?.causeName ?? null, - failure_cause_message: args.failure?.causeMessage ?? null, - failure_sql_state: args.failure?.sqlState ?? null, - query_class: telemetry.query_class ?? null, - retrieval_strategy: telemetry.retrieval_strategy ?? null, - payload_bytes: payloadBytes, - result_count: args.results.length, - top_document_id: topResult?.document_id ?? null, - top_document_title: topResult?.title ?? null, - top_file_name: topResult?.file_name ?? null, - top_score: topResult ? (topResult.hybrid_score ?? topResult.similarity ?? null) : null, - latency_ms: latencyMs, - latency_bucket: latencyBucket(latencyMs), - search_cache_hit: telemetry.search_cache_hit ?? null, - embedding_skipped: telemetry.embedding_skipped ?? null, - }, - }); + await args.supabase.from("rag_queries").insert({ + owner_id: args.ownerId, + query: args.query, + answer: null, + source_chunk_ids: args.results.map((result) => result.id), + model: "search", + metadata: { + event_type: args.failure ? "private_search_failure" : "private_search", + failure_code: args.failure?.code ?? null, + failure_cause_name: args.failure?.causeName ?? null, + failure_cause_message: args.failure?.causeMessage ?? null, + failure_sql_state: args.failure?.sqlState ?? null, + query_class: telemetry.query_class ?? null, + retrieval_strategy: telemetry.retrieval_strategy ?? null, + payload_bytes: payloadBytes, + result_count: args.results.length, + top_document_id: topResult?.document_id ?? null, + top_document_title: topResult?.title ?? null, + top_file_name: topResult?.file_name ?? null, + top_score: topResult ? (topResult.hybrid_score ?? topResult.similarity ?? null) : null, + latency_ms: latencyMs, + latency_bucket: latencyBucket(latencyMs), + search_cache_hit: telemetry.search_cache_hit ?? null, + embedding_skipped: telemetry.embedding_skipped ?? null, + }, + }); } catch { // Search telemetry must not affect the user-facing search path. } })(); } -async function buildPublicSearchPayload(body: SearchRequestBody) { - const supabase = createAdminClient(); +async function buildScopedSearchPayload( + body: SearchRequestBody, + supabase: ReturnType, + ownerId: string, +) { const search = await searchChunksWithTelemetry({ query: body.query, topK: body.mode === "documents" ? Math.max(body.topK ?? 12, Math.min(20, body.documentLimit)) : (body.topK ?? 8), documentId: body.documentId, documentIds: body.documentIds, - ownerId: undefined, + ownerId, }); const resultLimit = body.mode === "documents" ? Math.max(body.topK ?? 12, Math.min(20, body.documentLimit)) : (body.topK ?? 8); @@ -421,7 +427,7 @@ async function buildPublicSearchPayload(body: SearchRequestBody) { const relatedDocuments = body.includeRelatedDocuments ? await fetchRelatedDocuments({ supabase, - ownerId: undefined, + ownerId, query: body.query, results, limit: body.mode === "documents" ? body.documentLimit : undefined, @@ -444,6 +450,7 @@ async function buildPublicSearchPayload(body: SearchRequestBody) { }); logWeakSearch({ supabase, + ownerId, query: body.query, queryClass, route: body.mode, @@ -497,7 +504,7 @@ async function buildPublicSearchPayload(body: SearchRequestBody) { rrf_top_score: search.telemetry.rrf_top_score, }, }; - logPublicSearchObservation({ query: body.query, results, payload }); + logSearchObservation({ supabase, ownerId, query: body.query, results, payload }); return payload; } @@ -531,6 +538,9 @@ function extractSqlState(error: unknown) { } export async function POST(request: Request) { + let supabase: ReturnType | null = null; + let ownerId: string | null = null; + try { const body = searchSchema.parse(await request.json()); if (isDemoMode()) { @@ -564,6 +574,10 @@ export async function POST(request: Request) { }); } + supabase = createAdminClient(); + const user = await serverAuth.requireAuthenticatedUser(request, supabase); + ownerId = user.id; + const rateLimit = consumePublicSearchRateLimit(request.headers); if (rateLimit.limited) { return NextResponse.json( @@ -580,8 +594,10 @@ export async function POST(request: Request) { ); } - const key = publicSearchKey(body); - const { payload, coalesced } = await coalescePublicSearch(key, () => buildPublicSearchPayload(body)); + const key = scopedSearchKey(body, ownerId); + const { payload, coalesced } = await coalesceScopedSearch(key, () => + buildScopedSearchPayload(body, supabase!, ownerId!), + ); return NextResponse.json({ ...payload, telemetry: { @@ -590,6 +606,9 @@ export async function POST(request: Request) { }, }); } catch (error) { + if (error instanceof serverAuth.AuthenticationError) { + return serverAuth.unauthorizedResponse(error); + } if (error instanceof z.ZodError) { return jsonError(error, 400); } @@ -603,17 +622,21 @@ export async function POST(request: Request) { failure_code: code, }, }; - logPublicSearchObservation({ - query: "unknown", - results: [], - payload: failurePayload, - failure: { - code, - causeName: error.name, - causeMessage: error.message, - sqlState: extractSqlState(error), - }, - }); + if (ownerId && supabase) { + logSearchObservation({ + supabase, + ownerId, + query: "unknown", + results: [], + payload: failurePayload, + failure: { + code, + causeName: error.name, + causeMessage: error.message, + sqlState: extractSqlState(error), + }, + }); + } return jsonError( new PublicApiError("Search failed. Retry with a narrower question.", 500, { code, From 971ebe4bed3e601e7c61758d14e70908bf37aaa0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:31:01 +0800 Subject: [PATCH 6/7] fix: implement first planned fix batch --- tests/private-rag-access.test.ts | 306 +++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/private-rag-access.test.ts diff --git a/tests/private-rag-access.test.ts b/tests/private-rag-access.test.ts new file mode 100644 index 000000000..196c4786e --- /dev/null +++ b/tests/private-rag-access.test.ts @@ -0,0 +1,306 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const documentId = "11111111-1111-4111-8111-111111111111"; +const chunkId = "22222222-2222-4222-8222-222222222222"; + +const allowedRateLimit = { + limited: false, + limit: 100, + remaining: 99, + retryAfterSeconds: 0, + resetAt: Date.now() + 60_000, +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function createSupabaseMock() { + const inserts: Array<{ table: string; payload: unknown }> = []; + const from = vi.fn((table: string) => ({ + insert: vi.fn(async (payload: unknown) => { + inserts.push({ table, payload }); + return { data: null, error: null }; + }), + })); + + return { from, inserts }; +} + +function sampleSearchResult() { + return { + id: chunkId, + document_id: documentId, + title: "Clozapine monitoring guideline", + file_name: "clozapine.pdf", + page_number: 1, + chunk_index: 0, + section_heading: "Monitoring", + section_path: ["Monitoring"], + heading_level: 1, + parent_heading: null, + anchor_id: null, + content: "Clozapine monitoring source text.", + image_ids: [], + similarity: 0.92, + text_rank: 0.8, + hybrid_score: 0.93, + rrf_score: 0.03, + source_strength: "strong", + source_metadata: { + document_status: "current", + clinical_validation_status: "unverified", + extraction_quality: "good", + }, + document_labels: [], + images: [], + }; +} + +function mockRuntime(options: { demoMode?: boolean } = {}) { + vi.resetModules(); + + class MockAuthenticationError extends Error { + constructor() { + super("Authentication required."); + this.name = "AuthenticationError"; + } + } + + const supabase = createSupabaseMock(); + const createAdminClient = vi.fn(() => supabase); + const requireAuthenticatedUser = vi.fn(async (request: Request) => { + const id = request.headers.get("x-test-user"); + if (!id) throw new MockAuthenticationError(); + return { id }; + }); + const unauthorizedResponse = vi.fn(() => Response.json({ error: "Authentication required." }, { status: 401 })); + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [sampleSearchResult()], + telemetry: { + query_class: "document_lookup", + retrieval_strategy: "hybrid", + search_cache_hit: false, + embedding_skipped: false, + embedding_cache_hit: false, + text_fast_path_latency_ms: 0, + embedding_latency_ms: 0, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, + memory_card_count: 0, + memory_top_score: 0, + weighted_top_score: 0.93, + rrf_top_score: 0.03, + }, + })); + const answerQuestionWithScope = vi.fn(async () => ({ + answer: "Source-backed answer.", + grounded: true, + confidence: "high", + citations: [], + sources: [], + })); + const fetchRelatedDocuments = vi.fn(async () => []); + const demoSearch = vi.fn(() => []); + const demoAnswer = vi.fn(() => ({ + answer: "Demo answer.", + grounded: true, + confidence: "high", + citations: [], + sources: [], + routingMode: "fast", + })); + + vi.doMock("@/lib/env", () => ({ + env: {}, + isDemoMode: () => Boolean(options.demoMode), + isLocalNoAuthMode: () => false, + })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError: MockAuthenticationError, + requireAuthenticatedUser, + unauthorizedResponse, + })); + vi.doMock("@/lib/public-rate-limit", () => ({ + consumePublicAnswerRateLimit: vi.fn(() => allowedRateLimit), + consumePublicSearchRateLimit: vi.fn(() => allowedRateLimit), + })); + vi.doMock("@/lib/demo-data", () => ({ demoAnswer, demoSearch })); + vi.doMock("@/lib/rag", () => ({ answerQuestionWithScope, searchChunksWithTelemetry })); + vi.doMock("@/lib/document-enrichment", () => ({ + fetchRelatedDocuments, + toDocumentMatch: vi.fn((document: unknown) => document), + })); + vi.doMock("@/lib/evidence", () => ({ + buildSmartPanel: vi.fn(() => ({})), + buildVisualEvidence: vi.fn(() => []), + diversifySearchResults: vi.fn((results: unknown[]) => results), + })); + vi.doMock("@/lib/evidence-relevance", () => ({ + annotateDocumentMatches: vi.fn((_query: string, documents: unknown[]) => documents), + annotateSearchResults: vi.fn((_query: string, results: unknown[]) => results), + buildEvidenceRelevance: vi.fn(() => ({ + verdict: "direct", + score: 0.95, + directSourceCount: 1, + weakSourceCount: 0, + isSourceBacked: true, + matchedTerms: [], + missingTerms: [], + chips: [], + })), + })); + vi.doMock("@/lib/clinical-search", () => ({ + classifyRagQuery: vi.fn(() => ({ queryClass: "document_lookup" })), + normalizedClinicalSearchTokens: vi.fn((query: string) => query.toLowerCase().split(/\s+/).filter(Boolean)), + })); + vi.doMock("@/lib/smart-rag-api", () => ({ + buildSmartRagApiPlan: vi.fn(() => ({ + intent: "answer", + responseMode: "clinical_answer", + displayMode: "clinical_answer", + sourceLinkCount: 0, + })), + })); + vi.doMock("@/lib/image-filtering", () => ({ + isClinicalImageEvidence: vi.fn(() => true), + })); + + return { + answerQuestionWithScope, + createAdminClient, + demoAnswer, + demoSearch, + fetchRelatedDocuments, + requireAuthenticatedUser, + searchChunksWithTelemetry, + supabase, + }; +} + +function jsonRequest(path: string, body: Record, authenticated = false) { + const headers = new Headers({ "Content-Type": "application/json" }); + if (authenticated) headers.set("x-test-user", ownerId); + + return new Request(`http://localhost${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + +async function payload(response: Response) { + return (await response.json()) as Record; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("private RAG API access", () => { + it("rejects unauthenticated real search requests before retrieval", async () => { + const mocks = mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "clozapine monitoring" })); + + expect(response.status).toBe(401); + expect(await payload(response)).toEqual({ error: "Authentication required." }); + expect(mocks.searchChunksWithTelemetry).not.toHaveBeenCalled(); + expect(mocks.fetchRelatedDocuments).not.toHaveBeenCalled(); + }); + + it("scopes authenticated real search requests to the authenticated owner", async () => { + const mocks = mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "clozapine monitoring" }, true)); + + expect(response.status).toBe(200); + expect(mocks.searchChunksWithTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ ownerId, query: "clozapine monitoring" }), + ); + expect(mocks.fetchRelatedDocuments).toHaveBeenCalledWith( + expect.objectContaining({ ownerId, query: "clozapine monitoring" }), + ); + expect( + mocks.supabase.inserts.some(({ payload: insertPayload }) => + isRecord(insertPayload) && insertPayload.owner_id === ownerId, + ), + ).toBe(true); + }); + + it("keeps demo search anonymous", async () => { + const mocks = mockRuntime({ demoMode: true }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "demo question" })); + + expect(response.status).toBe(200); + expect(mocks.createAdminClient).not.toHaveBeenCalled(); + expect(mocks.searchChunksWithTelemetry).not.toHaveBeenCalled(); + expect(mocks.demoSearch).toHaveBeenCalledWith("demo question", 8, undefined, undefined); + }); + + it("rejects unauthenticated real answer requests before generation", async () => { + const mocks = mockRuntime(); + const { POST } = await import("../src/app/api/answer/route"); + + const response = await POST(jsonRequest("/api/answer", { query: "clozapine monitoring" })); + + expect(response.status).toBe(401); + expect(await payload(response)).toEqual({ error: "Authentication required." }); + expect(mocks.answerQuestionWithScope).not.toHaveBeenCalled(); + }); + + it("scopes authenticated real answer requests to the authenticated owner", async () => { + const mocks = mockRuntime(); + const { POST } = await import("../src/app/api/answer/route"); + + const response = await POST(jsonRequest("/api/answer", { query: "clozapine monitoring" }, true)); + + expect(response.status).toBe(200); + expect(mocks.answerQuestionWithScope).toHaveBeenCalledWith( + expect.objectContaining({ ownerId, query: "clozapine monitoring" }), + ); + }); + + it("keeps demo answer anonymous", async () => { + const mocks = mockRuntime({ demoMode: true }); + const { POST } = await import("../src/app/api/answer/route"); + + const response = await POST(jsonRequest("/api/answer", { query: "demo question" })); + + expect(response.status).toBe(200); + expect(mocks.createAdminClient).not.toHaveBeenCalled(); + expect(mocks.answerQuestionWithScope).not.toHaveBeenCalled(); + expect(mocks.demoAnswer).toHaveBeenCalledWith("demo question", undefined, undefined); + }); + + it("rejects unauthenticated real answer streams before generation", async () => { + const mocks = mockRuntime(); + const { POST } = await import("../src/app/api/answer/stream/route"); + + const response = await POST(jsonRequest("/api/answer/stream", { query: "clozapine monitoring" })); + + expect(response.status).toBe(401); + expect(await payload(response)).toEqual({ error: "Authentication required." }); + expect(mocks.answerQuestionWithScope).not.toHaveBeenCalled(); + }); + + it("scopes authenticated real answer streams to the authenticated owner", async () => { + const mocks = mockRuntime(); + const { POST } = await import("../src/app/api/answer/stream/route"); + + const response = await POST(jsonRequest("/api/answer/stream", { query: "clozapine monitoring" }, true)); + await response.text(); + + expect(response.status).toBe(200); + expect(mocks.answerQuestionWithScope).toHaveBeenCalledWith( + expect.objectContaining({ ownerId, query: "clozapine monitoring" }), + ); + }); +}); From ae29521c5795c37c5b767fed7614bbb9dac1b9ff Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:22:50 +0800 Subject: [PATCH 7/7] fix: gate private search UI and eval auth --- scripts/eval-search-api.ts | 27 ++++++++++++++- src/components/ClinicalDashboard.tsx | 7 ++-- tests/private-access-routes.test.ts | 50 ++++++++++++++-------------- tests/ui-smoke.spec.ts | 32 +++++++++++++++--- 4 files changed, 81 insertions(+), 35 deletions(-) diff --git a/scripts/eval-search-api.ts b/scripts/eval-search-api.ts index 4d7c5a716..be425aab0 100644 --- a/scripts/eval-search-api.ts +++ b/scripts/eval-search-api.ts @@ -6,6 +6,7 @@ loadEnvConfig(process.cwd()); type Args = { baseUrl?: string; + authToken?: string; limit?: number; question?: string; json: boolean; @@ -42,6 +43,7 @@ function parseArgs(argv: string[]): Args { if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); index += 1; if (token === "--base-url") args.baseUrl = value.replace(/\/+$/, ""); + if (token === "--auth-token") args.authToken = value; if (token === "--limit") args.limit = Number.parseInt(value, 10); if (token === "--question") args.question = value; } @@ -56,6 +58,19 @@ function ensuredBaseUrl() { }).trim(); } +function envAuthToken() { + return ( + process.env.RAG_EVAL_API_AUTH_TOKEN?.trim() || + process.env.RAG_EVAL_AUTH_TOKEN?.trim() || + process.env.SUPABASE_ACCESS_TOKEN?.trim() || + "" + ); +} + +function isLocalNoAuthEval() { + return process.env.LOCAL_NO_AUTH === "true" || process.env.NEXT_PUBLIC_LOCAL_NO_AUTH === "true"; +} + function validate(result: ApiSearchEvalResult, testCase: ReturnType[number]) { const failures: string[] = []; if (!result.ok) failures.push(`HTTP ${result.status}`); @@ -72,13 +87,17 @@ function validate(result: ApiSearchEvalResult, testCase: ReturnType result.failures.length > 0).map((result) => result.id); + const unauthorized = results.some((result) => result.status === 401); if (args.json) console.log(JSON.stringify({ baseUrl, results, thresholdFailures }, null, 2)); + if (unauthorized && !authToken && !isLocalNoAuthEval()) { + console.error( + "API search eval was rejected by the real-mode auth gate. Pass --auth-token or set RAG_EVAL_API_AUTH_TOKEN, RAG_EVAL_AUTH_TOKEN, or SUPABASE_ACCESS_TOKEN to a Supabase access token, or run the ensured local server with LOCAL_NO_AUTH=true and NEXT_PUBLIC_LOCAL_NO_AUTH=true.", + ); + } if (args.failOnThreshold && thresholdFailures.length > 0) process.exit(1); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d8a07430a..c934cd657 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3543,12 +3543,12 @@ export function ClinicalDashboard() { const supabaseEnvStatus = setupChecks.find((check) => check.id === "env")?.status; const browserAuthUnavailableDemoFallback = !auth.isConfigured && supabaseEnvStatus !== "ready"; const localNoAuthMode = isLocalNoAuthMode(); - const clientDemoMode = - demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true" || browserAuthUnavailableDemoFallback || localNoAuthMode; + const explicitDemoMode = demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true"; + const clientDemoMode = explicitDemoMode || browserAuthUnavailableDemoFallback || localNoAuthMode; const uploadReadOnlyMode = demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true" || browserAuthUnavailableDemoFallback; const canUsePrivateApis = localProjectReady && (localNoAuthMode || authStatus === "authenticated"); - const canRunSearch = clientDemoMode || hasReadyPublicSearchSetup(setupChecks); + const canRunSearch = explicitDemoMode || (hasReadyPublicSearchSetup(setupChecks) && canUsePrivateApis); const openGuide = useCallback(() => setGuideOpen(true), []); const closeGuide = useCallback(() => setGuideOpen(false), []); @@ -4652,4 +4652,3 @@ export function ClinicalDashboard() { ); } - diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 939e02cf0..f77f00785 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -661,7 +661,7 @@ describe("private document API access", () => { const { POST } = await import("../src/app/api/search/route"); const response = await POST( - request("/api/search", { + authenticatedRequest("/api/search", { method: "POST", body: JSON.stringify({ query: "agitation management tables", mode: "documents", documentLimit: 10 }), }), @@ -1147,7 +1147,7 @@ describe("private document API access", () => { expect(client.storageMocks.remove).not.toHaveBeenCalled(); }); - it("allows unauthenticated search and answer without owner scope", async () => { + it("rejects unauthenticated search and answer requests", async () => { const searchChunksWithTelemetry = vi.fn(async () => ({ results: [], telemetry: { @@ -1174,29 +1174,29 @@ describe("private document API access", () => { const searchRoute = await import("../src/app/api/search/route"); const answerRoute = await import("../src/app/api/answer/route"); - await searchRoute.POST( + const searchResponse = await searchRoute.POST( request("/api/search", { method: "POST", body: JSON.stringify({ query: "monitoring", documentIds: [otherDocumentId] }), }), ); - await answerRoute.POST( + const answerResponse = await answerRoute.POST( request("/api/answer", { method: "POST", body: JSON.stringify({ query: "monitoring", documentId: otherDocumentId }), }), ); - expect(searchChunksWithTelemetry).toHaveBeenCalledWith( - expect.objectContaining({ ownerId: undefined, documentIds: [otherDocumentId] }), - ); - expect(answerQuestionWithScope).toHaveBeenCalledWith( - expect.objectContaining({ ownerId: undefined, documentId: otherDocumentId }), - ); + expect(searchResponse.status).toBe(401); + expect(answerResponse.status).toBe(401); + expect(await payload(searchResponse)).toEqual({ error: "Authentication required." }); + expect(await payload(answerResponse)).toEqual({ error: "Authentication required." }); + expect(searchChunksWithTelemetry).not.toHaveBeenCalled(); + expect(answerQuestionWithScope).not.toHaveBeenCalled(); expect(client.auth.getUser).not.toHaveBeenCalled(); }); - it("rate limits public answer generation without limiting public search", async () => { + it("rate limits authenticated answer generation without limiting authenticated search", async () => { const searchChunksWithTelemetry = vi.fn(async () => ({ results: [], telemetry: { @@ -1223,7 +1223,7 @@ describe("private document API access", () => { const answerRoute = await import("../src/app/api/answer/route"); const searchRoute = await import("../src/app/api/search/route"); const answerRequest = () => - request("/api/answer", { + authenticatedRequest("/api/answer", { method: "POST", headers: { "x-forwarded-for": "203.0.113.10" }, body: JSON.stringify({ query: "monitoring" }), @@ -1241,7 +1241,7 @@ describe("private document API access", () => { expect(answerQuestionWithScope).toHaveBeenCalledTimes(30); const searchResponse = await searchRoute.POST( - request("/api/search", { + authenticatedRequest("/api/search", { method: "POST", headers: { "x-forwarded-for": "203.0.113.10" }, body: JSON.stringify({ query: "monitoring" }), @@ -1249,10 +1249,10 @@ describe("private document API access", () => { ); expect(searchResponse.status).toBe(200); - expect(searchChunksWithTelemetry).toHaveBeenCalledWith(expect.objectContaining({ ownerId: undefined })); + expect(searchChunksWithTelemetry).toHaveBeenCalledWith(expect.objectContaining({ ownerId: userId })); }); - it("rate limits abnormal public search bursts with retry metadata", async () => { + it("rate limits abnormal authenticated search bursts with retry metadata", async () => { const searchChunksWithTelemetry = vi.fn(async () => ({ results: [], telemetry: { @@ -1280,7 +1280,7 @@ describe("private document API access", () => { }); const searchRoute = await import("../src/app/api/search/route"); const searchRequest = () => - request("/api/search", { + authenticatedRequest("/api/search", { method: "POST", headers: { "x-forwarded-for": "203.0.113.20" }, body: JSON.stringify({ query: "monitoring", includeRelatedDocuments: false }), @@ -1299,7 +1299,7 @@ describe("private document API access", () => { expect(searchChunksWithTelemetry).toHaveBeenCalledTimes(2); }); - it("coalesces identical in-flight public search requests", async () => { + it("coalesces identical in-flight authenticated search requests", async () => { let releaseSearch!: () => void; const searchGate = new Promise((resolve) => { releaseSearch = resolve; @@ -1326,7 +1326,7 @@ describe("private document API access", () => { mockRuntime(client, { searchChunksWithTelemetry }); const searchRoute = await import("../src/app/api/search/route"); const searchRequest = () => - request("/api/search", { + authenticatedRequest("/api/search", { method: "POST", headers: { "x-real-ip": "203.0.113.21" }, body: JSON.stringify({ query: "monitoring", includeRelatedDocuments: false }), @@ -1350,7 +1350,7 @@ describe("private document API access", () => { expect(secondPayload.telemetry).toMatchObject({ coalesced: true }); }); - it("streams answer progress before the final answer without auth", async () => { + it("streams answer progress before the final answer for authenticated users", async () => { const answerQuestionWithScope = vi.fn(async (args: { onProgress?: (event: unknown) => void | Promise }) => { await args.onProgress?.({ stage: "retrieved", message: "Retrieved 2 candidate sources." }); return { @@ -1366,7 +1366,7 @@ describe("private document API access", () => { const { POST } = await import("../src/app/api/answer/stream/route"); const response = await POST( - request("/api/answer/stream", { + authenticatedRequest("/api/answer/stream", { method: "POST", body: JSON.stringify({ query: "monitoring", documentId: otherDocumentId }), }), @@ -1378,12 +1378,12 @@ describe("private document API access", () => { expect(body.indexOf("event: final")).toBeGreaterThan(body.indexOf("event: progress")); expect(body).toContain("Retrieved 2 candidate sources."); expect(answerQuestionWithScope).toHaveBeenCalledWith( - expect.objectContaining({ ownerId: undefined, documentId: otherDocumentId, onProgress: expect.any(Function) }), + expect.objectContaining({ ownerId: userId, documentId: otherDocumentId, onProgress: expect.any(Function) }), ); - expect(client.auth.getUser).not.toHaveBeenCalled(); + expect(client.auth.getUser).toHaveBeenCalledWith(token); }); - it("emits a structured SSE error when public streaming answers are rate limited", async () => { + it("emits a structured SSE error when authenticated streaming answers are rate limited", async () => { const answerQuestionWithScope = vi.fn(async () => ({ answer: "Owned evidence.", grounded: true, @@ -1397,7 +1397,7 @@ describe("private document API access", () => { const answerRoute = await import("../src/app/api/answer/route"); const streamRoute = await import("../src/app/api/answer/stream/route"); const answerRequest = () => - request("/api/answer", { + authenticatedRequest("/api/answer", { method: "POST", headers: { "x-real-ip": "203.0.113.11" }, body: JSON.stringify({ query: "monitoring" }), @@ -1409,7 +1409,7 @@ describe("private document API access", () => { } const response = await streamRoute.POST( - request("/api/answer/stream", { + authenticatedRequest("/api/answer/stream", { method: "POST", headers: { "x-real-ip": "203.0.113.11" }, body: JSON.stringify({ query: "monitoring", documentId: otherDocumentId }), diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 7747c1cc6..d0d403be4 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -490,18 +490,40 @@ test.describe("Clinical KB UI smoke coverage", () => { }); } - test("private mode unauthenticated dashboard allows public search", async ({ page }) => { + test("private mode unauthenticated dashboard gates real-mode search", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); + const answerRequests: string[] = []; + await page.route(/\/api\/local-project-id$/, async (route) => { + await route.fulfill({ + json: { + appName: "Clinical KB", + projectId: "test-project", + identityPath: "/api/local-project-id", + localServer: { + currentUrl: "http://localhost:4298", + currentPort: 4298, + projectPortStart: 4298, + projectPortEnd: 53210, + safeLocalOrigin: false, + requestOrigin: null, + requestReferer: null, + unsafeLocalCaller: "http://localhost:3000", + }, + }, + }); + }); await mockPrivateUnauthenticatedApi(page); + await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => { + answerRequests.push(route.request().url()); + await route.fulfill({ status: 401, json: { error: "Authentication required." } }); + }); await gotoApp(page, "/"); const questionInput = page.getByLabel("Search indexed guidelines by question or keyword"); await questionInput.fill("lithium monitoring"); - await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toBeEnabled(); - await page.getByRole("button", { name: "Generate source-backed answer" }).click(); - await expect(page.getByTestId("clinical-action-view")).toBeVisible(); + await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toBeDisabled(); await expect(page.getByTestId("answer-grounding-chip")).toHaveCount(0); - await expect(page.getByText("Sign in before searching private guideline documents")).toHaveCount(0); + expect(answerRequests).toEqual([]); await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toBeVisible(); await expectDomIntegrity(page, { mobileNav: true }); await expectNoPageHorizontalOverflow(page);