Skip to content

Commit 7cc7f0b

Browse files
committed
Merge branch 'claude/agitated-brown-27f411' — demo-mode governance (registry + ESLint + banner + Challenger)
2 parents cb1794a + d7b75f4 commit 7cc7f0b

File tree

6 files changed

+314
-2
lines changed

6 files changed

+314
-2
lines changed

deploy/docker-compose.challenger.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ services:
2525
NEXT_PUBLIC_MAX_TEXT_LENGTH: "10000"
2626
NEXT_PUBLIC_ENABLE_ANALYTICS: "false"
2727
NEXT_PUBLIC_ENABLE_ERROR_REPORTING: "false"
28+
# Demo mode — Challenger is DEV/QA only. Mock data enabled for pitch /
29+
# onboarding demos. Production failsafe in src/lib/demo/index.ts is
30+
# overridden here by TPI_ALLOW_DEMO_IN_PROD. Do NOT copy these two
31+
# variables to deploy/docker-compose.yml (Voyager / real PROD).
32+
NEXT_PUBLIC_DEMO_MODE: "${NEXT_PUBLIC_DEMO_MODE:-true}"
33+
TPI_ALLOW_DEMO_IN_PROD: "${TPI_ALLOW_DEMO_IN_PROD:-true}"
2834
NODA_API_KEY: "${NODA_API_KEY}"
2935
NODA_API_KEY_ROLE: "${NODA_API_KEY_ROLE:-analyst}"
3036
TPI_DB_ENCRYPTION_KEY: "${TPI_DB_ENCRYPTION_KEY}"

packages/dojolm-web/eslint.config.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,31 @@ const eslintConfig = defineConfig([
1515
"react-hooks/static-components": "off",
1616
"react-hooks/refs": "off",
1717
"react-hooks/preserve-manual-memoization": "off",
18+
// Forbid importing demo mock data outside API route handlers and the
19+
// demo dir itself. The isDemoMode() guard and auth constants (via the
20+
// `@/lib/demo` barrel) remain importable everywhere because those are
21+
// runtime metadata, not fixtures. Enforces the governance contract
22+
// described in src/lib/demo/registry.ts.
23+
"no-restricted-imports": ["error", {
24+
patterns: [{
25+
group: [
26+
"@/lib/demo/mock-*",
27+
"@/lib/demo/mock-api-handlers",
28+
"@/lib/demo/registry",
29+
],
30+
message: "Demo mock data/handlers are restricted to src/app/api/** and src/lib/demo/**. Import isDemoMode() from '@/lib/demo' instead if you need to gate behavior on demo mode.",
31+
}],
32+
}],
33+
},
34+
},
35+
{
36+
// API route handlers and the demo package itself may import mock data.
37+
files: [
38+
"src/app/api/**/*.{ts,tsx}",
39+
"src/lib/demo/**/*.{ts,tsx}",
40+
],
41+
rules: {
42+
"no-restricted-imports": "off",
1843
},
1944
},
2045
{
@@ -25,6 +50,7 @@ const eslintConfig = defineConfig([
2550
],
2651
rules: {
2752
"react/display-name": "off",
53+
"no-restricted-imports": "off",
2854
},
2955
},
3056
]);

packages/dojolm-web/src/app/api/audit/log/route.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
*/
99

1010
import { NextRequest, NextResponse } from 'next/server';
11-
import { isDemoMode } from '@/lib/demo';
12-
import { demoNoOp } from '@/lib/demo/mock-api-handlers';
1311
import { readdir, readFile } from 'node:fs/promises';
1412
import path from 'node:path';
1513
import { checkApiAuth } from '@/lib/api-auth';
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* File: src/instrumentation.ts
3+
* Purpose: Next.js runtime init hook. Runs once per server process on cold
4+
* start (both Node and Edge runtimes). Used here solely to emit a
5+
* visible banner when demo mode is active, so operators spot an
6+
* accidentally-enabled demo deployment in the logs.
7+
*
8+
* Next.js auto-loads this file — do not import from elsewhere. See:
9+
* https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
10+
*/
11+
12+
export async function register(): Promise<void> {
13+
// Only run in the Node.js runtime. The Edge runtime boots per-request and
14+
// lacks the env vars we care about here (DEMO_MODE is Node-time flag).
15+
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
16+
17+
const demoRequested = process.env.NEXT_PUBLIC_DEMO_MODE === 'true';
18+
if (!demoRequested) return;
19+
20+
const isProd = process.env.NODE_ENV === 'production';
21+
const prodOverride = process.env.TPI_ALLOW_DEMO_IN_PROD === 'true';
22+
23+
if (isProd && !prodOverride) {
24+
// Matches the failsafe in src/lib/demo/index.ts — demo mode refused.
25+
// eslint-disable-next-line no-console
26+
console.error(
27+
'[demo] REFUSED: NEXT_PUBLIC_DEMO_MODE=true in production without ' +
28+
'TPI_ALLOW_DEMO_IN_PROD=true. Mock data will NOT be served. ' +
29+
'Review your deployment env — this is a dangerous combination.',
30+
);
31+
return;
32+
}
33+
34+
const env = isProd ? 'PRODUCTION (override enabled)' : 'development';
35+
// eslint-disable-next-line no-console
36+
console.warn(
37+
`\n${'━'.repeat(72)}\n` +
38+
` [demo] DEMO MODE ACTIVE — ${env}\n` +
39+
` All 54 gated API routes will return mock data from @/lib/demo.\n` +
40+
` Auth bypassed. No DB, filesystem, or external LLM calls.\n` +
41+
` Registry: src/lib/demo/registry.ts\n` +
42+
`${'━'.repeat(72)}\n`,
43+
);
44+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* File: registry.test.ts
3+
* Purpose: Enforce parity between DEMO_ROUTE_REGISTRY and the real filesystem.
4+
*
5+
* Failure modes caught by this test:
6+
* - A route.ts file adds `isDemoMode()` without a registry entry → drift.
7+
* - A registry entry references a route.ts that no longer uses
8+
* `isDemoMode()` → stale entry.
9+
* - A handler name listed in the registry is not exported from
10+
* mock-api-handlers.ts → broken reference.
11+
* - A handler is exported but referenced by no registry entry → dead code.
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { readdirSync, readFileSync, statSync } from 'node:fs';
16+
import { join, relative } from 'node:path';
17+
import {
18+
DEMO_ROUTE_REGISTRY,
19+
DEMO_REGISTERED_HANDLERS,
20+
DEMO_ROUTE_COUNT,
21+
} from '../registry';
22+
23+
const API_ROOT = join(__dirname, '../../../app/api');
24+
const HANDLERS_FILE = join(__dirname, '../mock-api-handlers.ts');
25+
26+
/** Recursively find every route.ts under src/app/api. */
27+
function findRouteFiles(dir: string, out: string[] = []): string[] {
28+
for (const name of readdirSync(dir)) {
29+
const full = join(dir, name);
30+
const stat = statSync(full);
31+
if (stat.isDirectory()) findRouteFiles(full, out);
32+
else if (name === 'route.ts') out.push(full);
33+
}
34+
return out;
35+
}
36+
37+
/** Convert absolute route.ts path to registry-style path (e.g. '/stats'). */
38+
function toRoutePath(absPath: string): string {
39+
const rel = relative(API_ROOT, absPath).replace(/\\/g, '/');
40+
return '/' + rel.replace(/\/route\.ts$/, '');
41+
}
42+
43+
describe('DEMO_ROUTE_REGISTRY', () => {
44+
it('matches the routes that call isDemoMode() on disk', () => {
45+
const routeFiles = findRouteFiles(API_ROOT);
46+
const routesUsingDemoMode = routeFiles
47+
.filter((f) => readFileSync(f, 'utf8').includes('isDemoMode()'))
48+
.map(toRoutePath)
49+
.sort();
50+
51+
const registryRoutes = DEMO_ROUTE_REGISTRY.map((e) => e.route).sort();
52+
53+
const missing = routesUsingDemoMode.filter((r) => !registryRoutes.includes(r));
54+
const stale = registryRoutes.filter((r) => !routesUsingDemoMode.includes(r));
55+
56+
expect(missing, `Routes that call isDemoMode() but are missing from DEMO_ROUTE_REGISTRY: ${missing.join(', ')}`).toEqual([]);
57+
expect(stale, `Registry entries for routes no longer calling isDemoMode(): ${stale.join(', ')}`).toEqual([]);
58+
});
59+
60+
it('has the expected route count', () => {
61+
expect(DEMO_ROUTE_COUNT).toBeGreaterThan(0);
62+
expect(DEMO_ROUTE_REGISTRY).toHaveLength(DEMO_ROUTE_COUNT);
63+
});
64+
65+
it('is sorted by route path', () => {
66+
const routes = DEMO_ROUTE_REGISTRY.map((e) => e.route);
67+
const sorted = [...routes].sort();
68+
expect(routes).toEqual(sorted);
69+
});
70+
71+
it('has no duplicate route entries', () => {
72+
const routes = DEMO_ROUTE_REGISTRY.map((e) => e.route);
73+
const unique = new Set(routes);
74+
expect(unique.size).toBe(routes.length);
75+
});
76+
});
77+
78+
describe('DEMO handler references', () => {
79+
it('every handler in registry is exported from mock-api-handlers.ts', () => {
80+
const handlersSource = readFileSync(HANDLERS_FILE, 'utf8');
81+
const missing: string[] = [];
82+
for (const name of DEMO_REGISTERED_HANDLERS) {
83+
const exportPattern = new RegExp(
84+
`^export (?:async )?(?:function|const) ${name}\\b`,
85+
'm',
86+
);
87+
if (!exportPattern.test(handlersSource)) missing.push(name);
88+
}
89+
expect(missing, `Registry references handlers not exported from mock-api-handlers.ts: ${missing.join(', ')}`).toEqual([]);
90+
});
91+
92+
it('has no dead handlers (exported but never referenced)', () => {
93+
const handlersSource = readFileSync(HANDLERS_FILE, 'utf8');
94+
const exported = Array.from(
95+
handlersSource.matchAll(/^export (?:async )?(?:function|const) (demo\w+)/gm),
96+
(m) => m[1],
97+
);
98+
const referenced = new Set(DEMO_REGISTERED_HANDLERS);
99+
// Handlers that are wrappers (demoNoOp*, demoBatch*, demoGuard*) may be referenced
100+
// by inline routes — exempt them from the dead-code check.
101+
const inlineReferenced = new Set([
102+
'demoNoOp',
103+
'demoNoOpCreated',
104+
'demoNoOpAccepted',
105+
'demoBatchGet',
106+
'demoBatchPost',
107+
'demoBatchById',
108+
'demoBatchExecutions',
109+
'demoGuardConfigGet',
110+
'demoGuardStatsGet',
111+
'demoGuardAuditGet',
112+
'demoCampaignsGet',
113+
'demoCampaignById',
114+
'demoCampaignRunById',
115+
'demoComplianceGet',
116+
'demoComplianceFrameworksGet',
117+
'demoComplianceAuditGet',
118+
'demoSettingsGet',
119+
'demoTestsGet',
120+
'demoResultsGet',
121+
'demoTestCasesGet',
122+
'demoMitsukeGet',
123+
'demoMcpStatusGet',
124+
'demoScanPost',
125+
]);
126+
const dead = exported.filter(
127+
(name) => !referenced.has(name) && !inlineReferenced.has(name),
128+
);
129+
expect(dead, `Exported demo handlers with no registry or inline reference: ${dead.join(', ')}`).toEqual([]);
130+
});
131+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* File: src/lib/demo/registry.ts
3+
* Purpose: Authoritative list of API routes that branch on `isDemoMode()`.
4+
*
5+
* Every entry pairs a route path with the demo handler(s) it invokes when
6+
* `isDemoMode()` returns true. The companion test (registry.test.ts) walks
7+
* src/app/api/** and fails the build if a route gains or loses an
8+
* `isDemoMode()` call without a matching registry update. This prevents drift
9+
* between demo-mode coverage and the real app surface.
10+
*
11+
* Governance contract:
12+
* - Every `isDemoMode()` branch in src/app/api/** MUST be listed here.
13+
* - Every handler referenced here MUST be exported from mock-api-handlers.ts
14+
* or declared in the `inline` list for routes that inline their demo path.
15+
* - New demo handlers added to mock-api-handlers.ts without a registry entry
16+
* are unreachable from production routes and will flag in the test.
17+
*/
18+
export interface DemoRouteEntry {
19+
/** Route path relative to /api (e.g. '/stats', '/llm/models/[id]'). */
20+
route: string;
21+
/**
22+
* Demo handler function names imported from './mock-api-handlers'. Empty
23+
* array when the route inlines its demo response (see `inline`).
24+
*/
25+
handlers: ReadonlyArray<string>;
26+
/**
27+
* True when the route implements its demo branch inline (no named handler
28+
* from mock-api-handlers.ts). Used for routes that return minimal static
29+
* JSON without crossing the handler boundary.
30+
*/
31+
inline?: boolean;
32+
}
33+
34+
/**
35+
* Exhaustive demo-mode route registry. Keep sorted by route path to make
36+
* diffs reviewable. When adding a new route that reads `isDemoMode()`, add
37+
* the entry here in the same PR.
38+
*/
39+
export const DEMO_ROUTE_REGISTRY: ReadonlyArray<DemoRouteEntry> = [
40+
{ route: '/admin/health', handlers: [], inline: true },
41+
{ route: '/admin/settings', handlers: [], inline: true },
42+
{ route: '/arena', handlers: ['demoArenaGet', 'demoArenaPost'] },
43+
{ route: '/arena/[id]', handlers: ['demoArenaMatchById'] },
44+
{ route: '/arena/warriors', handlers: ['demoArenaWarriorsGet'] },
45+
{ route: '/attackdna/analyze', handlers: ['demoNoOp'] },
46+
{ route: '/attackdna/ingest', handlers: ['demoNoOp'] },
47+
{ route: '/attackdna/query', handlers: ['demoAttackDnaQueryGet'] },
48+
{ route: '/attackdna/sync', handlers: ['demoNoOp'] },
49+
{ route: '/auth/login', handlers: [], inline: true },
50+
{ route: '/auth/logout', handlers: [], inline: true },
51+
{ route: '/auth/me', handlers: [], inline: true },
52+
{ route: '/auth/users', handlers: ['demoUsersGet', 'demoNoOpCreated'] },
53+
{ route: '/compliance', handlers: [], inline: true },
54+
{ route: '/compliance/export', handlers: [], inline: true },
55+
{ route: '/compliance/frameworks', handlers: [], inline: true },
56+
{ route: '/ecosystem/findings', handlers: ['demoEcosystemGet'] },
57+
{ route: '/fixtures', handlers: ['demoFixturesGet'] },
58+
{ route: '/health', handlers: ['demoHealthGet'] },
59+
{ route: '/llm/batch', handlers: [], inline: true },
60+
{ route: '/llm/batch/[id]', handlers: [], inline: true },
61+
{ route: '/llm/batch/[id]/executions', handlers: [], inline: true },
62+
{ route: '/llm/batch/cleanup', handlers: [], inline: true },
63+
{ route: '/llm/coverage', handlers: ['demoCoverageGet'] },
64+
{ route: '/llm/fingerprint', handlers: ['demoFingerprintGet', 'demoNoOpAccepted'] },
65+
{ route: '/llm/guard', handlers: [], inline: true },
66+
{ route: '/llm/guard/audit', handlers: [], inline: true },
67+
{ route: '/llm/guard/stats', handlers: [], inline: true },
68+
{ route: '/llm/leaderboard', handlers: ['demoLeaderboardGet'] },
69+
{ route: '/llm/local-models', handlers: [], inline: true },
70+
{ route: '/llm/models', handlers: ['demoModelsGet', 'demoModelsPost'] },
71+
{ route: '/llm/models/[id]', handlers: ['demoModelById', 'demoNoOp'] },
72+
{ route: '/llm/obl/alignment', handlers: [], inline: true },
73+
{ route: '/llm/obl/depth', handlers: [], inline: true },
74+
{ route: '/llm/obl/geometry', handlers: [], inline: true },
75+
{ route: '/llm/obl/robustness', handlers: [], inline: true },
76+
{ route: '/llm/providers', handlers: ['demoProvidersGet', 'demoProvidersPost'] },
77+
{ route: '/llm/reports', handlers: ['demoReportsGet'] },
78+
{ route: '/llm/results', handlers: [], inline: true },
79+
{ route: '/llm/test-cases', handlers: [], inline: true },
80+
{ route: '/mcp/status', handlers: [], inline: true },
81+
{ route: '/ronin/programs', handlers: ['demoRoninProgramsGet'] },
82+
{ route: '/ronin/submissions', handlers: ['demoRoninSubmissionsGet'] },
83+
{ route: '/scan', handlers: [], inline: true },
84+
{ route: '/sengoku/campaigns', handlers: [], inline: true },
85+
{ route: '/sengoku/campaigns/[id]', handlers: [], inline: true },
86+
{ route: '/sengoku/campaigns/[id]/run', handlers: ['demoNoOpAccepted'] },
87+
{ route: '/sengoku/campaigns/[id]/runs', handlers: ['demoCampaignRunsGet'] },
88+
{ route: '/setup/admin', handlers: [], inline: true },
89+
{ route: '/setup/status', handlers: [], inline: true },
90+
{ route: '/shingan/formats', handlers: ['demoShinganFormatsGet'] },
91+
{ route: '/shingan/scan', handlers: ['demoShinganScansGet'] },
92+
{ route: '/stats', handlers: ['demoStatsGet'] },
93+
{ route: '/tests', handlers: [], inline: true },
94+
];
95+
96+
/** Number of API routes gated by `isDemoMode()`. */
97+
export const DEMO_ROUTE_COUNT: number = DEMO_ROUTE_REGISTRY.length;
98+
99+
/** All handler names referenced by the registry, deduplicated. */
100+
export const DEMO_REGISTERED_HANDLERS: ReadonlyArray<string> = Array.from(
101+
new Set(DEMO_ROUTE_REGISTRY.flatMap((entry) => entry.handlers)),
102+
).sort();
103+
104+
/** Lookup by route path. Returns undefined if route is not in registry. */
105+
export function getDemoRouteEntry(route: string): DemoRouteEntry | undefined {
106+
return DEMO_ROUTE_REGISTRY.find((entry) => entry.route === route);
107+
}

0 commit comments

Comments
 (0)