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.
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- 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 +341 -0
- package/dist/core/runtime/oauth/settings.d.ts +33 -0
- package/dist/core/runtime/oauth/settings.js +96 -0
- package/dist/elt/ingestion/importer.js +4 -0
- package/dist/interfaces/cli/commands.js +49 -5
- package/dist/interfaces/http/cli-options.d.ts +35 -2
- package/dist/interfaces/http/cli-options.js +119 -3
- package/dist/interfaces/http/oauth.d.ts +36 -0
- package/dist/interfaces/http/oauth.js +175 -0
- package/dist/interfaces/http/server.d.ts +45 -1
- package/dist/interfaces/http/server.js +400 -32
- package/dist/interfaces/mcp/main.js +21 -1
- package/dist/interfaces/mcp/server.d.ts +12 -1
- package/dist/interfaces/mcp/server.js +18 -4
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.js +4 -0
- package/docs/deployment/docker.md +48 -10
- package/docs/deployment/local-mcp.md +116 -8
- package/package.json +7 -1
|
@@ -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
|
-
|
|
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;
|