catence 0.2.1-beta.11 → 0.2.1-beta.13

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.11";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.13";
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.11';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.13';
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')
@@ -1,7 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import path from 'node:path';
3
3
  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';
4
+ 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
5
  import { createCatenceMcpServer } from '../mcp/server.js';
6
6
  import { generationStatus } from './generation-status.js';
7
7
  const MAX_REQUEST_BYTES = 1_000_000;
@@ -155,6 +155,40 @@ function parseAthleteFileBody(body) {
155
155
  expectedHash: (raw.expectedHash ?? null),
156
156
  };
157
157
  }
158
+ function parseAthleteSecretBody(body) {
159
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
160
+ throw new Error('Request body must be a JSON object.');
161
+ const raw = body;
162
+ for (const key of Object.keys(raw)) {
163
+ if (!['athleteId', 'provider', 'field', 'value'].includes(key))
164
+ throw new Error(`Unknown athlete-secret field: ${key}.`);
165
+ }
166
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
167
+ throw new Error('athleteId must be a string.');
168
+ if (typeof raw.provider !== 'string' || !raw.provider.trim())
169
+ throw new Error('provider is required.');
170
+ if (typeof raw.field !== 'string' || !raw.field.trim())
171
+ throw new Error('field is required.');
172
+ if (typeof raw.value !== 'string' || !raw.value.trim())
173
+ throw new Error('value is required.');
174
+ return { athleteId: raw.athleteId, provider: raw.provider.trim(), field: raw.field.trim(), value: raw.value };
175
+ }
176
+ function parseAthleteSecretRemovalBody(body) {
177
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
178
+ throw new Error('Request body must be a JSON object.');
179
+ const raw = body;
180
+ for (const key of Object.keys(raw)) {
181
+ if (!['athleteId', 'provider', 'field'].includes(key))
182
+ throw new Error(`Unknown athlete-secret field: ${key}.`);
183
+ }
184
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
185
+ throw new Error('athleteId must be a string.');
186
+ if (typeof raw.provider !== 'string' || !raw.provider.trim())
187
+ throw new Error('provider is required.');
188
+ if (typeof raw.field !== 'string' || !raw.field.trim())
189
+ throw new Error('field is required.');
190
+ return { athleteId: raw.athleteId, provider: raw.provider.trim(), field: raw.field.trim() };
191
+ }
158
192
  async function resolveSyncPaths(catalogPaths, staticPaths, body) {
159
193
  if (catalogPaths) {
160
194
  if (!body.athleteId)
@@ -406,6 +440,50 @@ export function createCatenceHttpServer(options = {}) {
406
440
  }
407
441
  return;
408
442
  }
443
+ if (pathname === '/api/v1/athlete-secrets' && request.method === 'GET') {
444
+ try {
445
+ const url = new URL(request.url ?? '/', 'http://localhost');
446
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
447
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
448
+ }
449
+ catch (error) {
450
+ const message = error instanceof Error ? error.message : String(error);
451
+ json(response, 400, { error: { code: 'athlete_secrets_unavailable', message } });
452
+ }
453
+ return;
454
+ }
455
+ if (pathname === '/api/v1/athlete-secrets' && request.method === 'PUT') {
456
+ try {
457
+ const parsed = parseAthleteSecretBody(await readJsonBody(request));
458
+ const url = new URL(request.url ?? '/', 'http://localhost');
459
+ if (!url.searchParams.get('athleteId') && parsed.athleteId)
460
+ url.searchParams.set('athleteId', parsed.athleteId);
461
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
462
+ await setAthleteSecret(paths, parsed.provider, parsed.field, parsed.value);
463
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
464
+ }
465
+ catch (error) {
466
+ const message = error instanceof Error ? error.message : String(error);
467
+ json(response, 400, { error: { code: 'athlete_secret_write_failed', message } });
468
+ }
469
+ return;
470
+ }
471
+ if (pathname === '/api/v1/athlete-secrets/remove' && request.method === 'POST') {
472
+ try {
473
+ const parsed = parseAthleteSecretRemovalBody(await readJsonBody(request));
474
+ const url = new URL(request.url ?? '/', 'http://localhost');
475
+ if (!url.searchParams.get('athleteId') && parsed.athleteId)
476
+ url.searchParams.set('athleteId', parsed.athleteId);
477
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
478
+ await deleteAthleteSecret(paths, parsed.provider, parsed.field);
479
+ json(response, 200, { athleteId: url.searchParams.get('athleteId') ?? 'local', providers: await describeAthleteSecrets(paths) });
480
+ }
481
+ catch (error) {
482
+ const message = error instanceof Error ? error.message : String(error);
483
+ json(response, 400, { error: { code: 'athlete_secret_remove_failed', message } });
484
+ }
485
+ return;
486
+ }
409
487
  if (pathname === '/api/v1/sync' && request.method === 'POST') {
410
488
  let body;
411
489
  try {
@@ -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';
@@ -248,11 +248,15 @@ profiles and preserve existing ones, limits, and `defaultProfile` unless
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
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.
251
+ Provider credentials are stored per athlete, either with `catence-data secret
252
+ set` (see [`local-mcp.md`](deployment/local-mcp.md#3-store-provider-credentials-stdin-only-never-shell-history))
253
+ or by an administrator from **Settings → Athletes → Credentials** in the
254
+ Console. The Console editor is write-only: it shows each field as configured or
255
+ not, and values can be replaced or removed (`catence-data secret remove`, or the
256
+ dialog's Remove button) but never read back. Those secrets are injected into the
257
+ provider worker environment, and inherited `GARMIN_*`/`INTERVALS_*`/`STRAVA_*`
258
+ variables are stripped first so an athlete without a provider cannot inherit its
259
+ credentials.
256
260
 
257
261
  For local and Docker development you can also keep credentials in env without
258
262
  `secret set` — see the two fallback modes below. File values always win over
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.11",
3
+ "version": "0.2.1-beta.13",
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": {