catence 0.2.1-beta.16 → 0.2.1-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import path from 'node:path';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
- import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, DashboardSnapshotService, deleteAthleteSecret, describeAthleteSecrets, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, listAthleteFileRevisions, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, parseAutoSyncInterval, readAutoSyncSchedule, readAthleteFile, readAthleteFileRevision, resolveAthlete, resolveCatalogPaths, setAthleteSecret, startDetachedSync, syncProgress, updateAthleteFile, updateAutoSyncSchedule, } from '../../runtime/index.js';
5
+ import { addAthlete, addMcpKey, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, countActiveMcpKeys, DashboardSnapshotService, deleteAthleteSecret, describeAthleteSecrets, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, filterRosterToScope, jsonSafe, listAthleteFileRevisions, listMcpKeys, loadCatalog, McpKeyNotFoundError, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, parseAutoSyncInterval, readAutoSyncSchedule, readAthleteFile, readAthleteFileRevision, readMcpOauthSettings, resolveAccountScope, resolveAthlete, resolveCatalogPaths, resolveKeyPrincipal, revokeMcpKey, rollMcpKey, scopeAllowsAthlete, setAthleteSecret, startDetachedSync, syncProgress, updateAthleteFile, updateAutoSyncSchedule, writeMcpOauthSettings, } from '../../runtime/index.js';
6
6
  import { activeBearerToken, ensureBearerToken, disableBearerToken, mcpExposureStatus } from './cli-options.js';
7
7
  import { createCatenceMcpServer } from '../mcp/server.js';
8
8
  import { generationStatus } from './generation-status.js';
@@ -10,16 +10,80 @@ const MAX_REQUEST_BYTES = 1_000_000;
10
10
  /** Endpoints that stay open when bearer authentication is enabled: they carry
11
11
  * no personal data and must answer unauthenticated readiness probes. */
12
12
  const UNAUTHENTICATED_PATHS = new Set(['/health', '/api/v1/health']);
13
- function isAuthorized(request, options) {
14
- const bearerToken = options.bearerToken !== undefined ? options.bearerToken : options.bearerTokenFile !== undefined ? activeBearerToken(options.bearerTokenFile) : undefined;
15
- if (bearerToken === undefined || bearerToken === null)
16
- return true;
17
- if (bearerToken === 'invalid')
18
- return false;
19
- const received = Buffer.from(request.headers.authorization ?? '');
20
- const expected = Buffer.from(`Bearer ${bearerToken}`);
13
+ /** Thrown when a principal resolves an athlete outside its scope; routes map it to 403 athlete_forbidden. */
14
+ class AthleteForbiddenError extends Error {
15
+ }
16
+ /** Full-access principal: unarmed loopback dev mode and the legacy shared token both mean "local admin". */
17
+ const FULL_ACCESS_PRINCIPAL = { kind: 'admin-token', role: 'admin', athleteIds: 'all' };
18
+ function bearerTokenFrom(request) {
19
+ const header = request.headers.authorization;
20
+ if (typeof header !== 'string' || !header.startsWith('Bearer '))
21
+ return null;
22
+ return header.slice('Bearer '.length);
23
+ }
24
+ function matchesLegacyToken(rawToken, expected) {
25
+ const received = Buffer.from(rawToken);
26
+ const expectedBuffer = Buffer.from(expected);
21
27
  // timingSafeEqual throws on length mismatch, so compare sizes first.
22
- return received.length === expected.length && timingSafeEqual(received, expected);
28
+ return received.length === expectedBuffer.length && timingSafeEqual(received, expectedBuffer);
29
+ }
30
+ function scopeAllows(principal, athleteId) {
31
+ return scopeAllowsAthlete(principal.athleteIds, athleteId);
32
+ }
33
+ function requireAdmin(principal, response) {
34
+ if (principal.role === 'admin')
35
+ return true;
36
+ json(response, 403, { error: { code: 'admin_required', message: 'An admin account is required for this action.' } });
37
+ return false;
38
+ }
39
+ /**
40
+ * Resolve the request's principal: the legacy shared token first, then a
41
+ * per-account MCP key, then an OAuth bearer token. The gate is armed when the
42
+ * legacy token is active, the key registry holds at least one active key, or
43
+ * the OAuth 2.1 server is effectively enabled; an unarmed server stays open
44
+ * for loopback dev. Resolution fails closed: a malformed registry or accounts
45
+ * store, a revoked key, or an unknown account never yields a principal.
46
+ */
47
+ async function resolvePrincipal(request, options, accountPaths) {
48
+ const legacyToken = options.bearerToken !== undefined ? options.bearerToken : options.bearerTokenFile !== undefined ? activeBearerToken(options.bearerTokenFile) : undefined;
49
+ const keysArmed = options.mcpKeysFile !== undefined && countActiveMcpKeys(options.mcpKeysFile) > 0;
50
+ const oauthArmed = options.oauthVerifier !== undefined && (options.oauthEnabled === undefined || options.oauthEnabled());
51
+ const armed = (legacyToken !== undefined && legacyToken !== null) || keysArmed || oauthArmed;
52
+ if (!armed)
53
+ return FULL_ACCESS_PRINCIPAL;
54
+ const rawToken = bearerTokenFrom(request);
55
+ if (rawToken === null)
56
+ return null;
57
+ if (typeof legacyToken === 'string' && matchesLegacyToken(rawToken, legacyToken))
58
+ return FULL_ACCESS_PRINCIPAL;
59
+ if (options.mcpKeysFile !== undefined) {
60
+ // A malformed accounts store fails closed: the key resolves to no principal.
61
+ const keyPrincipal = resolveKeyPrincipal(options.mcpKeysFile, rawToken, (username) => {
62
+ try {
63
+ return resolveAccountScope(accountPaths, username);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ });
69
+ if (keyPrincipal)
70
+ return { kind: 'key', username: keyPrincipal.account, role: keyPrincipal.role, athleteIds: keyPrincipal.athleteIds };
71
+ }
72
+ if (options.oauthVerifier !== undefined) {
73
+ const verified = await options.oauthVerifier(rawToken).catch(() => null);
74
+ if (verified) {
75
+ let scope = null;
76
+ try {
77
+ scope = resolveAccountScope(accountPaths, verified.username);
78
+ }
79
+ catch {
80
+ scope = null;
81
+ }
82
+ if (scope)
83
+ return { kind: 'oauth', username: verified.username, role: scope.role, athleteIds: scope.athleteIds };
84
+ }
85
+ }
86
+ return null;
23
87
  }
24
88
  function json(response, statusCode, value) {
25
89
  const body = JSON.stringify(jsonSafe(value));
@@ -92,11 +156,54 @@ async function dashboardSnapshot(paths, url) {
92
156
  await repository.close();
93
157
  }
94
158
  }
95
- async function dashboardPaths(catalogPaths, staticPaths, url) {
159
+ function parseMcpKeyCreateBody(body) {
160
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
161
+ throw new Error('Request body must be a JSON object.');
162
+ const raw = body;
163
+ for (const key of Object.keys(raw)) {
164
+ if (!['account', 'label'].includes(key))
165
+ throw new Error(`Unknown MCP key field: ${key}.`);
166
+ }
167
+ if (typeof raw.account !== 'string' || !raw.account.trim())
168
+ throw new Error('account is required.');
169
+ if (raw.label !== undefined && (typeof raw.label !== 'string' || raw.label.trim().length > 120)) {
170
+ throw new Error('label must be a string of at most 120 characters.');
171
+ }
172
+ return { account: raw.account.trim(), label: raw.label };
173
+ }
174
+ function parseMcpKeyRollBody(body) {
175
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
176
+ throw new Error('Request body must be a JSON object.');
177
+ const raw = body;
178
+ for (const key of Object.keys(raw)) {
179
+ if (key !== 'id')
180
+ throw new Error(`Unknown MCP key field: ${key}.`);
181
+ }
182
+ if (typeof raw.id !== 'string' || !raw.id.trim())
183
+ throw new Error('id is required.');
184
+ return { id: raw.id.trim() };
185
+ }
186
+ function parseMcpOauthSettingsBody(body) {
187
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
188
+ throw new Error('Request body must be a JSON object.');
189
+ const raw = body;
190
+ for (const key of Object.keys(raw)) {
191
+ if (!['enabled', 'publicUrl'].includes(key))
192
+ throw new Error(`Unknown OAuth settings field: ${key}.`);
193
+ }
194
+ if (typeof raw.enabled !== 'boolean')
195
+ throw new Error('enabled is required and must be a boolean.');
196
+ if (raw.publicUrl !== undefined && typeof raw.publicUrl !== 'string')
197
+ throw new Error('publicUrl must be a string.');
198
+ return { enabled: raw.enabled, publicUrl: raw.publicUrl };
199
+ }
200
+ async function dashboardPaths(catalogPaths, staticPaths, url, principal) {
96
201
  if (catalogPaths) {
97
202
  const athleteId = url.searchParams.get('athleteId');
98
203
  if (!athleteId)
99
204
  throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
205
+ if (!scopeAllows(principal, athleteId))
206
+ throw new AthleteForbiddenError(`This credential is not allowed to access athlete ${athleteId}.`);
100
207
  return (await resolveAthlete(catalogPaths, athleteId)).paths;
101
208
  }
102
209
  if (staticPaths)
@@ -233,10 +340,12 @@ function parseAthleteSecretRemovalBody(body) {
233
340
  throw new Error('field is required.');
234
341
  return { athleteId: raw.athleteId, provider: raw.provider.trim(), field: raw.field.trim() };
235
342
  }
236
- async function resolveSyncPaths(catalogPaths, staticPaths, body) {
343
+ async function resolveSyncPaths(catalogPaths, staticPaths, body, principal) {
237
344
  if (catalogPaths) {
238
345
  if (!body.athleteId)
239
346
  throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
347
+ if (!scopeAllows(principal, body.athleteId))
348
+ throw new AthleteForbiddenError(`This credential is not allowed to access athlete ${body.athleteId}.`);
240
349
  return (await resolveAthlete(catalogPaths, body.athleteId)).paths;
241
350
  }
242
351
  if (staticPaths)
@@ -286,7 +395,7 @@ async function lastSyncSummary(paths) {
286
395
  return null;
287
396
  }
288
397
  }
289
- async function handleMcpRequest(request, response, paths) {
398
+ async function handleMcpRequest(request, response, paths, principal) {
290
399
  if (request.method !== 'POST') {
291
400
  methodNotAllowed(response);
292
401
  return;
@@ -303,7 +412,9 @@ async function handleMcpRequest(request, response, paths) {
303
412
  });
304
413
  return;
305
414
  }
306
- const server = createCatenceMcpServer(paths);
415
+ // The HTTP gate has already authenticated this request; the MCP server
416
+ // re-enforces the principal's athlete scope on every tool and roster.
417
+ const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
307
418
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
308
419
  let cleanedUp = false;
309
420
  const cleanup = async () => {
@@ -343,23 +454,108 @@ async function handleMcpRequest(request, response, paths) {
343
454
  export function createCatenceHttpServer(options = {}) {
344
455
  const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
345
456
  const staticPaths = options.paths ?? null;
457
+ // Key and OAuth principals resolve their athlete scope through the Console
458
+ // accounts that live next to the catalog.
459
+ const accountPaths = catalogPaths ?? resolveCatalogPaths();
346
460
  const allowedOrigins = options.allowedOrigins ?? [];
347
461
  const startSync = options.startSync ?? startDetachedSync;
348
462
  const mergeModels = options.mergeModels ?? mergeOpenCodeGoConsoleProfiles;
463
+ // OAuth 2.1 is configured by a Console-managed settings file
464
+ // (--oauth-settings-file / <catalog-home>/oauth/settings.json), which
465
+ // overrides the startup `oauth` option (env/flags) at runtime; a malformed
466
+ // file fails closed to disabled. The Express sub-app (express,
467
+ // mcp-oauth-server) builds lazily on first effective use, keyed by the
468
+ // public URL (the issuer), so the stdio path and a never-enabled serve never
469
+ // import express. Its verifier is wired into the gate as oauthVerifier;
470
+ // resolvePrincipal then maps the verified username to the live account scope.
471
+ const oauthSettingsFile = options.oauthSettingsFile;
472
+ const bootstrapOauth = options.oauth !== undefined ? { publicUrl: options.oauth.publicUrl.replace(/\/+$/, '') } : null;
473
+ const oauthConfigurable = options.oauth !== undefined || oauthSettingsFile !== undefined;
474
+ const effectiveOauth = () => {
475
+ if (oauthSettingsFile !== undefined) {
476
+ const { state, settings } = readMcpOauthSettings(oauthSettingsFile);
477
+ if (state === 'ok' && settings !== null) {
478
+ return { enabled: settings.enabled, ...(settings.publicUrl ? { publicUrl: settings.publicUrl } : {}), source: 'console', settingsState: state };
479
+ }
480
+ if (state === 'invalid')
481
+ return { enabled: false, source: 'disabled', settingsState: state };
482
+ }
483
+ if (bootstrapOauth !== null)
484
+ return { enabled: true, publicUrl: bootstrapOauth.publicUrl, source: 'environment', settingsState: 'missing' };
485
+ return { enabled: false, source: 'disabled', settingsState: 'missing' };
486
+ };
487
+ let oauthApp = null;
488
+ const getOauthApp = (publicUrl) => {
489
+ if (oauthApp === null || oauthApp.publicUrl !== publicUrl) {
490
+ const ready = import('./oauth.js').then(({ createOAuthApp }) => createOAuthApp({ catalogRoot: accountPaths.root, publicUrl }));
491
+ // Surface a build failure per request (the outer catch answers 500); this
492
+ // no-op branch only marks the rejection handled when no request ever comes.
493
+ void ready.catch(() => { });
494
+ oauthApp = { publicUrl, ready };
495
+ }
496
+ return oauthApp.ready;
497
+ };
498
+ let effectiveOptions = options;
499
+ if (oauthConfigurable) {
500
+ const verifier = async (token) => {
501
+ const config = effectiveOauth();
502
+ if (!config.enabled || config.publicUrl === undefined)
503
+ return null;
504
+ const verified = await (await getOauthApp(config.publicUrl)).verifyAccessToken(token);
505
+ if (!verified?.userId)
506
+ return null;
507
+ return { username: verified.userId };
508
+ };
509
+ effectiveOptions = { ...options, oauthVerifier: verifier, oauthEnabled: () => effectiveOauth().enabled };
510
+ }
511
+ // The status shape mcpExposureStatus reports; present whenever OAuth is
512
+ // configurable so the Console can read and change it.
513
+ const oauthStatus = () => {
514
+ if (!oauthConfigurable)
515
+ return undefined;
516
+ const config = effectiveOauth();
517
+ return { enabled: config.enabled, ...(config.publicUrl ? { publicUrl: config.publicUrl } : {}) };
518
+ };
349
519
  return createServer((request, response) => {
350
520
  void (async () => {
521
+ const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
522
+ // The OAuth endpoints are the auth surface itself: they bypass the gate
523
+ // and CORS entirely and are delegated to the Express sub-app. When OAuth
524
+ // is effectively disabled they answer 404 — no metadata is advertised.
525
+ if (pathname.startsWith('/.well-known/oauth-') || pathname.startsWith('/oauth/')) {
526
+ const config = effectiveOauth();
527
+ if (config.enabled && config.publicUrl !== undefined) {
528
+ const handle = await getOauthApp(config.publicUrl);
529
+ handle.app(request, response);
530
+ return;
531
+ }
532
+ json(response, 404, { error: { code: 'not_found', message: 'No Catence endpoint matches this request.' } });
533
+ return;
534
+ }
351
535
  if (!addCorsHeaders(request, response, allowedOrigins)) {
352
536
  json(response, 403, { error: { code: 'origin_not_allowed', message: 'This browser origin is not allowed to access Catence.' } });
353
537
  return;
354
538
  }
355
- const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
356
539
  if (request.method === 'OPTIONS') {
357
540
  response.writeHead(204).end();
358
541
  return;
359
542
  }
360
- if (!UNAUTHENTICATED_PATHS.has(pathname) && !isAuthorized(request, options)) {
361
- // No WWW-Authenticate on purpose: MCP connector auto-detection treats a
362
- // plain 401 as a bearer gate rather than an OAuth server.
543
+ // Health probes stay open even when the gate is armed; they resolve no
544
+ // principal because nothing downstream consumes one.
545
+ const principal = UNAUTHENTICATED_PATHS.has(pathname)
546
+ ? FULL_ACCESS_PRINCIPAL
547
+ : await resolvePrincipal(request, effectiveOptions, accountPaths);
548
+ if (principal === null) {
549
+ // RFC 9728: point OAuth clients at the protected-resource metadata so
550
+ // they discover the authorization server. Without OAuth the 401 stays
551
+ // plain on purpose: MCP connector auto-detection treats it as a bearer
552
+ // gate rather than an OAuth server.
553
+ const config = effectiveOauth();
554
+ if (config.enabled && config.publicUrl !== undefined) {
555
+ const handle = await getOauthApp(config.publicUrl).catch(() => null);
556
+ if (handle !== null)
557
+ response.setHeader('www-authenticate', `Bearer resource_metadata="${handle.protectedResourceMetadataUrl}"`);
558
+ }
363
559
  json(response, 401, { error: { code: 'unauthorized', message: 'A valid Authorization: Bearer token is required.' } });
364
560
  return;
365
561
  }
@@ -378,7 +574,9 @@ export function createCatenceHttpServer(options = {}) {
378
574
  }
379
575
  try {
380
576
  const catalog = await loadCatalog(catalogPaths);
381
- json(response, 200, { defaultAthleteId: catalog.defaultAthleteId, athletes: catalog.athletes });
577
+ // The roster is narrowed to the principal's athlete scope.
578
+ const roster = filterRosterToScope(catalog.athletes, catalog.defaultAthleteId, principal.athleteIds);
579
+ json(response, 200, { defaultAthleteId: roster.defaultAthleteId, athletes: roster.athletes });
382
580
  }
383
581
  catch (error) {
384
582
  json(response, 400, { error: { code: 'catalog_unavailable', message: error instanceof Error ? error.message : String(error) } });
@@ -386,6 +584,8 @@ export function createCatenceHttpServer(options = {}) {
386
584
  return;
387
585
  }
388
586
  if (pathname === '/api/v1/athletes' && request.method === 'POST') {
587
+ if (!requireAdmin(principal, response))
588
+ return;
389
589
  if (!catalogPaths) {
390
590
  json(response, 400, {
391
591
  error: {
@@ -415,9 +615,13 @@ export function createCatenceHttpServer(options = {}) {
415
615
  if (pathname === '/api/v1/dashboard' && request.method === 'GET') {
416
616
  try {
417
617
  const url = new URL(request.url ?? '/', 'http://localhost');
418
- json(response, 200, await dashboardSnapshot(await dashboardPaths(catalogPaths, staticPaths, url), url));
618
+ json(response, 200, await dashboardSnapshot(await dashboardPaths(catalogPaths, staticPaths, url, principal), url));
419
619
  }
420
620
  catch (error) {
621
+ if (error instanceof AthleteForbiddenError) {
622
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
623
+ return;
624
+ }
421
625
  json(response, 400, { error: { code: 'dashboard_unavailable', message: error instanceof Error ? error.message : String(error) } });
422
626
  }
423
627
  return;
@@ -425,7 +629,7 @@ export function createCatenceHttpServer(options = {}) {
425
629
  if (pathname === '/api/v1/athlete-file' && request.method === 'GET') {
426
630
  try {
427
631
  const url = new URL(request.url ?? '/', 'http://localhost');
428
- const paths = await dashboardPaths(catalogPaths, staticPaths, url);
632
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
429
633
  const revisionId = url.searchParams.get('revision');
430
634
  if (revisionId) {
431
635
  let content;
@@ -448,6 +652,10 @@ export function createCatenceHttpServer(options = {}) {
448
652
  json(response, 200, { ...snapshot, revisions: await listAthleteFileRevisions(paths) });
449
653
  }
450
654
  catch (error) {
655
+ if (error instanceof AthleteForbiddenError) {
656
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
657
+ return;
658
+ }
451
659
  json(response, 400, { error: { code: 'athlete_file_unavailable', message: error instanceof Error ? error.message : String(error) } });
452
660
  }
453
661
  return;
@@ -455,7 +663,7 @@ export function createCatenceHttpServer(options = {}) {
455
663
  if (pathname === '/api/v1/athlete-file' && request.method === 'PUT') {
456
664
  try {
457
665
  const url = new URL(request.url ?? '/', 'http://localhost');
458
- const paths = await dashboardPaths(catalogPaths, staticPaths, url);
666
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
459
667
  const parsed = parseAthleteFileBody(await readJsonBody(request));
460
668
  const header = request.headers['if-match'];
461
669
  const headerHash = typeof header === 'string' && header.trim() ? header.trim() : undefined;
@@ -471,6 +679,10 @@ export function createCatenceHttpServer(options = {}) {
471
679
  json(response, 200, snapshot);
472
680
  }
473
681
  catch (error) {
682
+ if (error instanceof AthleteForbiddenError) {
683
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
684
+ return;
685
+ }
474
686
  if (error instanceof AthleteFileConflictError) {
475
687
  json(response, 409, {
476
688
  error: {
@@ -493,10 +705,14 @@ export function createCatenceHttpServer(options = {}) {
493
705
  if (pathname === '/api/v1/athlete-secrets' && request.method === 'GET') {
494
706
  try {
495
707
  const url = new URL(request.url ?? '/', 'http://localhost');
496
- const paths = await dashboardPaths(catalogPaths, staticPaths, url);
708
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
497
709
  json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
498
710
  }
499
711
  catch (error) {
712
+ if (error instanceof AthleteForbiddenError) {
713
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
714
+ return;
715
+ }
500
716
  const message = error instanceof Error ? error.message : String(error);
501
717
  json(response, 400, { error: { code: 'athlete_secrets_unavailable', message } });
502
718
  }
@@ -508,11 +724,15 @@ export function createCatenceHttpServer(options = {}) {
508
724
  const url = new URL(request.url ?? '/', 'http://localhost');
509
725
  if (!url.searchParams.get('athleteId') && parsed.athleteId)
510
726
  url.searchParams.set('athleteId', parsed.athleteId);
511
- const paths = await dashboardPaths(catalogPaths, staticPaths, url);
727
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
512
728
  await setAthleteSecret(paths, parsed.provider, parsed.field, parsed.value);
513
729
  json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
514
730
  }
515
731
  catch (error) {
732
+ if (error instanceof AthleteForbiddenError) {
733
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
734
+ return;
735
+ }
516
736
  const message = error instanceof Error ? error.message : String(error);
517
737
  json(response, 400, { error: { code: 'athlete_secret_write_failed', message } });
518
738
  }
@@ -524,11 +744,15 @@ export function createCatenceHttpServer(options = {}) {
524
744
  const url = new URL(request.url ?? '/', 'http://localhost');
525
745
  if (!url.searchParams.get('athleteId') && parsed.athleteId)
526
746
  url.searchParams.set('athleteId', parsed.athleteId);
527
- const paths = await dashboardPaths(catalogPaths, staticPaths, url);
747
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
528
748
  await deleteAthleteSecret(paths, parsed.provider, parsed.field);
529
749
  json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
530
750
  }
531
751
  catch (error) {
752
+ if (error instanceof AthleteForbiddenError) {
753
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
754
+ return;
755
+ }
532
756
  const message = error instanceof Error ? error.message : String(error);
533
757
  json(response, 400, { error: { code: 'athlete_secret_remove_failed', message } });
534
758
  }
@@ -538,7 +762,7 @@ export function createCatenceHttpServer(options = {}) {
538
762
  let body;
539
763
  try {
540
764
  body = parseSyncBody(await readJsonBody(request));
541
- const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
765
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, body, principal);
542
766
  // Optional model self-discovery runs before the data sync so new
543
767
  // OpenCode Go models appear without a manual script run. Failure is
544
768
  // non-fatal: it must never block or fail a data sync. The Console
@@ -567,6 +791,10 @@ export function createCatenceHttpServer(options = {}) {
567
791
  json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
568
792
  }
569
793
  catch (error) {
794
+ if (error instanceof AthleteForbiddenError) {
795
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
796
+ return;
797
+ }
570
798
  json(response, error instanceof DetachedSyncBusyError ? 409 : 400, {
571
799
  error: {
572
800
  code: error instanceof DetachedSyncBusyError ? 'sync_in_progress' : 'sync_start_failed',
@@ -579,11 +807,15 @@ export function createCatenceHttpServer(options = {}) {
579
807
  if (pathname === '/api/v1/sync/schedule' && request.method === 'GET') {
580
808
  try {
581
809
  const url = new URL(request.url ?? '/', 'http://localhost');
582
- const paths = await dashboardPaths(catalogPaths, staticPaths, url);
810
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
583
811
  const athleteId = catalogPaths ? (url.searchParams.get('athleteId') ?? '') : 'local';
584
812
  json(response, 200, await readAutoSyncSchedule(paths, athleteId));
585
813
  }
586
814
  catch (error) {
815
+ if (error instanceof AthleteForbiddenError) {
816
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
817
+ return;
818
+ }
587
819
  json(response, 400, { error: { code: 'schedule_unavailable', message: error instanceof Error ? error.message : String(error) } });
588
820
  }
589
821
  return;
@@ -592,7 +824,7 @@ export function createCatenceHttpServer(options = {}) {
592
824
  try {
593
825
  const body = parseSyncScheduleBody(await readJsonBody(request));
594
826
  const url = new URL(request.url ?? '/', 'http://localhost');
595
- const paths = await resolveSyncPaths(catalogPaths, staticPaths, { athleteId: body.athleteId });
827
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, { athleteId: body.athleteId }, principal);
596
828
  const athleteId = catalogPaths ? (body.athleteId ?? '') : 'local';
597
829
  json(response, 200, await updateAutoSyncSchedule(paths, athleteId, {
598
830
  enabled: body.enabled,
@@ -600,11 +832,17 @@ export function createCatenceHttpServer(options = {}) {
600
832
  }));
601
833
  }
602
834
  catch (error) {
835
+ if (error instanceof AthleteForbiddenError) {
836
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
837
+ return;
838
+ }
603
839
  json(response, 400, { error: { code: 'schedule_update_failed', message: error instanceof Error ? error.message : String(error) } });
604
840
  }
605
841
  return;
606
842
  }
607
843
  if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
844
+ if (!requireAdmin(principal, response))
845
+ return;
608
846
  // Refresh the OpenCode Go profiles without starting a data sync. The
609
847
  // Console's Models page calls this behind its authenticated proxy.
610
848
  try {
@@ -625,11 +863,15 @@ export function createCatenceHttpServer(options = {}) {
625
863
  if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
626
864
  try {
627
865
  const url = new URL(request.url ?? '/', 'http://localhost');
628
- const paths = await dashboardPaths(catalogPaths, staticPaths, url);
866
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
629
867
  const athleteId = catalogPaths ? (await resolveAthlete(catalogPaths, url.searchParams.get('athleteId') ?? '')).athlete.id : 'local';
630
868
  json(response, 200, { athleteId, progress: await syncProgress(paths), lastSync: await lastSyncSummary(paths) });
631
869
  }
632
870
  catch (error) {
871
+ if (error instanceof AthleteForbiddenError) {
872
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
873
+ return;
874
+ }
633
875
  json(response, 400, { error: { code: 'sync_status_unavailable', message: error instanceof Error ? error.message : String(error) } });
634
876
  }
635
877
  return;
@@ -660,14 +902,18 @@ export function createCatenceHttpServer(options = {}) {
660
902
  return;
661
903
  }
662
904
  if (pathname === '/api/v1/mcp-exposure' && request.method === 'GET') {
905
+ if (!requireAdmin(principal, response))
906
+ return;
663
907
  if (!options.bearerTokenFile) {
664
908
  json(response, 400, { error: { code: 'exposure_not_configured', message: 'This server was started without a bearer-token file; restart it with --auth-token-file to manage MCP exposure.' } });
665
909
  return;
666
910
  }
667
- json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`));
911
+ json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`, oauthStatus()));
668
912
  return;
669
913
  }
670
914
  if (pathname === '/api/v1/mcp-exposure' && request.method === 'POST') {
915
+ if (!requireAdmin(principal, response))
916
+ return;
671
917
  if (!options.bearerTokenFile) {
672
918
  json(response, 400, { error: { code: 'exposure_not_configured', message: 'This server was started without a bearer-token file; restart it with --auth-token-file to manage MCP exposure.' } });
673
919
  return;
@@ -681,15 +927,137 @@ export function createCatenceHttpServer(options = {}) {
681
927
  ensureBearerToken(options.bearerTokenFile);
682
928
  else
683
929
  disableBearerToken(options.bearerTokenFile);
684
- json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`));
930
+ json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`, oauthStatus()));
685
931
  }
686
932
  catch (error) {
687
933
  json(response, 400, { error: { code: 'exposure_update_failed', message: error instanceof Error ? error.message : String(error) } });
688
934
  }
689
935
  return;
690
936
  }
937
+ if (pathname === '/api/v1/mcp-keys' && request.method === 'GET') {
938
+ if (!requireAdmin(principal, response))
939
+ return;
940
+ if (!options.mcpKeysFile) {
941
+ json(response, 400, { error: { code: 'mcp_keys_not_configured', message: 'This server was started without an MCP keys file; restart it with --mcp-keys-file to manage per-account keys.' } });
942
+ return;
943
+ }
944
+ try {
945
+ // Listing never exposes token hashes.
946
+ json(response, 200, { keys: await listMcpKeys(options.mcpKeysFile) });
947
+ }
948
+ catch (error) {
949
+ json(response, 400, { error: { code: 'mcp_keys_unavailable', message: error instanceof Error ? error.message : String(error) } });
950
+ }
951
+ return;
952
+ }
953
+ if (pathname === '/api/v1/mcp-keys' && request.method === 'POST') {
954
+ if (!requireAdmin(principal, response))
955
+ return;
956
+ if (!options.mcpKeysFile) {
957
+ json(response, 400, { error: { code: 'mcp_keys_not_configured', message: 'This server was started without an MCP keys file; restart it with --mcp-keys-file to manage per-account keys.' } });
958
+ return;
959
+ }
960
+ try {
961
+ const body = parseMcpKeyCreateBody(await readJsonBody(request));
962
+ const { record, token } = await addMcpKey(options.mcpKeysFile, body);
963
+ // The raw token is returned exactly once; only its hash is stored.
964
+ json(response, 201, { id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token });
965
+ }
966
+ catch (error) {
967
+ json(response, 400, { error: { code: 'mcp_key_create_failed', message: error instanceof Error ? error.message : String(error) } });
968
+ }
969
+ return;
970
+ }
971
+ if (pathname === '/api/v1/mcp-keys' && request.method === 'DELETE') {
972
+ if (!requireAdmin(principal, response))
973
+ return;
974
+ if (!options.mcpKeysFile) {
975
+ json(response, 400, { error: { code: 'mcp_keys_not_configured', message: 'This server was started without an MCP keys file; restart it with --mcp-keys-file to manage per-account keys.' } });
976
+ return;
977
+ }
978
+ const id = new URL(request.url ?? '/', 'http://localhost').searchParams.get('id');
979
+ if (!id) {
980
+ json(response, 400, { error: { code: 'invalid_request', message: 'id is required.' } });
981
+ return;
982
+ }
983
+ try {
984
+ await revokeMcpKey(options.mcpKeysFile, id);
985
+ json(response, 200, { revoked: id });
986
+ }
987
+ catch (error) {
988
+ if (error instanceof McpKeyNotFoundError) {
989
+ json(response, 404, { error: { code: 'mcp_key_not_found', message: error.message } });
990
+ return;
991
+ }
992
+ json(response, 400, { error: { code: 'mcp_key_revoke_failed', message: error instanceof Error ? error.message : String(error) } });
993
+ }
994
+ return;
995
+ }
996
+ if (pathname === '/api/v1/mcp-keys/roll' && request.method === 'POST') {
997
+ if (!requireAdmin(principal, response))
998
+ return;
999
+ if (!options.mcpKeysFile) {
1000
+ json(response, 400, { error: { code: 'mcp_keys_not_configured', message: 'This server was started without an MCP keys file; restart it with --mcp-keys-file to manage per-account keys.' } });
1001
+ return;
1002
+ }
1003
+ try {
1004
+ const body = parseMcpKeyRollBody(await readJsonBody(request));
1005
+ const { record, token } = await rollMcpKey(options.mcpKeysFile, body.id);
1006
+ // The new raw token is returned exactly once; only its hash is stored.
1007
+ json(response, 200, { id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token });
1008
+ }
1009
+ catch (error) {
1010
+ if (error instanceof McpKeyNotFoundError) {
1011
+ json(response, 404, { error: { code: 'mcp_key_not_found', message: error.message } });
1012
+ return;
1013
+ }
1014
+ json(response, 400, { error: { code: 'mcp_key_roll_failed', message: error instanceof Error ? error.message : String(error) } });
1015
+ }
1016
+ return;
1017
+ }
1018
+ if (pathname === '/api/v1/mcp-oauth-settings' && request.method === 'GET') {
1019
+ if (!requireAdmin(principal, response))
1020
+ return;
1021
+ if (!oauthSettingsFile) {
1022
+ json(response, 400, { error: { code: 'mcp_oauth_not_configured', message: 'This server was started without an OAuth settings file; restart it with --oauth-settings-file to manage OAuth from the Console.' } });
1023
+ return;
1024
+ }
1025
+ const status = effectiveOauth();
1026
+ json(response, 200, {
1027
+ enabled: status.enabled,
1028
+ publicUrl: status.publicUrl ?? null,
1029
+ source: status.source,
1030
+ settingsFile: oauthSettingsFile,
1031
+ settingsState: status.settingsState,
1032
+ });
1033
+ return;
1034
+ }
1035
+ if (pathname === '/api/v1/mcp-oauth-settings' && request.method === 'POST') {
1036
+ if (!requireAdmin(principal, response))
1037
+ return;
1038
+ if (!oauthSettingsFile) {
1039
+ json(response, 400, { error: { code: 'mcp_oauth_not_configured', message: 'This server was started without an OAuth settings file; restart it with --oauth-settings-file to manage OAuth from the Console.' } });
1040
+ return;
1041
+ }
1042
+ try {
1043
+ const body = parseMcpOauthSettingsBody(await readJsonBody(request));
1044
+ await writeMcpOauthSettings(oauthSettingsFile, body);
1045
+ const status = effectiveOauth();
1046
+ json(response, 200, {
1047
+ enabled: status.enabled,
1048
+ publicUrl: status.publicUrl ?? null,
1049
+ source: status.source,
1050
+ settingsFile: oauthSettingsFile,
1051
+ settingsState: status.settingsState,
1052
+ });
1053
+ }
1054
+ catch (error) {
1055
+ json(response, 400, { error: { code: 'mcp_oauth_settings_update_failed', message: error instanceof Error ? error.message : String(error) } });
1056
+ }
1057
+ return;
1058
+ }
691
1059
  if (pathname === '/mcp') {
692
- await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
1060
+ await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths(), principal);
693
1061
  return;
694
1062
  }
695
1063
  json(response, 404, { error: { code: 'not_found', message: 'No Catence endpoint matches this request.' } });