catence 0.2.1-beta.18 → 0.2.1-beta.19
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/oauth/public-client.d.ts +28 -0
- package/dist/core/runtime/oauth/public-client.js +35 -0
- package/dist/elt/application/detached-sync.d.ts +7 -2
- package/dist/elt/application/detached-sync.js +10 -4
- package/dist/interfaces/http/oauth.d.ts +0 -20
- package/dist/interfaces/http/oauth.js +51 -2
- package/dist/interfaces/http/server.js +63 -21
- package/dist/interfaces/mcp/server.js +3 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +1 -0
- package/docs/deployment/local-mcp.md +48 -23
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.19";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.19';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-client normalization for OAuth client metadata.
|
|
3
|
+
*
|
|
4
|
+
* Some OAuth clients — OpenAI's ChatGPT/Codex connector is the known case —
|
|
5
|
+
* register (RFC 7591 DCR) or publish a client ID metadata document (CIMD)
|
|
6
|
+
* with `token_endpoint_auth_method: "private_key_jwt"`. Catence has no JWKS
|
|
7
|
+
* trust for client assertions, so it cannot honor that method; mcp-oauth-server
|
|
8
|
+
* rejects any method other than "none"/"client_secret_post" outright, which
|
|
9
|
+
* breaks those clients at registration or authorization time with
|
|
10
|
+
* `invalid_client: Unsupported token_endpoint_auth_method`.
|
|
11
|
+
*
|
|
12
|
+
* The fix is to treat such clients as what they are: public clients. No client
|
|
13
|
+
* secret is issued, redirect URIs are validated exactly as before, and PKCE
|
|
14
|
+
* with S256 stays enforced at the authorization and token endpoints — the same
|
|
15
|
+
* protection a public client gets with `"none"`. A `client_assertion` the
|
|
16
|
+
* client later sends at the token endpoint is ignored by the server's
|
|
17
|
+
* client-authentication step, which only reads `client_id`/`client_secret`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const PRIVATE_KEY_JWT = "private_key_jwt";
|
|
20
|
+
/**
|
|
21
|
+
* True when the metadata declares private_key_jwt client authentication.
|
|
22
|
+
*/
|
|
23
|
+
export declare function declaresPrivateKeyJwt(value: unknown): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Return the metadata with private_key_jwt downgraded to "none" (public
|
|
26
|
+
* client). Anything else passes through unchanged.
|
|
27
|
+
*/
|
|
28
|
+
export declare function asPublicClientMetadata<T>(value: T): T;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-client normalization for OAuth client metadata.
|
|
3
|
+
*
|
|
4
|
+
* Some OAuth clients — OpenAI's ChatGPT/Codex connector is the known case —
|
|
5
|
+
* register (RFC 7591 DCR) or publish a client ID metadata document (CIMD)
|
|
6
|
+
* with `token_endpoint_auth_method: "private_key_jwt"`. Catence has no JWKS
|
|
7
|
+
* trust for client assertions, so it cannot honor that method; mcp-oauth-server
|
|
8
|
+
* rejects any method other than "none"/"client_secret_post" outright, which
|
|
9
|
+
* breaks those clients at registration or authorization time with
|
|
10
|
+
* `invalid_client: Unsupported token_endpoint_auth_method`.
|
|
11
|
+
*
|
|
12
|
+
* The fix is to treat such clients as what they are: public clients. No client
|
|
13
|
+
* secret is issued, redirect URIs are validated exactly as before, and PKCE
|
|
14
|
+
* with S256 stays enforced at the authorization and token endpoints — the same
|
|
15
|
+
* protection a public client gets with `"none"`. A `client_assertion` the
|
|
16
|
+
* client later sends at the token endpoint is ignored by the server's
|
|
17
|
+
* client-authentication step, which only reads `client_id`/`client_secret`.
|
|
18
|
+
*/
|
|
19
|
+
export const PRIVATE_KEY_JWT = 'private_key_jwt';
|
|
20
|
+
/**
|
|
21
|
+
* True when the metadata declares private_key_jwt client authentication.
|
|
22
|
+
*/
|
|
23
|
+
export function declaresPrivateKeyJwt(value) {
|
|
24
|
+
return typeof value === 'object' && value !== null && value.token_endpoint_auth_method === PRIVATE_KEY_JWT;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Return the metadata with private_key_jwt downgraded to "none" (public
|
|
28
|
+
* client). Anything else passes through unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export function asPublicClientMetadata(value) {
|
|
31
|
+
if (!declaresPrivateKeyJwt(value))
|
|
32
|
+
return value;
|
|
33
|
+
return { ...value, token_endpoint_auth_method: 'none' };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=public-client.js.map
|
|
@@ -8,6 +8,11 @@ export type DetachedSyncRequest = {
|
|
|
8
8
|
provider?: DetachedSyncProvider;
|
|
9
9
|
from?: string;
|
|
10
10
|
refresh?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Run `catence-data backfill` instead of `sync`: extend history backwards
|
|
13
|
+
* from `from` without advancing the incremental cursors. Requires `from`.
|
|
14
|
+
*/
|
|
15
|
+
backfill?: boolean;
|
|
11
16
|
/**
|
|
12
17
|
* The `--home` value the detached `catence-data` child must receive.
|
|
13
18
|
*
|
|
@@ -35,8 +40,8 @@ export type DetachedSyncSpawner = (command: string, arguments_: readonly string[
|
|
|
35
40
|
unref(): void;
|
|
36
41
|
};
|
|
37
42
|
/**
|
|
38
|
-
* Spawn one detached `catence-data sync`
|
|
39
|
-
* return immediately.
|
|
43
|
+
* Spawn one detached `catence-data sync` (or `backfill`, when requested)
|
|
44
|
+
* process for an athlete store and return immediately.
|
|
40
45
|
*
|
|
41
46
|
* The child outlives this process (SSH deaths, Console restarts), logs to a
|
|
42
47
|
* file under the catalog home, and is guarded by the same live-progress
|
|
@@ -46,10 +46,15 @@ function assertValidRequest(request) {
|
|
|
46
46
|
if (request.from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(request.from)) {
|
|
47
47
|
throw new Error('from must use YYYY-MM-DD.');
|
|
48
48
|
}
|
|
49
|
+
// The CLI's backfill subcommand requires --from; enforce it here so the
|
|
50
|
+
// child never spawns into a usage error.
|
|
51
|
+
if (request.backfill && !request.from) {
|
|
52
|
+
throw new Error('from is required for a backfill.');
|
|
53
|
+
}
|
|
49
54
|
}
|
|
50
55
|
/**
|
|
51
|
-
* Spawn one detached `catence-data sync`
|
|
52
|
-
* return immediately.
|
|
56
|
+
* Spawn one detached `catence-data sync` (or `backfill`, when requested)
|
|
57
|
+
* process for an athlete store and return immediately.
|
|
53
58
|
*
|
|
54
59
|
* The child outlives this process (SSH deaths, Console restarts), logs to a
|
|
55
60
|
* file under the catalog home, and is guarded by the same live-progress
|
|
@@ -73,8 +78,9 @@ export async function startDetachedSync(request, dependencies = {}) {
|
|
|
73
78
|
const stamp = (dependencies.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, '-');
|
|
74
79
|
const logFile = path.join(request.paths.root, 'logs', `sync-${request.athleteId}-${stamp}.log`);
|
|
75
80
|
await mkdir(path.dirname(logFile), { recursive: true });
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
const subcommand = request.backfill ? 'backfill' : 'sync';
|
|
82
|
+
await writeFile(logFile, `[${new Date().toISOString()}] catence-data --athlete ${request.athleteId} ${subcommand} --provider ${provider}${request.from ? ` --from ${request.from}` : ''}${request.refresh ? ' --refresh' : ''}\n`, 'utf8');
|
|
83
|
+
const arguments_ = [cliInvocation().entry, '--home', request.cliHome ?? request.paths.root, '--athlete', request.athleteId, subcommand, '--provider', provider];
|
|
78
84
|
if (request.from)
|
|
79
85
|
arguments_.push('--from', request.from);
|
|
80
86
|
if (request.refresh)
|
|
@@ -10,26 +10,6 @@ export type OAuthAppHandle = {
|
|
|
10
10
|
/** The RFC 9728 metadata URL advertised in the 401 WWW-Authenticate challenge. */
|
|
11
11
|
protectedResourceMetadataUrl: string;
|
|
12
12
|
};
|
|
13
|
-
/**
|
|
14
|
-
* The OAuth 2.1 authorization server for `catence serve --oauth`, built on the
|
|
15
|
-
* mcp-oauth-server package over the file-backed model in core/runtime/oauth.
|
|
16
|
-
*
|
|
17
|
-
* Layout of the public surface (issuer = publicUrl, resource = publicUrl/mcp):
|
|
18
|
-
*
|
|
19
|
-
* GET /.well-known/oauth-authorization-server RFC 8414 AS metadata
|
|
20
|
-
* GET /.well-known/oauth-protected-resource[/mcp] RFC 9728 PRM metadata
|
|
21
|
-
* * /oauth/authorize, /oauth/token, /oauth/register, /oauth/revoke
|
|
22
|
-
* (mcpAuthRouter mounted at the root with baseUrl = publicUrl + /oauth)
|
|
23
|
-
* GET /oauth/consent login + consent form (round-trips the authorize params)
|
|
24
|
-
* POST /oauth/confirm authenticateHandler: bcrypt login, then issues the code
|
|
25
|
-
* POST /oauth/deny redirects to the client with error=access_denied
|
|
26
|
-
*
|
|
27
|
-
* The consent round-trip is the lib's contract: the authorize handler 302s to
|
|
28
|
-
* authorizationUrl with the full authorize request as query params, and
|
|
29
|
-
* authenticateHandler re-validates those same params from the confirm POST
|
|
30
|
-
* body — so the consent form echoes them as hidden fields next to the
|
|
31
|
-
* username/password inputs. Credentials and tokens are never logged.
|
|
32
|
-
*/
|
|
33
13
|
export declare function createOAuthApp(options: {
|
|
34
14
|
catalogRoot: string;
|
|
35
15
|
publicUrl: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { authenticateHandler, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter, OAuthServer, redirectUriMatches, } from 'mcp-oauth-server';
|
|
4
|
-
import { createFileOAuthServerModel, resolveCatalogPaths, verifyAccountPassword } from '../../runtime/index.js';
|
|
4
|
+
import { asPublicClientMetadata, createFileOAuthServerModel, declaresPrivateKeyJwt, resolveCatalogPaths, verifyAccountPassword } from '../../runtime/index.js';
|
|
5
5
|
/**
|
|
6
6
|
* The OAuth 2.1 authorization server for `catence serve --oauth`, built on the
|
|
7
7
|
* mcp-oauth-server package over the file-backed model in core/runtime/oauth.
|
|
@@ -22,6 +22,51 @@ import { createFileOAuthServerModel, resolveCatalogPaths, verifyAccountPassword
|
|
|
22
22
|
* body — so the consent form echoes them as hidden fields next to the
|
|
23
23
|
* username/password inputs. Credentials and tokens are never logged.
|
|
24
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* Fetch wrapper for client ID metadata documents (CIMD): clients such as
|
|
27
|
+
* OpenAI's connector declare `token_endpoint_auth_method: "private_key_jwt"`
|
|
28
|
+
* in their document. The server cannot verify a client JWT assertion, so the
|
|
29
|
+
* declaration is downgraded to a public client ("none") before the library
|
|
30
|
+
* validates and caches the document — PKCE still protects the flow. The
|
|
31
|
+
* rewritten body drops content-length; the library's size guard reads the
|
|
32
|
+
* actual stream anyway.
|
|
33
|
+
*/
|
|
34
|
+
function sanitizingMetadataFetch(fetchImpl) {
|
|
35
|
+
return async (input, init) => {
|
|
36
|
+
const response = await fetchImpl(input, init);
|
|
37
|
+
if (!response.ok)
|
|
38
|
+
return response;
|
|
39
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
40
|
+
if (!contentType.includes('application/json'))
|
|
41
|
+
return response;
|
|
42
|
+
let document;
|
|
43
|
+
try {
|
|
44
|
+
document = JSON.parse(await response.text());
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return response;
|
|
48
|
+
}
|
|
49
|
+
if (!declaresPrivateKeyJwt(document))
|
|
50
|
+
return response;
|
|
51
|
+
const rewritten = JSON.stringify(asPublicClientMetadata(document));
|
|
52
|
+
const headers = new Headers(response.headers);
|
|
53
|
+
headers.delete('content-length');
|
|
54
|
+
return new Response(rewritten, { status: response.status, statusText: response.statusText, headers });
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* DCR body sanitizer for POST /oauth/register. Mounted ahead of the library's
|
|
59
|
+
* router: it parses the JSON body and downgrades private_key_jwt to "none";
|
|
60
|
+
* the library's own express.json() then skips the already-consumed stream
|
|
61
|
+
* (body-parser returns early once the request is finished) and validates the
|
|
62
|
+
* sanitized metadata, issuing no client secret for the downgraded client.
|
|
63
|
+
*/
|
|
64
|
+
function sanitizeRegisterBody(request, _response, next) {
|
|
65
|
+
if (declaresPrivateKeyJwt(request.body)) {
|
|
66
|
+
request.body = asPublicClientMetadata(request.body);
|
|
67
|
+
}
|
|
68
|
+
next();
|
|
69
|
+
}
|
|
25
70
|
export async function createOAuthApp(options) {
|
|
26
71
|
const publicUrl = options.publicUrl.replace(/\/+$/, '');
|
|
27
72
|
const issuerUrl = new URL(publicUrl);
|
|
@@ -33,7 +78,7 @@ export async function createOAuthApp(options) {
|
|
|
33
78
|
authorizationUrl: new URL(`${publicUrl}/oauth/consent`),
|
|
34
79
|
resourceServerUrl,
|
|
35
80
|
scopesSupported: ['mcp:tools'],
|
|
36
|
-
clientIdMetadataDocuments:
|
|
81
|
+
clientIdMetadataDocuments: { fetch: sanitizingMetadataFetch(fetch) },
|
|
37
82
|
model: createFileOAuthServerModel(path.join(options.catalogRoot, 'oauth')),
|
|
38
83
|
});
|
|
39
84
|
const oauthMetadata = createOAuthMetadata({ provider, baseUrl: oauthBaseUrl });
|
|
@@ -60,6 +105,10 @@ export async function createOAuthApp(options) {
|
|
|
60
105
|
app.get('/.well-known/oauth-authorization-server', serveMetadata(oauthMetadata));
|
|
61
106
|
app.get('/.well-known/oauth-protected-resource', serveMetadata(protectedResourceMetadata));
|
|
62
107
|
app.get('/.well-known/oauth-protected-resource/mcp', serveMetadata(protectedResourceMetadata));
|
|
108
|
+
// DCR runs through the library's router, whose own express.json() would
|
|
109
|
+
// re-parse (and reject) an already-consumed stream; parsing here first
|
|
110
|
+
// leaves the sanitized body in place for it.
|
|
111
|
+
app.use('/oauth/register', express.json(), sanitizeRegisterBody);
|
|
63
112
|
app.use(mcpAuthRouter({
|
|
64
113
|
provider,
|
|
65
114
|
baseUrl: oauthBaseUrl,
|
|
@@ -36,6 +36,20 @@ function requireAdmin(principal, response) {
|
|
|
36
36
|
json(response, 403, { error: { code: 'admin_required', message: 'An admin account is required for this action.' } });
|
|
37
37
|
return false;
|
|
38
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
|
+
}
|
|
39
53
|
/**
|
|
40
54
|
* Resolve the request's principal: the legacy shared token first, then a
|
|
41
55
|
* per-account MCP key, then an OAuth bearer token. The gate is armed when the
|
|
@@ -245,7 +259,7 @@ function parseSyncBody(body) {
|
|
|
245
259
|
throw new Error('Request body must be a JSON object.');
|
|
246
260
|
const raw = body;
|
|
247
261
|
for (const key of Object.keys(raw)) {
|
|
248
|
-
if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels'].includes(key)) {
|
|
262
|
+
if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels', 'backfill'].includes(key)) {
|
|
249
263
|
throw new Error(`Unknown sync field: ${key}.`);
|
|
250
264
|
}
|
|
251
265
|
}
|
|
@@ -257,10 +271,15 @@ function parseSyncBody(body) {
|
|
|
257
271
|
if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
|
|
258
272
|
throw new Error('from must use YYYY-MM-DD.');
|
|
259
273
|
}
|
|
260
|
-
for (const flag of ['refresh', 'refreshModels']) {
|
|
274
|
+
for (const flag of ['refresh', 'refreshModels', 'backfill']) {
|
|
261
275
|
if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
|
|
262
276
|
throw new Error(`${flag} must be a boolean.`);
|
|
263
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
|
+
}
|
|
264
283
|
return raw;
|
|
265
284
|
}
|
|
266
285
|
function parseAthleteCreateBody(body) {
|
|
@@ -546,15 +565,24 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
546
565
|
? FULL_ACCESS_PRINCIPAL
|
|
547
566
|
: await resolvePrincipal(request, effectiveOptions, accountPaths);
|
|
548
567
|
if (principal === null) {
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
}
|
|
558
586
|
}
|
|
559
587
|
json(response, 401, { error: { code: 'unauthorized', message: 'A valid Authorization: Bearer token is required.' } });
|
|
560
588
|
return;
|
|
@@ -784,6 +812,7 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
784
812
|
provider: body.provider,
|
|
785
813
|
from: body.from,
|
|
786
814
|
refresh: body.refresh,
|
|
815
|
+
backfill: body.backfill,
|
|
787
816
|
// The child resolves the athlete within the catalog, not within
|
|
788
817
|
// the athlete store this route resolved for locks and logs.
|
|
789
818
|
cliHome: catalogPaths ? catalogPaths.root : undefined,
|
|
@@ -935,15 +964,15 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
935
964
|
return;
|
|
936
965
|
}
|
|
937
966
|
if (pathname === '/api/v1/mcp-keys' && request.method === 'GET') {
|
|
938
|
-
if (!requireAdmin(principal, response))
|
|
939
|
-
return;
|
|
940
967
|
if (!options.mcpKeysFile) {
|
|
941
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.' } });
|
|
942
969
|
return;
|
|
943
970
|
}
|
|
944
971
|
try {
|
|
945
|
-
// Listing never exposes token hashes.
|
|
946
|
-
|
|
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) });
|
|
947
976
|
}
|
|
948
977
|
catch (error) {
|
|
949
978
|
json(response, 400, { error: { code: 'mcp_keys_unavailable', message: error instanceof Error ? error.message : String(error) } });
|
|
@@ -951,14 +980,15 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
951
980
|
return;
|
|
952
981
|
}
|
|
953
982
|
if (pathname === '/api/v1/mcp-keys' && request.method === 'POST') {
|
|
954
|
-
if (!requireAdmin(principal, response))
|
|
955
|
-
return;
|
|
956
983
|
if (!options.mcpKeysFile) {
|
|
957
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.' } });
|
|
958
985
|
return;
|
|
959
986
|
}
|
|
960
987
|
try {
|
|
961
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;
|
|
962
992
|
const { record, token } = await addMcpKey(options.mcpKeysFile, body);
|
|
963
993
|
// The raw token is returned exactly once; only its hash is stored.
|
|
964
994
|
json(response, 201, { id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token });
|
|
@@ -969,8 +999,6 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
969
999
|
return;
|
|
970
1000
|
}
|
|
971
1001
|
if (pathname === '/api/v1/mcp-keys' && request.method === 'DELETE') {
|
|
972
|
-
if (!requireAdmin(principal, response))
|
|
973
|
-
return;
|
|
974
1002
|
if (!options.mcpKeysFile) {
|
|
975
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.' } });
|
|
976
1004
|
return;
|
|
@@ -981,6 +1009,14 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
981
1009
|
return;
|
|
982
1010
|
}
|
|
983
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
|
+
}
|
|
984
1020
|
await revokeMcpKey(options.mcpKeysFile, id);
|
|
985
1021
|
json(response, 200, { revoked: id });
|
|
986
1022
|
}
|
|
@@ -994,14 +1030,20 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
994
1030
|
return;
|
|
995
1031
|
}
|
|
996
1032
|
if (pathname === '/api/v1/mcp-keys/roll' && request.method === 'POST') {
|
|
997
|
-
if (!requireAdmin(principal, response))
|
|
998
|
-
return;
|
|
999
1033
|
if (!options.mcpKeysFile) {
|
|
1000
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.' } });
|
|
1001
1035
|
return;
|
|
1002
1036
|
}
|
|
1003
1037
|
try {
|
|
1004
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
|
+
}
|
|
1005
1047
|
const { record, token } = await rollMcpKey(options.mcpKeysFile, body.id);
|
|
1006
1048
|
// The new raw token is returned exactly once; only its hash is stored.
|
|
1007
1049
|
json(response, 200, { id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token });
|
|
@@ -315,11 +315,12 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
315
315
|
}))));
|
|
316
316
|
registerTool('start_detached_sync', {
|
|
317
317
|
title: 'Start a detached data sync',
|
|
318
|
-
description: 'Write-only sync trigger, named like the other explicit lock-guarded write tools. Spawns one detached catence-data sync process for this athlete and returns immediately with the run handle and log file; it refuses while another sync run is active. Track the run with catence_sync_progress; this tool never waits for completion.',
|
|
318
|
+
description: 'Write-only sync trigger, named like the other explicit lock-guarded write tools. Spawns one detached catence-data sync process for this athlete and returns immediately with the run handle and log file; it refuses while another sync run is active. Track the run with catence_sync_progress; this tool never waits for completion. Pass backfill with a from date to extend history backwards without advancing the incremental cursors (from is required when backfill is set).',
|
|
319
319
|
inputSchema: {
|
|
320
320
|
provider: z.enum(['intervals', 'garmin', 'strava', 'all']).optional(),
|
|
321
321
|
from: z.string().date().optional(),
|
|
322
322
|
refresh: z.boolean().optional(),
|
|
323
|
+
backfill: z.boolean().optional(),
|
|
323
324
|
},
|
|
324
325
|
}, tool('start_detached_sync', async (input) => {
|
|
325
326
|
const paths = activePaths();
|
|
@@ -330,6 +331,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
330
331
|
provider: input.provider,
|
|
331
332
|
from: input.from,
|
|
332
333
|
refresh: input.refresh,
|
|
334
|
+
backfill: input.backfill,
|
|
333
335
|
// The child resolves the athlete within the catalog, not within the
|
|
334
336
|
// athlete store this tool resolved for locks and logs: passing the
|
|
335
337
|
// store itself trips the legacy-store guard in catalog deployments.
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type { McpKeyPrincipal, McpKeyRecord, McpKeySummary } from '../core/runti
|
|
|
18
18
|
export { consoleAccountsPath, filterRosterToScope, loadConsoleAccounts, resolveAccountScope, scopeAllowsAthlete, verifyAccountPassword, } from '../core/runtime/console-accounts.js';
|
|
19
19
|
export type { ConsoleAccount, ConsoleAccountRole, ConsoleAccountScope } from '../core/runtime/console-accounts.js';
|
|
20
20
|
export { createFileOAuthServerModel, FileOAuthServerModel } from '../core/runtime/oauth/model.js';
|
|
21
|
+
export { asPublicClientMetadata, declaresPrivateKeyJwt, PRIVATE_KEY_JWT } from '../core/runtime/oauth/public-client.js';
|
|
21
22
|
export { normalizeMcpOauthSettings, readMcpOauthSettings, validateOauthPublicUrl, writeMcpOauthSettings, } from '../core/runtime/oauth/settings.js';
|
|
22
23
|
export type { McpOauthSettings, McpOauthSettingsInput, McpOauthSettingsRead, McpOauthSettingsState, } from '../core/runtime/oauth/settings.js';
|
|
23
24
|
export { athleteProviderEnvironment, deleteAthleteSecret, describeAthleteSecrets, providerSecretPath, readAthleteSecrets, secretProviderLabels, setAthleteSecret } from '../core/runtime/secrets.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, init
|
|
|
5
5
|
export { addMcpKey, countActiveMcpKeys, listMcpKeys, McpKeyNotFoundError, resolveKeyPrincipal, revokeMcpKey, rollMcpKey, } from '../core/runtime/mcp-keys.js';
|
|
6
6
|
export { consoleAccountsPath, filterRosterToScope, loadConsoleAccounts, resolveAccountScope, scopeAllowsAthlete, verifyAccountPassword, } from '../core/runtime/console-accounts.js';
|
|
7
7
|
export { createFileOAuthServerModel, FileOAuthServerModel } from '../core/runtime/oauth/model.js';
|
|
8
|
+
export { asPublicClientMetadata, declaresPrivateKeyJwt, PRIVATE_KEY_JWT } from '../core/runtime/oauth/public-client.js';
|
|
8
9
|
export { normalizeMcpOauthSettings, readMcpOauthSettings, validateOauthPublicUrl, writeMcpOauthSettings, } from '../core/runtime/oauth/settings.js';
|
|
9
10
|
export { athleteProviderEnvironment, deleteAthleteSecret, describeAthleteSecrets, providerSecretPath, readAthleteSecrets, secretProviderLabels, setAthleteSecret } from '../core/runtime/secrets.js';
|
|
10
11
|
export { ATHLETE_FILE_MAX_CHARACTERS, ATHLETE_FILE_TEMPLATE, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, hashAthleteFileContent, listAthleteFileRevisions, readAthleteFile, readAthleteFileRevision, updateAthleteFile, } from '../core/runtime/athlete-file.js';
|
|
@@ -109,7 +109,9 @@ keys, or OAuth authenticate and scope callers; see
|
|
|
109
109
|
Strava hydration tools: it spawns a detached `catence-data sync` child for
|
|
110
110
|
the selected athlete and returns immediately with the run handle and log
|
|
111
111
|
file. It refuses to start while another sync run is active (`sync_in_progress`);
|
|
112
|
-
follow the run with `catence_sync_progress`.
|
|
112
|
+
follow the run with `catence_sync_progress`. Pass `backfill: true` with a
|
|
113
|
+
`from` date to spawn `catence-data backfill` instead — history is re-imported
|
|
114
|
+
backwards from that date without advancing the incremental sync cursors.
|
|
113
115
|
|
|
114
116
|
### Adding another athlete
|
|
115
117
|
|
|
@@ -328,11 +330,16 @@ catence-data mcp disable # remove the file; remote access is refused again
|
|
|
328
330
|
|
|
329
331
|
- Every endpoint except `/health` and `/api/v1/health` (kept open for readiness
|
|
330
332
|
probes) answers `401` without an exact `Authorization: Bearer <token>`
|
|
331
|
-
header while the gate is on.
|
|
332
|
-
`WWW-Authenticate
|
|
333
|
-
classify the server as bearer-auth rather than OAuth
|
|
334
|
-
`401` instead gains a
|
|
335
|
-
|
|
333
|
+
header while the gate is on. A request with no token gets no
|
|
334
|
+
`WWW-Authenticate` at all, so MCP clients with auth auto-detection (Mistral
|
|
335
|
+
Work) classify the server as bearer-auth rather than OAuth; with OAuth
|
|
336
|
+
enabled a token-less `401` instead gains a
|
|
337
|
+
`WWW-Authenticate: Bearer resource_metadata="<public-url>/.well-known/oauth-protected-resource/mcp"`
|
|
338
|
+
challenge — see [OAuth 2.1 (ChatGPT/Codex)](#oauth-21-chatgptcodex). A request
|
|
339
|
+
that presents a wrong or expired token gets
|
|
340
|
+
`WWW-Authenticate: Bearer error="invalid_token", error_description="The access token is invalid or expired."`
|
|
341
|
+
with no `resource_metadata`: the client already holds a credential, so it is
|
|
342
|
+
told the token is bad instead of being steered into the OAuth flow.
|
|
336
343
|
- CORS preflights (`OPTIONS`) pass through without a token.
|
|
337
344
|
- An existing but unreadable or empty token file fails closed: the gate
|
|
338
345
|
rejects everything except the health probes until the file is fixed or
|
|
@@ -362,7 +369,11 @@ catence-data mcp disable # remove the file; remote access is refused again
|
|
|
362
369
|
returns `202` with the run handle immediately; an already-active run is
|
|
363
370
|
refused with `409 sync_in_progress`. Pass `"refreshModels": true` to also
|
|
364
371
|
refresh OpenCode Go model profiles first (failure is a non-fatal warning in
|
|
365
|
-
the response).
|
|
372
|
+
the response). Pass `"backfill": true` with a required `"from"` date
|
|
373
|
+
(`YYYY-MM-DD`) to run `catence-data backfill` instead: history is re-imported
|
|
374
|
+
backwards from that date without advancing the incremental cursors. The
|
|
375
|
+
Console's Status tab exposes the same control (date picker + "Backfill
|
|
376
|
+
history").
|
|
366
377
|
- `GET /api/v1/sync/status?athleteId=alex` reports live progress heartbeats,
|
|
367
378
|
recent runs, and best-effort last-completion timestamps per provider. It
|
|
368
379
|
keeps working while a sync holds the write lock (`lastSync` becomes `null`
|
|
@@ -398,11 +409,15 @@ catence-data mcp key roll <id> # new raw token, prin
|
|
|
398
409
|
catence-data mcp key revoke <id> # stops working immediately
|
|
399
410
|
```
|
|
400
411
|
|
|
401
|
-
All four accept `--file <path>` for a non-default registry.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
/api/v1/mcp-keys` and `POST
|
|
412
|
+
All four accept `--file <path>` for a non-default registry. Every Console
|
|
413
|
+
account manages its own keys in Settings → API keys (create with a label — the
|
|
414
|
+
raw token is shown once — plus roll and revoke); admins additionally see the
|
|
415
|
+
whole registry in Settings → MCP Exposure → API keys with an account picker.
|
|
416
|
+
The HTTP API `GET`/`POST`/`DELETE /api/v1/mcp-keys` and `POST
|
|
417
|
+
/api/v1/mcp-keys/roll` enforce the same boundary for direct callers and for
|
|
418
|
+
key- or OAuth-authenticated sessions: an admin principal may manage any
|
|
419
|
+
account's keys, any other principal only its own (`403 admin_required`
|
|
420
|
+
otherwise), and a non-admin's list shows only its own account's keys.
|
|
406
421
|
|
|
407
422
|
Scope semantics:
|
|
408
423
|
|
|
@@ -413,9 +428,10 @@ Scope semantics:
|
|
|
413
428
|
(`athlete_forbidden` tool error over MCP), and `list_athletes`,
|
|
414
429
|
`catence://athletes`, and `GET /api/v1/athletes` are filtered to the scope.
|
|
415
430
|
- Admin-only APIs (`POST /api/v1/athletes`, `/api/v1/mcp-exposure`,
|
|
416
|
-
`/api/v1/
|
|
417
|
-
|
|
418
|
-
account
|
|
431
|
+
`/api/v1/models/discover`) require an admin principal: the legacy token is
|
|
432
|
+
always admin, an account key is admin only when its account is. The key
|
|
433
|
+
registry APIs are account-scoped instead of admin-only — admins manage every
|
|
434
|
+
account's keys, any other principal only its own (see above).
|
|
419
435
|
- Fail closed: a revoked key, a deleted account, or an unreadable registry or
|
|
420
436
|
accounts file rejects the request (`401`) — never an anonymous fallback.
|
|
421
437
|
- The raw token is shown exactly once (on `add` and `roll`); only its hash is
|
|
@@ -455,23 +471,32 @@ its `source` (`console`, `environment`, or `disabled`).
|
|
|
455
471
|
`console/accounts.json`) and asks to approve or deny the client. The access
|
|
456
472
|
token carries the account username, and the athlete scope is that account's
|
|
457
473
|
live grants — scoped exactly like an account key.
|
|
458
|
-
- While OAuth is enabled, the `401` for a
|
|
459
|
-
|
|
474
|
+
- While OAuth is enabled, the `401` for a request with no bearer token gains
|
|
475
|
+
an RFC 9728 challenge —
|
|
460
476
|
`WWW-Authenticate: Bearer resource_metadata="<public-url>/.well-known/oauth-protected-resource/mcp"`
|
|
461
|
-
— so OAuth clients can discover the authorization server.
|
|
462
|
-
|
|
477
|
+
— so OAuth clients can discover the authorization server. A presented but
|
|
478
|
+
invalid token gets `invalid_token` without the challenge instead. Without
|
|
479
|
+
`--oauth` the `401` stays plain on purpose (Mistral connector
|
|
480
|
+
auto-detection).
|
|
481
|
+
- Clients that register (DCR) or publish a client ID metadata document with
|
|
482
|
+
`token_endpoint_auth_method: "private_key_jwt"` — OpenAI's ChatGPT/Codex
|
|
483
|
+
connector does — are accepted as public clients: the declaration is
|
|
484
|
+
downgraded to `"none"`, no client secret is issued, and PKCE with S256
|
|
485
|
+
still protects the flow. Catence cannot verify client JWT assertions, so
|
|
486
|
+
the method is never honored as such.
|
|
463
487
|
- Authorization-server state (registered clients, authorization codes, tokens)
|
|
464
488
|
lives in `<catalog-home>/oauth/` (directory mode 0700, files 0600).
|
|
465
489
|
|
|
466
|
-
Note: the
|
|
467
|
-
auto-detection (Mistral Work) from bearer to OAuth — smoke-test the
|
|
468
|
-
connector after enabling OAuth.
|
|
490
|
+
Note: the `resource_metadata` challenge on token-less `401`s can flip a client
|
|
491
|
+
with auth auto-detection (Mistral Work) from bearer to OAuth — smoke-test the
|
|
492
|
+
Mistral connector after enabling OAuth. A client that already sends a key
|
|
493
|
+
never sees the challenge; it only gets `invalid_token` when the key is wrong.
|
|
469
494
|
|
|
470
495
|
#### Client matrix
|
|
471
496
|
|
|
472
497
|
| Client | Setup | Credential |
|
|
473
498
|
| --- | --- | --- |
|
|
474
|
-
| Mistral Vibe (Work custom connector) | Connectors → Add Connector → Custom MCP Connector, URL `https://<host>/mcp` | Bearer credential (the shared token
|
|
499
|
+
| Mistral Vibe (Work custom connector) | Connectors → Add Connector → Custom MCP Connector, URL `https://<host>/mcp` | Bearer credential (the shared token or a per-account API key) |
|
|
475
500
|
| Claude (custom connector) | Authentication: "No sign in"; Request headers: `Authorization: Bearer <key>` | Per-account API key |
|
|
476
501
|
| Claude Code (CLI) | `claude mcp add --transport http --header "Authorization: Bearer <key>" <url>` | Per-account API key |
|
|
477
502
|
| ChatGPT / Codex | Connector OAuth connect flow | OAuth 2.1 (Console: MCP exposure → OAuth 2.1 sign-in, or `--oauth`) |
|
package/package.json
CHANGED