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.
- package/README.md +4 -4
- package/config.example.json +1 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +16 -10
- package/dist/core/query/repository.d.ts +1 -1
- package/dist/core/query/repository.js +31 -2
- package/dist/core/retrieval/index.js +23 -5
- package/dist/core/runtime/athlete-file.d.ts +49 -0
- package/dist/core/runtime/athlete-file.js +177 -0
- package/dist/core/runtime/configuration.js +2 -0
- package/dist/core/runtime/console-accounts.d.ts +52 -0
- package/dist/core/runtime/console-accounts.js +103 -0
- package/dist/core/runtime/mcp-keys.d.ts +65 -0
- package/dist/core/runtime/mcp-keys.js +171 -0
- package/dist/core/runtime/oauth/model.d.ts +44 -0
- package/dist/core/runtime/oauth/model.js +345 -0
- package/dist/core/runtime/oauth/public-client.d.ts +28 -0
- package/dist/core/runtime/oauth/public-client.js +35 -0
- package/dist/core/runtime/oauth/settings.d.ts +33 -0
- package/dist/core/runtime/oauth/settings.js +96 -0
- package/dist/core/runtime/secrets.d.ts +15 -0
- package/dist/core/runtime/secrets.js +48 -7
- package/dist/elt/application/autosync.d.ts +71 -0
- package/dist/elt/application/autosync.js +173 -0
- package/dist/elt/application/detached-sync.d.ts +7 -2
- package/dist/elt/application/detached-sync.js +10 -4
- package/dist/elt/application/management.d.ts +7 -0
- package/dist/elt/application/management.js +103 -7
- package/dist/elt/ingestion/importer.d.ts +11 -1
- package/dist/elt/ingestion/importer.js +63 -14
- package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
- package/dist/elt/ingestion/providers/intervals.js +21 -2
- package/dist/elt/normalization/normalizers.js +122 -0
- package/dist/elt/storage/database.d.ts +6 -1
- package/dist/elt/storage/database.js +7 -1
- package/dist/elt/storage/migrations.js +45 -1
- package/dist/elt/storage/progress-sidecar.d.ts +9 -0
- package/dist/elt/storage/progress-sidecar.js +31 -16
- package/dist/interfaces/cli/commands.js +117 -2
- package/dist/interfaces/http/cli-options.d.ts +64 -1
- package/dist/interfaces/http/cli-options.js +214 -2
- package/dist/interfaces/http/oauth.d.ts +16 -0
- package/dist/interfaces/http/oauth.js +228 -0
- package/dist/interfaces/http/server.d.ts +60 -1
- package/dist/interfaces/http/server.js +810 -16
- package/dist/interfaces/mcp/main.js +35 -3
- package/dist/interfaces/mcp/server.d.ts +21 -1
- package/dist/interfaces/mcp/server.js +107 -21
- package/dist/runtime/index.d.ts +15 -3
- package/dist/runtime/index.js +9 -2
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +76 -6
- package/docs/deployment/docker.md +173 -15
- package/docs/deployment/local-console.md +132 -21
- package/docs/deployment/local-mcp.md +190 -11
- package/docs/development.md +5 -3
- package/docs/llm-providers.md +4 -4
- package/package.json +11 -4
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +55 -7
- package/python/catence/providers/garmin/progress.py +71 -35
- package/python/catence/providers/garmin/staging.py +11 -4
- package/python/catence/providers/garmin/worker.py +420 -206
- 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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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;
|