From 710794ddecd46db44f949c544c580813a90cf644 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:22:18 +0800 Subject: [PATCH] fix(search): stop /api/search/universal serving demo docs to live public users The universal typeahead route short-circuited anonymous (no-cookie) and owner-less callers to runUniversalSearch({ demo: true }), which serves the synthetic demo corpus (demoSearch) for the documents domain instead of the real indexed public corpus. In production isDemoMode() is false, so every unauthenticated visitor hitting the typeahead was shown demo documents while /api/search and /api/answer served them the real public corpus. Drop both demo short-circuits so non-demo callers always run the live pipeline (demo:false + admin client). Anonymous callers resolve to ownerId undefined -> allowGlobalSearch, scoping the documents domain to the public corpus (owner_id IS NULL) exactly like /api/search; registry domains already used the real default catalogues. The anonymous path is now rate limited (it previously bypassed the limiter). Only an explicit demo/local deploy still serves fixtures. Adds regression tests: an anonymous live request runs demo:false against the public corpus, the anonymous path is rate limited, and the owner path is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/app/api/search/universal/route.ts | 26 +++--- tests/universal-search.test.ts | 117 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 14 deletions(-) diff --git a/src/app/api/search/universal/route.ts b/src/app/api/search/universal/route.ts index 210cd1bf3..d48b650d8 100644 --- a/src/app/api/search/universal/route.ts +++ b/src/app/api/search/universal/route.ts @@ -8,7 +8,7 @@ import { } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; +import { publicAccessContext } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { runUniversalSearch, universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search"; @@ -17,8 +17,13 @@ import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; // Typeahead-friendly GET: cross-entity federated search over documents + the registry -// catalogues. Access ladder mirrors /api/registry/records — demo/local serves fixtures, -// unauthenticated public serves the public catalogues, owners get their seeded records. +// catalogues. Only an explicit demo/local deploy serves synthetic fixtures. Unlike the pure +// registry routes (which can short-circuit unauthenticated callers to an in-bundle catalogue), +// the documents domain must reach the live retrieval pipeline, so every non-demo caller runs the +// real search: anonymous callers are scoped to the public corpus (ownerId undefined -> +// allowGlobalSearch) and rate limited, and owners get their own records. Serving demo documents +// to live public callers here previously leaked the synthetic corpus (see runUniversalSearch demo +// path); it must never be reachable in production. const universalSearchQuerySchema = z.object({ q: z.string().trim().min(2).max(200), limit: queryInteger({ fallback: 5, min: 1, max: 10 }), @@ -49,11 +54,6 @@ export async function GET(request: Request) { return universalResponse({ ...payload, demoMode: true }); } - if (!shouldResolvePublicCatalogAccess(request)) { - const payload = await runUniversalSearch({ query: q, limitPerDomain: limit, domains, demo: true }); - return universalResponse({ ...payload, publicAccess: true }); - } - const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); @@ -67,11 +67,9 @@ export async function GET(request: Request) { return rateLimitJsonResponse("Universal search requests are rate limited. Try again shortly.", rateLimit); } - if (!access.ownerId) { - const payload = await runUniversalSearch({ query: q, limitPerDomain: limit, domains, demo: true }); - return universalResponse({ ...payload, publicAccess: true }); - } - + // demo:false + supabase always run the live pipeline. An anonymous caller (ownerId + // undefined) is scoped to the public corpus via allowGlobalSearch and the real default + // catalogues — never the synthetic demo fixtures. const payload = await runUniversalSearch({ query: q, limitPerDomain: limit, @@ -80,7 +78,7 @@ export async function GET(request: Request) { ownerId: access.ownerId, demo: false, }); - return universalResponse(payload); + return universalResponse(access.ownerId ? payload : { ...payload, publicAccess: true }); } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); diff --git a/tests/universal-search.test.ts b/tests/universal-search.test.ts index abba267d8..d7bfab3ba 100644 --- a/tests/universal-search.test.ts +++ b/tests/universal-search.test.ts @@ -199,3 +199,120 @@ describe("runUniversalSearch (query intelligence & ranking)", () => { expect(expanded.some((match) => match.medication.name.toLowerCase() === "clozapine")).toBe(true); }); }); + +// Regression guard: outside an explicit demo/local deploy, no caller — including an anonymous, +// no-cookie live visitor — may be served the synthetic demo corpus. The documents domain must +// reach the real retrieval pipeline (demo:false + supabase), scoped to the public corpus for +// anonymous callers, and the anonymous path must be rate limited. +describe("GET /api/search/universal (live public/owner path)", () => { + const userId = "11111111-1111-4111-8111-111111111111"; + const token = "session-token"; + + type RunArgs = { demo: boolean; ownerId?: string; supabase?: unknown }; + const createRunMock = () => + vi.fn<(args: RunArgs) => Promise<{ query: string; groups: unknown[]; tookMs: number }>>(async () => ({ + query: "clozapine", + groups: [], + tookMs: 1, + })); + + function createSupabaseMock(options: { limited?: boolean } = {}) { + const rpc = vi.fn(async () => ({ + data: [ + { + limited: Boolean(options.limited), + limit_value: 120, + remaining: options.limited ? 0 : 119, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + ], + error: null, + })); + const getUser = vi.fn(async (receivedToken?: string) => + receivedToken === token + ? { data: { user: { id: userId } }, error: null } + : { data: { user: null }, error: { message: "Invalid token" } }, + ); + return { rpc, auth: { getUser } }; + } + + function mockRuntime( + client: ReturnType, + runUniversalSearch: ReturnType, + ) { + // env:{} keeps isDemoMode false (forcing the live path) while leaving the Supabase public + // keys unset, so the cookie-session probe resolves anonymous without a network call. + vi.doMock("@/lib/env", () => ({ + env: {}, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requireOpenAIEnv: () => undefined, + requireServerEnv: () => undefined, + })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client })); + // Replace only the entrypoint; the schema still needs the real domain list. + vi.doMock("@/lib/universal-search", () => ({ + runUniversalSearch, + universalSearchDomains: ["documents", "medications", "services", "forms", "differentials", "tools"], + })); + } + + it("runs the REAL public pipeline (never demo fixtures) for an unauthenticated live request", async () => { + const client = createSupabaseMock(); + const runUniversalSearch = createRunMock(); + mockRuntime(client, runUniversalSearch); + const { GET } = await import("../src/app/api/search/universal/route"); + + const response = await GET(new Request("http://localhost/api/search/universal?q=clozapine")); + const payload = (await response.json()) as { demoMode?: boolean; publicAccess?: boolean }; + + expect(response.status).toBe(200); + // The anonymous public view is flagged; demo is NOT — the synthetic corpus must not leak. + expect(payload.publicAccess).toBe(true); + expect(payload.demoMode).toBeUndefined(); + // The live pipeline is invoked with demo:false + a supabase client and no ownerId, so the + // documents domain runs allowGlobalSearch against the real public corpus. + expect(runUniversalSearch).toHaveBeenCalledTimes(1); + const args = runUniversalSearch.mock.calls[0]![0]; + expect(args.demo).toBe(false); + expect(args.ownerId).toBeUndefined(); + expect(args.supabase).toBeDefined(); + // The anonymous path is now rate limited (it previously short-circuited before any limit). + expect(client.rpc).toHaveBeenCalledWith("consume_api_subject_rate_limit", expect.anything()); + }); + + it("rate limits the anonymous universal-search path instead of leaving it unthrottled", async () => { + const client = createSupabaseMock({ limited: true }); + const runUniversalSearch = createRunMock(); + mockRuntime(client, runUniversalSearch); + const { GET } = await import("../src/app/api/search/universal/route"); + + const response = await GET(new Request("http://localhost/api/search/universal?q=clozapine")); + + expect(response.status).toBe(429); + // A throttled request must never fall through to any search pipeline. + expect(runUniversalSearch).not.toHaveBeenCalled(); + }); + + it("serves an authenticated owner their own records with demo:false and no public flag", async () => { + const client = createSupabaseMock(); + const runUniversalSearch = createRunMock(); + mockRuntime(client, runUniversalSearch); + const { GET } = await import("../src/app/api/search/universal/route"); + + const response = await GET( + new Request("http://localhost/api/search/universal?q=clozapine", { + headers: { Authorization: `Bearer ${token}` }, + }), + ); + const payload = (await response.json()) as { demoMode?: boolean; publicAccess?: boolean }; + + expect(response.status).toBe(200); + expect(payload.publicAccess).toBeUndefined(); + expect(payload.demoMode).toBeUndefined(); + const args = runUniversalSearch.mock.calls[0]![0]; + expect(args.demo).toBe(false); + expect(args.ownerId).toBe(userId); + }); +});