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.
@@ -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.18";
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.18';
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` process for an athlete store and
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` process for an athlete store and
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
- await writeFile(logFile, `[${new Date().toISOString()}] catence-data --athlete ${request.athleteId} sync --provider ${provider}${request.from ? ` --from ${request.from}` : ''}${request.refresh ? ' --refresh' : ''}\n`, 'utf8');
77
- const arguments_ = [cliInvocation().entry, '--home', request.cliHome ?? request.paths.root, '--athlete', request.athleteId, 'sync', '--provider', provider];
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: true,
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
- // 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}"`);
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
- json(response, 200, { keys: await listMcpKeys(options.mcpKeysFile) });
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.
@@ -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';
@@ -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. The `401` deliberately carries no
332
- `WWW-Authenticate`, so MCP clients with auth auto-detection (Mistral Work)
333
- classify the server as bearer-auth rather than OAuth. With OAuth enabled the
334
- `401` instead gains a `WWW-Authenticate: Bearer resource_metadata="<public-url>/.well-known/oauth-protected-resource/mcp"`
335
- challenge — see [OAuth 2.1 (ChatGPT/Codex)](#oauth-21-chatgptcodex).
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. Admins manage the
402
- same registry in the Console under Settings → MCP Exposure → API keys (create
403
- with an account picker and a label — the raw token is shown once — and revoke
404
- by id), backed by the admin-only HTTP API `GET`/`POST`/`DELETE
405
- /api/v1/mcp-keys` and `POST /api/v1/mcp-keys/roll`.
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/mcp-keys`, `/api/v1/models/discover`) require an admin principal:
417
- the legacy token is always admin, an account key is admin only when its
418
- account is.
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 missing or invalid bearer token
459
- gains an RFC 9728 challenge —
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. Without `--oauth`
462
- the `401` stays plain on purpose (Mistral connector auto-detection).
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 added `WWW-Authenticate` challenge can flip a client with auth
467
- auto-detection (Mistral Work) from bearer to OAuth — smoke-test the Mistral
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) — unchanged |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.18",
3
+ "version": "0.2.1-beta.19",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {