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); + }); +});