catence 0.2.1-beta.2 → 0.2.1-beta.20

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.
Files changed (67) hide show
  1. package/README.md +4 -4
  2. package/config.example.json +1 -0
  3. package/dist/contracts/release.d.ts +1 -1
  4. package/dist/contracts/release.js +1 -1
  5. package/dist/contracts/runtime.d.ts +4 -0
  6. package/dist/core/query/catalog.d.ts +1 -1
  7. package/dist/core/query/catalog.js +16 -10
  8. package/dist/core/query/repository.d.ts +1 -1
  9. package/dist/core/query/repository.js +31 -2
  10. package/dist/core/retrieval/index.js +23 -5
  11. package/dist/core/runtime/athlete-file.d.ts +49 -0
  12. package/dist/core/runtime/athlete-file.js +177 -0
  13. package/dist/core/runtime/configuration.js +2 -0
  14. package/dist/core/runtime/console-accounts.d.ts +52 -0
  15. package/dist/core/runtime/console-accounts.js +103 -0
  16. package/dist/core/runtime/mcp-keys.d.ts +65 -0
  17. package/dist/core/runtime/mcp-keys.js +171 -0
  18. package/dist/core/runtime/oauth/model.d.ts +44 -0
  19. package/dist/core/runtime/oauth/model.js +345 -0
  20. package/dist/core/runtime/oauth/public-client.d.ts +28 -0
  21. package/dist/core/runtime/oauth/public-client.js +35 -0
  22. package/dist/core/runtime/oauth/settings.d.ts +33 -0
  23. package/dist/core/runtime/oauth/settings.js +96 -0
  24. package/dist/core/runtime/secrets.d.ts +15 -0
  25. package/dist/core/runtime/secrets.js +48 -7
  26. package/dist/elt/application/autosync.d.ts +71 -0
  27. package/dist/elt/application/autosync.js +173 -0
  28. package/dist/elt/application/detached-sync.d.ts +7 -2
  29. package/dist/elt/application/detached-sync.js +10 -4
  30. package/dist/elt/application/management.d.ts +7 -0
  31. package/dist/elt/application/management.js +103 -7
  32. package/dist/elt/ingestion/importer.d.ts +11 -1
  33. package/dist/elt/ingestion/importer.js +63 -14
  34. package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
  35. package/dist/elt/ingestion/providers/intervals.js +21 -2
  36. package/dist/elt/normalization/normalizers.js +122 -0
  37. package/dist/elt/storage/database.d.ts +6 -1
  38. package/dist/elt/storage/database.js +7 -1
  39. package/dist/elt/storage/migrations.js +45 -1
  40. package/dist/elt/storage/progress-sidecar.d.ts +9 -0
  41. package/dist/elt/storage/progress-sidecar.js +31 -16
  42. package/dist/interfaces/cli/commands.js +117 -2
  43. package/dist/interfaces/http/cli-options.d.ts +64 -1
  44. package/dist/interfaces/http/cli-options.js +214 -2
  45. package/dist/interfaces/http/oauth.d.ts +16 -0
  46. package/dist/interfaces/http/oauth.js +228 -0
  47. package/dist/interfaces/http/server.d.ts +60 -1
  48. package/dist/interfaces/http/server.js +748 -15
  49. package/dist/interfaces/mcp/main.js +35 -3
  50. package/dist/interfaces/mcp/server.d.ts +12 -1
  51. package/dist/interfaces/mcp/server.js +100 -19
  52. package/dist/runtime/index.d.ts +15 -3
  53. package/dist/runtime/index.js +9 -2
  54. package/docs/architecture.md +1 -1
  55. package/docs/configuration.md +76 -6
  56. package/docs/deployment/docker.md +173 -15
  57. package/docs/deployment/local-console.md +132 -21
  58. package/docs/deployment/local-mcp.md +190 -11
  59. package/docs/development.md +5 -3
  60. package/docs/llm-providers.md +4 -4
  61. package/package.json +11 -4
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +55 -7
  64. package/python/catence/providers/garmin/progress.py +71 -35
  65. package/python/catence/providers/garmin/staging.py +11 -4
  66. package/python/catence/providers/garmin/worker.py +420 -206
  67. package/uv.lock +3 -3
@@ -1,10 +1,104 @@
1
+ import { timingSafeEqual } from 'node:crypto';
1
2
  import { createServer } from 'node:http';
2
3
  import path from 'node:path';
3
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
- import { catenceRuntimeHealth, DashboardSnapshotService, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } 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
+ import { activeBearerToken, ensureBearerToken, disableBearerToken, mcpExposureStatus } from './cli-options.js';
5
7
  import { createCatenceMcpServer } from '../mcp/server.js';
6
8
  import { generationStatus } from './generation-status.js';
7
9
  const MAX_REQUEST_BYTES = 1_000_000;
10
+ /** Endpoints that stay open when bearer authentication is enabled: they carry
11
+ * no personal data and must answer unauthenticated readiness probes. */
12
+ const UNAUTHENTICATED_PATHS = new Set(['/health', '/api/v1/health']);
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);
27
+ // timingSafeEqual throws on length mismatch, so compare sizes first.
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
+ * True when the principal may manage API keys bound to `account`: admins manage
41
+ * every account's keys; a key or OAuth principal manages only its own
42
+ * account's keys (self-service from the Console's account settings).
43
+ */
44
+ function canManageKeysFor(principal, account) {
45
+ return principal.role === 'admin' || principal.username === account;
46
+ }
47
+ function requireKeyManagementFor(principal, account, response) {
48
+ if (canManageKeysFor(principal, account))
49
+ return true;
50
+ json(response, 403, { error: { code: 'admin_required', message: 'An admin account is required to manage API keys for another account.' } });
51
+ return false;
52
+ }
53
+ /**
54
+ * Resolve the request's principal: the legacy shared token first, then a
55
+ * per-account MCP key, then an OAuth bearer token. The gate is armed when the
56
+ * legacy token is active, the key registry holds at least one active key, or
57
+ * the OAuth 2.1 server is effectively enabled; an unarmed server stays open
58
+ * for loopback dev. Resolution fails closed: a malformed registry or accounts
59
+ * store, a revoked key, or an unknown account never yields a principal.
60
+ */
61
+ async function resolvePrincipal(request, options, accountPaths) {
62
+ const legacyToken = options.bearerToken !== undefined ? options.bearerToken : options.bearerTokenFile !== undefined ? activeBearerToken(options.bearerTokenFile) : undefined;
63
+ const keysArmed = options.mcpKeysFile !== undefined && countActiveMcpKeys(options.mcpKeysFile) > 0;
64
+ const oauthArmed = options.oauthVerifier !== undefined && (options.oauthEnabled === undefined || options.oauthEnabled());
65
+ const armed = (legacyToken !== undefined && legacyToken !== null) || keysArmed || oauthArmed;
66
+ if (!armed)
67
+ return FULL_ACCESS_PRINCIPAL;
68
+ const rawToken = bearerTokenFrom(request);
69
+ if (rawToken === null)
70
+ return null;
71
+ if (typeof legacyToken === 'string' && matchesLegacyToken(rawToken, legacyToken))
72
+ return FULL_ACCESS_PRINCIPAL;
73
+ if (options.mcpKeysFile !== undefined) {
74
+ // A malformed accounts store fails closed: the key resolves to no principal.
75
+ const keyPrincipal = resolveKeyPrincipal(options.mcpKeysFile, rawToken, (username) => {
76
+ try {
77
+ return resolveAccountScope(accountPaths, username);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ });
83
+ if (keyPrincipal)
84
+ return { kind: 'key', username: keyPrincipal.account, role: keyPrincipal.role, athleteIds: keyPrincipal.athleteIds };
85
+ }
86
+ if (options.oauthVerifier !== undefined) {
87
+ const verified = await options.oauthVerifier(rawToken).catch(() => null);
88
+ if (verified) {
89
+ let scope = null;
90
+ try {
91
+ scope = resolveAccountScope(accountPaths, verified.username);
92
+ }
93
+ catch {
94
+ scope = null;
95
+ }
96
+ if (scope)
97
+ return { kind: 'oauth', username: verified.username, role: scope.role, athleteIds: scope.athleteIds };
98
+ }
99
+ }
100
+ return null;
101
+ }
8
102
  function json(response, statusCode, value) {
9
103
  const body = JSON.stringify(jsonSafe(value));
10
104
  response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
@@ -18,8 +112,8 @@ function addCorsHeaders(request, response, allowedOrigins) {
18
112
  return false;
19
113
  response.setHeader('access-control-allow-origin', origin);
20
114
  response.setHeader('vary', 'Origin');
21
- response.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS');
22
- response.setHeader('access-control-allow-headers', 'content-type, mcp-session-id');
115
+ response.setHeader('access-control-allow-methods', 'GET, POST, PUT, DELETE, OPTIONS');
116
+ response.setHeader('access-control-allow-headers', 'content-type, if-match, mcp-session-id');
23
117
  return true;
24
118
  }
25
119
  async function readJsonBody(request) {
@@ -76,17 +170,88 @@ async function dashboardSnapshot(paths, url) {
76
170
  await repository.close();
77
171
  }
78
172
  }
79
- async function dashboardPaths(catalogPaths, staticPaths, url) {
173
+ function parseMcpKeyCreateBody(body) {
174
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
175
+ throw new Error('Request body must be a JSON object.');
176
+ const raw = body;
177
+ for (const key of Object.keys(raw)) {
178
+ if (!['account', 'label'].includes(key))
179
+ throw new Error(`Unknown MCP key field: ${key}.`);
180
+ }
181
+ if (typeof raw.account !== 'string' || !raw.account.trim())
182
+ throw new Error('account is required.');
183
+ if (raw.label !== undefined && (typeof raw.label !== 'string' || raw.label.trim().length > 120)) {
184
+ throw new Error('label must be a string of at most 120 characters.');
185
+ }
186
+ return { account: raw.account.trim(), label: raw.label };
187
+ }
188
+ function parseMcpKeyRollBody(body) {
189
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
190
+ throw new Error('Request body must be a JSON object.');
191
+ const raw = body;
192
+ for (const key of Object.keys(raw)) {
193
+ if (key !== 'id')
194
+ throw new Error(`Unknown MCP key field: ${key}.`);
195
+ }
196
+ if (typeof raw.id !== 'string' || !raw.id.trim())
197
+ throw new Error('id is required.');
198
+ return { id: raw.id.trim() };
199
+ }
200
+ function parseMcpOauthSettingsBody(body) {
201
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
202
+ throw new Error('Request body must be a JSON object.');
203
+ const raw = body;
204
+ for (const key of Object.keys(raw)) {
205
+ if (!['enabled', 'publicUrl'].includes(key))
206
+ throw new Error(`Unknown OAuth settings field: ${key}.`);
207
+ }
208
+ if (typeof raw.enabled !== 'boolean')
209
+ throw new Error('enabled is required and must be a boolean.');
210
+ if (raw.publicUrl !== undefined && typeof raw.publicUrl !== 'string')
211
+ throw new Error('publicUrl must be a string.');
212
+ return { enabled: raw.enabled, publicUrl: raw.publicUrl };
213
+ }
214
+ async function dashboardPaths(catalogPaths, staticPaths, url, principal) {
80
215
  if (catalogPaths) {
81
216
  const athleteId = url.searchParams.get('athleteId');
82
217
  if (!athleteId)
83
218
  throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
219
+ if (!scopeAllows(principal, athleteId))
220
+ throw new AthleteForbiddenError(`This credential is not allowed to access athlete ${athleteId}.`);
84
221
  return (await resolveAthlete(catalogPaths, athleteId)).paths;
85
222
  }
86
223
  if (staticPaths)
87
224
  return staticPaths;
88
225
  throw new Error('No Catence data store is configured.');
89
226
  }
227
+ function parseSyncScheduleBody(body) {
228
+ if (body === undefined)
229
+ return {};
230
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
231
+ throw new Error('Request body must be a JSON object.');
232
+ const raw = body;
233
+ for (const key of Object.keys(raw)) {
234
+ if (!['athleteId', 'enabled', 'intervalMinutes'].includes(key)) {
235
+ throw new Error(`Unknown auto-sync field: ${key}.`);
236
+ }
237
+ }
238
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
239
+ throw new Error('athleteId must be a string.');
240
+ if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean')
241
+ throw new Error('enabled must be a boolean.');
242
+ if (raw.intervalMinutes !== undefined) {
243
+ if (typeof raw.intervalMinutes === 'string') {
244
+ raw.intervalMinutes = parseAutoSyncInterval(raw.intervalMinutes);
245
+ }
246
+ else if (typeof raw.intervalMinutes !== 'number') {
247
+ throw new Error('intervalMinutes must be a number or a label like 15m, 2h, 24h.');
248
+ }
249
+ }
250
+ if (raw.enabled === undefined && raw.intervalMinutes === undefined) {
251
+ throw new Error('Provide enabled, intervalMinutes, or both.');
252
+ }
253
+ return raw;
254
+ }
90
255
  function parseSyncBody(body) {
91
256
  if (body === undefined)
92
257
  return {};
@@ -94,7 +259,7 @@ function parseSyncBody(body) {
94
259
  throw new Error('Request body must be a JSON object.');
95
260
  const raw = body;
96
261
  for (const key of Object.keys(raw)) {
97
- if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels'].includes(key)) {
262
+ if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels', 'backfill'].includes(key)) {
98
263
  throw new Error(`Unknown sync field: ${key}.`);
99
264
  }
100
265
  }
@@ -106,16 +271,100 @@ function parseSyncBody(body) {
106
271
  if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
107
272
  throw new Error('from must use YYYY-MM-DD.');
108
273
  }
109
- for (const flag of ['refresh', 'refreshModels']) {
274
+ for (const flag of ['refresh', 'refreshModels', 'backfill']) {
110
275
  if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
111
276
  throw new Error(`${flag} must be a boolean.`);
112
277
  }
278
+ // A backfill extends history backwards from an explicit date; the CLI's
279
+ // backfill subcommand requires --from, so the API does the same.
280
+ if (raw.backfill === true && raw.from === undefined) {
281
+ throw new Error('from is required for a backfill.');
282
+ }
113
283
  return raw;
114
284
  }
115
- async function resolveSyncPaths(catalogPaths, staticPaths, body) {
285
+ function parseAthleteCreateBody(body) {
286
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
287
+ throw new Error('Request body must be a JSON object.');
288
+ const raw = body;
289
+ for (const key of Object.keys(raw)) {
290
+ if (!['id', 'label', 'setDefault'].includes(key))
291
+ throw new Error(`Unknown athlete field: ${key}.`);
292
+ }
293
+ if (typeof raw.id !== 'string' || !raw.id.trim())
294
+ throw new Error('id is required.');
295
+ if (typeof raw.label !== 'string' || !raw.label.trim())
296
+ throw new Error('label is required.');
297
+ if (raw.setDefault !== undefined && typeof raw.setDefault !== 'boolean')
298
+ throw new Error('setDefault must be a boolean.');
299
+ return { id: raw.id.trim(), label: raw.label.trim(), setDefault: raw.setDefault === true };
300
+ }
301
+ function parseAthleteFileBody(body) {
302
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
303
+ throw new Error('Request body must be a JSON object.');
304
+ const raw = body;
305
+ for (const key of Object.keys(raw)) {
306
+ if (!['operation', 'section', 'content', 'expectedHash'].includes(key))
307
+ throw new Error(`Unknown athlete-file field: ${key}.`);
308
+ }
309
+ if (typeof raw.content !== 'string')
310
+ throw new Error('content must be a string.');
311
+ const operation = raw.operation ?? 'replace';
312
+ if (operation !== 'replace' && operation !== 'append' && operation !== 'replace_section') {
313
+ throw new Error('operation must be replace, append, or replace_section.');
314
+ }
315
+ if (raw.section !== undefined && typeof raw.section !== 'string')
316
+ throw new Error('section must be a string.');
317
+ if (raw.expectedHash !== undefined && raw.expectedHash !== null && typeof raw.expectedHash !== 'string') {
318
+ throw new Error('expectedHash must be a string or null.');
319
+ }
320
+ return {
321
+ operation,
322
+ section: raw.section,
323
+ content: raw.content,
324
+ hasExpectedHash: Object.prototype.hasOwnProperty.call(raw, 'expectedHash'),
325
+ expectedHash: (raw.expectedHash ?? null),
326
+ };
327
+ }
328
+ function parseAthleteSecretBody(body) {
329
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
330
+ throw new Error('Request body must be a JSON object.');
331
+ const raw = body;
332
+ for (const key of Object.keys(raw)) {
333
+ if (!['athleteId', 'provider', 'field', 'value'].includes(key))
334
+ throw new Error(`Unknown athlete-secret field: ${key}.`);
335
+ }
336
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
337
+ throw new Error('athleteId must be a string.');
338
+ if (typeof raw.provider !== 'string' || !raw.provider.trim())
339
+ throw new Error('provider is required.');
340
+ if (typeof raw.field !== 'string' || !raw.field.trim())
341
+ throw new Error('field is required.');
342
+ if (typeof raw.value !== 'string' || !raw.value.trim())
343
+ throw new Error('value is required.');
344
+ return { athleteId: raw.athleteId, provider: raw.provider.trim(), field: raw.field.trim(), value: raw.value };
345
+ }
346
+ function parseAthleteSecretRemovalBody(body) {
347
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
348
+ throw new Error('Request body must be a JSON object.');
349
+ const raw = body;
350
+ for (const key of Object.keys(raw)) {
351
+ if (!['athleteId', 'provider', 'field'].includes(key))
352
+ throw new Error(`Unknown athlete-secret field: ${key}.`);
353
+ }
354
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
355
+ throw new Error('athleteId must be a string.');
356
+ if (typeof raw.provider !== 'string' || !raw.provider.trim())
357
+ throw new Error('provider is required.');
358
+ if (typeof raw.field !== 'string' || !raw.field.trim())
359
+ throw new Error('field is required.');
360
+ return { athleteId: raw.athleteId, provider: raw.provider.trim(), field: raw.field.trim() };
361
+ }
362
+ async function resolveSyncPaths(catalogPaths, staticPaths, body, principal) {
116
363
  if (catalogPaths) {
117
364
  if (!body.athleteId)
118
365
  throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
366
+ if (!scopeAllows(principal, body.athleteId))
367
+ throw new AthleteForbiddenError(`This credential is not allowed to access athlete ${body.athleteId}.`);
119
368
  return (await resolveAthlete(catalogPaths, body.athleteId)).paths;
120
369
  }
121
370
  if (staticPaths)
@@ -165,7 +414,7 @@ async function lastSyncSummary(paths) {
165
414
  return null;
166
415
  }
167
416
  }
168
- async function handleMcpRequest(request, response, paths) {
417
+ async function handleMcpRequest(request, response, paths, principal) {
169
418
  if (request.method !== 'POST') {
170
419
  methodNotAllowed(response);
171
420
  return;
@@ -182,7 +431,9 @@ async function handleMcpRequest(request, response, paths) {
182
431
  });
183
432
  return;
184
433
  }
185
- const server = createCatenceMcpServer(paths);
434
+ // The HTTP gate has already authenticated this request; the MCP server
435
+ // re-enforces the principal's athlete scope on every tool and roster.
436
+ const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
186
437
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
187
438
  let cleanedUp = false;
188
439
  const cleanup = async () => {
@@ -222,20 +473,120 @@ async function handleMcpRequest(request, response, paths) {
222
473
  export function createCatenceHttpServer(options = {}) {
223
474
  const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
224
475
  const staticPaths = options.paths ?? null;
476
+ // Key and OAuth principals resolve their athlete scope through the Console
477
+ // accounts that live next to the catalog.
478
+ const accountPaths = catalogPaths ?? resolveCatalogPaths();
225
479
  const allowedOrigins = options.allowedOrigins ?? [];
226
480
  const startSync = options.startSync ?? startDetachedSync;
227
481
  const mergeModels = options.mergeModels ?? mergeOpenCodeGoConsoleProfiles;
482
+ // OAuth 2.1 is configured by a Console-managed settings file
483
+ // (--oauth-settings-file / <catalog-home>/oauth/settings.json), which
484
+ // overrides the startup `oauth` option (env/flags) at runtime; a malformed
485
+ // file fails closed to disabled. The Express sub-app (express,
486
+ // mcp-oauth-server) builds lazily on first effective use, keyed by the
487
+ // public URL (the issuer), so the stdio path and a never-enabled serve never
488
+ // import express. Its verifier is wired into the gate as oauthVerifier;
489
+ // resolvePrincipal then maps the verified username to the live account scope.
490
+ const oauthSettingsFile = options.oauthSettingsFile;
491
+ const bootstrapOauth = options.oauth !== undefined ? { publicUrl: options.oauth.publicUrl.replace(/\/+$/, '') } : null;
492
+ const oauthConfigurable = options.oauth !== undefined || oauthSettingsFile !== undefined;
493
+ const effectiveOauth = () => {
494
+ if (oauthSettingsFile !== undefined) {
495
+ const { state, settings } = readMcpOauthSettings(oauthSettingsFile);
496
+ if (state === 'ok' && settings !== null) {
497
+ return { enabled: settings.enabled, ...(settings.publicUrl ? { publicUrl: settings.publicUrl } : {}), source: 'console', settingsState: state };
498
+ }
499
+ if (state === 'invalid')
500
+ return { enabled: false, source: 'disabled', settingsState: state };
501
+ }
502
+ if (bootstrapOauth !== null)
503
+ return { enabled: true, publicUrl: bootstrapOauth.publicUrl, source: 'environment', settingsState: 'missing' };
504
+ return { enabled: false, source: 'disabled', settingsState: 'missing' };
505
+ };
506
+ let oauthApp = null;
507
+ const getOauthApp = (publicUrl) => {
508
+ if (oauthApp === null || oauthApp.publicUrl !== publicUrl) {
509
+ const ready = import('./oauth.js').then(({ createOAuthApp }) => createOAuthApp({ catalogRoot: accountPaths.root, publicUrl }));
510
+ // Surface a build failure per request (the outer catch answers 500); this
511
+ // no-op branch only marks the rejection handled when no request ever comes.
512
+ void ready.catch(() => { });
513
+ oauthApp = { publicUrl, ready };
514
+ }
515
+ return oauthApp.ready;
516
+ };
517
+ let effectiveOptions = options;
518
+ if (oauthConfigurable) {
519
+ const verifier = async (token) => {
520
+ const config = effectiveOauth();
521
+ if (!config.enabled || config.publicUrl === undefined)
522
+ return null;
523
+ const verified = await (await getOauthApp(config.publicUrl)).verifyAccessToken(token);
524
+ if (!verified?.userId)
525
+ return null;
526
+ return { username: verified.userId };
527
+ };
528
+ effectiveOptions = { ...options, oauthVerifier: verifier, oauthEnabled: () => effectiveOauth().enabled };
529
+ }
530
+ // The status shape mcpExposureStatus reports; present whenever OAuth is
531
+ // configurable so the Console can read and change it.
532
+ const oauthStatus = () => {
533
+ if (!oauthConfigurable)
534
+ return undefined;
535
+ const config = effectiveOauth();
536
+ return { enabled: config.enabled, ...(config.publicUrl ? { publicUrl: config.publicUrl } : {}) };
537
+ };
228
538
  return createServer((request, response) => {
229
539
  void (async () => {
540
+ const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
541
+ // The OAuth endpoints are the auth surface itself: they bypass the gate
542
+ // and CORS entirely and are delegated to the Express sub-app. When OAuth
543
+ // is effectively disabled they answer 404 — no metadata is advertised.
544
+ if (pathname.startsWith('/.well-known/oauth-') || pathname.startsWith('/oauth/')) {
545
+ const config = effectiveOauth();
546
+ if (config.enabled && config.publicUrl !== undefined) {
547
+ const handle = await getOauthApp(config.publicUrl);
548
+ handle.app(request, response);
549
+ return;
550
+ }
551
+ json(response, 404, { error: { code: 'not_found', message: 'No Catence endpoint matches this request.' } });
552
+ return;
553
+ }
230
554
  if (!addCorsHeaders(request, response, allowedOrigins)) {
231
555
  json(response, 403, { error: { code: 'origin_not_allowed', message: 'This browser origin is not allowed to access Catence.' } });
232
556
  return;
233
557
  }
234
- const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
235
558
  if (request.method === 'OPTIONS') {
236
559
  response.writeHead(204).end();
237
560
  return;
238
561
  }
562
+ // Health probes stay open even when the gate is armed; they resolve no
563
+ // principal because nothing downstream consumes one.
564
+ const principal = UNAUTHENTICATED_PATHS.has(pathname)
565
+ ? FULL_ACCESS_PRINCIPAL
566
+ : await resolvePrincipal(request, effectiveOptions, accountPaths);
567
+ if (principal === null) {
568
+ // A presented-but-rejected token is a bearer problem, not an OAuth
569
+ // discovery problem: answer RFC 6750 invalid_token without
570
+ // resource_metadata, so static-key clients get a clear "your token is
571
+ // wrong" instead of being steered into the OAuth flow. With no token,
572
+ // RFC 9728 points OAuth clients at the protected-resource metadata so
573
+ // they discover the authorization server; without OAuth the 401 stays
574
+ // plain on purpose: MCP connector auto-detection treats it as a
575
+ // bearer gate rather than an OAuth server.
576
+ if (bearerTokenFrom(request) !== null) {
577
+ response.setHeader('www-authenticate', 'Bearer error="invalid_token", error_description="The access token is invalid or expired."');
578
+ }
579
+ else {
580
+ const config = effectiveOauth();
581
+ if (config.enabled && config.publicUrl !== undefined) {
582
+ const handle = await getOauthApp(config.publicUrl).catch(() => null);
583
+ if (handle !== null)
584
+ response.setHeader('www-authenticate', `Bearer resource_metadata="${handle.protectedResourceMetadataUrl}"`);
585
+ }
586
+ }
587
+ json(response, 401, { error: { code: 'unauthorized', message: 'A valid Authorization: Bearer token is required.' } });
588
+ return;
589
+ }
239
590
  if (pathname === '/health' && request.method === 'GET') {
240
591
  json(response, 200, catenceRuntimeHealth());
241
592
  return;
@@ -251,28 +602,195 @@ export function createCatenceHttpServer(options = {}) {
251
602
  }
252
603
  try {
253
604
  const catalog = await loadCatalog(catalogPaths);
254
- json(response, 200, { defaultAthleteId: catalog.defaultAthleteId, athletes: catalog.athletes });
605
+ // The roster is narrowed to the principal's athlete scope.
606
+ const roster = filterRosterToScope(catalog.athletes, catalog.defaultAthleteId, principal.athleteIds);
607
+ json(response, 200, { defaultAthleteId: roster.defaultAthleteId, athletes: roster.athletes });
255
608
  }
256
609
  catch (error) {
257
610
  json(response, 400, { error: { code: 'catalog_unavailable', message: error instanceof Error ? error.message : String(error) } });
258
611
  }
259
612
  return;
260
613
  }
614
+ if (pathname === '/api/v1/athletes' && request.method === 'POST') {
615
+ if (!requireAdmin(principal, response))
616
+ return;
617
+ if (!catalogPaths) {
618
+ json(response, 400, {
619
+ error: {
620
+ code: 'catalog_required',
621
+ message: 'Adding athletes requires a Catence catalog; this server uses a single legacy data store.',
622
+ },
623
+ });
624
+ return;
625
+ }
626
+ try {
627
+ const body = parseAthleteCreateBody(await readJsonBody(request));
628
+ const catalog = await loadCatalog(catalogPaths);
629
+ if (catalog.athletes.some((athlete) => athlete.id === body.id)) {
630
+ json(response, 409, { error: { code: 'athlete_exists', message: `Athlete ${body.id} already exists.` } });
631
+ return;
632
+ }
633
+ const updated = await addAthlete(catalogPaths, body);
634
+ json(response, 201, { defaultAthleteId: updated.defaultAthleteId, athletes: updated.athletes });
635
+ }
636
+ catch (error) {
637
+ json(response, 400, {
638
+ error: { code: 'athlete_create_failed', message: error instanceof Error ? error.message : String(error) },
639
+ });
640
+ }
641
+ return;
642
+ }
261
643
  if (pathname === '/api/v1/dashboard' && request.method === 'GET') {
262
644
  try {
263
645
  const url = new URL(request.url ?? '/', 'http://localhost');
264
- json(response, 200, await dashboardSnapshot(await dashboardPaths(catalogPaths, staticPaths, url), url));
646
+ json(response, 200, await dashboardSnapshot(await dashboardPaths(catalogPaths, staticPaths, url, principal), url));
265
647
  }
266
648
  catch (error) {
649
+ if (error instanceof AthleteForbiddenError) {
650
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
651
+ return;
652
+ }
267
653
  json(response, 400, { error: { code: 'dashboard_unavailable', message: error instanceof Error ? error.message : String(error) } });
268
654
  }
269
655
  return;
270
656
  }
657
+ if (pathname === '/api/v1/athlete-file' && request.method === 'GET') {
658
+ try {
659
+ const url = new URL(request.url ?? '/', 'http://localhost');
660
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
661
+ const revisionId = url.searchParams.get('revision');
662
+ if (revisionId) {
663
+ let content;
664
+ try {
665
+ content = await readAthleteFileRevision(paths, revisionId);
666
+ }
667
+ catch (error) {
668
+ if (!(error instanceof AthleteFileValidationError))
669
+ throw error;
670
+ content = null;
671
+ }
672
+ if (content === null) {
673
+ json(response, 404, { error: { code: 'athlete_file_revision_not_found', message: 'No athlete-file revision matches this identifier.' } });
674
+ return;
675
+ }
676
+ json(response, 200, { revisionId, content });
677
+ return;
678
+ }
679
+ const snapshot = await readAthleteFile(paths);
680
+ json(response, 200, { ...snapshot, revisions: await listAthleteFileRevisions(paths) });
681
+ }
682
+ catch (error) {
683
+ if (error instanceof AthleteForbiddenError) {
684
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
685
+ return;
686
+ }
687
+ json(response, 400, { error: { code: 'athlete_file_unavailable', message: error instanceof Error ? error.message : String(error) } });
688
+ }
689
+ return;
690
+ }
691
+ if (pathname === '/api/v1/athlete-file' && request.method === 'PUT') {
692
+ try {
693
+ const url = new URL(request.url ?? '/', 'http://localhost');
694
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
695
+ const parsed = parseAthleteFileBody(await readJsonBody(request));
696
+ const header = request.headers['if-match'];
697
+ const headerHash = typeof header === 'string' && header.trim() ? header.trim() : undefined;
698
+ if (!headerHash && !parsed.hasExpectedHash) {
699
+ throw new AthleteFileValidationError('expectedHash is required: send the If-Match header or the expectedHash body field (null to create the file).');
700
+ }
701
+ const snapshot = await updateAthleteFile(paths, {
702
+ operation: parsed.operation,
703
+ section: parsed.section,
704
+ content: parsed.content,
705
+ expectedHash: headerHash ?? parsed.expectedHash,
706
+ });
707
+ json(response, 200, snapshot);
708
+ }
709
+ catch (error) {
710
+ if (error instanceof AthleteForbiddenError) {
711
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
712
+ return;
713
+ }
714
+ if (error instanceof AthleteFileConflictError) {
715
+ json(response, 409, {
716
+ error: {
717
+ code: 'athlete_file_conflict',
718
+ message: error.message,
719
+ currentHash: error.currentHash,
720
+ currentContent: error.currentContent,
721
+ },
722
+ });
723
+ }
724
+ else if (error instanceof AthleteFileTooLargeError || error instanceof AthleteFileValidationError) {
725
+ json(response, 400, { error: { code: 'invalid_request', message: error.message } });
726
+ }
727
+ else {
728
+ json(response, 400, { error: { code: 'athlete_file_write_failed', message: error instanceof Error ? error.message : String(error) } });
729
+ }
730
+ }
731
+ return;
732
+ }
733
+ if (pathname === '/api/v1/athlete-secrets' && request.method === 'GET') {
734
+ try {
735
+ const url = new URL(request.url ?? '/', 'http://localhost');
736
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
737
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
738
+ }
739
+ catch (error) {
740
+ if (error instanceof AthleteForbiddenError) {
741
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
742
+ return;
743
+ }
744
+ const message = error instanceof Error ? error.message : String(error);
745
+ json(response, 400, { error: { code: 'athlete_secrets_unavailable', message } });
746
+ }
747
+ return;
748
+ }
749
+ if (pathname === '/api/v1/athlete-secrets' && request.method === 'PUT') {
750
+ try {
751
+ const parsed = parseAthleteSecretBody(await readJsonBody(request));
752
+ const url = new URL(request.url ?? '/', 'http://localhost');
753
+ if (!url.searchParams.get('athleteId') && parsed.athleteId)
754
+ url.searchParams.set('athleteId', parsed.athleteId);
755
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
756
+ await setAthleteSecret(paths, parsed.provider, parsed.field, parsed.value);
757
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
758
+ }
759
+ catch (error) {
760
+ if (error instanceof AthleteForbiddenError) {
761
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
762
+ return;
763
+ }
764
+ const message = error instanceof Error ? error.message : String(error);
765
+ json(response, 400, { error: { code: 'athlete_secret_write_failed', message } });
766
+ }
767
+ return;
768
+ }
769
+ if (pathname === '/api/v1/athlete-secrets/remove' && request.method === 'POST') {
770
+ try {
771
+ const parsed = parseAthleteSecretRemovalBody(await readJsonBody(request));
772
+ const url = new URL(request.url ?? '/', 'http://localhost');
773
+ if (!url.searchParams.get('athleteId') && parsed.athleteId)
774
+ url.searchParams.set('athleteId', parsed.athleteId);
775
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
776
+ await deleteAthleteSecret(paths, parsed.provider, parsed.field);
777
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
778
+ }
779
+ catch (error) {
780
+ if (error instanceof AthleteForbiddenError) {
781
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
782
+ return;
783
+ }
784
+ const message = error instanceof Error ? error.message : String(error);
785
+ json(response, 400, { error: { code: 'athlete_secret_remove_failed', message } });
786
+ }
787
+ return;
788
+ }
271
789
  if (pathname === '/api/v1/sync' && request.method === 'POST') {
272
790
  let body;
273
791
  try {
274
792
  body = parseSyncBody(await readJsonBody(request));
275
- const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
793
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, body, principal);
276
794
  // Optional model self-discovery runs before the data sync so new
277
795
  // OpenCode Go models appear without a manual script run. Failure is
278
796
  // non-fatal: it must never block or fail a data sync. The Console
@@ -294,6 +812,7 @@ export function createCatenceHttpServer(options = {}) {
294
812
  provider: body.provider,
295
813
  from: body.from,
296
814
  refresh: body.refresh,
815
+ backfill: body.backfill,
297
816
  // The child resolves the athlete within the catalog, not within
298
817
  // the athlete store this route resolved for locks and logs.
299
818
  cliHome: catalogPaths ? catalogPaths.root : undefined,
@@ -301,6 +820,10 @@ export function createCatenceHttpServer(options = {}) {
301
820
  json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
302
821
  }
303
822
  catch (error) {
823
+ if (error instanceof AthleteForbiddenError) {
824
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
825
+ return;
826
+ }
304
827
  json(response, error instanceof DetachedSyncBusyError ? 409 : 400, {
305
828
  error: {
306
829
  code: error instanceof DetachedSyncBusyError ? 'sync_in_progress' : 'sync_start_failed',
@@ -310,7 +833,45 @@ export function createCatenceHttpServer(options = {}) {
310
833
  }
311
834
  return;
312
835
  }
836
+ if (pathname === '/api/v1/sync/schedule' && request.method === 'GET') {
837
+ try {
838
+ const url = new URL(request.url ?? '/', 'http://localhost');
839
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
840
+ const athleteId = catalogPaths ? (url.searchParams.get('athleteId') ?? '') : 'local';
841
+ json(response, 200, await readAutoSyncSchedule(paths, athleteId));
842
+ }
843
+ catch (error) {
844
+ if (error instanceof AthleteForbiddenError) {
845
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
846
+ return;
847
+ }
848
+ json(response, 400, { error: { code: 'schedule_unavailable', message: error instanceof Error ? error.message : String(error) } });
849
+ }
850
+ return;
851
+ }
852
+ if (pathname === '/api/v1/sync/schedule' && request.method === 'PUT') {
853
+ try {
854
+ const body = parseSyncScheduleBody(await readJsonBody(request));
855
+ const url = new URL(request.url ?? '/', 'http://localhost');
856
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, { athleteId: body.athleteId }, principal);
857
+ const athleteId = catalogPaths ? (body.athleteId ?? '') : 'local';
858
+ json(response, 200, await updateAutoSyncSchedule(paths, athleteId, {
859
+ enabled: body.enabled,
860
+ ...(body.intervalMinutes === undefined ? {} : { intervalMinutes: parseAutoSyncInterval(String(body.intervalMinutes)) }),
861
+ }));
862
+ }
863
+ catch (error) {
864
+ if (error instanceof AthleteForbiddenError) {
865
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
866
+ return;
867
+ }
868
+ json(response, 400, { error: { code: 'schedule_update_failed', message: error instanceof Error ? error.message : String(error) } });
869
+ }
870
+ return;
871
+ }
313
872
  if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
873
+ if (!requireAdmin(principal, response))
874
+ return;
314
875
  // Refresh the OpenCode Go profiles without starting a data sync. The
315
876
  // Console's Models page calls this behind its authenticated proxy.
316
877
  try {
@@ -331,11 +892,15 @@ export function createCatenceHttpServer(options = {}) {
331
892
  if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
332
893
  try {
333
894
  const url = new URL(request.url ?? '/', 'http://localhost');
334
- const paths = await dashboardPaths(catalogPaths, staticPaths, url);
895
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
335
896
  const athleteId = catalogPaths ? (await resolveAthlete(catalogPaths, url.searchParams.get('athleteId') ?? '')).athlete.id : 'local';
336
897
  json(response, 200, { athleteId, progress: await syncProgress(paths), lastSync: await lastSyncSummary(paths) });
337
898
  }
338
899
  catch (error) {
900
+ if (error instanceof AthleteForbiddenError) {
901
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
902
+ return;
903
+ }
339
904
  json(response, 400, { error: { code: 'sync_status_unavailable', message: error instanceof Error ? error.message : String(error) } });
340
905
  }
341
906
  return;
@@ -365,8 +930,176 @@ export function createCatenceHttpServer(options = {}) {
365
930
  }
366
931
  return;
367
932
  }
933
+ if (pathname === '/api/v1/mcp-exposure' && request.method === 'GET') {
934
+ if (!requireAdmin(principal, response))
935
+ return;
936
+ if (!options.bearerTokenFile) {
937
+ 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.' } });
938
+ return;
939
+ }
940
+ json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`, oauthStatus()));
941
+ return;
942
+ }
943
+ if (pathname === '/api/v1/mcp-exposure' && request.method === 'POST') {
944
+ if (!requireAdmin(principal, response))
945
+ return;
946
+ if (!options.bearerTokenFile) {
947
+ 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.' } });
948
+ return;
949
+ }
950
+ try {
951
+ const body = await readJsonBody(request);
952
+ if (typeof body !== 'object' || body === null || Array.isArray(body) || !('enabled' in body) || typeof body.enabled !== 'boolean') {
953
+ throw new Error('Request body must be a JSON object with a boolean "enabled" field.');
954
+ }
955
+ if (body.enabled)
956
+ ensureBearerToken(options.bearerTokenFile);
957
+ else
958
+ disableBearerToken(options.bearerTokenFile);
959
+ json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`, oauthStatus()));
960
+ }
961
+ catch (error) {
962
+ json(response, 400, { error: { code: 'exposure_update_failed', message: error instanceof Error ? error.message : String(error) } });
963
+ }
964
+ return;
965
+ }
966
+ if (pathname === '/api/v1/mcp-keys' && request.method === 'GET') {
967
+ if (!options.mcpKeysFile) {
968
+ 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.' } });
969
+ return;
970
+ }
971
+ try {
972
+ // Listing never exposes token hashes. Admins see the whole registry;
973
+ // a key or OAuth principal sees only its own account's keys.
974
+ const keys = await listMcpKeys(options.mcpKeysFile);
975
+ json(response, 200, { keys: principal.role === 'admin' ? keys : keys.filter((key) => key.account === principal.username) });
976
+ }
977
+ catch (error) {
978
+ json(response, 400, { error: { code: 'mcp_keys_unavailable', message: error instanceof Error ? error.message : String(error) } });
979
+ }
980
+ return;
981
+ }
982
+ if (pathname === '/api/v1/mcp-keys' && request.method === 'POST') {
983
+ if (!options.mcpKeysFile) {
984
+ 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.' } });
985
+ return;
986
+ }
987
+ try {
988
+ const body = parseMcpKeyCreateBody(await readJsonBody(request));
989
+ // Admins may key any account; a key or OAuth principal only its own.
990
+ if (!requireKeyManagementFor(principal, body.account, response))
991
+ return;
992
+ const { record, token } = await addMcpKey(options.mcpKeysFile, body);
993
+ // The raw token is returned exactly once; only its hash is stored.
994
+ json(response, 201, { id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token });
995
+ }
996
+ catch (error) {
997
+ json(response, 400, { error: { code: 'mcp_key_create_failed', message: error instanceof Error ? error.message : String(error) } });
998
+ }
999
+ return;
1000
+ }
1001
+ if (pathname === '/api/v1/mcp-keys' && request.method === 'DELETE') {
1002
+ if (!options.mcpKeysFile) {
1003
+ 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.' } });
1004
+ return;
1005
+ }
1006
+ const id = new URL(request.url ?? '/', 'http://localhost').searchParams.get('id');
1007
+ if (!id) {
1008
+ json(response, 400, { error: { code: 'invalid_request', message: 'id is required.' } });
1009
+ return;
1010
+ }
1011
+ try {
1012
+ // Admins may revoke any key; a key or OAuth principal only its own.
1013
+ // An unknown id falls through to the same 404 as the admin path.
1014
+ if (principal.role !== 'admin') {
1015
+ const keys = await listMcpKeys(options.mcpKeysFile);
1016
+ const target = keys.find((key) => key.id === id);
1017
+ if (target && !requireKeyManagementFor(principal, target.account, response))
1018
+ return;
1019
+ }
1020
+ await revokeMcpKey(options.mcpKeysFile, id);
1021
+ json(response, 200, { revoked: id });
1022
+ }
1023
+ catch (error) {
1024
+ if (error instanceof McpKeyNotFoundError) {
1025
+ json(response, 404, { error: { code: 'mcp_key_not_found', message: error.message } });
1026
+ return;
1027
+ }
1028
+ json(response, 400, { error: { code: 'mcp_key_revoke_failed', message: error instanceof Error ? error.message : String(error) } });
1029
+ }
1030
+ return;
1031
+ }
1032
+ if (pathname === '/api/v1/mcp-keys/roll' && request.method === 'POST') {
1033
+ if (!options.mcpKeysFile) {
1034
+ 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.' } });
1035
+ return;
1036
+ }
1037
+ try {
1038
+ const body = parseMcpKeyRollBody(await readJsonBody(request));
1039
+ // Admins may roll any key; a key or OAuth principal only its own.
1040
+ // An unknown id falls through to the same 404 as the admin path.
1041
+ if (principal.role !== 'admin') {
1042
+ const keys = await listMcpKeys(options.mcpKeysFile);
1043
+ const target = keys.find((key) => key.id === body.id);
1044
+ if (target && !requireKeyManagementFor(principal, target.account, response))
1045
+ return;
1046
+ }
1047
+ const { record, token } = await rollMcpKey(options.mcpKeysFile, body.id);
1048
+ // The new raw token is returned exactly once; only its hash is stored.
1049
+ json(response, 200, { id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token });
1050
+ }
1051
+ catch (error) {
1052
+ if (error instanceof McpKeyNotFoundError) {
1053
+ json(response, 404, { error: { code: 'mcp_key_not_found', message: error.message } });
1054
+ return;
1055
+ }
1056
+ json(response, 400, { error: { code: 'mcp_key_roll_failed', message: error instanceof Error ? error.message : String(error) } });
1057
+ }
1058
+ return;
1059
+ }
1060
+ if (pathname === '/api/v1/mcp-oauth-settings' && request.method === 'GET') {
1061
+ if (!requireAdmin(principal, response))
1062
+ return;
1063
+ if (!oauthSettingsFile) {
1064
+ 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.' } });
1065
+ return;
1066
+ }
1067
+ const status = effectiveOauth();
1068
+ json(response, 200, {
1069
+ enabled: status.enabled,
1070
+ publicUrl: status.publicUrl ?? null,
1071
+ source: status.source,
1072
+ settingsFile: oauthSettingsFile,
1073
+ settingsState: status.settingsState,
1074
+ });
1075
+ return;
1076
+ }
1077
+ if (pathname === '/api/v1/mcp-oauth-settings' && request.method === 'POST') {
1078
+ if (!requireAdmin(principal, response))
1079
+ return;
1080
+ if (!oauthSettingsFile) {
1081
+ 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.' } });
1082
+ return;
1083
+ }
1084
+ try {
1085
+ const body = parseMcpOauthSettingsBody(await readJsonBody(request));
1086
+ await writeMcpOauthSettings(oauthSettingsFile, body);
1087
+ const status = effectiveOauth();
1088
+ json(response, 200, {
1089
+ enabled: status.enabled,
1090
+ publicUrl: status.publicUrl ?? null,
1091
+ source: status.source,
1092
+ settingsFile: oauthSettingsFile,
1093
+ settingsState: status.settingsState,
1094
+ });
1095
+ }
1096
+ catch (error) {
1097
+ json(response, 400, { error: { code: 'mcp_oauth_settings_update_failed', message: error instanceof Error ? error.message : String(error) } });
1098
+ }
1099
+ return;
1100
+ }
368
1101
  if (pathname === '/mcp') {
369
- await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
1102
+ await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths(), principal);
370
1103
  return;
371
1104
  }
372
1105
  json(response, 404, { error: { code: 'not_found', message: 'No Catence endpoint matches this request.' } });