diff --git a/src/lib/demo-data.ts b/src/lib/demo-data.ts index 7b0f7e49f..9929c1789 100644 --- a/src/lib/demo-data.ts +++ b/src/lib/demo-data.ts @@ -331,7 +331,25 @@ function scoreChunk(query: string, chunk: SearchResult) { return Math.min(0.98, chunk.similarity * 0.6 + keywordScore + tokenScore); } +// Fail closed: synthetic demo content must never reach a live production request. Reads +// process.env directly (not isDemoMode) so it cannot be silently disabled by a test that mocks +// @/lib/env, and so every current/future caller of demoSearch is covered — this is what prevents +// a recurrence of the /api/search/universal leak. In production isLocalNoAuthMode and the +// Supabase-config demo fallback are already disabled, so an explicit NEXT_PUBLIC_DEMO_MODE=true +// deploy is the sole legitimate case. Mirrors the fail-closed requireOwnerScope retrieval guard. +function assertDemoDataAllowed(context: string) { + if (process.env.NODE_ENV === "production" && process.env.NEXT_PUBLIC_DEMO_MODE !== "true") { + throw new Error( + `Refusing to serve synthetic demo data (${context}) to a live production request. ` + + "Demo fixtures are only permitted when NEXT_PUBLIC_DEMO_MODE=true.", + ); + } +} + export function demoSearch(query: string, topK = 8, documentId?: string, documentIds?: string[]) { + // Single choke point for synthetic document search (demoAnswer routes through here too), so the + // guard covers any current or future caller, not just the routes audited today. + assertDemoDataAllowed("demoSearch"); const filters = documentIds?.length ? documentIds : documentId ? [documentId] : null; return demoChunks .filter((chunk) => !filters || filters.includes(chunk.document_id)) diff --git a/tests/demo-data.test.ts b/tests/demo-data.test.ts index a932c5823..677f6f6d1 100644 --- a/tests/demo-data.test.ts +++ b/tests/demo-data.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { demoAnswer, demoDocuments, demoSearch, getDemoDocumentPayload } from "../src/lib/demo-data"; describe("demo data mode", () => { @@ -83,3 +83,25 @@ describe("demo data mode", () => { expect(payload?.images[0].caption).toContain("monitoring table"); }); }); + +// Class-level guard so a future route cannot reintroduce the /api/search/universal leak: demo +// fixtures must fail closed in a live production request and only run under an explicit demo deploy. +describe("demoSearch production safety guard", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("refuses to serve synthetic demo data to a live production request", async () => { + vi.stubEnv("NODE_ENV", "production"); + const { demoSearch: guarded } = await import("../src/lib/demo-data"); + expect(() => guarded("lithium toxicity")).toThrow(/synthetic demo data/i); + }); + + it("still serves fixtures for an explicit NEXT_PUBLIC_DEMO_MODE=true deploy", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "true"); + const { demoSearch: guarded } = await import("../src/lib/demo-data"); + expect(() => guarded("lithium toxicity")).not.toThrow(); + }); +});