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
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { readMcpOauthSettings } from '../../runtime/index.js';
|
|
1
4
|
export const SERVE_USAGE = `Usage: catence serve [options]
|
|
2
5
|
|
|
3
6
|
Run Catence as a local Streamable HTTP MCP server.
|
|
@@ -7,7 +10,155 @@ Options:
|
|
|
7
10
|
--host <host> Listener host. Defaults to 127.0.0.1.
|
|
8
11
|
--port <port> Listener port. Defaults to 8787.
|
|
9
12
|
--allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.
|
|
13
|
+
--auth-token-file <path> Bearer-token file for the HTTP gate. Its presence switches authentication on;
|
|
14
|
+
generate/inspect it with 'catence-data mcp enable|token'.
|
|
15
|
+
Defaults to CATENCE_MCP_TOKEN_FILE or <catalog-home>/mcp-token.
|
|
16
|
+
--mcp-keys-file <path> Per-account MCP key registry for the HTTP gate. Any active key in it
|
|
17
|
+
switches authentication on; manage keys with 'catence-data mcp key'.
|
|
18
|
+
Defaults to CATENCE_MCP_KEYS_FILE or <catalog-home>/mcp-keys.json.
|
|
19
|
+
--oauth Run the OAuth 2.1 authorization server (issuer = --public-url) so
|
|
20
|
+
ChatGPT/Codex-style MCP clients connect with a per-account consent
|
|
21
|
+
login. Requires --public-url. Defaults to CATENCE_OAUTH_ENABLED=1/true.
|
|
22
|
+
The Console settings file overrides this at runtime.
|
|
23
|
+
--public-url <url> Public https URL of this server (no query string or fragment), e.g.
|
|
24
|
+
the tunnel URL. Required when --oauth is enabled.
|
|
25
|
+
Defaults to CATENCE_PUBLIC_URL.
|
|
26
|
+
--oauth-settings-file <path>
|
|
27
|
+
Console-managed OAuth settings that override --oauth/--public-url
|
|
28
|
+
at runtime. Defaults to CATENCE_OAUTH_SETTINGS_FILE or
|
|
29
|
+
<catalog-home>/oauth/settings.json.
|
|
10
30
|
-h, --help Show this help text.`;
|
|
31
|
+
/** The canonical token location inside a catalog home. */
|
|
32
|
+
export function defaultBearerTokenPath(catalogRoot) {
|
|
33
|
+
return `${catalogRoot.replace(/[/\\]+$/, '')}/mcp-token`;
|
|
34
|
+
}
|
|
35
|
+
/** The canonical per-account MCP key registry location inside a catalog home. */
|
|
36
|
+
export function defaultMcpKeysPath(catalogRoot) {
|
|
37
|
+
return `${catalogRoot.replace(/[/\\]+$/, '')}/mcp-keys.json`;
|
|
38
|
+
}
|
|
39
|
+
/** The canonical Console-managed OAuth settings location inside a catalog home. */
|
|
40
|
+
export function defaultOauthSettingsPath(catalogRoot) {
|
|
41
|
+
return `${catalogRoot.replace(/[/\\]+$/, '')}/oauth/settings.json`;
|
|
42
|
+
}
|
|
43
|
+
/** Read and validate a stored bearer token: trimmed, non-empty, or fail loudly. */
|
|
44
|
+
export function loadBearerToken(filePath) {
|
|
45
|
+
let token;
|
|
46
|
+
try {
|
|
47
|
+
token = readFileSync(filePath, 'utf8').trim();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error(`Cannot read the auth token file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
51
|
+
}
|
|
52
|
+
if (!token)
|
|
53
|
+
throw new Error(`The auth token file ${filePath} is empty.`);
|
|
54
|
+
return token;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The bearer token for the server: the stored one, or a freshly generated
|
|
58
|
+
* token written to the file (mode 0600) when it does not exist yet. Users
|
|
59
|
+
* never hand-craft tokens; they enable auth and read the result with
|
|
60
|
+
* `catence-data mcp-token`.
|
|
61
|
+
*/
|
|
62
|
+
export function ensureBearerToken(filePath) {
|
|
63
|
+
if (existsSync(filePath))
|
|
64
|
+
return loadBearerToken(filePath);
|
|
65
|
+
const token = randomBytes(32).toString('hex');
|
|
66
|
+
writeFileSync(filePath, token, { mode: 0o600 });
|
|
67
|
+
return token;
|
|
68
|
+
}
|
|
69
|
+
export function disableBearerToken(filePath) {
|
|
70
|
+
if (existsSync(filePath))
|
|
71
|
+
rmSync(filePath);
|
|
72
|
+
}
|
|
73
|
+
/** The bearer token when exposure is enabled, or null when no token file exists. */
|
|
74
|
+
export function readBearerToken(filePath) {
|
|
75
|
+
return existsSync(filePath) ? loadBearerToken(filePath) : null;
|
|
76
|
+
}
|
|
77
|
+
const tokenCache = new Map();
|
|
78
|
+
/**
|
|
79
|
+
* The currently active bearer token for the HTTP gate, cached by the token
|
|
80
|
+
* file's mtime so enabling or disabling is picked up without a restart. A
|
|
81
|
+
* missing file disables the gate; an existing but unreadable or empty file
|
|
82
|
+
* fails closed (the gate rejects everything except health probes).
|
|
83
|
+
*/
|
|
84
|
+
export function activeBearerToken(filePath) {
|
|
85
|
+
let mtimeMs = 0;
|
|
86
|
+
try {
|
|
87
|
+
mtimeMs = statSync(filePath).mtimeMs;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
tokenCache.set(filePath, { mtimeMs: 0, token: null });
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const cached = tokenCache.get(filePath);
|
|
94
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
95
|
+
return cached.token === null ? 'invalid' : cached.token;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const token = loadBearerToken(filePath);
|
|
99
|
+
tokenCache.set(filePath, { mtimeMs, token });
|
|
100
|
+
return token;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
tokenCache.set(filePath, { mtimeMs, token: null });
|
|
104
|
+
return 'invalid';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function mcpExposureStatus(filePath, endpointBase, oauth) {
|
|
108
|
+
const token = readBearerToken(filePath);
|
|
109
|
+
const base = endpointBase.replace(/\/+$/, '');
|
|
110
|
+
return {
|
|
111
|
+
enabled: token !== null,
|
|
112
|
+
tokenFile: filePath,
|
|
113
|
+
endpoint: `${base}/mcp`,
|
|
114
|
+
healthEndpoint: `${base}/health`,
|
|
115
|
+
...(token !== null ? { token } : {}),
|
|
116
|
+
...(oauth !== undefined ? { oauth } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** True when CATENCE_OAUTH_ENABLED is 1/true. */
|
|
120
|
+
export function oauthEnabledFromEnv(env = process.env) {
|
|
121
|
+
const value = env.CATENCE_OAUTH_ENABLED;
|
|
122
|
+
return value === '1' || value === 'true';
|
|
123
|
+
}
|
|
124
|
+
/** The OAuth exposure reported by `catence-data mcp status`, read from the environment. */
|
|
125
|
+
export function oauthStatusFromEnv(env = process.env) {
|
|
126
|
+
const publicUrl = env.CATENCE_PUBLIC_URL || undefined;
|
|
127
|
+
return { enabled: oauthEnabledFromEnv(env), ...(publicUrl !== undefined ? { publicUrl } : {}) };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The effective OAuth exposure: the Console settings file when a valid one
|
|
131
|
+
* exists (a malformed one fails closed to disabled), else the environment.
|
|
132
|
+
* `source` records where the effective values come from.
|
|
133
|
+
*/
|
|
134
|
+
export function oauthStatusFromSettings(settingsFile, env = process.env) {
|
|
135
|
+
const { state, settings } = readMcpOauthSettings(settingsFile);
|
|
136
|
+
if (state === 'ok' && settings !== null) {
|
|
137
|
+
return { enabled: settings.enabled, ...(settings.publicUrl ? { publicUrl: settings.publicUrl } : {}), source: 'console' };
|
|
138
|
+
}
|
|
139
|
+
if (state === 'invalid')
|
|
140
|
+
return { enabled: false, source: 'disabled' };
|
|
141
|
+
const fromEnv = oauthStatusFromEnv(env);
|
|
142
|
+
return { ...fromEnv, source: fromEnv.enabled ? 'environment' : 'disabled' };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Validate the public URL: an absolute https URL with no query string or
|
|
146
|
+
* fragment (it becomes the advertised OAuth issuer), trailing slashes stripped.
|
|
147
|
+
*/
|
|
148
|
+
function parsePublicUrl(value, option) {
|
|
149
|
+
let url;
|
|
150
|
+
try {
|
|
151
|
+
url = new URL(value);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new Error(`${option} must be a valid absolute URL.`);
|
|
155
|
+
}
|
|
156
|
+
if (url.protocol !== 'https:')
|
|
157
|
+
throw new Error(`${option} must use https: the OAuth issuer is advertised publicly.`);
|
|
158
|
+
if (url.search || url.hash)
|
|
159
|
+
throw new Error(`${option} must not contain a query string or fragment.`);
|
|
160
|
+
return value.replace(/\/+$/, '');
|
|
161
|
+
}
|
|
11
162
|
function requiredValue(arguments_, index, option) {
|
|
12
163
|
const value = arguments_[index + 1];
|
|
13
164
|
if (!value || value.startsWith('-'))
|
|
@@ -27,10 +178,16 @@ export function parseServeCliOptions(arguments_) {
|
|
|
27
178
|
let host = process.env.CATENCE_HTTP_HOST ?? '127.0.0.1';
|
|
28
179
|
let port = process.env.CATENCE_HTTP_PORT ? parsePort(process.env.CATENCE_HTTP_PORT) : 8787;
|
|
29
180
|
const allowedOrigins = [];
|
|
181
|
+
let authTokenFile = process.env.CATENCE_MCP_TOKEN_FILE || undefined;
|
|
182
|
+
let mcpKeysFile = process.env.CATENCE_MCP_KEYS_FILE || undefined;
|
|
183
|
+
let oauthSettingsFile = process.env.CATENCE_OAUTH_SETTINGS_FILE || undefined;
|
|
184
|
+
let oauth = oauthEnabledFromEnv();
|
|
185
|
+
let publicUrl = process.env.CATENCE_PUBLIC_URL || undefined;
|
|
186
|
+
let publicUrlSource = 'CATENCE_PUBLIC_URL';
|
|
30
187
|
for (let index = 0; index < arguments_.length; index++) {
|
|
31
188
|
const argument = arguments_[index];
|
|
32
189
|
if (argument === '-h' || argument === '--help')
|
|
33
|
-
return { home, host, port, allowedOrigins, help: true };
|
|
190
|
+
return { home, host, port, allowedOrigins, authTokenFile, mcpKeysFile, oauthSettingsFile, oauth, publicUrl, help: true };
|
|
34
191
|
if (argument === '--home') {
|
|
35
192
|
home = requiredValue(arguments_, index, '--home');
|
|
36
193
|
index++;
|
|
@@ -42,6 +199,39 @@ export function parseServeCliOptions(arguments_) {
|
|
|
42
199
|
throw new Error('--home requires a value.');
|
|
43
200
|
continue;
|
|
44
201
|
}
|
|
202
|
+
if (argument === '--auth-token-file') {
|
|
203
|
+
authTokenFile = requiredValue(arguments_, index, '--auth-token-file');
|
|
204
|
+
index++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (argument.startsWith('--auth-token-file=')) {
|
|
208
|
+
authTokenFile = argument.slice('--auth-token-file='.length);
|
|
209
|
+
if (!authTokenFile)
|
|
210
|
+
throw new Error('--auth-token-file requires a value.');
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (argument === '--mcp-keys-file') {
|
|
214
|
+
mcpKeysFile = requiredValue(arguments_, index, '--mcp-keys-file');
|
|
215
|
+
index++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (argument.startsWith('--mcp-keys-file=')) {
|
|
219
|
+
mcpKeysFile = argument.slice('--mcp-keys-file='.length);
|
|
220
|
+
if (!mcpKeysFile)
|
|
221
|
+
throw new Error('--mcp-keys-file requires a value.');
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (argument === '--oauth-settings-file') {
|
|
225
|
+
oauthSettingsFile = requiredValue(arguments_, index, '--oauth-settings-file');
|
|
226
|
+
index++;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (argument.startsWith('--oauth-settings-file=')) {
|
|
230
|
+
oauthSettingsFile = argument.slice('--oauth-settings-file='.length);
|
|
231
|
+
if (!oauthSettingsFile)
|
|
232
|
+
throw new Error('--oauth-settings-file requires a value.');
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
45
235
|
if (argument === '--host') {
|
|
46
236
|
host = requiredValue(arguments_, index, '--host');
|
|
47
237
|
index++;
|
|
@@ -74,8 +264,30 @@ export function parseServeCliOptions(arguments_) {
|
|
|
74
264
|
allowedOrigins.push(origin);
|
|
75
265
|
continue;
|
|
76
266
|
}
|
|
267
|
+
if (argument === '--oauth') {
|
|
268
|
+
oauth = true;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (argument === '--public-url') {
|
|
272
|
+
publicUrl = requiredValue(arguments_, index, '--public-url');
|
|
273
|
+
publicUrlSource = '--public-url';
|
|
274
|
+
index++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (argument.startsWith('--public-url=')) {
|
|
278
|
+
publicUrl = argument.slice('--public-url='.length);
|
|
279
|
+
if (!publicUrl)
|
|
280
|
+
throw new Error('--public-url requires a value.');
|
|
281
|
+
publicUrlSource = '--public-url';
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
77
284
|
throw new Error(`Unknown option: ${argument}`);
|
|
78
285
|
}
|
|
79
|
-
|
|
286
|
+
if (publicUrl !== undefined)
|
|
287
|
+
publicUrl = parsePublicUrl(publicUrl, publicUrlSource);
|
|
288
|
+
if (oauth && publicUrl === undefined) {
|
|
289
|
+
throw new Error('--oauth requires --public-url (or CATENCE_PUBLIC_URL): the OAuth issuer is the public URL of this server.');
|
|
290
|
+
}
|
|
291
|
+
return { home, host, port, allowedOrigins, authTokenFile, mcpKeysFile, oauthSettingsFile, oauth, publicUrl, help: false };
|
|
80
292
|
}
|
|
81
293
|
//# sourceMappingURL=cli-options.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Express } from 'express';
|
|
2
|
+
/** What the HTTP gate needs from the lazily built OAuth sub-app. */
|
|
3
|
+
export type OAuthAppHandle = {
|
|
4
|
+
/** The Express app; mounted by path delegation for /.well-known/oauth-* and /oauth/*. */
|
|
5
|
+
app: Express;
|
|
6
|
+
/** Verify a bearer access token; null when unknown, expired, or revoked. */
|
|
7
|
+
verifyAccessToken: (token: string) => Promise<{
|
|
8
|
+
userId?: string;
|
|
9
|
+
} | null>;
|
|
10
|
+
/** The RFC 9728 metadata URL advertised in the 401 WWW-Authenticate challenge. */
|
|
11
|
+
protectedResourceMetadataUrl: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function createOAuthApp(options: {
|
|
14
|
+
catalogRoot: string;
|
|
15
|
+
publicUrl: string;
|
|
16
|
+
}): Promise<OAuthAppHandle>;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { authenticateHandler, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter, OAuthServer, redirectUriMatches, } from 'mcp-oauth-server';
|
|
4
|
+
import { asPublicClientMetadata, createFileOAuthServerModel, declaresPrivateKeyJwt, resolveCatalogPaths, verifyAccountPassword } from '../../runtime/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* The OAuth 2.1 authorization server for `catence serve --oauth`, built on the
|
|
7
|
+
* mcp-oauth-server package over the file-backed model in core/runtime/oauth.
|
|
8
|
+
*
|
|
9
|
+
* Layout of the public surface (issuer = publicUrl, resource = publicUrl/mcp):
|
|
10
|
+
*
|
|
11
|
+
* GET /.well-known/oauth-authorization-server RFC 8414 AS metadata
|
|
12
|
+
* GET /.well-known/oauth-protected-resource[/mcp] RFC 9728 PRM metadata
|
|
13
|
+
* * /oauth/authorize, /oauth/token, /oauth/register, /oauth/revoke
|
|
14
|
+
* (mcpAuthRouter mounted at the root with baseUrl = publicUrl + /oauth)
|
|
15
|
+
* GET /oauth/consent login + consent form (round-trips the authorize params)
|
|
16
|
+
* POST /oauth/confirm authenticateHandler: bcrypt login, then issues the code
|
|
17
|
+
* POST /oauth/deny redirects to the client with error=access_denied
|
|
18
|
+
*
|
|
19
|
+
* The consent round-trip is the lib's contract: the authorize handler 302s to
|
|
20
|
+
* authorizationUrl with the full authorize request as query params, and
|
|
21
|
+
* authenticateHandler re-validates those same params from the confirm POST
|
|
22
|
+
* body — so the consent form echoes them as hidden fields next to the
|
|
23
|
+
* username/password inputs. Credentials and tokens are never logged.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Fetch wrapper for client ID metadata documents (CIMD): clients such as
|
|
27
|
+
* OpenAI's connector declare `token_endpoint_auth_method: "private_key_jwt"`
|
|
28
|
+
* in their document. The server cannot verify a client JWT assertion, so the
|
|
29
|
+
* declaration is downgraded to a public client ("none") before the library
|
|
30
|
+
* validates and caches the document — PKCE still protects the flow. The
|
|
31
|
+
* rewritten body drops content-length; the library's size guard reads the
|
|
32
|
+
* actual stream anyway.
|
|
33
|
+
*
|
|
34
|
+
* Parsing happens on a clone: every passthrough path must hand the library a
|
|
35
|
+
* response whose body is still readable, and a consumed stream would surface
|
|
36
|
+
* as an opaque 500 instead of the library's proper OAuth error.
|
|
37
|
+
*/
|
|
38
|
+
function sanitizingMetadataFetch(fetchImpl) {
|
|
39
|
+
return async (input, init) => {
|
|
40
|
+
const response = await fetchImpl(input, init);
|
|
41
|
+
if (!response.ok)
|
|
42
|
+
return response;
|
|
43
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
44
|
+
if (!contentType.includes('application/json'))
|
|
45
|
+
return response;
|
|
46
|
+
let document;
|
|
47
|
+
try {
|
|
48
|
+
document = JSON.parse(await response.clone().text());
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return response;
|
|
52
|
+
}
|
|
53
|
+
if (!declaresPrivateKeyJwt(document))
|
|
54
|
+
return response;
|
|
55
|
+
const rewritten = JSON.stringify(asPublicClientMetadata(document));
|
|
56
|
+
const headers = new Headers(response.headers);
|
|
57
|
+
headers.delete('content-length');
|
|
58
|
+
return new Response(rewritten, { status: response.status, statusText: response.statusText, headers });
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* DCR body sanitizer for POST /oauth/register. Mounted ahead of the library's
|
|
63
|
+
* router: it parses the JSON body and downgrades private_key_jwt to "none";
|
|
64
|
+
* the library's own express.json() then skips the already-consumed stream
|
|
65
|
+
* (body-parser returns early once the request is finished) and validates the
|
|
66
|
+
* sanitized metadata, issuing no client secret for the downgraded client.
|
|
67
|
+
*/
|
|
68
|
+
function sanitizeRegisterBody(request, _response, next) {
|
|
69
|
+
if (declaresPrivateKeyJwt(request.body)) {
|
|
70
|
+
request.body = asPublicClientMetadata(request.body);
|
|
71
|
+
}
|
|
72
|
+
next();
|
|
73
|
+
}
|
|
74
|
+
export async function createOAuthApp(options) {
|
|
75
|
+
const publicUrl = options.publicUrl.replace(/\/+$/, '');
|
|
76
|
+
const issuerUrl = new URL(publicUrl);
|
|
77
|
+
const resourceServerUrl = new URL(`${publicUrl}/mcp`);
|
|
78
|
+
const oauthBaseUrl = new URL(`${publicUrl}/oauth`);
|
|
79
|
+
const accountPaths = resolveCatalogPaths(options.catalogRoot);
|
|
80
|
+
const provider = new OAuthServer({
|
|
81
|
+
issuerUrl,
|
|
82
|
+
authorizationUrl: new URL(`${publicUrl}/oauth/consent`),
|
|
83
|
+
resourceServerUrl,
|
|
84
|
+
scopesSupported: ['mcp:tools'],
|
|
85
|
+
clientIdMetadataDocuments: { fetch: sanitizingMetadataFetch(fetch) },
|
|
86
|
+
model: createFileOAuthServerModel(path.join(options.catalogRoot, 'oauth')),
|
|
87
|
+
});
|
|
88
|
+
const oauthMetadata = createOAuthMetadata({ provider, baseUrl: oauthBaseUrl });
|
|
89
|
+
const protectedResourceMetadata = {
|
|
90
|
+
resource: resourceServerUrl.href,
|
|
91
|
+
authorization_servers: [issuerUrl.href],
|
|
92
|
+
scopes_supported: ['mcp:tools'],
|
|
93
|
+
bearer_methods_supported: ['header'],
|
|
94
|
+
};
|
|
95
|
+
const protectedResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl);
|
|
96
|
+
// Behind the tunnel the peer is the tunnel itself, so X-Forwarded-For is
|
|
97
|
+
// always set; express-rate-limit v8 refuses to run with it while
|
|
98
|
+
// `trust proxy` is false. Key the limiters on the socket address instead.
|
|
99
|
+
const rateLimitValidate = { xForwardedForHeader: false, forwardedHeader: false };
|
|
100
|
+
const app = express();
|
|
101
|
+
app.disable('x-powered-by');
|
|
102
|
+
// The issuer carries no path, so the canonical RFC 8414/RFC 9728 locations
|
|
103
|
+
// are the bare well-known paths; the lib's router serves the same documents
|
|
104
|
+
// at the /oauth-suffixed variants. These routes shadow nothing the lib needs.
|
|
105
|
+
const serveMetadata = (document) => (_request, response) => {
|
|
106
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
107
|
+
response.status(200).json(document);
|
|
108
|
+
};
|
|
109
|
+
app.get('/.well-known/oauth-authorization-server', serveMetadata(oauthMetadata));
|
|
110
|
+
app.get('/.well-known/oauth-protected-resource', serveMetadata(protectedResourceMetadata));
|
|
111
|
+
app.get('/.well-known/oauth-protected-resource/mcp', serveMetadata(protectedResourceMetadata));
|
|
112
|
+
// DCR runs through the library's router, whose own express.json() would
|
|
113
|
+
// re-parse (and reject) an already-consumed stream; parsing here first
|
|
114
|
+
// leaves the sanitized body in place for it.
|
|
115
|
+
app.use('/oauth/register', express.json(), sanitizeRegisterBody);
|
|
116
|
+
app.use(mcpAuthRouter({
|
|
117
|
+
provider,
|
|
118
|
+
baseUrl: oauthBaseUrl,
|
|
119
|
+
resourceServerUrl,
|
|
120
|
+
authorizationOptions: { rateLimit: { validate: rateLimitValidate } },
|
|
121
|
+
tokenOptions: { rateLimit: { validate: rateLimitValidate } },
|
|
122
|
+
clientRegistrationOptions: { rateLimit: { validate: rateLimitValidate } },
|
|
123
|
+
revocationOptions: { rateLimit: { validate: rateLimitValidate } },
|
|
124
|
+
}));
|
|
125
|
+
app.get('/oauth/consent', async (request, response) => {
|
|
126
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
127
|
+
const clientId = typeof request.query.client_id === 'string' ? request.query.client_id : '';
|
|
128
|
+
const client = clientId ? await provider.getClient(clientId).catch(() => undefined) : undefined;
|
|
129
|
+
if (!client) {
|
|
130
|
+
response.status(400).json({ error: 'invalid_request', error_description: 'Unknown client_id.' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
response.status(200).type('html').send(renderConsentPage(request.query, client.client_name ?? client.client_id));
|
|
134
|
+
});
|
|
135
|
+
// authenticateHandler returns an Express Router: it must be mounted with
|
|
136
|
+
// app.use so the mount path is stripped before the router matches its
|
|
137
|
+
// internal POST / route (app.post(path, router) leaves req.url intact and
|
|
138
|
+
// the router 404s).
|
|
139
|
+
app.use('/oauth/confirm', authenticateHandler({
|
|
140
|
+
provider,
|
|
141
|
+
rateLimit: { windowMs: 15 * 60 * 1000, max: 20, validate: rateLimitValidate },
|
|
142
|
+
getUser: async (request) => {
|
|
143
|
+
const username = typeof request.body?.username === 'string' ? request.body.username : '';
|
|
144
|
+
const password = typeof request.body?.password === 'string' ? request.body.password : '';
|
|
145
|
+
if (!username || !password)
|
|
146
|
+
return null;
|
|
147
|
+
const valid = await verifyAccountPassword(accountPaths, username, password);
|
|
148
|
+
return valid ? username : null;
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
app.post('/oauth/deny', express.urlencoded({ extended: false }), async (request, response) => {
|
|
152
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
153
|
+
const body = (request.body ?? {});
|
|
154
|
+
const clientId = typeof body.client_id === 'string' ? body.client_id : '';
|
|
155
|
+
const redirectUri = typeof body.redirect_uri === 'string' ? body.redirect_uri : '';
|
|
156
|
+
const state = typeof body.state === 'string' ? body.state : undefined;
|
|
157
|
+
const client = clientId ? await provider.getClient(clientId).catch(() => undefined) : undefined;
|
|
158
|
+
const redirectRegistered = client !== undefined && redirectUri !== '' && client.redirect_uris.some((registered) => redirectUriMatches(redirectUri, registered));
|
|
159
|
+
if (!client || !redirectRegistered) {
|
|
160
|
+
response.status(400).json({ error: 'invalid_request', error_description: 'Unknown client_id or unregistered redirect_uri.' });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const target = new URL(redirectUri);
|
|
164
|
+
target.searchParams.set('error', 'access_denied');
|
|
165
|
+
if (state)
|
|
166
|
+
target.searchParams.set('state', state);
|
|
167
|
+
target.searchParams.set('iss', issuerUrl.href);
|
|
168
|
+
response.redirect(302, target.toString());
|
|
169
|
+
});
|
|
170
|
+
const verifyAccessToken = async (token) => {
|
|
171
|
+
try {
|
|
172
|
+
const authInfo = await provider.verifyAccessToken(token);
|
|
173
|
+
return { userId: authInfo.userId };
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Unknown, expired, or revoked tokens all fail closed to null.
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
return { app, verifyAccessToken, protectedResourceMetadataUrl };
|
|
181
|
+
}
|
|
182
|
+
/** The authorize request parameters the consent form must echo back. */
|
|
183
|
+
const CONSENT_PARAMETERS = ['client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'state', 'scope', 'resource'];
|
|
184
|
+
function escapeHtml(value) {
|
|
185
|
+
return value
|
|
186
|
+
.replace(/&/g, '&')
|
|
187
|
+
.replace(/</g, '<')
|
|
188
|
+
.replace(/>/g, '>')
|
|
189
|
+
.replace(/"/g, '"')
|
|
190
|
+
.replace(/'/g, ''');
|
|
191
|
+
}
|
|
192
|
+
function renderConsentPage(query, clientName) {
|
|
193
|
+
const hiddenInputs = CONSENT_PARAMETERS
|
|
194
|
+
.filter((name) => typeof query[name] === 'string' && query[name] !== '')
|
|
195
|
+
.map((name) => ` <input type="hidden" name="${name}" value="${escapeHtml(query[name])}">`)
|
|
196
|
+
.join('\n');
|
|
197
|
+
const redirectUri = typeof query.redirect_uri === 'string' ? query.redirect_uri : '';
|
|
198
|
+
let redirectHost = redirectUri;
|
|
199
|
+
try {
|
|
200
|
+
redirectHost = new URL(redirectUri).host;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Display the raw value when it does not parse; the confirm step rejects it.
|
|
204
|
+
}
|
|
205
|
+
return `<!doctype html>
|
|
206
|
+
<html lang="en">
|
|
207
|
+
<head>
|
|
208
|
+
<meta charset="utf-8">
|
|
209
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
210
|
+
<title>Catence — authorize ${escapeHtml(clientName)}</title>
|
|
211
|
+
</head>
|
|
212
|
+
<body>
|
|
213
|
+
<h1>Authorize ${escapeHtml(clientName)}</h1>
|
|
214
|
+
<p><strong>${escapeHtml(clientName)}</strong> is asking to access your Catence data${redirectHost ? ` (redirecting to ${escapeHtml(redirectHost)})` : ''}.</p>
|
|
215
|
+
<form method="post">
|
|
216
|
+
${hiddenInputs}
|
|
217
|
+
<p><label>Username <input type="text" name="username" autocomplete="username" required></label></p>
|
|
218
|
+
<p><label>Password <input type="password" name="password" autocomplete="current-password" required></label></p>
|
|
219
|
+
<p>
|
|
220
|
+
<button type="submit" formaction="/oauth/confirm">Approve</button>
|
|
221
|
+
<button type="submit" formaction="/oauth/deny">Deny</button>
|
|
222
|
+
</p>
|
|
223
|
+
</form>
|
|
224
|
+
</body>
|
|
225
|
+
</html>
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -1,11 +1,63 @@
|
|
|
1
1
|
import { type Server } from 'node:http';
|
|
2
|
-
import { type DetachedSyncSpawner, mergeOpenCodeGoConsoleProfiles, startDetachedSync, type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
|
|
2
|
+
import { type DetachedSyncSpawner, mergeOpenCodeGoConsoleProfiles, startDetachedSync, type CatalogPaths, type CatencePaths, type ConsoleAccountRole } from '../../runtime/index.js';
|
|
3
3
|
export type CatenceHttpServerOptions = {
|
|
4
4
|
/** A shared catalog for public servers. All data APIs then require athleteId. */
|
|
5
5
|
catalogPaths?: CatalogPaths;
|
|
6
6
|
/** Internal compatibility hook for tests and embedded single-store callers. */
|
|
7
7
|
paths?: CatencePaths;
|
|
8
8
|
allowedOrigins?: readonly string[];
|
|
9
|
+
/**
|
|
10
|
+
* Static bearer token for the gate (tests and embedded single-store callers).
|
|
11
|
+
* Mutually exclusive with bearerTokenFile.
|
|
12
|
+
*/
|
|
13
|
+
bearerToken?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Token file whose presence switches the gate on: every request except the
|
|
16
|
+
* health endpoints and CORS preflights must present
|
|
17
|
+
* `Authorization: Bearer <token>`. The file is re-read on mtime change, so
|
|
18
|
+
* `catence-data mcp enable|disable` (and the /api/v1/mcp-exposure route)
|
|
19
|
+
* take effect without a restart. The MCP server has no other
|
|
20
|
+
* authentication; this gates it (and the JSON APIs) for non-loopback
|
|
21
|
+
* exposure such as a Cloudflare Tunnel to an MCP client.
|
|
22
|
+
*/
|
|
23
|
+
bearerTokenFile?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Per-account MCP key registry for the gate. Any active key in it arms
|
|
26
|
+
* authentication and resolves to that account's athlete scope; keys are
|
|
27
|
+
* managed with `catence-data mcp key` and the /api/v1/mcp-keys routes.
|
|
28
|
+
*/
|
|
29
|
+
mcpKeysFile?: string;
|
|
30
|
+
/**
|
|
31
|
+
* OAuth bearer-token verifier (Task B plugs in the MCP OAuth server). Its
|
|
32
|
+
* presence arms the gate when oauthEnabled() is unset or true; verified
|
|
33
|
+
* usernames resolve through the same Console accounts as MCP keys.
|
|
34
|
+
*/
|
|
35
|
+
oauthVerifier?: (token: string) => Promise<{
|
|
36
|
+
username: string;
|
|
37
|
+
} | null>;
|
|
38
|
+
/**
|
|
39
|
+
* Live check whether the OAuth 2.1 server is effectively enabled. Wired by
|
|
40
|
+
* the server itself when OAuth is configurable (the `oauth` startup option
|
|
41
|
+
* or an oauthSettingsFile); its truth arms the gate together with the
|
|
42
|
+
* oauthVerifier.
|
|
43
|
+
*/
|
|
44
|
+
oauthEnabled?: () => boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Run the OAuth 2.1 authorization server (issuer = publicUrl) for
|
|
47
|
+
* ChatGPT/Codex-style clients. The Express sub-app is built lazily on first
|
|
48
|
+
* effective use and serves /.well-known/oauth-* and /oauth/* ahead of the
|
|
49
|
+
* gate; its verifyAccessToken is wired in as the oauthVerifier. The
|
|
50
|
+
* oauthSettingsFile overrides this at runtime.
|
|
51
|
+
*/
|
|
52
|
+
oauth?: {
|
|
53
|
+
publicUrl: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Console-managed OAuth settings file for the gate and the
|
|
57
|
+
* /api/v1/mcp-oauth-settings routes. A valid file overrides the `oauth`
|
|
58
|
+
* startup option at runtime; a malformed one fails closed to disabled.
|
|
59
|
+
*/
|
|
60
|
+
oauthSettingsFile?: string;
|
|
9
61
|
/** Internal compatibility hook for tests: override the detached sync spawner. */
|
|
10
62
|
startSync?: typeof startDetachedSync;
|
|
11
63
|
/** Internal compatibility hook for tests: passed into the default startDetachedSync. */
|
|
@@ -13,6 +65,13 @@ export type CatenceHttpServerOptions = {
|
|
|
13
65
|
/** Internal compatibility hook for tests: override OpenCode Go model discovery. */
|
|
14
66
|
mergeModels?: typeof mergeOpenCodeGoConsoleProfiles;
|
|
15
67
|
};
|
|
68
|
+
/** The authenticated identity behind one request and the athletes it may touch. */
|
|
69
|
+
export type CatencePrincipal = {
|
|
70
|
+
kind: 'admin-token' | 'key' | 'oauth';
|
|
71
|
+
username?: string;
|
|
72
|
+
role: ConsoleAccountRole;
|
|
73
|
+
athleteIds: string[] | 'all';
|
|
74
|
+
};
|
|
16
75
|
/**
|
|
17
76
|
* Create Catence's local Streamable HTTP server.
|
|
18
77
|
*
|