catence 0.2.1-beta.12 → 0.2.1-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,7 +120,7 @@ Optional local Streamable HTTP MCP and HTTP APIs:
120
120
  catence serve --host 127.0.0.1 --port 8787
121
121
  ```
122
122
 
123
- `GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route. `POST /api/v1/athletes` creates a catalog athlete (`{"id": "sam", "label": "Sam", "setDefault": false}`); the packaged Console exposes the same call to admins from **Settings → Athletes**. The MCP server has no authentication of its own.
123
+ `GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route. `POST /api/v1/athletes` creates a catalog athlete (`{"id": "sam", "label": "Sam", "setDefault": false}`); the packaged Console exposes the same call to admins from **Settings → Athletes**. The MCP server has no authentication of its own unless `--auth-token-file` (or `CATENCE_MCP_TOKEN_FILE`) requires a bearer token on every endpoint except the health probes.
124
124
 
125
125
  ## Configuration
126
126
 
@@ -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.12";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.14";
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.12';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.14';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -16,9 +16,24 @@ declare const secretSchema: z.ZodObject<{
16
16
  }, z.core.$strict>;
17
17
  export type AthleteSecrets = z.infer<typeof secretSchema>;
18
18
  export type SecretProvider = keyof AthleteSecrets;
19
+ /** Display labels for the Console credentials editor. */
20
+ export declare const secretProviderLabels: Record<SecretProvider, string>;
21
+ export type SecretFieldDescription = {
22
+ name: string;
23
+ configured: boolean;
24
+ };
25
+ export type SecretProviderDescription = {
26
+ id: SecretProvider;
27
+ label: string;
28
+ fields: SecretFieldDescription[];
29
+ };
19
30
  export declare function providerSecretPath(paths: CatencePaths): string;
20
31
  export declare function readAthleteSecrets(paths: CatencePaths): Promise<AthleteSecrets>;
32
+ /** Metadata only: which provider fields are configured. Values are never returned. */
33
+ export declare function describeAthleteSecrets(paths: CatencePaths): Promise<SecretProviderDescription[]>;
21
34
  export declare function setAthleteSecret(paths: CatencePaths, provider: SecretProvider, field: string, value: string): Promise<void>;
35
+ /** Remove one stored provider field; an already-absent field is a no-op. */
36
+ export declare function deleteAthleteSecret(paths: CatencePaths, provider: SecretProvider, field: string): Promise<void>;
22
37
  /** Build only the provider environment needed by a selected athlete worker. */
23
38
  export declare function athleteProviderEnvironment(paths: CatencePaths, base?: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv>;
24
39
  export {};
@@ -12,6 +12,13 @@ const secretFields = {
12
12
  intervals: ['apiKey', 'athleteId'],
13
13
  strava: ['clientId', 'clientSecret'],
14
14
  };
15
+ const secretProviderOrder = ['garmin', 'intervals', 'strava'];
16
+ /** Display labels for the Console credentials editor. */
17
+ export const secretProviderLabels = {
18
+ garmin: 'Garmin',
19
+ intervals: 'Intervals.icu',
20
+ strava: 'Strava',
21
+ };
15
22
  export function providerSecretPath(paths) {
16
23
  return path.join(paths.secrets, 'providers.json');
17
24
  }
@@ -26,14 +33,17 @@ export async function readAthleteSecrets(paths) {
26
33
  throw new Error(`Invalid provider secrets at ${providerSecretPath(paths)}: ${message}`);
27
34
  }
28
35
  }
29
- export async function setAthleteSecret(paths, provider, field, value) {
30
- if (!secretFields[provider].includes(field))
31
- throw new Error(`${field} is not a supported ${provider} secret field.`);
32
- if (!value.trim())
33
- throw new Error('Secret values cannot be empty.');
34
- await ensurePaths(paths);
36
+ /** Metadata only: which provider fields are configured. Values are never returned. */
37
+ export async function describeAthleteSecrets(paths) {
35
38
  const secrets = await readAthleteSecrets(paths);
36
- const next = { ...secrets, [provider]: { ...(secrets[provider] ?? {}), [field]: value } };
39
+ return secretProviderOrder.map((provider) => ({
40
+ id: provider,
41
+ label: secretProviderLabels[provider],
42
+ fields: secretFields[provider].map((name) => ({ name, configured: name in (secrets[provider] ?? {}) })),
43
+ }));
44
+ }
45
+ async function writeAthleteSecrets(paths, next) {
46
+ await ensurePaths(paths);
37
47
  const { writeFile, rename } = await import('node:fs/promises');
38
48
  const destination = providerSecretPath(paths);
39
49
  const temporary = `${destination}.tmp`;
@@ -42,6 +52,37 @@ export async function setAthleteSecret(paths, provider, field, value) {
42
52
  await rename(temporary, destination);
43
53
  await chmod(destination, 0o600);
44
54
  }
55
+ export async function setAthleteSecret(paths, provider, field, value) {
56
+ if (!secretFields[provider].includes(field))
57
+ throw new Error(`${field} is not a supported ${provider} secret field.`);
58
+ if (!value.trim())
59
+ throw new Error('Secret values cannot be empty.');
60
+ const secrets = await readAthleteSecrets(paths);
61
+ const next = { ...secrets, [provider]: { ...(secrets[provider] ?? {}), [field]: value } };
62
+ await writeAthleteSecrets(paths, next);
63
+ }
64
+ /** Remove one stored provider field; an already-absent field is a no-op. */
65
+ export async function deleteAthleteSecret(paths, provider, field) {
66
+ if (!(provider in secretFields))
67
+ throw new Error(`${provider} is not a supported provider.`);
68
+ if (!secretFields[provider].includes(field))
69
+ throw new Error(`${field} is not a supported ${provider} secret field.`);
70
+ const secrets = await readAthleteSecrets(paths);
71
+ const existing = secrets[provider];
72
+ if (!existing || !(field in existing))
73
+ return;
74
+ const remaining = {};
75
+ for (const [name, value] of Object.entries(existing)) {
76
+ if (name !== field && typeof value === 'string')
77
+ remaining[name] = value;
78
+ }
79
+ const draft = Object.assign({}, secrets);
80
+ if (Object.keys(remaining).length === 0)
81
+ delete draft[provider];
82
+ else
83
+ draft[provider] = remaining;
84
+ await writeAthleteSecrets(paths, secretSchema.parse(draft));
85
+ }
45
86
  function isTruthyEnvValue(value) {
46
87
  if (!value)
47
88
  return false;
@@ -2,7 +2,7 @@
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import { Command } from 'commander';
5
- import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
5
+ import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, deleteAthleteSecret, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
6
6
  const program = new Command()
7
7
  .name('catence-data')
8
8
  .description('Athlete-scoped Catence data setup, extraction, and normalization.')
@@ -61,7 +61,7 @@ athleteCommand.command('add')
61
61
  .action(async (options) => {
62
62
  process.stdout.write(`${JSON.stringify(await addAthlete(currentCatalog(), { id: options.id, label: options.label, setDefault: options.default }), null, 2)}\n`);
63
63
  });
64
- const secretCommand = program.command('secret').description('Write a provider value into one athlete’s owner-only secret store.');
64
+ const secretCommand = program.command('secret').description('Write or remove a provider value in one athlete’s owner-only secret store.');
65
65
  secretCommand.command('set')
66
66
  .requiredOption('--provider <provider>', 'garmin, intervals, or strava')
67
67
  .requiredOption('--field <field>', 'provider secret field')
@@ -73,6 +73,16 @@ secretCommand.command('set')
73
73
  await setAthleteSecret(paths, options.provider, options.field, await stdinSecret());
74
74
  process.stdout.write(`${JSON.stringify({ athleteId: program.opts().athlete, provider: options.provider, field: options.field, stored: true }, null, 2)}\n`);
75
75
  });
76
+ secretCommand.command('remove')
77
+ .requiredOption('--provider <provider>', 'garmin, intervals, or strava')
78
+ .requiredOption('--field <field>', 'provider secret field')
79
+ .action(async (options) => {
80
+ if (!['garmin', 'intervals', 'strava'].includes(options.provider))
81
+ throw new Error('--provider must be garmin, intervals, or strava.');
82
+ const { paths } = await currentAthlete();
83
+ await deleteAthleteSecret(paths, options.provider, options.field);
84
+ process.stdout.write(`${JSON.stringify({ athleteId: program.opts().athlete, provider: options.provider, field: options.field, removed: true }, null, 2)}\n`);
85
+ });
76
86
  program.command('demo')
77
87
  .description('Create a generated demo athlete in an isolated demo catalog without contacting providers.')
78
88
  .option('--days <days>', 'generated days of wellness and training data', '90')
@@ -3,7 +3,10 @@ export type ServeCliOptions = {
3
3
  host: string;
4
4
  port: number;
5
5
  allowedOrigins: string[];
6
+ authTokenFile?: string;
6
7
  help: boolean;
7
8
  };
8
- export declare const SERVE_USAGE = "Usage: catence serve [options]\n\nRun Catence as a local Streamable HTTP MCP server.\n\nOptions:\n --home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.\n --host <host> Listener host. Defaults to 127.0.0.1.\n --port <port> Listener port. Defaults to 8787.\n --allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.\n -h, --help Show this help text.";
9
+ export declare const SERVE_USAGE = "Usage: catence serve [options]\n\nRun Catence as a local Streamable HTTP MCP server.\n\nOptions:\n --home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.\n --host <host> Listener host. Defaults to 127.0.0.1.\n --port <port> Listener port. Defaults to 8787.\n --allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.\n --auth-token-file <path> Require 'Authorization: Bearer <token>' on every request except\n the health endpoints. Defaults to CATENCE_MCP_TOKEN_FILE.\n -h, --help Show this help text.";
10
+ /** Read and validate the bearer token from a file: trimmed, non-empty, or fail loudly. */
11
+ export declare function loadBearerToken(filePath: string): string;
9
12
  export declare function parseServeCliOptions(arguments_: readonly string[]): ServeCliOptions;
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  export const SERVE_USAGE = `Usage: catence serve [options]
2
3
 
3
4
  Run Catence as a local Streamable HTTP MCP server.
@@ -7,7 +8,22 @@ Options:
7
8
  --host <host> Listener host. Defaults to 127.0.0.1.
8
9
  --port <port> Listener port. Defaults to 8787.
9
10
  --allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.
11
+ --auth-token-file <path> Require 'Authorization: Bearer <token>' on every request except
12
+ the health endpoints. Defaults to CATENCE_MCP_TOKEN_FILE.
10
13
  -h, --help Show this help text.`;
14
+ /** Read and validate the bearer token from a file: trimmed, non-empty, or fail loudly. */
15
+ export function loadBearerToken(filePath) {
16
+ let token;
17
+ try {
18
+ token = readFileSync(filePath, 'utf8').trim();
19
+ }
20
+ catch (error) {
21
+ throw new Error(`Cannot read the auth token file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
22
+ }
23
+ if (!token)
24
+ throw new Error(`The auth token file ${filePath} is empty.`);
25
+ return token;
26
+ }
11
27
  function requiredValue(arguments_, index, option) {
12
28
  const value = arguments_[index + 1];
13
29
  if (!value || value.startsWith('-'))
@@ -27,10 +43,11 @@ export function parseServeCliOptions(arguments_) {
27
43
  let host = process.env.CATENCE_HTTP_HOST ?? '127.0.0.1';
28
44
  let port = process.env.CATENCE_HTTP_PORT ? parsePort(process.env.CATENCE_HTTP_PORT) : 8787;
29
45
  const allowedOrigins = [];
46
+ let authTokenFile = process.env.CATENCE_MCP_TOKEN_FILE || undefined;
30
47
  for (let index = 0; index < arguments_.length; index++) {
31
48
  const argument = arguments_[index];
32
49
  if (argument === '-h' || argument === '--help')
33
- return { home, host, port, allowedOrigins, help: true };
50
+ return { home, host, port, allowedOrigins, authTokenFile, help: true };
34
51
  if (argument === '--home') {
35
52
  home = requiredValue(arguments_, index, '--home');
36
53
  index++;
@@ -42,6 +59,17 @@ export function parseServeCliOptions(arguments_) {
42
59
  throw new Error('--home requires a value.');
43
60
  continue;
44
61
  }
62
+ if (argument === '--auth-token-file') {
63
+ authTokenFile = requiredValue(arguments_, index, '--auth-token-file');
64
+ index++;
65
+ continue;
66
+ }
67
+ if (argument.startsWith('--auth-token-file=')) {
68
+ authTokenFile = argument.slice('--auth-token-file='.length);
69
+ if (!authTokenFile)
70
+ throw new Error('--auth-token-file requires a value.');
71
+ continue;
72
+ }
45
73
  if (argument === '--host') {
46
74
  host = requiredValue(arguments_, index, '--host');
47
75
  index++;
@@ -76,6 +104,6 @@ export function parseServeCliOptions(arguments_) {
76
104
  }
77
105
  throw new Error(`Unknown option: ${argument}`);
78
106
  }
79
- return { home, host, port, allowedOrigins, help: false };
107
+ return { home, host, port, allowedOrigins, authTokenFile, help: false };
80
108
  }
81
109
  //# sourceMappingURL=cli-options.js.map
@@ -6,6 +6,13 @@ export type CatenceHttpServerOptions = {
6
6
  /** Internal compatibility hook for tests and embedded single-store callers. */
7
7
  paths?: CatencePaths;
8
8
  allowedOrigins?: readonly string[];
9
+ /**
10
+ * When set, every request except the health endpoints and CORS preflights
11
+ * must present `Authorization: Bearer <token>`. The MCP server has no
12
+ * authentication of its own; this gates it (and the JSON APIs) for
13
+ * non-loopback exposure such as a Cloudflare Tunnel to an MCP client.
14
+ */
15
+ bearerToken?: string;
9
16
  /** Internal compatibility hook for tests: override the detached sync spawner. */
10
17
  startSync?: typeof startDetachedSync;
11
18
  /** Internal compatibility hook for tests: passed into the default startDetachedSync. */
@@ -1,10 +1,22 @@
1
+ import { timingSafeEqual } from 'node:crypto';
1
2
  import { createServer } from 'node:http';
2
3
  import path from 'node:path';
3
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
- import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, DashboardSnapshotService, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, listAthleteFileRevisions, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, readAthleteFile, readAthleteFileRevision, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, updateAthleteFile, } from '../../runtime/index.js';
5
+ import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, DashboardSnapshotService, deleteAthleteSecret, describeAthleteSecrets, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, listAthleteFileRevisions, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, readAthleteFile, readAthleteFileRevision, resolveAthlete, resolveCatalogPaths, setAthleteSecret, startDetachedSync, syncProgress, updateAthleteFile, } from '../../runtime/index.js';
5
6
  import { createCatenceMcpServer } from '../mcp/server.js';
6
7
  import { generationStatus } from './generation-status.js';
7
8
  const MAX_REQUEST_BYTES = 1_000_000;
9
+ /** Endpoints that stay open when bearer authentication is enabled: they carry
10
+ * no personal data and must answer unauthenticated readiness probes. */
11
+ const UNAUTHENTICATED_PATHS = new Set(['/health', '/api/v1/health']);
12
+ function isAuthorized(request, bearerToken) {
13
+ if (!bearerToken)
14
+ return true;
15
+ const received = Buffer.from(request.headers.authorization ?? '');
16
+ const expected = Buffer.from(`Bearer ${bearerToken}`);
17
+ // timingSafeEqual throws on length mismatch, so compare sizes first.
18
+ return received.length === expected.length && timingSafeEqual(received, expected);
19
+ }
8
20
  function json(response, statusCode, value) {
9
21
  const body = JSON.stringify(jsonSafe(value));
10
22
  response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
@@ -155,6 +167,40 @@ function parseAthleteFileBody(body) {
155
167
  expectedHash: (raw.expectedHash ?? null),
156
168
  };
157
169
  }
170
+ function parseAthleteSecretBody(body) {
171
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
172
+ throw new Error('Request body must be a JSON object.');
173
+ const raw = body;
174
+ for (const key of Object.keys(raw)) {
175
+ if (!['athleteId', 'provider', 'field', 'value'].includes(key))
176
+ throw new Error(`Unknown athlete-secret field: ${key}.`);
177
+ }
178
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
179
+ throw new Error('athleteId must be a string.');
180
+ if (typeof raw.provider !== 'string' || !raw.provider.trim())
181
+ throw new Error('provider is required.');
182
+ if (typeof raw.field !== 'string' || !raw.field.trim())
183
+ throw new Error('field is required.');
184
+ if (typeof raw.value !== 'string' || !raw.value.trim())
185
+ throw new Error('value is required.');
186
+ return { athleteId: raw.athleteId, provider: raw.provider.trim(), field: raw.field.trim(), value: raw.value };
187
+ }
188
+ function parseAthleteSecretRemovalBody(body) {
189
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
190
+ throw new Error('Request body must be a JSON object.');
191
+ const raw = body;
192
+ for (const key of Object.keys(raw)) {
193
+ if (!['athleteId', 'provider', 'field'].includes(key))
194
+ throw new Error(`Unknown athlete-secret field: ${key}.`);
195
+ }
196
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
197
+ throw new Error('athleteId must be a string.');
198
+ if (typeof raw.provider !== 'string' || !raw.provider.trim())
199
+ throw new Error('provider is required.');
200
+ if (typeof raw.field !== 'string' || !raw.field.trim())
201
+ throw new Error('field is required.');
202
+ return { athleteId: raw.athleteId, provider: raw.provider.trim(), field: raw.field.trim() };
203
+ }
158
204
  async function resolveSyncPaths(catalogPaths, staticPaths, body) {
159
205
  if (catalogPaths) {
160
206
  if (!body.athleteId)
@@ -279,6 +325,12 @@ export function createCatenceHttpServer(options = {}) {
279
325
  response.writeHead(204).end();
280
326
  return;
281
327
  }
328
+ if (!UNAUTHENTICATED_PATHS.has(pathname) && !isAuthorized(request, options.bearerToken)) {
329
+ // No WWW-Authenticate on purpose: MCP connector auto-detection treats a
330
+ // plain 401 as a bearer gate rather than an OAuth server.
331
+ json(response, 401, { error: { code: 'unauthorized', message: 'A valid Authorization: Bearer token is required.' } });
332
+ return;
333
+ }
282
334
  if (pathname === '/health' && request.method === 'GET') {
283
335
  json(response, 200, catenceRuntimeHealth());
284
336
  return;
@@ -406,6 +458,50 @@ export function createCatenceHttpServer(options = {}) {
406
458
  }
407
459
  return;
408
460
  }
461
+ if (pathname === '/api/v1/athlete-secrets' && request.method === 'GET') {
462
+ try {
463
+ const url = new URL(request.url ?? '/', 'http://localhost');
464
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
465
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
466
+ }
467
+ catch (error) {
468
+ const message = error instanceof Error ? error.message : String(error);
469
+ json(response, 400, { error: { code: 'athlete_secrets_unavailable', message } });
470
+ }
471
+ return;
472
+ }
473
+ if (pathname === '/api/v1/athlete-secrets' && request.method === 'PUT') {
474
+ try {
475
+ const parsed = parseAthleteSecretBody(await readJsonBody(request));
476
+ const url = new URL(request.url ?? '/', 'http://localhost');
477
+ if (!url.searchParams.get('athleteId') && parsed.athleteId)
478
+ url.searchParams.set('athleteId', parsed.athleteId);
479
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
480
+ await setAthleteSecret(paths, parsed.provider, parsed.field, parsed.value);
481
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
482
+ }
483
+ catch (error) {
484
+ const message = error instanceof Error ? error.message : String(error);
485
+ json(response, 400, { error: { code: 'athlete_secret_write_failed', message } });
486
+ }
487
+ return;
488
+ }
489
+ if (pathname === '/api/v1/athlete-secrets/remove' && request.method === 'POST') {
490
+ try {
491
+ const parsed = parseAthleteSecretRemovalBody(await readJsonBody(request));
492
+ const url = new URL(request.url ?? '/', 'http://localhost');
493
+ if (!url.searchParams.get('athleteId') && parsed.athleteId)
494
+ url.searchParams.set('athleteId', parsed.athleteId);
495
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
496
+ await deleteAthleteSecret(paths, parsed.provider, parsed.field);
497
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
498
+ }
499
+ catch (error) {
500
+ const message = error instanceof Error ? error.message : String(error);
501
+ json(response, 400, { error: { code: 'athlete_secret_remove_failed', message } });
502
+ }
503
+ return;
504
+ }
409
505
  if (pathname === '/api/v1/sync' && request.method === 'POST') {
410
506
  let body;
411
507
  try {
@@ -2,7 +2,7 @@
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
5
- import { parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
5
+ import { loadBearerToken, parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
6
6
  import { createCatenceHttpServer } from '../http/server.js';
7
7
  import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
8
8
  import { createCatenceMcpServer } from './server.js';
@@ -46,9 +46,11 @@ async function run() {
46
46
  return;
47
47
  }
48
48
  await migrateCatalogStores(options.home);
49
+ const bearerToken = options.authTokenFile ? loadBearerToken(options.authTokenFile) : undefined;
49
50
  const httpServer = createCatenceHttpServer({
50
51
  catalogPaths: resolveCatalogPaths(options.home),
51
52
  allowedOrigins: options.allowedOrigins,
53
+ bearerToken,
52
54
  });
53
55
  await new Promise((resolve, reject) => {
54
56
  httpServer.once('error', reject);
@@ -58,6 +60,8 @@ async function run() {
58
60
  });
59
61
  });
60
62
  console.error(`Catence Streamable HTTP server is listening at http://${options.host}:${options.port}/mcp`);
63
+ if (bearerToken)
64
+ console.error('Bearer-token authentication is enabled; health endpoints stay open for readiness probes.');
61
65
  const close = () => {
62
66
  httpServer.close(() => process.exit(0));
63
67
  };
@@ -13,8 +13,8 @@ export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth
13
13
  export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
14
14
  export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
15
15
  export type { Athlete, CatalogPaths, CatenceCatalog } from '../core/runtime/catalog.js';
16
- export { athleteProviderEnvironment, providerSecretPath, readAthleteSecrets, setAthleteSecret } from '../core/runtime/secrets.js';
17
- export type { AthleteSecrets, SecretProvider } from '../core/runtime/secrets.js';
16
+ export { athleteProviderEnvironment, deleteAthleteSecret, describeAthleteSecrets, providerSecretPath, readAthleteSecrets, secretProviderLabels, setAthleteSecret } from '../core/runtime/secrets.js';
17
+ export type { AthleteSecrets, SecretFieldDescription, SecretProvider, SecretProviderDescription } from '../core/runtime/secrets.js';
18
18
  export { ATHLETE_FILE_MAX_CHARACTERS, ATHLETE_FILE_TEMPLATE, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, hashAthleteFileContent, listAthleteFileRevisions, readAthleteFile, readAthleteFileRevision, updateAthleteFile, } from '../core/runtime/athlete-file.js';
19
19
  export type { AthleteFileOperation, AthleteFileRevision, AthleteFileSnapshot, AthleteFileUpdate } from '../core/runtime/athlete-file.js';
20
20
  export { SlidingWindowLimiter } from '../core/runtime/limiter.js';
@@ -2,7 +2,7 @@ export { channelForVersion, compareReleaseVersions, fetchNpmDistTags, fetchPypiR
2
2
  export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
3
3
  export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
4
4
  export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
5
- export { athleteProviderEnvironment, providerSecretPath, readAthleteSecrets, setAthleteSecret } from '../core/runtime/secrets.js';
5
+ export { athleteProviderEnvironment, deleteAthleteSecret, describeAthleteSecrets, providerSecretPath, readAthleteSecrets, secretProviderLabels, setAthleteSecret } from '../core/runtime/secrets.js';
6
6
  export { ATHLETE_FILE_MAX_CHARACTERS, ATHLETE_FILE_TEMPLATE, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, hashAthleteFileContent, listAthleteFileRevisions, readAthleteFile, readAthleteFileRevision, updateAthleteFile, } from '../core/runtime/athlete-file.js';
7
7
  export { SlidingWindowLimiter } from '../core/runtime/limiter.js';
8
8
  export { DashboardSnapshotService } from '../core/dashboard/snapshot.js';
@@ -247,12 +247,17 @@ profiles and preserve existing ones, limits, and `defaultProfile` unless
247
247
  | --- | --- |
248
248
  | `CATENCE_HOME` | Catalog home (default `~/.catence`); equivalent to `--home` |
249
249
  | `CATENCE_HTTP_HOST` / `CATENCE_HTTP_PORT` | Defaults for `catence serve --host/--port` (host `127.0.0.1`, port `8787`) |
250
-
251
- Provider credentials are normally stored per athlete with
252
- `catence-data secret set` (see [`local-mcp.md`](deployment/local-mcp.md#3-store-provider-credentials-stdin-only-never-shell-history)); those secrets are injected
253
- into the provider worker environment, and inherited `GARMIN_*`/`INTERVALS_*`/
254
- `STRAVA_*` variables are stripped first so an athlete without a provider cannot
255
- inherit its credentials.
250
+ | `CATENCE_MCP_TOKEN_FILE` | Default for `catence serve --auth-token-file`; when set, every endpoint except `/health` and `/api/v1/health` requires `Authorization: Bearer <token>` |
251
+
252
+ Provider credentials are stored per athlete, either with `catence-data secret
253
+ set` (see [`local-mcp.md`](deployment/local-mcp.md#3-store-provider-credentials-stdin-only-never-shell-history))
254
+ or by an administrator from **Settings → Athletes → Credentials** in the
255
+ Console. The Console editor is write-only: it shows each field as configured or
256
+ not, and values can be replaced or removed (`catence-data secret remove`, or the
257
+ dialog's Remove button) but never read back. Those secrets are injected into the
258
+ provider worker environment, and inherited `GARMIN_*`/`INTERVALS_*`/`STRAVA_*`
259
+ variables are stripped first so an athlete without a provider cannot inherit its
260
+ credentials.
256
261
 
257
262
  For local and Docker development you can also keep credentials in env without
258
263
  `secret set` — see the two fallback modes below. File values always win over
@@ -506,6 +506,34 @@ claude mcp add --transport http catence -- http://<tailscale-ip>:8787/mcp
506
506
  same-origin route.
507
507
  - SSH port forwarding keeps the trust boundary at your SSH credentials.
508
508
 
509
+ ### Bearer-token authentication for remote MCP clients
510
+
511
+ To serve the MCP endpoint to a remote MCP client (for example a Mistral Work
512
+ custom connector) over a Cloudflare Tunnel, enable bearer-token
513
+ authentication instead of leaving the runtime unauthenticated. Set
514
+ `CATENCE_MCP_TOKEN_FILE` in `catence-deploy/.env` to a host file holding one
515
+ token (`openssl rand -hex 32`); the deploy script bind-mounts that file
516
+ read-only into the container at the same path:
517
+
518
+ ```sh
519
+ # once, when the stack is already deployed:
520
+ printf %s "$(openssl rand -hex 32)" > /srv/catence-mcp-token # mode 0600, outside the data volume
521
+ echo "CATENCE_MCP_TOKEN_FILE=/srv/catence-mcp-token" >> catence-deploy/.env
522
+ ./scripts/deploy-console.sh beta # re-renders compose with the token mount
523
+ ```
524
+
525
+ With the token configured, every runtime endpoint except `/health` and
526
+ `/api/v1/health` answers `401` without an exact `Authorization: Bearer
527
+ <token>` header — including `/mcp` and the JSON APIs. The Console attaches
528
+ the header automatically to its own runtime calls, and refuses to start when
529
+ the configured file is unreadable or empty.
530
+
531
+ Keep `CATENCE_MCP_BIND=127.0.0.1` (the default): the tunnel client on the
532
+ host forwards to the host-loopback mapping, so the port still never binds a
533
+ LAN interface. Point the tunnel ingress for the MCP hostname at
534
+ `http://127.0.0.1:8787` and complete the client's credential flow with the
535
+ same token. Rotate by rewriting the file and restarting the stack.
536
+
509
537
  ## Exposing the Console safely
510
538
 
511
539
  Keep the Console port bound to `127.0.0.1` (the default) and front it with a
@@ -77,6 +77,12 @@ printf %s 'strava-client-secret' | catence-data --athlete alex secret set --prov
77
77
  Set only the providers you actually sync; `secret set` accepts each field
78
78
  independently.
79
79
 
80
+ Administrators can also manage any athlete's credentials from **Settings →
81
+ Athletes → Credentials** in the Console. The dialog is write-only — it shows
82
+ each field as configured or not and lets you replace or remove values, but
83
+ values are never displayed. `catence-data --athlete <id> secret remove
84
+ --provider <provider> --field <field>` mirrors the dialog for scripted setups.
85
+
80
86
  ## 4. Sync data and build retrieval context
81
87
 
82
88
  ```sh
@@ -282,10 +288,12 @@ per-account grants at the login, then server-owned scoping per chat.
282
288
 
283
289
  Console pages and chats reach the runtime through the authenticated Console
284
290
  origin: Chainlit middleware proxies the Console's API surface — `GET
285
- /api/v1/dashboard`, `GET`/`POST /api/v1/athletes` (POST is admin-only), `GET
286
- /api/v1/threads/<id>/generation`, and the models, accounts, tool-server, and
287
- sync routes — to the runtime only when the Console's JWT cookie is valid
288
- (otherwise 401), so raw port 8787 does not need to be exposed.
291
+ /api/v1/dashboard`, `GET`/`POST /api/v1/athletes` (POST is admin-only),
292
+ `GET`/`PUT /api/v1/athlete-secrets` and `POST /api/v1/athlete-secrets/remove`
293
+ (all admin-only), `GET /api/v1/threads/<id>/generation`, and the models,
294
+ accounts, tool-server, and sync routes — to the runtime only when the Console's
295
+ JWT cookie is valid (otherwise 401), so raw port 8787 does not need to be
296
+ exposed.
289
297
 
290
298
  ## Model discovery (OpenCode Go)
291
299
 
@@ -302,6 +302,31 @@ catence serve --host 127.0.0.1 --port 8787
302
302
  By default it is loopback-only. `CATENCE_HTTP_HOST` and `CATENCE_HTTP_PORT`
303
303
  override the host and port when the flags are omitted.
304
304
 
305
+ #### Bearer-token authentication
306
+
307
+ The server has no authentication of its own; when it must be reachable beyond
308
+ loopback (for example through a Cloudflare Tunnel to an MCP client such as
309
+ Mistral Work), require a bearer token with `--auth-token-file <path>`
310
+ (environment equivalent: `CATENCE_MCP_TOKEN_FILE`):
311
+
312
+ ```sh
313
+ catence serve --host 127.0.0.1 --port 8787 --auth-token-file /path/to/token
314
+ ```
315
+
316
+ - The file holds one token (whitespace-trimmed); generate one with
317
+ `openssl rand -hex 32`. An unreadable or empty file aborts startup.
318
+ - Every endpoint except `/health` and `/api/v1/health` (kept open for readiness
319
+ probes) then answers `401` without an exact
320
+ `Authorization: Bearer <token>` header. The `401` deliberately carries no
321
+ `WWW-Authenticate`, so MCP clients with auth auto-detection (Mistral Work)
322
+ classify the server as bearer-auth rather than OAuth.
323
+ - CORS preflights (`OPTIONS`) pass through without a token.
324
+ - Rotation: rewrite the file and restart the server. Keep the token out of
325
+ `.env`, `config.json`, shell history, and repositories.
326
+ - The Console attaches the same header automatically to its runtime calls when
327
+ `CATENCE_MCP_TOKEN_FILE` is set, and refuses to start if the file is
328
+ unreadable.
329
+
305
330
  - `GET /api/v1/athletes` returns IDs and labels only.
306
331
  - `POST /api/v1/athletes` creates a catalog athlete from
307
332
  `{"id": "sam", "label": "Sam", "setDefault": false}` and answers with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.12",
3
+ "version": "0.2.1-beta.14",
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": {