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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +4 -4
  2. package/config.example.json +1 -0
  3. package/dist/contracts/release.d.ts +1 -1
  4. package/dist/contracts/release.js +1 -1
  5. package/dist/contracts/runtime.d.ts +4 -0
  6. package/dist/core/query/catalog.d.ts +1 -1
  7. package/dist/core/query/catalog.js +16 -10
  8. package/dist/core/query/repository.d.ts +1 -1
  9. package/dist/core/query/repository.js +31 -2
  10. package/dist/core/retrieval/index.js +23 -5
  11. package/dist/core/runtime/athlete-file.d.ts +49 -0
  12. package/dist/core/runtime/athlete-file.js +177 -0
  13. package/dist/core/runtime/configuration.js +2 -0
  14. package/dist/core/runtime/console-accounts.d.ts +52 -0
  15. package/dist/core/runtime/console-accounts.js +103 -0
  16. package/dist/core/runtime/mcp-keys.d.ts +65 -0
  17. package/dist/core/runtime/mcp-keys.js +171 -0
  18. package/dist/core/runtime/oauth/model.d.ts +44 -0
  19. package/dist/core/runtime/oauth/model.js +345 -0
  20. package/dist/core/runtime/oauth/public-client.d.ts +28 -0
  21. package/dist/core/runtime/oauth/public-client.js +35 -0
  22. package/dist/core/runtime/oauth/settings.d.ts +33 -0
  23. package/dist/core/runtime/oauth/settings.js +96 -0
  24. package/dist/core/runtime/secrets.d.ts +15 -0
  25. package/dist/core/runtime/secrets.js +48 -7
  26. package/dist/elt/application/autosync.d.ts +71 -0
  27. package/dist/elt/application/autosync.js +173 -0
  28. package/dist/elt/application/detached-sync.d.ts +7 -2
  29. package/dist/elt/application/detached-sync.js +10 -4
  30. package/dist/elt/application/management.d.ts +7 -0
  31. package/dist/elt/application/management.js +103 -7
  32. package/dist/elt/ingestion/importer.d.ts +11 -1
  33. package/dist/elt/ingestion/importer.js +63 -14
  34. package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
  35. package/dist/elt/ingestion/providers/intervals.js +21 -2
  36. package/dist/elt/normalization/normalizers.js +122 -0
  37. package/dist/elt/storage/database.d.ts +6 -1
  38. package/dist/elt/storage/database.js +7 -1
  39. package/dist/elt/storage/migrations.js +45 -1
  40. package/dist/elt/storage/progress-sidecar.d.ts +9 -0
  41. package/dist/elt/storage/progress-sidecar.js +31 -16
  42. package/dist/interfaces/cli/commands.js +117 -2
  43. package/dist/interfaces/http/cli-options.d.ts +64 -1
  44. package/dist/interfaces/http/cli-options.js +214 -2
  45. package/dist/interfaces/http/oauth.d.ts +16 -0
  46. package/dist/interfaces/http/oauth.js +228 -0
  47. package/dist/interfaces/http/server.d.ts +60 -1
  48. package/dist/interfaces/http/server.js +810 -16
  49. package/dist/interfaces/mcp/main.js +35 -3
  50. package/dist/interfaces/mcp/server.d.ts +21 -1
  51. package/dist/interfaces/mcp/server.js +107 -21
  52. package/dist/runtime/index.d.ts +15 -3
  53. package/dist/runtime/index.js +9 -2
  54. package/docs/architecture.md +1 -1
  55. package/docs/configuration.md +76 -6
  56. package/docs/deployment/docker.md +173 -15
  57. package/docs/deployment/local-console.md +132 -21
  58. package/docs/deployment/local-mcp.md +190 -11
  59. package/docs/development.md +5 -3
  60. package/docs/llm-providers.md +4 -4
  61. package/package.json +11 -4
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +55 -7
  64. package/python/catence/providers/garmin/progress.py +71 -35
  65. package/python/catence/providers/garmin/staging.py +11 -4
  66. package/python/catence/providers/garmin/worker.py +420 -206
  67. package/uv.lock +3 -3
@@ -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
- return { home, host, port, allowedOrigins, help: false };
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, '&amp;')
187
+ .replace(/</g, '&lt;')
188
+ .replace(/>/g, '&gt;')
189
+ .replace(/"/g, '&quot;')
190
+ .replace(/'/g, '&#39;');
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
  *