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