catence 0.2.1-beta.16 → 0.2.1-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ /**
6
+ * File-backed OAuthServerModel for the mcp-oauth-server package.
7
+ *
8
+ * Layout under <catalogHome>/oauth/ (directory 0700, files 0600, atomic
9
+ * tmp+rename writes, mirroring mcp-keys.ts):
10
+ *
11
+ * clients/<clientId>.json registered client (clientId is a UUID)
12
+ * codes/<sha256(code)>.json authorization code (raw code never at rest)
13
+ * access/<sha256(token)>.json access token (raw token never at rest)
14
+ * refresh/<sha256(token)>.json refresh token (raw token never at rest)
15
+ * cimd/<sha256(clientIdUrl)>.json cached client id metadata document
16
+ *
17
+ * Every path component is either a validated UUID or a SHA-256 hex digest, so
18
+ * no record id reaches the filesystem unvalidated. Records are immutable once
19
+ * written; consume operations claim a record with an atomic rename before
20
+ * deleting it, so exactly one concurrent caller wins. Expired records are
21
+ * swept on read; malformed records fail closed (undefined) and are removed.
22
+ */
23
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
24
+ const uuidSchema = z.string().regex(UUID_PATTERN, 'must be a UUID');
25
+ const isoTimestampSchema = z.string().datetime();
26
+ // The RFC 7591 metadata fields the lib persists on a client. Registered
27
+ // clients key them by a UUID client_id; a cached Client ID Metadata Document
28
+ // keys them by the document URL instead (see cimdClientRecordSchema).
29
+ const clientMetadataFields = {
30
+ redirect_uris: z.array(z.string()),
31
+ token_endpoint_auth_method: z.string().optional(),
32
+ grant_types: z.array(z.string()).optional(),
33
+ response_types: z.array(z.string()).optional(),
34
+ application_type: z.string().optional(),
35
+ client_name: z.string().optional(),
36
+ client_uri: z.string().optional(),
37
+ logo_uri: z.string().optional(),
38
+ scope: z.string().optional(),
39
+ contacts: z.array(z.string()).optional(),
40
+ tos_uri: z.string().optional(),
41
+ policy_uri: z.string().optional(),
42
+ jwks_uri: z.string().optional(),
43
+ jwks: z.any().optional(),
44
+ software_id: z.string().optional(),
45
+ software_version: z.string().optional(),
46
+ // The lib's OAuthClientMetadataSchema passes software_statement through to
47
+ // the stored client; the strict record schema must accept it too.
48
+ software_statement: z.string().optional(),
49
+ };
50
+ const clientRecordSchema = z.object({
51
+ ...clientMetadataFields,
52
+ client_id: uuidSchema,
53
+ client_secret: z.string().optional(),
54
+ client_id_issued_at: z.number().optional(),
55
+ client_secret_expires_at: z.number().optional(),
56
+ }).strict();
57
+ // A cached CIMD document's client_id is the document URL, not a UUID. The
58
+ // record is keyed by the hash of that URL, never by the id itself.
59
+ const cimdClientRecordSchema = z.object({
60
+ ...clientMetadataFields,
61
+ client_id: z.string().min(1),
62
+ client_secret: z.string().optional(),
63
+ client_id_issued_at: z.number().optional(),
64
+ client_secret_expires_at: z.number().optional(),
65
+ }).strict();
66
+ const authorizationCodeRecordSchema = z.object({
67
+ clientId: uuidSchema,
68
+ userId: z.string(),
69
+ expiresAt: isoTimestampSchema,
70
+ scopes: z.array(z.string()),
71
+ resource: z.string().optional(),
72
+ codeChallenge: z.string(),
73
+ redirectUri: z.string(),
74
+ state: z.string().optional(),
75
+ grantId: uuidSchema.optional(),
76
+ }).strict();
77
+ const tokenRecordSchema = z.object({
78
+ clientId: uuidSchema,
79
+ userId: z.string().optional(),
80
+ expiresAt: isoTimestampSchema,
81
+ scopes: z.array(z.string()),
82
+ resource: z.string().optional(),
83
+ grantId: uuidSchema.optional(),
84
+ }).strict();
85
+ const clientIdMetadataDocumentRecordSchema = z.object({
86
+ client: cimdClientRecordSchema,
87
+ expiresAt: isoTimestampSchema,
88
+ }).strict();
89
+ function hashSecret(value) {
90
+ return createHash('sha256').update(value, 'utf8').digest('hex');
91
+ }
92
+ function isExpired(expiresAtIso) {
93
+ return new Date(expiresAtIso).getTime() <= Date.now();
94
+ }
95
+ async function writeRecord(filePath, record) {
96
+ await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
97
+ const temporary = `${filePath}.tmp`;
98
+ await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
99
+ await rename(temporary, filePath);
100
+ }
101
+ /**
102
+ * Read one record file. A missing file is undefined; a malformed file fails
103
+ * closed (undefined) and is swept so it cannot linger. Read errors other than
104
+ * ENOENT propagate: the caller's fail-closed wrapper turns them into a
105
+ * rejection, never into an anonymous success.
106
+ */
107
+ async function readRecord(filePath, schema) {
108
+ let contents;
109
+ try {
110
+ contents = await readFile(filePath, 'utf8');
111
+ }
112
+ catch (error) {
113
+ if (error.code === 'ENOENT')
114
+ return undefined;
115
+ throw error;
116
+ }
117
+ try {
118
+ return schema.parse(JSON.parse(contents));
119
+ }
120
+ catch {
121
+ await unlink(filePath).catch(() => { });
122
+ return undefined;
123
+ }
124
+ }
125
+ async function removeRecord(filePath) {
126
+ await unlink(filePath).catch((error) => {
127
+ if (error.code !== 'ENOENT')
128
+ throw error;
129
+ });
130
+ }
131
+ export class FileOAuthServerModel {
132
+ root;
133
+ constructor(root) {
134
+ this.root = root;
135
+ }
136
+ clientFile(clientId) {
137
+ return path.join(this.root, 'clients', `${clientId}.json`);
138
+ }
139
+ codeFile(authorizationCode) {
140
+ return path.join(this.root, 'codes', `${hashSecret(authorizationCode)}.json`);
141
+ }
142
+ accessTokenFile(accessToken) {
143
+ return path.join(this.root, 'access', `${hashSecret(accessToken)}.json`);
144
+ }
145
+ refreshTokenFile(refreshToken) {
146
+ return path.join(this.root, 'refresh', `${hashSecret(refreshToken)}.json`);
147
+ }
148
+ cimdFile(clientId) {
149
+ return path.join(this.root, 'cimd', `${hashSecret(clientId)}.json`);
150
+ }
151
+ async getClient(clientId) {
152
+ // Validate before any filesystem use: client ids become path components.
153
+ if (!UUID_PATTERN.test(clientId))
154
+ return undefined;
155
+ const record = await readRecord(this.clientFile(clientId), clientRecordSchema);
156
+ return record;
157
+ }
158
+ async registerClient(client) {
159
+ if (!UUID_PATTERN.test(client.client_id))
160
+ throw new Error(`Invalid OAuth client id: ${client.client_id}`);
161
+ await writeRecord(this.clientFile(client.client_id), clientRecordSchema.parse(client));
162
+ return client;
163
+ }
164
+ async saveAuthorizationCode(code, _client) {
165
+ const record = {
166
+ clientId: code.clientId,
167
+ userId: code.userId,
168
+ expiresAt: code.expiresAt.toISOString(),
169
+ scopes: code.scopes ?? [],
170
+ codeChallenge: code.codeChallenge,
171
+ redirectUri: code.redirectUri,
172
+ ...(code.resource !== undefined ? { resource: code.resource } : {}),
173
+ ...(code.state !== undefined ? { state: code.state } : {}),
174
+ ...(code.grantId !== undefined ? { grantId: code.grantId } : {}),
175
+ };
176
+ await writeRecord(this.codeFile(code.authorizationCode), record);
177
+ }
178
+ /**
179
+ * Read + invalidate in one operation, scoped to the client. The rename is
180
+ * the atomic claim: when several requests present the same code only one
181
+ * rename succeeds, and the losers see ENOENT and return undefined. A request
182
+ * from the wrong client consumes nothing.
183
+ */
184
+ async consumeAuthorizationCode(authorizationCode, clientId) {
185
+ const record = await this.consumeRecord(this.codeFile(authorizationCode), clientId, authorizationCodeRecordSchema);
186
+ if (!record)
187
+ return undefined;
188
+ return {
189
+ authorizationCode,
190
+ clientId: record.clientId,
191
+ userId: record.userId,
192
+ expiresAt: new Date(record.expiresAt),
193
+ scopes: record.scopes,
194
+ codeChallenge: record.codeChallenge,
195
+ redirectUri: record.redirectUri,
196
+ ...(record.resource !== undefined ? { resource: record.resource } : {}),
197
+ ...(record.state !== undefined ? { state: record.state } : {}),
198
+ ...(record.grantId !== undefined ? { grantId: record.grantId } : {}),
199
+ };
200
+ }
201
+ async revokeAuthorizationCode(authorizationCode) {
202
+ await removeRecord(this.codeFile(authorizationCode));
203
+ }
204
+ async saveAccessToken(token, _client) {
205
+ await writeRecord(this.accessTokenFile(token.token), FileOAuthServerModel.tokenRecord(token));
206
+ }
207
+ async getAccessToken(accessToken) {
208
+ const record = await readRecord(this.accessTokenFile(accessToken), tokenRecordSchema);
209
+ if (!record)
210
+ return undefined;
211
+ if (isExpired(record.expiresAt)) {
212
+ await removeRecord(this.accessTokenFile(accessToken));
213
+ return undefined;
214
+ }
215
+ return FileOAuthServerModel.accessToken(record, accessToken);
216
+ }
217
+ /**
218
+ * Revoke only when the token was issued to the requesting client; never
219
+ * throw when nothing matches (RFC 7009 §2.2 answers 200 either way).
220
+ */
221
+ async revokeAccessToken(accessToken, clientId) {
222
+ return this.revokeTokenRecord(this.accessTokenFile(accessToken), accessToken, clientId);
223
+ }
224
+ async saveRefreshToken(token, _client) {
225
+ await writeRecord(this.refreshTokenFile(token.token), FileOAuthServerModel.tokenRecord(token));
226
+ }
227
+ async revokeRefreshToken(refreshToken, clientId) {
228
+ return this.revokeTokenRecord(this.refreshTokenFile(refreshToken), refreshToken, clientId);
229
+ }
230
+ /** Refresh tokens rotate on every use, so consume is atomic + client-scoped like the code consume. */
231
+ async consumeRefreshToken(refreshToken, clientId) {
232
+ const record = await this.consumeRecord(this.refreshTokenFile(refreshToken), clientId, tokenRecordSchema);
233
+ if (!record)
234
+ return undefined;
235
+ return FileOAuthServerModel.refreshToken(record, refreshToken);
236
+ }
237
+ /** Sweep every access and refresh token belonging to one authorization grant. */
238
+ async revokeGrant(grantId) {
239
+ if (!grantId)
240
+ return;
241
+ for (const directory of ['access', 'refresh']) {
242
+ const directoryPath = path.join(this.root, directory);
243
+ let entries;
244
+ try {
245
+ entries = await readdir(directoryPath);
246
+ }
247
+ catch (error) {
248
+ if (error.code === 'ENOENT')
249
+ continue;
250
+ throw error;
251
+ }
252
+ for (const entry of entries) {
253
+ if (!entry.endsWith('.json'))
254
+ continue;
255
+ const filePath = path.join(directoryPath, entry);
256
+ const record = await readRecord(filePath, tokenRecordSchema);
257
+ if (record?.grantId === grantId)
258
+ await removeRecord(filePath);
259
+ }
260
+ }
261
+ }
262
+ async saveClientIdMetadataDocument(document) {
263
+ // The cache key is a client-supplied URL: hash it so it never becomes a
264
+ // path component.
265
+ const record = {
266
+ client: cimdClientRecordSchema.parse(document.client),
267
+ expiresAt: document.expiresAt.toISOString(),
268
+ };
269
+ await writeRecord(this.cimdFile(document.client.client_id), record);
270
+ }
271
+ async getClientIdMetadataDocument(clientId) {
272
+ const record = await readRecord(this.cimdFile(clientId), clientIdMetadataDocumentRecordSchema);
273
+ if (!record)
274
+ return undefined;
275
+ if (isExpired(record.expiresAt)) {
276
+ await removeRecord(this.cimdFile(clientId));
277
+ return undefined;
278
+ }
279
+ return { client: record.client, expiresAt: new Date(record.expiresAt) };
280
+ }
281
+ static tokenRecord(token) {
282
+ return {
283
+ clientId: token.clientId,
284
+ expiresAt: token.expiresAt.toISOString(),
285
+ scopes: token.scopes,
286
+ ...(token.userId !== undefined ? { userId: token.userId } : {}),
287
+ ...(token.resource !== undefined ? { resource: token.resource } : {}),
288
+ ...(token.grantId !== undefined ? { grantId: token.grantId } : {}),
289
+ };
290
+ }
291
+ static accessToken(record, token) {
292
+ return {
293
+ token,
294
+ clientId: record.clientId,
295
+ expiresAt: new Date(record.expiresAt),
296
+ scopes: record.scopes,
297
+ ...(record.userId !== undefined ? { userId: record.userId } : {}),
298
+ ...(record.resource !== undefined ? { resource: record.resource } : {}),
299
+ ...(record.grantId !== undefined ? { grantId: record.grantId } : {}),
300
+ };
301
+ }
302
+ static refreshToken(record, token) {
303
+ return FileOAuthServerModel.accessToken(record, token);
304
+ }
305
+ async consumeRecord(filePath, clientId, schema) {
306
+ const record = await readRecord(filePath, schema);
307
+ if (!record)
308
+ return undefined;
309
+ if (record.clientId !== clientId)
310
+ return undefined;
311
+ if (isExpired(record.expiresAt)) {
312
+ await removeRecord(filePath);
313
+ return undefined;
314
+ }
315
+ // Claim the record atomically before deleting it: the rename succeeds for
316
+ // exactly one caller even when several requests race the same credential.
317
+ const claimed = `${filePath}.consumed-${randomBytes(6).toString('hex')}`;
318
+ try {
319
+ await rename(filePath, claimed);
320
+ }
321
+ catch (error) {
322
+ if (error.code === 'ENOENT')
323
+ return undefined;
324
+ throw error;
325
+ }
326
+ await removeRecord(claimed);
327
+ return record;
328
+ }
329
+ async revokeTokenRecord(filePath, token, clientId) {
330
+ const record = await readRecord(filePath, tokenRecordSchema);
331
+ if (!record || record.clientId !== clientId)
332
+ return undefined;
333
+ await removeRecord(filePath);
334
+ return FileOAuthServerModel.accessToken(record, token);
335
+ }
336
+ }
337
+ /** The file-backed OAuth server model rooted at <catalogHome>/oauth. */
338
+ export function createFileOAuthServerModel(oauthRoot) {
339
+ return new FileOAuthServerModel(oauthRoot);
340
+ }
341
+ //# sourceMappingURL=model.js.map
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ declare const settingsSchema: z.ZodObject<{
3
+ formatVersion: z.ZodLiteral<1>;
4
+ enabled: z.ZodBoolean;
5
+ publicUrl: z.ZodString;
6
+ }, z.core.$strict>;
7
+ export type McpOauthSettings = z.infer<typeof settingsSchema>;
8
+ export type McpOauthSettingsState = 'ok' | 'missing' | 'invalid';
9
+ export type McpOauthSettingsRead = {
10
+ state: McpOauthSettingsState;
11
+ settings: McpOauthSettings | null;
12
+ };
13
+ export type McpOauthSettingsInput = {
14
+ enabled: boolean;
15
+ publicUrl?: string;
16
+ };
17
+ /**
18
+ * Validate the public URL: an absolute https URL with no query string or
19
+ * fragment (it becomes the advertised OAuth issuer), trailing slashes stripped.
20
+ * Mirrors the CLI's --public-url rule (cli-options.ts) at the write boundary.
21
+ */
22
+ export declare function validateOauthPublicUrl(value: string): string;
23
+ /** Validate and normalize a settings write; throws on invalid input (fail loud). */
24
+ export declare function normalizeMcpOauthSettings(input: McpOauthSettingsInput): McpOauthSettings;
25
+ /**
26
+ * The settings read for resolution, cached by file mtime so Console writes are
27
+ * picked up without a restart. A missing file is 'missing'; an unreadable or
28
+ * malformed one is 'invalid' and fails closed downstream.
29
+ */
30
+ export declare function readMcpOauthSettings(settingsFile: string): McpOauthSettingsRead;
31
+ /** Atomically write validated settings; the write is the only mutation. */
32
+ export declare function writeMcpOauthSettings(settingsFile: string, input: McpOauthSettingsInput): Promise<McpOauthSettings>;
33
+ export {};
@@ -0,0 +1,96 @@
1
+ import { mkdir, rename, writeFile } from 'node:fs/promises';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ /**
6
+ * Console-managed OAuth 2.1 exposure settings, stored at
7
+ * <catalogHome>/oauth/settings.json (directory 0700, file 0600, atomic
8
+ * tmp+rename writes, mirroring mcp-keys.ts).
9
+ *
10
+ * The file is the runtime override for the startup --oauth/--public-url
11
+ * (CATENCE_OAUTH_ENABLED/CATENCE_PUBLIC_URL) values: once a valid file exists
12
+ * it alone decides whether the OAuth 2.1 authorization server is enabled, so
13
+ * the Console can switch OAuth on and off without a restart. A malformed file
14
+ * fails closed (disabled) until the Console writes a valid one.
15
+ */
16
+ const MCP_OAUTH_SETTINGS_FORMAT_VERSION = 1;
17
+ const settingsSchema = z.object({
18
+ formatVersion: z.literal(MCP_OAUTH_SETTINGS_FORMAT_VERSION),
19
+ enabled: z.boolean(),
20
+ // Normalized at write time: absolute https URL, no query/fragment, trailing
21
+ // slashes stripped; empty when never set.
22
+ publicUrl: z.string().max(2048),
23
+ }).strict()
24
+ // An enabled record without a public URL is semantically invalid (the URL is
25
+ // the advertised issuer); the read fails closed for it like any malformed file.
26
+ .refine((value) => !value.enabled || value.publicUrl.length > 0, { message: 'enabled requires a publicUrl' });
27
+ /**
28
+ * Validate the public URL: an absolute https URL with no query string or
29
+ * fragment (it becomes the advertised OAuth issuer), trailing slashes stripped.
30
+ * Mirrors the CLI's --public-url rule (cli-options.ts) at the write boundary.
31
+ */
32
+ export function validateOauthPublicUrl(value) {
33
+ let url;
34
+ try {
35
+ url = new URL(value);
36
+ }
37
+ catch {
38
+ throw new Error('publicUrl must be a valid absolute URL.');
39
+ }
40
+ if (url.protocol !== 'https:')
41
+ throw new Error('publicUrl must use https: the OAuth issuer is advertised publicly.');
42
+ if (url.search || url.hash)
43
+ throw new Error('publicUrl must not contain a query string or fragment.');
44
+ return value.replace(/\/+$/, '');
45
+ }
46
+ /** Validate and normalize a settings write; throws on invalid input (fail loud). */
47
+ export function normalizeMcpOauthSettings(input) {
48
+ const publicUrl = (input.publicUrl ?? '').trim();
49
+ if (input.enabled && !publicUrl) {
50
+ throw new Error('publicUrl is required when OAuth is enabled: the OAuth issuer is the public URL of this server.');
51
+ }
52
+ return {
53
+ formatVersion: MCP_OAUTH_SETTINGS_FORMAT_VERSION,
54
+ enabled: input.enabled,
55
+ publicUrl: publicUrl ? validateOauthPublicUrl(publicUrl) : '',
56
+ };
57
+ }
58
+ const settingsCache = new Map();
59
+ /**
60
+ * The settings read for resolution, cached by file mtime so Console writes are
61
+ * picked up without a restart. A missing file is 'missing'; an unreadable or
62
+ * malformed one is 'invalid' and fails closed downstream.
63
+ */
64
+ export function readMcpOauthSettings(settingsFile) {
65
+ let mtimeMs = 0;
66
+ try {
67
+ mtimeMs = statSync(settingsFile).mtimeMs;
68
+ }
69
+ catch {
70
+ const read = { state: 'missing', settings: null };
71
+ settingsCache.set(settingsFile, { mtimeMs: 0, read });
72
+ return read;
73
+ }
74
+ const cached = settingsCache.get(settingsFile);
75
+ if (cached && cached.mtimeMs === mtimeMs)
76
+ return cached.read;
77
+ let read;
78
+ try {
79
+ read = { state: 'ok', settings: settingsSchema.parse(JSON.parse(readFileSync(settingsFile, 'utf8'))) };
80
+ }
81
+ catch {
82
+ read = { state: 'invalid', settings: null };
83
+ }
84
+ settingsCache.set(settingsFile, { mtimeMs, read });
85
+ return read;
86
+ }
87
+ /** Atomically write validated settings; the write is the only mutation. */
88
+ export async function writeMcpOauthSettings(settingsFile, input) {
89
+ const settings = normalizeMcpOauthSettings(input);
90
+ await mkdir(path.dirname(settingsFile), { recursive: true, mode: 0o700 });
91
+ const temporary = `${settingsFile}.tmp`;
92
+ await writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
93
+ await rename(temporary, settingsFile);
94
+ return settings;
95
+ }
96
+ //# sourceMappingURL=settings.js.map
@@ -103,6 +103,10 @@ export async function importJsonl(database, runId, filePath, log) {
103
103
  await database.run('ROLLBACK');
104
104
  throw error;
105
105
  }
106
+ if (inTransaction) {
107
+ await database.run('COMMIT');
108
+ inTransaction = false;
109
+ }
106
110
  const qualityStartedAt = Date.now();
107
111
  for (const activityId of pass.deferredQualityActivityIds) {
108
112
  await refreshActivityQuality(database, activityId);
@@ -2,8 +2,8 @@
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 { defaultBearerTokenPath, disableBearerToken, ensureBearerToken, mcpExposureStatus, readBearerToken, } from '../http/cli-options.js';
6
- import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, deleteAthleteSecret, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, parseAutoSyncInterval, planSelfUpdate, readAutoSyncSchedule, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, updateAutoSyncSchedule, } from '../../runtime/index.js';
5
+ import { defaultBearerTokenPath, defaultMcpKeysPath, defaultOauthSettingsPath, disableBearerToken, ensureBearerToken, mcpExposureStatus, oauthStatusFromSettings, readBearerToken, } from '../http/cli-options.js';
6
+ import { addAthlete, addMcpKey, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, deleteAthleteSecret, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, listMcpKeys, loadCatalog, npmGlobalPrefix, parseAutoSyncInterval, planSelfUpdate, readAutoSyncSchedule, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, revokeMcpKey, rollMcpKey, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, updateAutoSyncSchedule, } from '../../runtime/index.js';
7
7
  const program = new Command()
8
8
  .name('catence-data')
9
9
  .description('Athlete-scoped Catence data setup, extraction, and normalization.')
@@ -38,14 +38,26 @@ async function stdinSecret() {
38
38
  throw new Error('--value-stdin received an empty secret.');
39
39
  return value;
40
40
  }
41
- const mcpCommand = program.command('mcp').description('Manage Streamable HTTP MCP exposure: the bearer-token gate, its token, and endpoint URLs.');
41
+ const mcpCommand = program.command('mcp').description('Manage Streamable HTTP MCP exposure: the bearer-token gate, its token, per-account keys, and endpoint URLs.');
42
42
  const mcpLocalEndpoint = () => `http://127.0.0.1:${process.env.CATENCE_HTTP_PORT ?? '8787'}`;
43
+ // The keys registry resolves like the serve option: --file, then the
44
+ // environment, then the catalog home default.
45
+ const mcpKeysFile = (file) => file ?? process.env.CATENCE_MCP_KEYS_FILE ?? defaultMcpKeysPath(currentCatalog().root);
46
+ // The OAuth settings file resolves like the serve option: the environment,
47
+ // then the catalog home default.
48
+ const mcpOauthSettingsFile = () => process.env.CATENCE_OAUTH_SETTINGS_FILE ?? defaultOauthSettingsPath(currentCatalog().root);
43
49
  mcpCommand.command('status')
44
- .description('Report whether the bearer-token gate is enabled, the token, and the endpoint URLs.')
50
+ .description('Report whether the bearer-token gate is enabled, the token, the per-account key counts, and the endpoint URLs.')
45
51
  .option('--file <path>', 'non-default token file path')
46
52
  .action(async (options) => {
47
53
  const tokenFile = options.file ?? defaultBearerTokenPath(currentCatalog().root);
48
- process.stdout.write(`${JSON.stringify(mcpExposureStatus(tokenFile, mcpLocalEndpoint()), null, 2)}\n`);
54
+ const keys = await listMcpKeys(mcpKeysFile());
55
+ const status = {
56
+ ...mcpExposureStatus(tokenFile, mcpLocalEndpoint()),
57
+ keys: { total: keys.length, active: keys.filter((key) => key.revokedAt === undefined).length },
58
+ oauth: oauthStatusFromSettings(mcpOauthSettingsFile()),
59
+ };
60
+ process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
49
61
  });
50
62
  mcpCommand.command('enable')
51
63
  .description('Enable the bearer-token gate: generate the token file (mode 0600) if it does not exist.')
@@ -73,6 +85,38 @@ mcpCommand.command('token')
73
85
  throw new Error(`MCP exposure is not enabled; run 'catence-data mcp enable' first.`);
74
86
  process.stdout.write(`${token}\n`);
75
87
  });
88
+ const mcpKeyCommand = mcpCommand.command('key').description('Manage per-account MCP bearer keys; raw tokens are printed exactly once and stored only as hashes.');
89
+ mcpKeyCommand.command('add')
90
+ .description('Create a per-account MCP key and print its raw token exactly once.')
91
+ .requiredOption('--account <username>', 'Console account username the key acts for')
92
+ .option('--label <label>', 'human-readable key label')
93
+ .option('--file <path>', 'non-default MCP keys file path')
94
+ .action(async (options) => {
95
+ const { record, token } = await addMcpKey(mcpKeysFile(options.file), { account: options.account, label: options.label });
96
+ process.stdout.write(`${JSON.stringify({ id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token }, null, 2)}\n`);
97
+ });
98
+ mcpKeyCommand.command('list')
99
+ .description('List MCP keys without their token hashes.')
100
+ .option('--file <path>', 'non-default MCP keys file path')
101
+ .action(async (options) => {
102
+ process.stdout.write(`${JSON.stringify({ keys: await listMcpKeys(mcpKeysFile(options.file)) }, null, 2)}\n`);
103
+ });
104
+ mcpKeyCommand.command('revoke')
105
+ .description('Revoke an MCP key by id; its token stops working immediately.')
106
+ .argument('<id>', 'key id')
107
+ .option('--file <path>', 'non-default MCP keys file path')
108
+ .action(async (id, options) => {
109
+ await revokeMcpKey(mcpKeysFile(options.file), id);
110
+ process.stdout.write(`${JSON.stringify({ revoked: id }, null, 2)}\n`);
111
+ });
112
+ mcpKeyCommand.command('roll')
113
+ .description('Replace an MCP key’s token and print the new raw token exactly once.')
114
+ .argument('<id>', 'key id')
115
+ .option('--file <path>', 'non-default MCP keys file path')
116
+ .action(async (id, options) => {
117
+ const { record, token } = await rollMcpKey(mcpKeysFile(options.file), id);
118
+ process.stdout.write(`${JSON.stringify({ id: record.id, label: record.label, account: record.account, createdAt: record.createdAt, token }, null, 2)}\n`);
119
+ });
76
120
  program.command('setup')
77
121
  .description('Create a home catalog and its first athlete store. Existing 0.1 stores must be re-synced.')
78
122
  .option('--athlete <id>', 'first athlete ID')
@@ -4,11 +4,19 @@ export type ServeCliOptions = {
4
4
  port: number;
5
5
  allowedOrigins: string[];
6
6
  authTokenFile?: string;
7
+ mcpKeysFile?: string;
8
+ oauthSettingsFile?: string;
9
+ oauth: boolean;
10
+ publicUrl?: string;
7
11
  help: boolean;
8
12
  };
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> Bearer-token file for the HTTP gate. Its presence switches authentication on;\n generate/inspect it with 'catence-data mcp enable|token'.\n Defaults to CATENCE_MCP_TOKEN_FILE or <catalog-home>/mcp-token.\n -h, --help Show this help text.";
13
+ 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> Bearer-token file for the HTTP gate. Its presence switches authentication on;\n generate/inspect it with 'catence-data mcp enable|token'.\n Defaults to CATENCE_MCP_TOKEN_FILE or <catalog-home>/mcp-token.\n --mcp-keys-file <path> Per-account MCP key registry for the HTTP gate. Any active key in it\n switches authentication on; manage keys with 'catence-data mcp key'.\n Defaults to CATENCE_MCP_KEYS_FILE or <catalog-home>/mcp-keys.json.\n --oauth Run the OAuth 2.1 authorization server (issuer = --public-url) so\n ChatGPT/Codex-style MCP clients connect with a per-account consent\n login. Requires --public-url. Defaults to CATENCE_OAUTH_ENABLED=1/true.\n The Console settings file overrides this at runtime.\n --public-url <url> Public https URL of this server (no query string or fragment), e.g.\n the tunnel URL. Required when --oauth is enabled.\n Defaults to CATENCE_PUBLIC_URL.\n --oauth-settings-file <path>\n Console-managed OAuth settings that override --oauth/--public-url\n at runtime. Defaults to CATENCE_OAUTH_SETTINGS_FILE or\n <catalog-home>/oauth/settings.json.\n -h, --help Show this help text.";
10
14
  /** The canonical token location inside a catalog home. */
11
15
  export declare function defaultBearerTokenPath(catalogRoot: string): string;
16
+ /** The canonical per-account MCP key registry location inside a catalog home. */
17
+ export declare function defaultMcpKeysPath(catalogRoot: string): string;
18
+ /** The canonical Console-managed OAuth settings location inside a catalog home. */
19
+ export declare function defaultOauthSettingsPath(catalogRoot: string): string;
12
20
  /** Read and validate a stored bearer token: trimmed, non-empty, or fail loudly. */
13
21
  export declare function loadBearerToken(filePath: string): string;
14
22
  /**
@@ -34,6 +42,31 @@ export type McpExposureStatus = {
34
42
  endpoint: string;
35
43
  healthEndpoint: string;
36
44
  token?: string;
45
+ oauth?: {
46
+ enabled: boolean;
47
+ publicUrl?: string;
48
+ };
49
+ };
50
+ export declare function mcpExposureStatus(filePath: string, endpointBase: string, oauth?: {
51
+ enabled: boolean;
52
+ publicUrl?: string;
53
+ }): McpExposureStatus;
54
+ /** True when CATENCE_OAUTH_ENABLED is 1/true. */
55
+ export declare function oauthEnabledFromEnv(env?: NodeJS.ProcessEnv): boolean;
56
+ /** The OAuth exposure reported by `catence-data mcp status`, read from the environment. */
57
+ export declare function oauthStatusFromEnv(env?: NodeJS.ProcessEnv): {
58
+ enabled: boolean;
59
+ publicUrl?: string;
60
+ };
61
+ export type McpOauthStatusSource = 'console' | 'environment' | 'disabled';
62
+ /**
63
+ * The effective OAuth exposure: the Console settings file when a valid one
64
+ * exists (a malformed one fails closed to disabled), else the environment.
65
+ * `source` records where the effective values come from.
66
+ */
67
+ export declare function oauthStatusFromSettings(settingsFile: string, env?: NodeJS.ProcessEnv): {
68
+ enabled: boolean;
69
+ publicUrl?: string;
70
+ source: McpOauthStatusSource;
37
71
  };
38
- export declare function mcpExposureStatus(filePath: string, endpointBase: string): McpExposureStatus;
39
72
  export declare function parseServeCliOptions(arguments_: readonly string[]): ServeCliOptions;