Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions src/app/api/search/universal/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 }),
Expand Down Expand Up @@ -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);

Expand All @@ -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,
Expand All @@ -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();
Expand Down
117 changes: 117 additions & 0 deletions tests/universal-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createSupabaseMock>,
runUniversalSearch: ReturnType<typeof createRunMock>,
) {
// 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);
});
});
Loading