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.
- package/README.md +4 -4
- package/config.example.json +1 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +16 -10
- package/dist/core/query/repository.d.ts +1 -1
- package/dist/core/query/repository.js +31 -2
- package/dist/core/retrieval/index.js +23 -5
- package/dist/core/runtime/athlete-file.d.ts +49 -0
- package/dist/core/runtime/athlete-file.js +177 -0
- package/dist/core/runtime/configuration.js +2 -0
- package/dist/core/runtime/console-accounts.d.ts +52 -0
- package/dist/core/runtime/console-accounts.js +103 -0
- package/dist/core/runtime/mcp-keys.d.ts +65 -0
- package/dist/core/runtime/mcp-keys.js +171 -0
- package/dist/core/runtime/oauth/model.d.ts +44 -0
- package/dist/core/runtime/oauth/model.js +345 -0
- package/dist/core/runtime/oauth/public-client.d.ts +28 -0
- package/dist/core/runtime/oauth/public-client.js +35 -0
- package/dist/core/runtime/oauth/settings.d.ts +33 -0
- package/dist/core/runtime/oauth/settings.js +96 -0
- package/dist/core/runtime/secrets.d.ts +15 -0
- package/dist/core/runtime/secrets.js +48 -7
- package/dist/elt/application/autosync.d.ts +71 -0
- package/dist/elt/application/autosync.js +173 -0
- package/dist/elt/application/detached-sync.d.ts +7 -2
- package/dist/elt/application/detached-sync.js +10 -4
- package/dist/elt/application/management.d.ts +7 -0
- package/dist/elt/application/management.js +103 -7
- package/dist/elt/ingestion/importer.d.ts +11 -1
- package/dist/elt/ingestion/importer.js +63 -14
- package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
- package/dist/elt/ingestion/providers/intervals.js +21 -2
- package/dist/elt/normalization/normalizers.js +122 -0
- package/dist/elt/storage/database.d.ts +6 -1
- package/dist/elt/storage/database.js +7 -1
- package/dist/elt/storage/migrations.js +45 -1
- package/dist/elt/storage/progress-sidecar.d.ts +9 -0
- package/dist/elt/storage/progress-sidecar.js +31 -16
- package/dist/interfaces/cli/commands.js +117 -2
- package/dist/interfaces/http/cli-options.d.ts +64 -1
- package/dist/interfaces/http/cli-options.js +214 -2
- package/dist/interfaces/http/oauth.d.ts +16 -0
- package/dist/interfaces/http/oauth.js +228 -0
- package/dist/interfaces/http/server.d.ts +60 -1
- package/dist/interfaces/http/server.js +810 -16
- package/dist/interfaces/mcp/main.js +35 -3
- package/dist/interfaces/mcp/server.d.ts +21 -1
- package/dist/interfaces/mcp/server.js +107 -21
- package/dist/runtime/index.d.ts +15 -3
- package/dist/runtime/index.js +9 -2
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +76 -6
- package/docs/deployment/docker.md +173 -15
- package/docs/deployment/local-console.md +132 -21
- package/docs/deployment/local-mcp.md +190 -11
- package/docs/development.md +5 -3
- package/docs/llm-providers.md +4 -4
- package/package.json +11 -4
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +55 -7
- package/python/catence/providers/garmin/progress.py +71 -35
- package/python/catence/providers/garmin/staging.py +11 -4
- package/python/catence/providers/garmin/worker.py +420 -206
- 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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.' } });
|