phase3-security.test.mjs
506 lines 24.0 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Phase 3 Security Remediation Tests
3 *
4 * Covers all 6 Phase 3 items from docs/SECURITY-AUDIT-PLAN.md:
5 * 3.1 — JWT token-in-URL: OAuth redirect uses URL fragment (#token=); gateway JWT expiry shortened from 7d
6 * 3.2 — Image proxy: short-lived HMAC-signed token replaces full JWT in ?token= query param
7 * 3.3 — Bridge write routes: requireBridgeEditorOrAdmin guards all mutation endpoints
8 * 3.4 — MCP refresh tokens: durable createGatewayRefreshStore (not in-memory Map)
9 * 3.5 — CORS on canister: corsHeaders() locks origin when gateway_auth_secret is set (Motoko structural)
10 * 3.6 — path-to-regexp ReDoS CVE resolved (npm audit passes)
11 */
12
13 import { test, describe } from 'node:test';
14 import assert from 'node:assert/strict';
15 import fs from 'node:fs';
16 import path from 'node:path';
17 import crypto from 'node:crypto';
18 import { fileURLToPath } from 'node:url';
19
20 const __dirname = path.dirname(fileURLToPath(import.meta.url));
21 const ROOT = path.resolve(__dirname, '..');
22
23 // ---------------------------------------------------------------------------
24 // 3.1 JWT token-in-URL: fragment-based redirect + shortened expiry
25 // ---------------------------------------------------------------------------
26 describe('3.1 JWT token-in-URL: OAuth redirects use fragment, gateway expiry shortened', () => {
27 let gatewaySource;
28 let selfHostedSource;
29
30 const loadGateway = () => {
31 if (!gatewaySource) gatewaySource = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
32 return gatewaySource;
33 };
34 const loadSelfHosted = () => {
35 if (!selfHostedSource) selfHostedSource = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
36 return selfHostedSource;
37 };
38
39 test('gateway postLoginRedirect uses # fragment, not ?token= query param', () => {
40 const src = loadGateway();
41 assert.ok(src.includes('/hub/#'), 'postLoginRedirect must redirect to #fragment');
42 const fnBlock = src.slice(src.indexOf('function postLoginRedirect'));
43 const fnEnd = fnBlock.indexOf('\n}');
44 const fnBody = fnBlock.slice(0, fnEnd);
45 assert.ok(!fnBody.includes('?token='), 'postLoginRedirect must NOT use ?token= query');
46 });
47
48 test('gateway JWT_EXPIRY default is no longer 7d', () => {
49 const src = loadGateway();
50 const match = src.match(/JWT_EXPIRY\s*=\s*process\.env\.HUB_JWT_EXPIRY\s*\|\|\s*'([^']+)'/);
51 assert.ok(match, 'JWT_EXPIRY constant must exist with default');
52 assert.notEqual(match[1], '7d', 'default JWT_EXPIRY must not be 7d');
53 assert.equal(match[1], '24h', 'default JWT_EXPIRY should be 24h');
54 });
55
56 test('self-hosted handleAuthCallback uses # fragment, not ?token= query param', () => {
57 const src = loadSelfHosted();
58 assert.ok(
59 src.includes('/#token=') || src.includes("'/#token='"),
60 'self-hosted redirect must use # fragment for token'
61 );
62 const postRedirectBlock = src.slice(src.indexOf('function handleAuthCallback'));
63 assert.ok(
64 !postRedirectBlock.includes('/?token='),
65 'handleAuthCallback must NOT use ?token= query param'
66 );
67 });
68 });
69
70 // ---------------------------------------------------------------------------
71 // 3.2 Image proxy: short-lived HMAC-signed token
72 // ---------------------------------------------------------------------------
73 describe('3.2 Image proxy: HMAC-signed token replaces full JWT in query param', () => {
74 const SECRET = 'test-secret-key-for-phase3-tests';
75 const UID = 'google:123456';
76
77 function signImageProxyToken(secret, uid) {
78 const TTL = 300;
79 const exp = Math.floor(Date.now() / 1000) + TTL;
80 const payload = `img\0${uid}\0${exp}`;
81 const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
82 return `${exp}.${Buffer.from(uid).toString('base64url')}.${sig}`;
83 }
84
85 function verifyImageProxyToken(secret, token) {
86 if (typeof token !== 'string') return null;
87 const parts = token.split('.');
88 if (parts.length !== 3) return null;
89 const [expStr, uidB64, sig] = parts;
90 const exp = parseInt(expStr, 10);
91 if (!exp || Math.floor(Date.now() / 1000) > exp) return null;
92 let uid;
93 try { uid = Buffer.from(uidB64, 'base64url').toString(); } catch (_) { return null; }
94 if (!uid) return null;
95 const payload = `img\0${uid}\0${exp}`;
96 const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
97 const sigBuf = Buffer.from(sig);
98 const expectedBuf = Buffer.from(expected);
99 if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) return null;
100 return uid;
101 }
102
103 test('signImageProxyToken produces a 3-part dot-separated token', () => {
104 const token = signImageProxyToken(SECRET, UID);
105 const parts = token.split('.');
106 assert.equal(parts.length, 3, 'token must have 3 parts: exp.uid_b64.sig');
107 });
108
109 test('verifyImageProxyToken returns uid for a valid token', () => {
110 const token = signImageProxyToken(SECRET, UID);
111 const result = verifyImageProxyToken(SECRET, token);
112 assert.equal(result, UID);
113 });
114
115 test('verifyImageProxyToken rejects tampered signature', () => {
116 const token = signImageProxyToken(SECRET, UID);
117 const tampered = token.slice(0, -4) + 'XXXX';
118 assert.equal(verifyImageProxyToken(SECRET, tampered), null);
119 });
120
121 test('verifyImageProxyToken rejects wrong secret', () => {
122 const token = signImageProxyToken(SECRET, UID);
123 assert.equal(verifyImageProxyToken('wrong-secret', token), null);
124 });
125
126 test('verifyImageProxyToken rejects expired token', () => {
127 const exp = Math.floor(Date.now() / 1000) - 10;
128 const payload = `img\0${UID}\0${exp}`;
129 const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url');
130 const token = `${exp}.${Buffer.from(UID).toString('base64url')}.${sig}`;
131 assert.equal(verifyImageProxyToken(SECRET, token), null);
132 });
133
134 test('verifyImageProxyToken rejects invalid format', () => {
135 assert.equal(verifyImageProxyToken(SECRET, ''), null);
136 assert.equal(verifyImageProxyToken(SECRET, 'not.a.valid.token'), null);
137 assert.equal(verifyImageProxyToken(SECRET, null), null);
138 assert.equal(verifyImageProxyToken(SECRET, undefined), null);
139 });
140
141 test('gateway server has image-proxy-token signing endpoint', () => {
142 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
143 assert.ok(src.includes("'/api/v1/vault/image-proxy-token'"), 'gateway must expose image-proxy-token endpoint');
144 assert.ok(src.includes('signImageProxyToken'), 'gateway must use signImageProxyToken');
145 });
146
147 test('self-hosted server has image-proxy-token signing endpoint', () => {
148 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
149 assert.ok(src.includes("'/api/v1/vault/image-proxy-token'"), 'self-hosted must expose image-proxy-token endpoint');
150 assert.ok(src.includes('signImageProxyToken'), 'self-hosted must use signImageProxyToken');
151 });
152
153 test('gateway image proxy uses verifyImageProxyToken for query token auth', () => {
154 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
155 assert.ok(src.includes('verifyImageProxyToken'), 'gateway image proxy must use verifyImageProxyToken');
156 });
157
158 test('gateway image proxy has backward-compat JWT fallback for ?token=', () => {
159 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
160 assert.ok(src.includes('Backward compat'), 'gateway must include JWT fallback for pre-signed-token hub.js');
161 });
162
163 test('self-hosted image proxy has backward-compat JWT fallback for ?token=', () => {
164 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
165 assert.ok(src.includes('Backward compat'), 'self-hosted must include JWT fallback for pre-signed-token hub.js');
166 });
167 });
168
169 // ---------------------------------------------------------------------------
170 // 3.3 Bridge write routes: requireBridgeEditorOrAdmin on mutations
171 // ---------------------------------------------------------------------------
172 describe('3.3 Bridge write routes guarded by requireBridgeEditorOrAdmin', () => {
173 let bridgeSrc;
174 const load = () => {
175 if (!bridgeSrc) bridgeSrc = fs.readFileSync(path.join(ROOT, 'hub/bridge/server.mjs'), 'utf8');
176 return bridgeSrc;
177 };
178
179 test('POST /api/v1/vault/sync has requireBridgeEditorOrAdmin', () => {
180 const src = load();
181 const syncLine = src.split('\n').find((l) => l.includes("'/api/v1/vault/sync'") && l.includes('app.post'));
182 assert.ok(syncLine, 'sync route must exist');
183 assert.ok(syncLine.includes('requireBridgeEditorOrAdmin'), '/vault/sync must require editor or admin');
184 });
185
186 test('POST /api/v1/index has requireBridgeEditorOrAdmin', () => {
187 const src = load();
188 const indexLine = src.split('\n').find((l) => l.includes("'/api/v1/index'") && l.includes('app.post'));
189 assert.ok(indexLine, 'index route must exist');
190 assert.ok(indexLine.includes('requireBridgeEditorOrAdmin'), '/index must require editor or admin');
191 });
192
193 test('POST /api/v1/index removes stale rows so search cannot return paths no longer in the export', () => {
194 // Contract intent: after `feat/bridge-embed-hash-cache`, the bridge does incremental
195 // indexing rather than blind delete-then-upsert. Same semantic guarantee, two paths:
196 // - empty vault → store.deleteByVaultId(vaultId) clears every row for the vault;
197 // - non-empty vault → store.deleteByChunkIds(orphanIds) removes chunk_ids in the
198 // store that are absent from the current export (deleted notes / renamed paths).
199 // Both calls must remain in the source so a future refactor cannot silently regress
200 // the security property that prompted this test.
201 const src = load();
202 assert.ok(
203 src.includes('store.deleteByVaultId(vaultId)'),
204 'bridge index must call store.deleteByVaultId(vaultId) on the empty / first-run path',
205 );
206 assert.ok(
207 src.includes('store.deleteByChunkIds(partitioned.orphanIds)'),
208 'bridge index must call store.deleteByChunkIds(partitioned.orphanIds) for incremental orphan cleanup',
209 );
210 assert.ok(
211 src.includes('search cannot return paths') ||
212 src.includes('search cannot return paths no longer in the export'),
213 'bridge index must keep the comment explaining the search-orphan invariant',
214 );
215 });
216
217 test('POST /api/v1/index JSON includes vectors_deleted for operators', () => {
218 const src = load();
219 assert.ok(
220 src.includes('vectors_deleted') && src.includes('chunksIndexed') && src.includes('notesProcessed'),
221 'bridge index response must expose vectors_deleted alongside notesProcessed/chunksIndexed',
222 );
223 });
224
225 test('GET /api/v1/bridge-version exists for deploy verification', () => {
226 const src = load();
227 assert.ok(
228 src.includes("app.get('/api/v1/bridge-version'") && src.includes('COMMIT_REF'),
229 'bridge must expose unauthenticated GET /api/v1/bridge-version with commit metadata',
230 );
231 });
232
233 test('POST /api/v1/memory/store has requireBridgeEditorOrAdmin', () => {
234 const src = load();
235 const storeLine = src.split('\n').find((l) => l.includes("'/api/v1/memory/store'") && l.includes('app.post'));
236 assert.ok(storeLine, 'memory/store route must exist');
237 assert.ok(storeLine.includes('requireBridgeEditorOrAdmin'), '/memory/store must require editor or admin');
238 });
239
240 test('DELETE /api/v1/memory/clear has requireBridgeEditorOrAdmin', () => {
241 const src = load();
242 const clearLine = src.split('\n').find((l) => l.includes("'/api/v1/memory/clear'") && l.includes('app.delete'));
243 assert.ok(clearLine, 'memory/clear route must exist');
244 assert.ok(clearLine.includes('requireBridgeEditorOrAdmin'), '/memory/clear must require editor or admin');
245 });
246
247 test('POST /api/v1/memory/consolidate has requireBridgeEditorOrAdmin', () => {
248 const src = load();
249 const consolLine = src.split('\n').find((l) => l.includes("'/api/v1/memory/consolidate'") && l.includes('app.post'));
250 assert.ok(consolLine, 'memory/consolidate route must exist');
251 assert.ok(consolLine.includes('requireBridgeEditorOrAdmin'), '/memory/consolidate must require editor or admin');
252 });
253
254 test('requireBridgeEditorOrAdmin blocks viewer role', () => {
255 const src = load();
256 const fnBlock = src.slice(src.indexOf('async function requireBridgeEditorOrAdmin'));
257 assert.ok(fnBlock.includes("role === 'viewer'"), 'middleware must check for viewer role');
258 assert.ok(fnBlock.includes('403'), 'middleware must return 403 for viewers');
259 });
260 });
261
262 // ---------------------------------------------------------------------------
263 // 3.4 MCP durable refresh store (Phase A) — no in-memory plaintext Map
264 // ---------------------------------------------------------------------------
265 describe('3.4 MCP refresh token store — durable gateway store', () => {
266 let mcpSrc;
267 const load = () => {
268 if (!mcpSrc) mcpSrc = fs.readFileSync(path.join(ROOT, 'hub/gateway/mcp-oauth-provider.mjs'), 'utf8');
269 return mcpSrc;
270 };
271
272 test('KnowtationOAuthProvider requires refreshStore (no in-memory _refreshTokens Map)', () => {
273 const src = load();
274 assert.ok(src.includes('requires refreshStore'), 'constructor must require refreshStore');
275 assert.ok(!src.includes('this._refreshTokens = new Map'), 'must not use in-memory refresh Map');
276 assert.ok(src.includes('DEFAULT_TOKEN_TTL_MS'), 'must align refresh TTL with refresh-token-core');
277 assert.ok(src.includes('DEFAULT_FAMILY_TTL_MS'), 'must align family TTL with refresh-token-core');
278 });
279
280 test('exchangeAuthorizationCode issues via refreshStore', () => {
281 const src = load();
282 assert.ok(src.includes('this._refreshStore.issue'), 'must issue durable refresh tokens');
283 assert.ok(src.includes("meta:"), 'must attach agent/client meta');
284 });
285
286 test('exchangeRefreshToken rotates via refreshStore', () => {
287 const src = load();
288 assert.ok(src.includes('this._refreshStore.rotate'), 'must rotate via durable store');
289 });
290
291 test('server wires MCP provider with shared refreshStore', () => {
292 const serverSrc = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
293 assert.ok(serverSrc.includes('refreshStore,'), 'must pass refreshStore into KnowtationOAuthProvider');
294 assert.ok(serverSrc.includes("consistency: 'strong'"), 'persistent host must use strong consistency');
295 });
296 });
297
298 // ---------------------------------------------------------------------------
299 // 3.4b MCP OAuth: SDK express-rate-limit behind Nginx (proxy validate relaxations)
300 // ---------------------------------------------------------------------------
301 describe('3.4b MCP OAuth: SDK rate limit behind Nginx', () => {
302 test('gateway disables express-rate-limit validations for mcpAuthRouter (keep limiters)', () => {
303 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
304 assert.ok(src.includes('app.set(\'trust proxy\', 1)'), 'gateway must set trust proxy for X-Forwarded-For');
305 const block = src.slice(src.indexOf('app._mcpOAuthProvider = oauthProvider'), src.indexOf('[gateway] MCP OAuth 2.1 endpoints mounted'));
306 assert.ok(
307 block.includes('rateLimit: { validate: false }'),
308 'must set rateLimit.validate false so ERR_ERL_* does not break /token behind Nginx',
309 );
310 assert.match(block, /authorizationOptions:\s*mcpOAuthSdkRateLimitOpts/);
311 assert.match(block, /tokenOptions:\s*mcpOAuthSdkRateLimitOpts/);
312 });
313 });
314
315 // ---------------------------------------------------------------------------
316 // 3.5 CORS on canister: locked origin when gateway_auth_secret is set
317 // ---------------------------------------------------------------------------
318 describe('3.5 Canister CORS locked to gateway origin when auth secret set', () => {
319 let mainMo;
320 let migrationMo;
321 const loadMain = () => {
322 if (!mainMo) mainMo = fs.readFileSync(path.join(ROOT, 'hub/icp/src/hub/main.mo'), 'utf8');
323 return mainMo;
324 };
325 const loadMigration = () => {
326 if (!migrationMo) migrationMo = fs.readFileSync(path.join(ROOT, 'hub/icp/src/hub/Migration.mo'), 'utf8');
327 return migrationMo;
328 };
329
330 test('corsHeaders() checks gateway_auth_secret and cors_allowed_origin', () => {
331 const src = loadMain();
332 const corsBlock = src.slice(src.indexOf('func corsHeaders'));
333 assert.ok(corsBlock.includes('gateway_auth_secret'), 'corsHeaders must check gateway_auth_secret');
334 assert.ok(corsBlock.includes('cors_allowed_origin'), 'corsHeaders must check cors_allowed_origin');
335 });
336
337 test('corsHeaders() returns specific origin when both secrets are set', () => {
338 const src = loadMain();
339 const corsBlock = src.slice(src.indexOf('func corsHeaders'), src.indexOf('func corsHeaders') + 500);
340 assert.ok(corsBlock.includes('"*"'), 'must have wildcard fallback');
341 assert.ok(corsBlock.includes('storage.cors_allowed_origin'), 'must use stored origin when locked');
342 });
343
344 test('admin_set_cors_origin function exists and requires controller', () => {
345 const src = loadMain();
346 assert.ok(src.includes('admin_set_cors_origin'), 'must have admin_set_cors_origin function');
347 const fnBlock = src.slice(src.indexOf('admin_set_cors_origin'));
348 assert.ok(fnBlock.includes('isController'), 'must verify caller is controller');
349 assert.ok(fnBlock.includes('FORBIDDEN'), 'must trap non-controllers');
350 });
351
352 test('StableStorage type includes cors_allowed_origin field', () => {
353 const src = loadMigration();
354 const stableBlock = src.slice(src.lastIndexOf('public type StableStorage'));
355 assert.ok(stableBlock.includes('cors_allowed_origin'), 'StableStorage must have cors_allowed_origin');
356 });
357
358 test('migration preserves gateway_auth_secret and cors_allowed_origin', () => {
359 const src = loadMigration();
360 const migBlock = src.slice(src.indexOf('public func migration'));
361 assert.ok(migBlock.includes('gateway_auth_secret = old.storage.gateway_auth_secret'),
362 'migration must preserve existing gateway auth secret');
363 // V7 stable already includes cors_allowed_origin; actor hook maps V7→current by preserving it.
364 assert.ok(migBlock.includes('cors_allowed_origin = old.storage.cors_allowed_origin'),
365 'migration must preserve cors_allowed_origin from V7 storage');
366 });
367
368 test('saveStable preserves cors_allowed_origin', () => {
369 const src = loadMain();
370 const saveBlock = src.slice(src.indexOf('func saveStable'));
371 assert.ok(saveBlock.includes('keepCorsOrigin'), 'saveStable must preserve cors origin');
372 assert.ok(saveBlock.includes('cors_allowed_origin = keepCorsOrigin'), 'saveStable must write cors origin');
373 });
374 });
375
376 // ---------------------------------------------------------------------------
377 // 3.6 path-to-regexp ReDoS CVE resolved
378 // ---------------------------------------------------------------------------
379 // ---------------------------------------------------------------------------
380 // Bridge → canister X-Gateway-Auth header (Phase 0 compatibility fix)
381 // ---------------------------------------------------------------------------
382 describe('Bridge canister calls include X-Gateway-Auth header', () => {
383 let bridgeSrc;
384 const load = () => {
385 if (!bridgeSrc) bridgeSrc = fs.readFileSync(path.join(ROOT, 'hub/bridge/server.mjs'), 'utf8');
386 return bridgeSrc;
387 };
388
389 test('bridge reads CANISTER_AUTH_SECRET from env (same var name as gateway)', () => {
390 const src = load();
391 assert.ok(src.includes('CANISTER_AUTH_SECRET'), 'bridge must read CANISTER_AUTH_SECRET env var');
392 });
393
394 test('bridge has canisterHeaders() helper that injects x-gateway-auth', () => {
395 const src = load();
396 assert.ok(src.includes('function canisterHeaders'), 'bridge must define canisterHeaders helper');
397 assert.ok(src.includes("'x-gateway-auth'"), 'canisterHeaders must set x-gateway-auth header');
398 });
399
400 test('canisterHeaders() is used at every canister fetch call site', () => {
401 const src = load();
402 // Count how many times we call fetch on the canister URL (CANISTER_URL or ${base}/api)
403 const fetchCanisterCount = (src.match(/fetch\(CANISTER_URL|fetch\(`\$\{CANISTER_URL\}|fetch\(`\$\{base\}/g) || []).length;
404 // Count how many times canisterHeaders appears near those calls
405 const canisterHeadersCount = (src.match(/canisterHeaders\(/g) || []).length;
406 assert.ok(
407 canisterHeadersCount >= fetchCanisterCount,
408 `canisterHeaders() must appear at least as many times as canister fetch calls (fetches: ${fetchCanisterCount}, canisterHeaders: ${canisterHeadersCount})`,
409 );
410 });
411 });
412
413 describe('3.6 path-to-regexp ReDoS CVE resolved', () => {
414 test('hub/package-lock.json has path-to-regexp >= 0.1.13', () => {
415 const lockPath = path.join(ROOT, 'hub/package-lock.json');
416 if (!fs.existsSync(lockPath)) return;
417 const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
418 const packages = lock.packages || {};
419 for (const [pkg, info] of Object.entries(packages)) {
420 if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') {
421 const ver = info.version;
422 if (ver && ver.startsWith('0.1.')) {
423 const patch = parseInt(ver.split('.')[2], 10);
424 assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`);
425 }
426 }
427 }
428 });
429
430 test('hub/gateway/package-lock.json has path-to-regexp >= 0.1.13', () => {
431 const lockPath = path.join(ROOT, 'hub/gateway/package-lock.json');
432 if (!fs.existsSync(lockPath)) return;
433 const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
434 const packages = lock.packages || {};
435 for (const [pkg, info] of Object.entries(packages)) {
436 if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') {
437 const ver = info.version;
438 if (ver && ver.startsWith('0.1.')) {
439 const patch = parseInt(ver.split('.')[2], 10);
440 assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`);
441 }
442 }
443 }
444 });
445
446 test('root package-lock.json has path-to-regexp >= 0.1.13', () => {
447 const lockPath = path.join(ROOT, 'package-lock.json');
448 if (!fs.existsSync(lockPath)) return;
449 const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
450 const packages = lock.packages || {};
451 for (const [pkg, info] of Object.entries(packages)) {
452 if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') {
453 const ver = info.version;
454 if (ver && ver.startsWith('0.1.')) {
455 const patch = parseInt(ver.split('.')[2], 10);
456 assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`);
457 }
458 }
459 }
460 });
461 });
462
463 // ---------------------------------------------------------------------------
464 // 3.7 Muse thin bridge (Option C): operator proxy route markers
465 // ---------------------------------------------------------------------------
466 describe('3.7 Muse thin bridge: operator proxy present on gateway and Node Hub', () => {
467 test('gateway registers GET /api/v1/operator/muse/proxy with requireAdmin', () => {
468 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
469 assert.ok(
470 src.includes('/api/v1/operator/muse/proxy'),
471 'gateway must expose operator Muse proxy path',
472 );
473 assert.ok(
474 src.includes('fetchMuseProxiedGet') && src.includes('parseMuseConfigFromEnv'),
475 'gateway must use muse-thin-bridge helpers for proxy',
476 );
477 });
478
479 test('self-hosted Hub registers GET /api/v1/operator/muse/proxy with jwtAuth and admin role', () => {
480 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
481 assert.ok(
482 src.includes('/api/v1/operator/muse/proxy'),
483 'Node Hub must expose operator Muse proxy path',
484 );
485 assert.ok(
486 src.includes('fetchMuseProxiedGet') && src.includes("requireRole('admin')"),
487 'Node Hub must gate Muse proxy with admin role',
488 );
489 });
490
491 test('Node Hub exposes POST /api/v1/settings/muse for self-hosted YAML Muse URL', () => {
492 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
493 assert.ok(
494 src.includes("'/api/v1/settings/muse'"),
495 'Node Hub must allow admins to persist muse.url in config/local.yaml',
496 );
497 });
498
499 test('gateway rejects POST /api/v1/settings/muse (hosted operator-only)', () => {
500 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
501 assert.ok(
502 src.includes("'/api/v1/settings/muse'") && src.includes('501'),
503 'gateway must not allow browser clients to set Muse URL',
504 );
505 });
506 });
File History 4 commits
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 30 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 49 days ago