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