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
18 changes: 18 additions & 0 deletions src/lib/demo-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
24 changes: 23 additions & 1 deletion tests/demo-data.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
Loading