catence 0.2.1-beta.2 → 0.2.1-beta.21

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 +810 -16
  49. package/dist/interfaces/mcp/main.js +35 -3
  50. package/dist/interfaces/mcp/server.d.ts +21 -1
  51. package/dist/interfaces/mcp/server.js +107 -21
  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,105 @@
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 { createCatenceMcpServer } from '../mcp/server.js';
5
+ import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/sdk/types.js';
6
+ 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';
7
+ import { activeBearerToken, ensureBearerToken, disableBearerToken, mcpExposureStatus } from './cli-options.js';
8
+ import { CATENCE_MCP_SERVER_INFO, createCatenceMcpServer, MCP_INSTRUCTIONS } from '../mcp/server.js';
6
9
  import { generationStatus } from './generation-status.js';
7
10
  const MAX_REQUEST_BYTES = 1_000_000;
11
+ /** Endpoints that stay open when bearer authentication is enabled: they carry
12
+ * no personal data and must answer unauthenticated readiness probes. */
13
+ const UNAUTHENTICATED_PATHS = new Set(['/health', '/api/v1/health']);
14
+ /** Thrown when a principal resolves an athlete outside its scope; routes map it to 403 athlete_forbidden. */
15
+ class AthleteForbiddenError extends Error {
16
+ }
17
+ /** Full-access principal: unarmed loopback dev mode and the legacy shared token both mean "local admin". */
18
+ const FULL_ACCESS_PRINCIPAL = { kind: 'admin-token', role: 'admin', athleteIds: 'all' };
19
+ function bearerTokenFrom(request) {
20
+ const header = request.headers.authorization;
21
+ if (typeof header !== 'string' || !header.startsWith('Bearer '))
22
+ return null;
23
+ return header.slice('Bearer '.length);
24
+ }
25
+ function matchesLegacyToken(rawToken, expected) {
26
+ const received = Buffer.from(rawToken);
27
+ const expectedBuffer = Buffer.from(expected);
28
+ // timingSafeEqual throws on length mismatch, so compare sizes first.
29
+ return received.length === expectedBuffer.length && timingSafeEqual(received, expectedBuffer);
30
+ }
31
+ function scopeAllows(principal, athleteId) {
32
+ return scopeAllowsAthlete(principal.athleteIds, athleteId);
33
+ }
34
+ function requireAdmin(principal, response) {
35
+ if (principal.role === 'admin')
36
+ return true;
37
+ json(response, 403, { error: { code: 'admin_required', message: 'An admin account is required for this action.' } });
38
+ return false;
39
+ }
40
+ /**
41
+ * True when the principal may manage API keys bound to `account`: admins manage
42
+ * every account's keys; a key or OAuth principal manages only its own
43
+ * account's keys (self-service from the Console's account settings).
44
+ */
45
+ function canManageKeysFor(principal, account) {
46
+ return principal.role === 'admin' || principal.username === account;
47
+ }
48
+ function requireKeyManagementFor(principal, account, response) {
49
+ if (canManageKeysFor(principal, account))
50
+ return true;
51
+ json(response, 403, { error: { code: 'admin_required', message: 'An admin account is required to manage API keys for another account.' } });
52
+ return false;
53
+ }
54
+ /**
55
+ * Resolve the request's principal: the legacy shared token first, then a
56
+ * per-account MCP key, then an OAuth bearer token. The gate is armed when the
57
+ * legacy token is active, the key registry holds at least one active key, or
58
+ * the OAuth 2.1 server is effectively enabled; an unarmed server stays open
59
+ * for loopback dev. Resolution fails closed: a malformed registry or accounts
60
+ * store, a revoked key, or an unknown account never yields a principal.
61
+ */
62
+ async function resolvePrincipal(request, options, accountPaths) {
63
+ const legacyToken = options.bearerToken !== undefined ? options.bearerToken : options.bearerTokenFile !== undefined ? activeBearerToken(options.bearerTokenFile) : undefined;
64
+ const keysArmed = options.mcpKeysFile !== undefined && countActiveMcpKeys(options.mcpKeysFile) > 0;
65
+ const oauthArmed = options.oauthVerifier !== undefined && (options.oauthEnabled === undefined || options.oauthEnabled());
66
+ const armed = (legacyToken !== undefined && legacyToken !== null) || keysArmed || oauthArmed;
67
+ if (!armed)
68
+ return FULL_ACCESS_PRINCIPAL;
69
+ const rawToken = bearerTokenFrom(request);
70
+ if (rawToken === null)
71
+ return null;
72
+ if (typeof legacyToken === 'string' && matchesLegacyToken(rawToken, legacyToken))
73
+ return FULL_ACCESS_PRINCIPAL;
74
+ if (options.mcpKeysFile !== undefined) {
75
+ // A malformed accounts store fails closed: the key resolves to no principal.
76
+ const keyPrincipal = resolveKeyPrincipal(options.mcpKeysFile, rawToken, (username) => {
77
+ try {
78
+ return resolveAccountScope(accountPaths, username);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ });
84
+ if (keyPrincipal)
85
+ return { kind: 'key', username: keyPrincipal.account, role: keyPrincipal.role, athleteIds: keyPrincipal.athleteIds };
86
+ }
87
+ if (options.oauthVerifier !== undefined) {
88
+ const verified = await options.oauthVerifier(rawToken).catch(() => null);
89
+ if (verified) {
90
+ let scope = null;
91
+ try {
92
+ scope = resolveAccountScope(accountPaths, verified.username);
93
+ }
94
+ catch {
95
+ scope = null;
96
+ }
97
+ if (scope)
98
+ return { kind: 'oauth', username: verified.username, role: scope.role, athleteIds: scope.athleteIds };
99
+ }
100
+ }
101
+ return null;
102
+ }
8
103
  function json(response, statusCode, value) {
9
104
  const body = JSON.stringify(jsonSafe(value));
10
105
  response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
@@ -18,8 +113,8 @@ function addCorsHeaders(request, response, allowedOrigins) {
18
113
  return false;
19
114
  response.setHeader('access-control-allow-origin', origin);
20
115
  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');
116
+ response.setHeader('access-control-allow-methods', 'GET, POST, PUT, DELETE, OPTIONS');
117
+ response.setHeader('access-control-allow-headers', 'content-type, if-match, mcp-session-id');
23
118
  return true;
24
119
  }
25
120
  async function readJsonBody(request) {
@@ -76,17 +171,88 @@ async function dashboardSnapshot(paths, url) {
76
171
  await repository.close();
77
172
  }
78
173
  }
79
- async function dashboardPaths(catalogPaths, staticPaths, url) {
174
+ function parseMcpKeyCreateBody(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 (!['account', 'label'].includes(key))
180
+ throw new Error(`Unknown MCP key field: ${key}.`);
181
+ }
182
+ if (typeof raw.account !== 'string' || !raw.account.trim())
183
+ throw new Error('account is required.');
184
+ if (raw.label !== undefined && (typeof raw.label !== 'string' || raw.label.trim().length > 120)) {
185
+ throw new Error('label must be a string of at most 120 characters.');
186
+ }
187
+ return { account: raw.account.trim(), label: raw.label };
188
+ }
189
+ function parseMcpKeyRollBody(body) {
190
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
191
+ throw new Error('Request body must be a JSON object.');
192
+ const raw = body;
193
+ for (const key of Object.keys(raw)) {
194
+ if (key !== 'id')
195
+ throw new Error(`Unknown MCP key field: ${key}.`);
196
+ }
197
+ if (typeof raw.id !== 'string' || !raw.id.trim())
198
+ throw new Error('id is required.');
199
+ return { id: raw.id.trim() };
200
+ }
201
+ function parseMcpOauthSettingsBody(body) {
202
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
203
+ throw new Error('Request body must be a JSON object.');
204
+ const raw = body;
205
+ for (const key of Object.keys(raw)) {
206
+ if (!['enabled', 'publicUrl'].includes(key))
207
+ throw new Error(`Unknown OAuth settings field: ${key}.`);
208
+ }
209
+ if (typeof raw.enabled !== 'boolean')
210
+ throw new Error('enabled is required and must be a boolean.');
211
+ if (raw.publicUrl !== undefined && typeof raw.publicUrl !== 'string')
212
+ throw new Error('publicUrl must be a string.');
213
+ return { enabled: raw.enabled, publicUrl: raw.publicUrl };
214
+ }
215
+ async function dashboardPaths(catalogPaths, staticPaths, url, principal) {
80
216
  if (catalogPaths) {
81
217
  const athleteId = url.searchParams.get('athleteId');
82
218
  if (!athleteId)
83
219
  throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
220
+ if (!scopeAllows(principal, athleteId))
221
+ throw new AthleteForbiddenError(`This credential is not allowed to access athlete ${athleteId}.`);
84
222
  return (await resolveAthlete(catalogPaths, athleteId)).paths;
85
223
  }
86
224
  if (staticPaths)
87
225
  return staticPaths;
88
226
  throw new Error('No Catence data store is configured.');
89
227
  }
228
+ function parseSyncScheduleBody(body) {
229
+ if (body === undefined)
230
+ return {};
231
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
232
+ throw new Error('Request body must be a JSON object.');
233
+ const raw = body;
234
+ for (const key of Object.keys(raw)) {
235
+ if (!['athleteId', 'enabled', 'intervalMinutes'].includes(key)) {
236
+ throw new Error(`Unknown auto-sync field: ${key}.`);
237
+ }
238
+ }
239
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
240
+ throw new Error('athleteId must be a string.');
241
+ if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean')
242
+ throw new Error('enabled must be a boolean.');
243
+ if (raw.intervalMinutes !== undefined) {
244
+ if (typeof raw.intervalMinutes === 'string') {
245
+ raw.intervalMinutes = parseAutoSyncInterval(raw.intervalMinutes);
246
+ }
247
+ else if (typeof raw.intervalMinutes !== 'number') {
248
+ throw new Error('intervalMinutes must be a number or a label like 15m, 2h, 24h.');
249
+ }
250
+ }
251
+ if (raw.enabled === undefined && raw.intervalMinutes === undefined) {
252
+ throw new Error('Provide enabled, intervalMinutes, or both.');
253
+ }
254
+ return raw;
255
+ }
90
256
  function parseSyncBody(body) {
91
257
  if (body === undefined)
92
258
  return {};
@@ -94,7 +260,7 @@ function parseSyncBody(body) {
94
260
  throw new Error('Request body must be a JSON object.');
95
261
  const raw = body;
96
262
  for (const key of Object.keys(raw)) {
97
- if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels'].includes(key)) {
263
+ if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels', 'backfill'].includes(key)) {
98
264
  throw new Error(`Unknown sync field: ${key}.`);
99
265
  }
100
266
  }
@@ -106,16 +272,100 @@ function parseSyncBody(body) {
106
272
  if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
107
273
  throw new Error('from must use YYYY-MM-DD.');
108
274
  }
109
- for (const flag of ['refresh', 'refreshModels']) {
275
+ for (const flag of ['refresh', 'refreshModels', 'backfill']) {
110
276
  if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
111
277
  throw new Error(`${flag} must be a boolean.`);
112
278
  }
279
+ // A backfill extends history backwards from an explicit date; the CLI's
280
+ // backfill subcommand requires --from, so the API does the same.
281
+ if (raw.backfill === true && raw.from === undefined) {
282
+ throw new Error('from is required for a backfill.');
283
+ }
113
284
  return raw;
114
285
  }
115
- async function resolveSyncPaths(catalogPaths, staticPaths, body) {
286
+ function parseAthleteCreateBody(body) {
287
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
288
+ throw new Error('Request body must be a JSON object.');
289
+ const raw = body;
290
+ for (const key of Object.keys(raw)) {
291
+ if (!['id', 'label', 'setDefault'].includes(key))
292
+ throw new Error(`Unknown athlete field: ${key}.`);
293
+ }
294
+ if (typeof raw.id !== 'string' || !raw.id.trim())
295
+ throw new Error('id is required.');
296
+ if (typeof raw.label !== 'string' || !raw.label.trim())
297
+ throw new Error('label is required.');
298
+ if (raw.setDefault !== undefined && typeof raw.setDefault !== 'boolean')
299
+ throw new Error('setDefault must be a boolean.');
300
+ return { id: raw.id.trim(), label: raw.label.trim(), setDefault: raw.setDefault === true };
301
+ }
302
+ function parseAthleteFileBody(body) {
303
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
304
+ throw new Error('Request body must be a JSON object.');
305
+ const raw = body;
306
+ for (const key of Object.keys(raw)) {
307
+ if (!['operation', 'section', 'content', 'expectedHash'].includes(key))
308
+ throw new Error(`Unknown athlete-file field: ${key}.`);
309
+ }
310
+ if (typeof raw.content !== 'string')
311
+ throw new Error('content must be a string.');
312
+ const operation = raw.operation ?? 'replace';
313
+ if (operation !== 'replace' && operation !== 'append' && operation !== 'replace_section') {
314
+ throw new Error('operation must be replace, append, or replace_section.');
315
+ }
316
+ if (raw.section !== undefined && typeof raw.section !== 'string')
317
+ throw new Error('section must be a string.');
318
+ if (raw.expectedHash !== undefined && raw.expectedHash !== null && typeof raw.expectedHash !== 'string') {
319
+ throw new Error('expectedHash must be a string or null.');
320
+ }
321
+ return {
322
+ operation,
323
+ section: raw.section,
324
+ content: raw.content,
325
+ hasExpectedHash: Object.prototype.hasOwnProperty.call(raw, 'expectedHash'),
326
+ expectedHash: (raw.expectedHash ?? null),
327
+ };
328
+ }
329
+ function parseAthleteSecretBody(body) {
330
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
331
+ throw new Error('Request body must be a JSON object.');
332
+ const raw = body;
333
+ for (const key of Object.keys(raw)) {
334
+ if (!['athleteId', 'provider', 'field', 'value'].includes(key))
335
+ throw new Error(`Unknown athlete-secret field: ${key}.`);
336
+ }
337
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
338
+ throw new Error('athleteId must be a string.');
339
+ if (typeof raw.provider !== 'string' || !raw.provider.trim())
340
+ throw new Error('provider is required.');
341
+ if (typeof raw.field !== 'string' || !raw.field.trim())
342
+ throw new Error('field is required.');
343
+ if (typeof raw.value !== 'string' || !raw.value.trim())
344
+ throw new Error('value is required.');
345
+ return { athleteId: raw.athleteId, provider: raw.provider.trim(), field: raw.field.trim(), value: raw.value };
346
+ }
347
+ function parseAthleteSecretRemovalBody(body) {
348
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
349
+ throw new Error('Request body must be a JSON object.');
350
+ const raw = body;
351
+ for (const key of Object.keys(raw)) {
352
+ if (!['athleteId', 'provider', 'field'].includes(key))
353
+ throw new Error(`Unknown athlete-secret field: ${key}.`);
354
+ }
355
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
356
+ throw new Error('athleteId must be a string.');
357
+ if (typeof raw.provider !== 'string' || !raw.provider.trim())
358
+ throw new Error('provider is required.');
359
+ if (typeof raw.field !== 'string' || !raw.field.trim())
360
+ throw new Error('field is required.');
361
+ return { athleteId: raw.athleteId, provider: raw.provider.trim(), field: raw.field.trim() };
362
+ }
363
+ async function resolveSyncPaths(catalogPaths, staticPaths, body, principal) {
116
364
  if (catalogPaths) {
117
365
  if (!body.athleteId)
118
366
  throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
367
+ if (!scopeAllows(principal, body.athleteId))
368
+ throw new AthleteForbiddenError(`This credential is not allowed to access athlete ${body.athleteId}.`);
119
369
  return (await resolveAthlete(catalogPaths, body.athleteId)).paths;
120
370
  }
121
371
  if (staticPaths)
@@ -165,11 +415,46 @@ async function lastSyncSummary(paths) {
165
415
  return null;
166
416
  }
167
417
  }
168
- async function handleMcpRequest(request, response, paths) {
418
+ /**
419
+ * The MCP revision ChatGPT's hosted connector speaks during setup. The bundled
420
+ * SDK predates it, but the request shapes the revision uses — server/discover,
421
+ * then stateless tools/list and tools/call without initialize — are already
422
+ * handled. Only the protocol header value is rejected, so it is bridged down
423
+ * and server/discover is answered from the same descriptor initialize reports.
424
+ */
425
+ const MODERN_MCP_PROTOCOL_VERSION = '2026-07-28';
426
+ /** A single JSON-RPC request (batches are left to the transport). */
427
+ function singleJsonRpcRequest(body) {
428
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
429
+ return null;
430
+ const method = body.method;
431
+ return typeof method === 'string' ? body : null;
432
+ }
433
+ /**
434
+ * Server capabilities exactly as the SDK's initialize response reports them.
435
+ * getCapabilities() is the runtime accessor that builds that response; the
436
+ * typings mark it private.
437
+ */
438
+ function mcpServerCapabilities(server) {
439
+ return server.server.getCapabilities();
440
+ }
441
+ async function handleMcpRequest(request, response, paths, principal) {
169
442
  if (request.method !== 'POST') {
170
443
  methodNotAllowed(response);
171
444
  return;
172
445
  }
446
+ // ChatGPT's discovery probe and the requests that follow it carry the
447
+ // 2026-07-28 revision; the transport understands only the older list. The
448
+ // Node adapter rebuilds the web-standard headers from rawHeaders (not the
449
+ // parsed headers object), so both views are rewritten.
450
+ if (request.headers['mcp-protocol-version'] === MODERN_MCP_PROTOCOL_VERSION) {
451
+ request.headers['mcp-protocol-version'] = LATEST_PROTOCOL_VERSION;
452
+ const rawHeaders = request.rawHeaders;
453
+ for (let index = 0; index < rawHeaders.length; index += 2) {
454
+ if (rawHeaders[index]?.toLowerCase() === 'mcp-protocol-version')
455
+ rawHeaders[index + 1] = LATEST_PROTOCOL_VERSION;
456
+ }
457
+ }
173
458
  let body;
174
459
  try {
175
460
  body = await readJsonBody(request);
@@ -182,7 +467,34 @@ async function handleMcpRequest(request, response, paths) {
182
467
  });
183
468
  return;
184
469
  }
185
- const server = createCatenceMcpServer(paths);
470
+ const jsonRpc = singleJsonRpcRequest(body);
471
+ if (jsonRpc?.method === 'server/discover') {
472
+ // Modern clients probe discovery before anything else; answer from the
473
+ // same descriptor the initialize response reports so the two never drift.
474
+ const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
475
+ try {
476
+ json(response, 200, {
477
+ jsonrpc: '2.0',
478
+ id: jsonRpc.id ?? null,
479
+ result: {
480
+ resultType: 'complete',
481
+ supportedVersions: [MODERN_MCP_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS],
482
+ capabilities: mcpServerCapabilities(server),
483
+ _meta: { 'io.modelcontextprotocol/serverInfo': CATENCE_MCP_SERVER_INFO },
484
+ instructions: MCP_INSTRUCTIONS,
485
+ ttlMs: 3_600_000,
486
+ cacheScope: 'public',
487
+ },
488
+ });
489
+ }
490
+ finally {
491
+ await server.close();
492
+ }
493
+ return;
494
+ }
495
+ // The HTTP gate has already authenticated this request; the MCP server
496
+ // re-enforces the principal's athlete scope on every tool and roster.
497
+ const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
186
498
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
187
499
  let cleanedUp = false;
188
500
  const cleanup = async () => {
@@ -222,20 +534,120 @@ async function handleMcpRequest(request, response, paths) {
222
534
  export function createCatenceHttpServer(options = {}) {
223
535
  const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
224
536
  const staticPaths = options.paths ?? null;
537
+ // Key and OAuth principals resolve their athlete scope through the Console
538
+ // accounts that live next to the catalog.
539
+ const accountPaths = catalogPaths ?? resolveCatalogPaths();
225
540
  const allowedOrigins = options.allowedOrigins ?? [];
226
541
  const startSync = options.startSync ?? startDetachedSync;
227
542
  const mergeModels = options.mergeModels ?? mergeOpenCodeGoConsoleProfiles;
543
+ // OAuth 2.1 is configured by a Console-managed settings file
544
+ // (--oauth-settings-file / <catalog-home>/oauth/settings.json), which
545
+ // overrides the startup `oauth` option (env/flags) at runtime; a malformed
546
+ // file fails closed to disabled. The Express sub-app (express,
547
+ // mcp-oauth-server) builds lazily on first effective use, keyed by the
548
+ // public URL (the issuer), so the stdio path and a never-enabled serve never
549
+ // import express. Its verifier is wired into the gate as oauthVerifier;
550
+ // resolvePrincipal then maps the verified username to the live account scope.
551
+ const oauthSettingsFile = options.oauthSettingsFile;
552
+ const bootstrapOauth = options.oauth !== undefined ? { publicUrl: options.oauth.publicUrl.replace(/\/+$/, '') } : null;
553
+ const oauthConfigurable = options.oauth !== undefined || oauthSettingsFile !== undefined;
554
+ const effectiveOauth = () => {
555
+ if (oauthSettingsFile !== undefined) {
556
+ const { state, settings } = readMcpOauthSettings(oauthSettingsFile);
557
+ if (state === 'ok' && settings !== null) {
558
+ return { enabled: settings.enabled, ...(settings.publicUrl ? { publicUrl: settings.publicUrl } : {}), source: 'console', settingsState: state };
559
+ }
560
+ if (state === 'invalid')
561
+ return { enabled: false, source: 'disabled', settingsState: state };
562
+ }
563
+ if (bootstrapOauth !== null)
564
+ return { enabled: true, publicUrl: bootstrapOauth.publicUrl, source: 'environment', settingsState: 'missing' };
565
+ return { enabled: false, source: 'disabled', settingsState: 'missing' };
566
+ };
567
+ let oauthApp = null;
568
+ const getOauthApp = (publicUrl) => {
569
+ if (oauthApp === null || oauthApp.publicUrl !== publicUrl) {
570
+ const ready = import('./oauth.js').then(({ createOAuthApp }) => createOAuthApp({ catalogRoot: accountPaths.root, publicUrl }));
571
+ // Surface a build failure per request (the outer catch answers 500); this
572
+ // no-op branch only marks the rejection handled when no request ever comes.
573
+ void ready.catch(() => { });
574
+ oauthApp = { publicUrl, ready };
575
+ }
576
+ return oauthApp.ready;
577
+ };
578
+ let effectiveOptions = options;
579
+ if (oauthConfigurable) {
580
+ const verifier = async (token) => {
581
+ const config = effectiveOauth();
582
+ if (!config.enabled || config.publicUrl === undefined)
583
+ return null;
584
+ const verified = await (await getOauthApp(config.publicUrl)).verifyAccessToken(token);
585
+ if (!verified?.userId)
586
+ return null;
587
+ return { username: verified.userId };
588
+ };
589
+ effectiveOptions = { ...options, oauthVerifier: verifier, oauthEnabled: () => effectiveOauth().enabled };
590
+ }
591
+ // The status shape mcpExposureStatus reports; present whenever OAuth is
592
+ // configurable so the Console can read and change it.
593
+ const oauthStatus = () => {
594
+ if (!oauthConfigurable)
595
+ return undefined;
596
+ const config = effectiveOauth();
597
+ return { enabled: config.enabled, ...(config.publicUrl ? { publicUrl: config.publicUrl } : {}) };
598
+ };
228
599
  return createServer((request, response) => {
229
600
  void (async () => {
601
+ const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
602
+ // The OAuth endpoints are the auth surface itself: they bypass the gate
603
+ // and CORS entirely and are delegated to the Express sub-app. When OAuth
604
+ // is effectively disabled they answer 404 — no metadata is advertised.
605
+ if (pathname.startsWith('/.well-known/oauth-') || pathname.startsWith('/oauth/')) {
606
+ const config = effectiveOauth();
607
+ if (config.enabled && config.publicUrl !== undefined) {
608
+ const handle = await getOauthApp(config.publicUrl);
609
+ handle.app(request, response);
610
+ return;
611
+ }
612
+ json(response, 404, { error: { code: 'not_found', message: 'No Catence endpoint matches this request.' } });
613
+ return;
614
+ }
230
615
  if (!addCorsHeaders(request, response, allowedOrigins)) {
231
616
  json(response, 403, { error: { code: 'origin_not_allowed', message: 'This browser origin is not allowed to access Catence.' } });
232
617
  return;
233
618
  }
234
- const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
235
619
  if (request.method === 'OPTIONS') {
236
620
  response.writeHead(204).end();
237
621
  return;
238
622
  }
623
+ // Health probes stay open even when the gate is armed; they resolve no
624
+ // principal because nothing downstream consumes one.
625
+ const principal = UNAUTHENTICATED_PATHS.has(pathname)
626
+ ? FULL_ACCESS_PRINCIPAL
627
+ : await resolvePrincipal(request, effectiveOptions, accountPaths);
628
+ if (principal === null) {
629
+ // A presented-but-rejected token is a bearer problem, not an OAuth
630
+ // discovery problem: answer RFC 6750 invalid_token without
631
+ // resource_metadata, so static-key clients get a clear "your token is
632
+ // wrong" instead of being steered into the OAuth flow. With no token,
633
+ // RFC 9728 points OAuth clients at the protected-resource metadata so
634
+ // they discover the authorization server; without OAuth the 401 stays
635
+ // plain on purpose: MCP connector auto-detection treats it as a
636
+ // bearer gate rather than an OAuth server.
637
+ if (bearerTokenFrom(request) !== null) {
638
+ response.setHeader('www-authenticate', 'Bearer error="invalid_token", error_description="The access token is invalid or expired."');
639
+ }
640
+ else {
641
+ const config = effectiveOauth();
642
+ if (config.enabled && config.publicUrl !== undefined) {
643
+ const handle = await getOauthApp(config.publicUrl).catch(() => null);
644
+ if (handle !== null)
645
+ response.setHeader('www-authenticate', `Bearer resource_metadata="${handle.protectedResourceMetadataUrl}"`);
646
+ }
647
+ }
648
+ json(response, 401, { error: { code: 'unauthorized', message: 'A valid Authorization: Bearer token is required.' } });
649
+ return;
650
+ }
239
651
  if (pathname === '/health' && request.method === 'GET') {
240
652
  json(response, 200, catenceRuntimeHealth());
241
653
  return;
@@ -251,28 +663,195 @@ export function createCatenceHttpServer(options = {}) {
251
663
  }
252
664
  try {
253
665
  const catalog = await loadCatalog(catalogPaths);
254
- json(response, 200, { defaultAthleteId: catalog.defaultAthleteId, athletes: catalog.athletes });
666
+ // The roster is narrowed to the principal's athlete scope.
667
+ const roster = filterRosterToScope(catalog.athletes, catalog.defaultAthleteId, principal.athleteIds);
668
+ json(response, 200, { defaultAthleteId: roster.defaultAthleteId, athletes: roster.athletes });
255
669
  }
256
670
  catch (error) {
257
671
  json(response, 400, { error: { code: 'catalog_unavailable', message: error instanceof Error ? error.message : String(error) } });
258
672
  }
259
673
  return;
260
674
  }
675
+ if (pathname === '/api/v1/athletes' && request.method === 'POST') {
676
+ if (!requireAdmin(principal, response))
677
+ return;
678
+ if (!catalogPaths) {
679
+ json(response, 400, {
680
+ error: {
681
+ code: 'catalog_required',
682
+ message: 'Adding athletes requires a Catence catalog; this server uses a single legacy data store.',
683
+ },
684
+ });
685
+ return;
686
+ }
687
+ try {
688
+ const body = parseAthleteCreateBody(await readJsonBody(request));
689
+ const catalog = await loadCatalog(catalogPaths);
690
+ if (catalog.athletes.some((athlete) => athlete.id === body.id)) {
691
+ json(response, 409, { error: { code: 'athlete_exists', message: `Athlete ${body.id} already exists.` } });
692
+ return;
693
+ }
694
+ const updated = await addAthlete(catalogPaths, body);
695
+ json(response, 201, { defaultAthleteId: updated.defaultAthleteId, athletes: updated.athletes });
696
+ }
697
+ catch (error) {
698
+ json(response, 400, {
699
+ error: { code: 'athlete_create_failed', message: error instanceof Error ? error.message : String(error) },
700
+ });
701
+ }
702
+ return;
703
+ }
261
704
  if (pathname === '/api/v1/dashboard' && request.method === 'GET') {
262
705
  try {
263
706
  const url = new URL(request.url ?? '/', 'http://localhost');
264
- json(response, 200, await dashboardSnapshot(await dashboardPaths(catalogPaths, staticPaths, url), url));
707
+ json(response, 200, await dashboardSnapshot(await dashboardPaths(catalogPaths, staticPaths, url, principal), url));
265
708
  }
266
709
  catch (error) {
710
+ if (error instanceof AthleteForbiddenError) {
711
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
712
+ return;
713
+ }
267
714
  json(response, 400, { error: { code: 'dashboard_unavailable', message: error instanceof Error ? error.message : String(error) } });
268
715
  }
269
716
  return;
270
717
  }
718
+ if (pathname === '/api/v1/athlete-file' && request.method === 'GET') {
719
+ try {
720
+ const url = new URL(request.url ?? '/', 'http://localhost');
721
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
722
+ const revisionId = url.searchParams.get('revision');
723
+ if (revisionId) {
724
+ let content;
725
+ try {
726
+ content = await readAthleteFileRevision(paths, revisionId);
727
+ }
728
+ catch (error) {
729
+ if (!(error instanceof AthleteFileValidationError))
730
+ throw error;
731
+ content = null;
732
+ }
733
+ if (content === null) {
734
+ json(response, 404, { error: { code: 'athlete_file_revision_not_found', message: 'No athlete-file revision matches this identifier.' } });
735
+ return;
736
+ }
737
+ json(response, 200, { revisionId, content });
738
+ return;
739
+ }
740
+ const snapshot = await readAthleteFile(paths);
741
+ json(response, 200, { ...snapshot, revisions: await listAthleteFileRevisions(paths) });
742
+ }
743
+ catch (error) {
744
+ if (error instanceof AthleteForbiddenError) {
745
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
746
+ return;
747
+ }
748
+ json(response, 400, { error: { code: 'athlete_file_unavailable', message: error instanceof Error ? error.message : String(error) } });
749
+ }
750
+ return;
751
+ }
752
+ if (pathname === '/api/v1/athlete-file' && request.method === 'PUT') {
753
+ try {
754
+ const url = new URL(request.url ?? '/', 'http://localhost');
755
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
756
+ const parsed = parseAthleteFileBody(await readJsonBody(request));
757
+ const header = request.headers['if-match'];
758
+ const headerHash = typeof header === 'string' && header.trim() ? header.trim() : undefined;
759
+ if (!headerHash && !parsed.hasExpectedHash) {
760
+ throw new AthleteFileValidationError('expectedHash is required: send the If-Match header or the expectedHash body field (null to create the file).');
761
+ }
762
+ const snapshot = await updateAthleteFile(paths, {
763
+ operation: parsed.operation,
764
+ section: parsed.section,
765
+ content: parsed.content,
766
+ expectedHash: headerHash ?? parsed.expectedHash,
767
+ });
768
+ json(response, 200, snapshot);
769
+ }
770
+ catch (error) {
771
+ if (error instanceof AthleteForbiddenError) {
772
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
773
+ return;
774
+ }
775
+ if (error instanceof AthleteFileConflictError) {
776
+ json(response, 409, {
777
+ error: {
778
+ code: 'athlete_file_conflict',
779
+ message: error.message,
780
+ currentHash: error.currentHash,
781
+ currentContent: error.currentContent,
782
+ },
783
+ });
784
+ }
785
+ else if (error instanceof AthleteFileTooLargeError || error instanceof AthleteFileValidationError) {
786
+ json(response, 400, { error: { code: 'invalid_request', message: error.message } });
787
+ }
788
+ else {
789
+ json(response, 400, { error: { code: 'athlete_file_write_failed', message: error instanceof Error ? error.message : String(error) } });
790
+ }
791
+ }
792
+ return;
793
+ }
794
+ if (pathname === '/api/v1/athlete-secrets' && request.method === 'GET') {
795
+ try {
796
+ const url = new URL(request.url ?? '/', 'http://localhost');
797
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
798
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
799
+ }
800
+ catch (error) {
801
+ if (error instanceof AthleteForbiddenError) {
802
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
803
+ return;
804
+ }
805
+ const message = error instanceof Error ? error.message : String(error);
806
+ json(response, 400, { error: { code: 'athlete_secrets_unavailable', message } });
807
+ }
808
+ return;
809
+ }
810
+ if (pathname === '/api/v1/athlete-secrets' && request.method === 'PUT') {
811
+ try {
812
+ const parsed = parseAthleteSecretBody(await readJsonBody(request));
813
+ const url = new URL(request.url ?? '/', 'http://localhost');
814
+ if (!url.searchParams.get('athleteId') && parsed.athleteId)
815
+ url.searchParams.set('athleteId', parsed.athleteId);
816
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
817
+ await setAthleteSecret(paths, parsed.provider, parsed.field, parsed.value);
818
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
819
+ }
820
+ catch (error) {
821
+ if (error instanceof AthleteForbiddenError) {
822
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
823
+ return;
824
+ }
825
+ const message = error instanceof Error ? error.message : String(error);
826
+ json(response, 400, { error: { code: 'athlete_secret_write_failed', message } });
827
+ }
828
+ return;
829
+ }
830
+ if (pathname === '/api/v1/athlete-secrets/remove' && request.method === 'POST') {
831
+ try {
832
+ const parsed = parseAthleteSecretRemovalBody(await readJsonBody(request));
833
+ const url = new URL(request.url ?? '/', 'http://localhost');
834
+ if (!url.searchParams.get('athleteId') && parsed.athleteId)
835
+ url.searchParams.set('athleteId', parsed.athleteId);
836
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
837
+ await deleteAthleteSecret(paths, parsed.provider, parsed.field);
838
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
839
+ }
840
+ catch (error) {
841
+ if (error instanceof AthleteForbiddenError) {
842
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
843
+ return;
844
+ }
845
+ const message = error instanceof Error ? error.message : String(error);
846
+ json(response, 400, { error: { code: 'athlete_secret_remove_failed', message } });
847
+ }
848
+ return;
849
+ }
271
850
  if (pathname === '/api/v1/sync' && request.method === 'POST') {
272
851
  let body;
273
852
  try {
274
853
  body = parseSyncBody(await readJsonBody(request));
275
- const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
854
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, body, principal);
276
855
  // Optional model self-discovery runs before the data sync so new
277
856
  // OpenCode Go models appear without a manual script run. Failure is
278
857
  // non-fatal: it must never block or fail a data sync. The Console
@@ -294,6 +873,7 @@ export function createCatenceHttpServer(options = {}) {
294
873
  provider: body.provider,
295
874
  from: body.from,
296
875
  refresh: body.refresh,
876
+ backfill: body.backfill,
297
877
  // The child resolves the athlete within the catalog, not within
298
878
  // the athlete store this route resolved for locks and logs.
299
879
  cliHome: catalogPaths ? catalogPaths.root : undefined,
@@ -301,6 +881,10 @@ export function createCatenceHttpServer(options = {}) {
301
881
  json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
302
882
  }
303
883
  catch (error) {
884
+ if (error instanceof AthleteForbiddenError) {
885
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
886
+ return;
887
+ }
304
888
  json(response, error instanceof DetachedSyncBusyError ? 409 : 400, {
305
889
  error: {
306
890
  code: error instanceof DetachedSyncBusyError ? 'sync_in_progress' : 'sync_start_failed',
@@ -310,7 +894,45 @@ export function createCatenceHttpServer(options = {}) {
310
894
  }
311
895
  return;
312
896
  }
897
+ if (pathname === '/api/v1/sync/schedule' && request.method === 'GET') {
898
+ try {
899
+ const url = new URL(request.url ?? '/', 'http://localhost');
900
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
901
+ const athleteId = catalogPaths ? (url.searchParams.get('athleteId') ?? '') : 'local';
902
+ json(response, 200, await readAutoSyncSchedule(paths, athleteId));
903
+ }
904
+ catch (error) {
905
+ if (error instanceof AthleteForbiddenError) {
906
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
907
+ return;
908
+ }
909
+ json(response, 400, { error: { code: 'schedule_unavailable', message: error instanceof Error ? error.message : String(error) } });
910
+ }
911
+ return;
912
+ }
913
+ if (pathname === '/api/v1/sync/schedule' && request.method === 'PUT') {
914
+ try {
915
+ const body = parseSyncScheduleBody(await readJsonBody(request));
916
+ const url = new URL(request.url ?? '/', 'http://localhost');
917
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, { athleteId: body.athleteId }, principal);
918
+ const athleteId = catalogPaths ? (body.athleteId ?? '') : 'local';
919
+ json(response, 200, await updateAutoSyncSchedule(paths, athleteId, {
920
+ enabled: body.enabled,
921
+ ...(body.intervalMinutes === undefined ? {} : { intervalMinutes: parseAutoSyncInterval(String(body.intervalMinutes)) }),
922
+ }));
923
+ }
924
+ catch (error) {
925
+ if (error instanceof AthleteForbiddenError) {
926
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
927
+ return;
928
+ }
929
+ json(response, 400, { error: { code: 'schedule_update_failed', message: error instanceof Error ? error.message : String(error) } });
930
+ }
931
+ return;
932
+ }
313
933
  if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
934
+ if (!requireAdmin(principal, response))
935
+ return;
314
936
  // Refresh the OpenCode Go profiles without starting a data sync. The
315
937
  // Console's Models page calls this behind its authenticated proxy.
316
938
  try {
@@ -331,11 +953,15 @@ export function createCatenceHttpServer(options = {}) {
331
953
  if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
332
954
  try {
333
955
  const url = new URL(request.url ?? '/', 'http://localhost');
334
- const paths = await dashboardPaths(catalogPaths, staticPaths, url);
956
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url, principal);
335
957
  const athleteId = catalogPaths ? (await resolveAthlete(catalogPaths, url.searchParams.get('athleteId') ?? '')).athlete.id : 'local';
336
958
  json(response, 200, { athleteId, progress: await syncProgress(paths), lastSync: await lastSyncSummary(paths) });
337
959
  }
338
960
  catch (error) {
961
+ if (error instanceof AthleteForbiddenError) {
962
+ json(response, 403, { error: { code: 'athlete_forbidden', message: error.message } });
963
+ return;
964
+ }
339
965
  json(response, 400, { error: { code: 'sync_status_unavailable', message: error instanceof Error ? error.message : String(error) } });
340
966
  }
341
967
  return;
@@ -365,8 +991,176 @@ export function createCatenceHttpServer(options = {}) {
365
991
  }
366
992
  return;
367
993
  }
994
+ if (pathname === '/api/v1/mcp-exposure' && request.method === 'GET') {
995
+ if (!requireAdmin(principal, response))
996
+ return;
997
+ if (!options.bearerTokenFile) {
998
+ 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.' } });
999
+ return;
1000
+ }
1001
+ json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`, oauthStatus()));
1002
+ return;
1003
+ }
1004
+ if (pathname === '/api/v1/mcp-exposure' && request.method === 'POST') {
1005
+ if (!requireAdmin(principal, response))
1006
+ return;
1007
+ if (!options.bearerTokenFile) {
1008
+ 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.' } });
1009
+ return;
1010
+ }
1011
+ try {
1012
+ const body = await readJsonBody(request);
1013
+ if (typeof body !== 'object' || body === null || Array.isArray(body) || !('enabled' in body) || typeof body.enabled !== 'boolean') {
1014
+ throw new Error('Request body must be a JSON object with a boolean "enabled" field.');
1015
+ }
1016
+ if (body.enabled)
1017
+ ensureBearerToken(options.bearerTokenFile);
1018
+ else
1019
+ disableBearerToken(options.bearerTokenFile);
1020
+ json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`, oauthStatus()));
1021
+ }
1022
+ catch (error) {
1023
+ json(response, 400, { error: { code: 'exposure_update_failed', message: error instanceof Error ? error.message : String(error) } });
1024
+ }
1025
+ return;
1026
+ }
1027
+ if (pathname === '/api/v1/mcp-keys' && request.method === 'GET') {
1028
+ if (!options.mcpKeysFile) {
1029
+ 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.' } });
1030
+ return;
1031
+ }
1032
+ try {
1033
+ // Listing never exposes token hashes. Admins see the whole registry;
1034
+ // a key or OAuth principal sees only its own account's keys.
1035
+ const keys = await listMcpKeys(options.mcpKeysFile);
1036
+ json(response, 200, { keys: principal.role === 'admin' ? keys : keys.filter((key) => key.account === principal.username) });
1037
+ }
1038
+ catch (error) {
1039
+ json(response, 400, { error: { code: 'mcp_keys_unavailable', message: error instanceof Error ? error.message : String(error) } });
1040
+ }
1041
+ return;
1042
+ }
1043
+ if (pathname === '/api/v1/mcp-keys' && request.method === 'POST') {
1044
+ if (!options.mcpKeysFile) {
1045
+ 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.' } });
1046
+ return;
1047
+ }
1048
+ try {
1049
+ const body = parseMcpKeyCreateBody(await readJsonBody(request));
1050
+ // Admins may key any account; a key or OAuth principal only its own.
1051
+ if (!requireKeyManagementFor(principal, body.account, response))
1052
+ return;
1053
+ const { record, token } = await addMcpKey(options.mcpKeysFile, body);
1054
+ // The raw token is returned exactly once; only its hash is stored.
1055
+ json(response, 201, { id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token });
1056
+ }
1057
+ catch (error) {
1058
+ json(response, 400, { error: { code: 'mcp_key_create_failed', message: error instanceof Error ? error.message : String(error) } });
1059
+ }
1060
+ return;
1061
+ }
1062
+ if (pathname === '/api/v1/mcp-keys' && request.method === 'DELETE') {
1063
+ if (!options.mcpKeysFile) {
1064
+ 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.' } });
1065
+ return;
1066
+ }
1067
+ const id = new URL(request.url ?? '/', 'http://localhost').searchParams.get('id');
1068
+ if (!id) {
1069
+ json(response, 400, { error: { code: 'invalid_request', message: 'id is required.' } });
1070
+ return;
1071
+ }
1072
+ try {
1073
+ // Admins may revoke any key; a key or OAuth principal only its own.
1074
+ // An unknown id falls through to the same 404 as the admin path.
1075
+ if (principal.role !== 'admin') {
1076
+ const keys = await listMcpKeys(options.mcpKeysFile);
1077
+ const target = keys.find((key) => key.id === id);
1078
+ if (target && !requireKeyManagementFor(principal, target.account, response))
1079
+ return;
1080
+ }
1081
+ await revokeMcpKey(options.mcpKeysFile, id);
1082
+ json(response, 200, { revoked: id });
1083
+ }
1084
+ catch (error) {
1085
+ if (error instanceof McpKeyNotFoundError) {
1086
+ json(response, 404, { error: { code: 'mcp_key_not_found', message: error.message } });
1087
+ return;
1088
+ }
1089
+ json(response, 400, { error: { code: 'mcp_key_revoke_failed', message: error instanceof Error ? error.message : String(error) } });
1090
+ }
1091
+ return;
1092
+ }
1093
+ if (pathname === '/api/v1/mcp-keys/roll' && request.method === 'POST') {
1094
+ if (!options.mcpKeysFile) {
1095
+ 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.' } });
1096
+ return;
1097
+ }
1098
+ try {
1099
+ const body = parseMcpKeyRollBody(await readJsonBody(request));
1100
+ // Admins may roll any key; a key or OAuth principal only its own.
1101
+ // An unknown id falls through to the same 404 as the admin path.
1102
+ if (principal.role !== 'admin') {
1103
+ const keys = await listMcpKeys(options.mcpKeysFile);
1104
+ const target = keys.find((key) => key.id === body.id);
1105
+ if (target && !requireKeyManagementFor(principal, target.account, response))
1106
+ return;
1107
+ }
1108
+ const { record, token } = await rollMcpKey(options.mcpKeysFile, body.id);
1109
+ // The new raw token is returned exactly once; only its hash is stored.
1110
+ json(response, 200, { id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token });
1111
+ }
1112
+ catch (error) {
1113
+ if (error instanceof McpKeyNotFoundError) {
1114
+ json(response, 404, { error: { code: 'mcp_key_not_found', message: error.message } });
1115
+ return;
1116
+ }
1117
+ json(response, 400, { error: { code: 'mcp_key_roll_failed', message: error instanceof Error ? error.message : String(error) } });
1118
+ }
1119
+ return;
1120
+ }
1121
+ if (pathname === '/api/v1/mcp-oauth-settings' && request.method === 'GET') {
1122
+ if (!requireAdmin(principal, response))
1123
+ return;
1124
+ if (!oauthSettingsFile) {
1125
+ 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.' } });
1126
+ return;
1127
+ }
1128
+ const status = effectiveOauth();
1129
+ json(response, 200, {
1130
+ enabled: status.enabled,
1131
+ publicUrl: status.publicUrl ?? null,
1132
+ source: status.source,
1133
+ settingsFile: oauthSettingsFile,
1134
+ settingsState: status.settingsState,
1135
+ });
1136
+ return;
1137
+ }
1138
+ if (pathname === '/api/v1/mcp-oauth-settings' && request.method === 'POST') {
1139
+ if (!requireAdmin(principal, response))
1140
+ return;
1141
+ if (!oauthSettingsFile) {
1142
+ 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.' } });
1143
+ return;
1144
+ }
1145
+ try {
1146
+ const body = parseMcpOauthSettingsBody(await readJsonBody(request));
1147
+ await writeMcpOauthSettings(oauthSettingsFile, body);
1148
+ const status = effectiveOauth();
1149
+ json(response, 200, {
1150
+ enabled: status.enabled,
1151
+ publicUrl: status.publicUrl ?? null,
1152
+ source: status.source,
1153
+ settingsFile: oauthSettingsFile,
1154
+ settingsState: status.settingsState,
1155
+ });
1156
+ }
1157
+ catch (error) {
1158
+ json(response, 400, { error: { code: 'mcp_oauth_settings_update_failed', message: error instanceof Error ? error.message : String(error) } });
1159
+ }
1160
+ return;
1161
+ }
368
1162
  if (pathname === '/mcp') {
369
- await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
1163
+ await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths(), principal);
370
1164
  return;
371
1165
  }
372
1166
  json(response, 404, { error: { code: 'not_found', message: 'No Catence endpoint matches this request.' } });