catence 0.2.1-beta.2 → 0.2.1-beta.21

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.
Files changed (67) hide show
  1. package/README.md +4 -4
  2. package/config.example.json +1 -0
  3. package/dist/contracts/release.d.ts +1 -1
  4. package/dist/contracts/release.js +1 -1
  5. package/dist/contracts/runtime.d.ts +4 -0
  6. package/dist/core/query/catalog.d.ts +1 -1
  7. package/dist/core/query/catalog.js +16 -10
  8. package/dist/core/query/repository.d.ts +1 -1
  9. package/dist/core/query/repository.js +31 -2
  10. package/dist/core/retrieval/index.js +23 -5
  11. package/dist/core/runtime/athlete-file.d.ts +49 -0
  12. package/dist/core/runtime/athlete-file.js +177 -0
  13. package/dist/core/runtime/configuration.js +2 -0
  14. package/dist/core/runtime/console-accounts.d.ts +52 -0
  15. package/dist/core/runtime/console-accounts.js +103 -0
  16. package/dist/core/runtime/mcp-keys.d.ts +65 -0
  17. package/dist/core/runtime/mcp-keys.js +171 -0
  18. package/dist/core/runtime/oauth/model.d.ts +44 -0
  19. package/dist/core/runtime/oauth/model.js +345 -0
  20. package/dist/core/runtime/oauth/public-client.d.ts +28 -0
  21. package/dist/core/runtime/oauth/public-client.js +35 -0
  22. package/dist/core/runtime/oauth/settings.d.ts +33 -0
  23. package/dist/core/runtime/oauth/settings.js +96 -0
  24. package/dist/core/runtime/secrets.d.ts +15 -0
  25. package/dist/core/runtime/secrets.js +48 -7
  26. package/dist/elt/application/autosync.d.ts +71 -0
  27. package/dist/elt/application/autosync.js +173 -0
  28. package/dist/elt/application/detached-sync.d.ts +7 -2
  29. package/dist/elt/application/detached-sync.js +10 -4
  30. package/dist/elt/application/management.d.ts +7 -0
  31. package/dist/elt/application/management.js +103 -7
  32. package/dist/elt/ingestion/importer.d.ts +11 -1
  33. package/dist/elt/ingestion/importer.js +63 -14
  34. package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
  35. package/dist/elt/ingestion/providers/intervals.js +21 -2
  36. package/dist/elt/normalization/normalizers.js +122 -0
  37. package/dist/elt/storage/database.d.ts +6 -1
  38. package/dist/elt/storage/database.js +7 -1
  39. package/dist/elt/storage/migrations.js +45 -1
  40. package/dist/elt/storage/progress-sidecar.d.ts +9 -0
  41. package/dist/elt/storage/progress-sidecar.js +31 -16
  42. package/dist/interfaces/cli/commands.js +117 -2
  43. package/dist/interfaces/http/cli-options.d.ts +64 -1
  44. package/dist/interfaces/http/cli-options.js +214 -2
  45. package/dist/interfaces/http/oauth.d.ts +16 -0
  46. package/dist/interfaces/http/oauth.js +228 -0
  47. package/dist/interfaces/http/server.d.ts +60 -1
  48. package/dist/interfaces/http/server.js +810 -16
  49. package/dist/interfaces/mcp/main.js +35 -3
  50. package/dist/interfaces/mcp/server.d.ts +21 -1
  51. package/dist/interfaces/mcp/server.js +107 -21
  52. package/dist/runtime/index.d.ts +15 -3
  53. package/dist/runtime/index.js +9 -2
  54. package/docs/architecture.md +1 -1
  55. package/docs/configuration.md +76 -6
  56. package/docs/deployment/docker.md +173 -15
  57. package/docs/deployment/local-console.md +132 -21
  58. package/docs/deployment/local-mcp.md +190 -11
  59. package/docs/development.md +5 -3
  60. package/docs/llm-providers.md +4 -4
  61. package/package.json +11 -4
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +55 -7
  64. package/python/catence/providers/garmin/progress.py +71 -35
  65. package/python/catence/providers/garmin/staging.py +11 -4
  66. package/python/catence/providers/garmin/worker.py +420 -206
  67. package/uv.lock +3 -3
@@ -0,0 +1,345 @@
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
+ // Codes and tokens are keyed by a hashed secret, never by the client id, and
67
+ // can be issued to either kind of client: a DCR-registered client (UUID) or a
68
+ // client identified by a Client ID Metadata Document (an HTTPS URL). Both are
69
+ // persisted verbatim so the reader must accept both.
70
+ const authorizationCodeRecordSchema = z.object({
71
+ clientId: z.string().min(1),
72
+ userId: z.string(),
73
+ expiresAt: isoTimestampSchema,
74
+ scopes: z.array(z.string()),
75
+ resource: z.string().optional(),
76
+ codeChallenge: z.string(),
77
+ redirectUri: z.string(),
78
+ state: z.string().optional(),
79
+ grantId: uuidSchema.optional(),
80
+ }).strict();
81
+ const tokenRecordSchema = z.object({
82
+ clientId: z.string().min(1),
83
+ userId: z.string().optional(),
84
+ expiresAt: isoTimestampSchema,
85
+ scopes: z.array(z.string()),
86
+ resource: z.string().optional(),
87
+ grantId: uuidSchema.optional(),
88
+ }).strict();
89
+ const clientIdMetadataDocumentRecordSchema = z.object({
90
+ client: cimdClientRecordSchema,
91
+ expiresAt: isoTimestampSchema,
92
+ }).strict();
93
+ function hashSecret(value) {
94
+ return createHash('sha256').update(value, 'utf8').digest('hex');
95
+ }
96
+ function isExpired(expiresAtIso) {
97
+ return new Date(expiresAtIso).getTime() <= Date.now();
98
+ }
99
+ async function writeRecord(filePath, record) {
100
+ await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
101
+ const temporary = `${filePath}.tmp`;
102
+ await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
103
+ await rename(temporary, filePath);
104
+ }
105
+ /**
106
+ * Read one record file. A missing file is undefined; a malformed file fails
107
+ * closed (undefined) and is swept so it cannot linger. Read errors other than
108
+ * ENOENT propagate: the caller's fail-closed wrapper turns them into a
109
+ * rejection, never into an anonymous success.
110
+ */
111
+ async function readRecord(filePath, schema) {
112
+ let contents;
113
+ try {
114
+ contents = await readFile(filePath, 'utf8');
115
+ }
116
+ catch (error) {
117
+ if (error.code === 'ENOENT')
118
+ return undefined;
119
+ throw error;
120
+ }
121
+ try {
122
+ return schema.parse(JSON.parse(contents));
123
+ }
124
+ catch {
125
+ await unlink(filePath).catch(() => { });
126
+ return undefined;
127
+ }
128
+ }
129
+ async function removeRecord(filePath) {
130
+ await unlink(filePath).catch((error) => {
131
+ if (error.code !== 'ENOENT')
132
+ throw error;
133
+ });
134
+ }
135
+ export class FileOAuthServerModel {
136
+ root;
137
+ constructor(root) {
138
+ this.root = root;
139
+ }
140
+ clientFile(clientId) {
141
+ return path.join(this.root, 'clients', `${clientId}.json`);
142
+ }
143
+ codeFile(authorizationCode) {
144
+ return path.join(this.root, 'codes', `${hashSecret(authorizationCode)}.json`);
145
+ }
146
+ accessTokenFile(accessToken) {
147
+ return path.join(this.root, 'access', `${hashSecret(accessToken)}.json`);
148
+ }
149
+ refreshTokenFile(refreshToken) {
150
+ return path.join(this.root, 'refresh', `${hashSecret(refreshToken)}.json`);
151
+ }
152
+ cimdFile(clientId) {
153
+ return path.join(this.root, 'cimd', `${hashSecret(clientId)}.json`);
154
+ }
155
+ async getClient(clientId) {
156
+ // Validate before any filesystem use: client ids become path components.
157
+ if (!UUID_PATTERN.test(clientId))
158
+ return undefined;
159
+ const record = await readRecord(this.clientFile(clientId), clientRecordSchema);
160
+ return record;
161
+ }
162
+ async registerClient(client) {
163
+ if (!UUID_PATTERN.test(client.client_id))
164
+ throw new Error(`Invalid OAuth client id: ${client.client_id}`);
165
+ await writeRecord(this.clientFile(client.client_id), clientRecordSchema.parse(client));
166
+ return client;
167
+ }
168
+ async saveAuthorizationCode(code, _client) {
169
+ const record = {
170
+ clientId: code.clientId,
171
+ userId: code.userId,
172
+ expiresAt: code.expiresAt.toISOString(),
173
+ scopes: code.scopes ?? [],
174
+ codeChallenge: code.codeChallenge,
175
+ redirectUri: code.redirectUri,
176
+ ...(code.resource !== undefined ? { resource: code.resource } : {}),
177
+ ...(code.state !== undefined ? { state: code.state } : {}),
178
+ ...(code.grantId !== undefined ? { grantId: code.grantId } : {}),
179
+ };
180
+ await writeRecord(this.codeFile(code.authorizationCode), record);
181
+ }
182
+ /**
183
+ * Read + invalidate in one operation, scoped to the client. The rename is
184
+ * the atomic claim: when several requests present the same code only one
185
+ * rename succeeds, and the losers see ENOENT and return undefined. A request
186
+ * from the wrong client consumes nothing.
187
+ */
188
+ async consumeAuthorizationCode(authorizationCode, clientId) {
189
+ const record = await this.consumeRecord(this.codeFile(authorizationCode), clientId, authorizationCodeRecordSchema);
190
+ if (!record)
191
+ return undefined;
192
+ return {
193
+ authorizationCode,
194
+ clientId: record.clientId,
195
+ userId: record.userId,
196
+ expiresAt: new Date(record.expiresAt),
197
+ scopes: record.scopes,
198
+ codeChallenge: record.codeChallenge,
199
+ redirectUri: record.redirectUri,
200
+ ...(record.resource !== undefined ? { resource: record.resource } : {}),
201
+ ...(record.state !== undefined ? { state: record.state } : {}),
202
+ ...(record.grantId !== undefined ? { grantId: record.grantId } : {}),
203
+ };
204
+ }
205
+ async revokeAuthorizationCode(authorizationCode) {
206
+ await removeRecord(this.codeFile(authorizationCode));
207
+ }
208
+ async saveAccessToken(token, _client) {
209
+ await writeRecord(this.accessTokenFile(token.token), FileOAuthServerModel.tokenRecord(token));
210
+ }
211
+ async getAccessToken(accessToken) {
212
+ const record = await readRecord(this.accessTokenFile(accessToken), tokenRecordSchema);
213
+ if (!record)
214
+ return undefined;
215
+ if (isExpired(record.expiresAt)) {
216
+ await removeRecord(this.accessTokenFile(accessToken));
217
+ return undefined;
218
+ }
219
+ return FileOAuthServerModel.accessToken(record, accessToken);
220
+ }
221
+ /**
222
+ * Revoke only when the token was issued to the requesting client; never
223
+ * throw when nothing matches (RFC 7009 §2.2 answers 200 either way).
224
+ */
225
+ async revokeAccessToken(accessToken, clientId) {
226
+ return this.revokeTokenRecord(this.accessTokenFile(accessToken), accessToken, clientId);
227
+ }
228
+ async saveRefreshToken(token, _client) {
229
+ await writeRecord(this.refreshTokenFile(token.token), FileOAuthServerModel.tokenRecord(token));
230
+ }
231
+ async revokeRefreshToken(refreshToken, clientId) {
232
+ return this.revokeTokenRecord(this.refreshTokenFile(refreshToken), refreshToken, clientId);
233
+ }
234
+ /** Refresh tokens rotate on every use, so consume is atomic + client-scoped like the code consume. */
235
+ async consumeRefreshToken(refreshToken, clientId) {
236
+ const record = await this.consumeRecord(this.refreshTokenFile(refreshToken), clientId, tokenRecordSchema);
237
+ if (!record)
238
+ return undefined;
239
+ return FileOAuthServerModel.refreshToken(record, refreshToken);
240
+ }
241
+ /** Sweep every access and refresh token belonging to one authorization grant. */
242
+ async revokeGrant(grantId) {
243
+ if (!grantId)
244
+ return;
245
+ for (const directory of ['access', 'refresh']) {
246
+ const directoryPath = path.join(this.root, directory);
247
+ let entries;
248
+ try {
249
+ entries = await readdir(directoryPath);
250
+ }
251
+ catch (error) {
252
+ if (error.code === 'ENOENT')
253
+ continue;
254
+ throw error;
255
+ }
256
+ for (const entry of entries) {
257
+ if (!entry.endsWith('.json'))
258
+ continue;
259
+ const filePath = path.join(directoryPath, entry);
260
+ const record = await readRecord(filePath, tokenRecordSchema);
261
+ if (record?.grantId === grantId)
262
+ await removeRecord(filePath);
263
+ }
264
+ }
265
+ }
266
+ async saveClientIdMetadataDocument(document) {
267
+ // The cache key is a client-supplied URL: hash it so it never becomes a
268
+ // path component.
269
+ const record = {
270
+ client: cimdClientRecordSchema.parse(document.client),
271
+ expiresAt: document.expiresAt.toISOString(),
272
+ };
273
+ await writeRecord(this.cimdFile(document.client.client_id), record);
274
+ }
275
+ async getClientIdMetadataDocument(clientId) {
276
+ const record = await readRecord(this.cimdFile(clientId), clientIdMetadataDocumentRecordSchema);
277
+ if (!record)
278
+ return undefined;
279
+ if (isExpired(record.expiresAt)) {
280
+ await removeRecord(this.cimdFile(clientId));
281
+ return undefined;
282
+ }
283
+ return { client: record.client, expiresAt: new Date(record.expiresAt) };
284
+ }
285
+ static tokenRecord(token) {
286
+ return {
287
+ clientId: token.clientId,
288
+ expiresAt: token.expiresAt.toISOString(),
289
+ scopes: token.scopes,
290
+ ...(token.userId !== undefined ? { userId: token.userId } : {}),
291
+ ...(token.resource !== undefined ? { resource: token.resource } : {}),
292
+ ...(token.grantId !== undefined ? { grantId: token.grantId } : {}),
293
+ };
294
+ }
295
+ static accessToken(record, token) {
296
+ return {
297
+ token,
298
+ clientId: record.clientId,
299
+ expiresAt: new Date(record.expiresAt),
300
+ scopes: record.scopes,
301
+ ...(record.userId !== undefined ? { userId: record.userId } : {}),
302
+ ...(record.resource !== undefined ? { resource: record.resource } : {}),
303
+ ...(record.grantId !== undefined ? { grantId: record.grantId } : {}),
304
+ };
305
+ }
306
+ static refreshToken(record, token) {
307
+ return FileOAuthServerModel.accessToken(record, token);
308
+ }
309
+ async consumeRecord(filePath, clientId, schema) {
310
+ const record = await readRecord(filePath, schema);
311
+ if (!record)
312
+ return undefined;
313
+ if (record.clientId !== clientId)
314
+ return undefined;
315
+ if (isExpired(record.expiresAt)) {
316
+ await removeRecord(filePath);
317
+ return undefined;
318
+ }
319
+ // Claim the record atomically before deleting it: the rename succeeds for
320
+ // exactly one caller even when several requests race the same credential.
321
+ const claimed = `${filePath}.consumed-${randomBytes(6).toString('hex')}`;
322
+ try {
323
+ await rename(filePath, claimed);
324
+ }
325
+ catch (error) {
326
+ if (error.code === 'ENOENT')
327
+ return undefined;
328
+ throw error;
329
+ }
330
+ await removeRecord(claimed);
331
+ return record;
332
+ }
333
+ async revokeTokenRecord(filePath, token, clientId) {
334
+ const record = await readRecord(filePath, tokenRecordSchema);
335
+ if (!record || record.clientId !== clientId)
336
+ return undefined;
337
+ await removeRecord(filePath);
338
+ return FileOAuthServerModel.accessToken(record, token);
339
+ }
340
+ }
341
+ /** The file-backed OAuth server model rooted at <catalogHome>/oauth. */
342
+ export function createFileOAuthServerModel(oauthRoot) {
343
+ return new FileOAuthServerModel(oauthRoot);
344
+ }
345
+ //# sourceMappingURL=model.js.map
@@ -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
@@ -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
@@ -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;