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.
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { readMcpOauthSettings } from '../../runtime/index.js';
3
4
  export const SERVE_USAGE = `Usage: catence serve [options]
4
5
 
5
6
  Run Catence as a local Streamable HTTP MCP server.
@@ -12,11 +13,33 @@ Options:
12
13
  --auth-token-file <path> Bearer-token file for the HTTP gate. Its presence switches authentication on;
13
14
  generate/inspect it with 'catence-data mcp enable|token'.
14
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.
15
30
  -h, --help Show this help text.`;
16
31
  /** The canonical token location inside a catalog home. */
17
32
  export function defaultBearerTokenPath(catalogRoot) {
18
33
  return `${catalogRoot.replace(/[/\\]+$/, '')}/mcp-token`;
19
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
+ }
20
43
  /** Read and validate a stored bearer token: trimmed, non-empty, or fail loudly. */
21
44
  export function loadBearerToken(filePath) {
22
45
  let token;
@@ -81,7 +104,7 @@ export function activeBearerToken(filePath) {
81
104
  return 'invalid';
82
105
  }
83
106
  }
84
- export function mcpExposureStatus(filePath, endpointBase) {
107
+ export function mcpExposureStatus(filePath, endpointBase, oauth) {
85
108
  const token = readBearerToken(filePath);
86
109
  const base = endpointBase.replace(/\/+$/, '');
87
110
  return {
@@ -90,8 +113,52 @@ export function mcpExposureStatus(filePath, endpointBase) {
90
113
  endpoint: `${base}/mcp`,
91
114
  healthEndpoint: `${base}/health`,
92
115
  ...(token !== null ? { token } : {}),
116
+ ...(oauth !== undefined ? { oauth } : {}),
93
117
  };
94
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
+ }
95
162
  function requiredValue(arguments_, index, option) {
96
163
  const value = arguments_[index + 1];
97
164
  if (!value || value.startsWith('-'))
@@ -112,10 +179,15 @@ export function parseServeCliOptions(arguments_) {
112
179
  let port = process.env.CATENCE_HTTP_PORT ? parsePort(process.env.CATENCE_HTTP_PORT) : 8787;
113
180
  const allowedOrigins = [];
114
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';
115
187
  for (let index = 0; index < arguments_.length; index++) {
116
188
  const argument = arguments_[index];
117
189
  if (argument === '-h' || argument === '--help')
118
- return { home, host, port, allowedOrigins, authTokenFile, help: true };
190
+ return { home, host, port, allowedOrigins, authTokenFile, mcpKeysFile, oauthSettingsFile, oauth, publicUrl, help: true };
119
191
  if (argument === '--home') {
120
192
  home = requiredValue(arguments_, index, '--home');
121
193
  index++;
@@ -138,6 +210,28 @@ export function parseServeCliOptions(arguments_) {
138
210
  throw new Error('--auth-token-file requires a value.');
139
211
  continue;
140
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
+ }
141
235
  if (argument === '--host') {
142
236
  host = requiredValue(arguments_, index, '--host');
143
237
  index++;
@@ -170,8 +264,30 @@ export function parseServeCliOptions(arguments_) {
170
264
  allowedOrigins.push(origin);
171
265
  continue;
172
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
+ }
173
284
  throw new Error(`Unknown option: ${argument}`);
174
285
  }
175
- return { home, host, port, allowedOrigins, authTokenFile, 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 };
176
292
  }
177
293
  //# sourceMappingURL=cli-options.js.map
@@ -0,0 +1,36 @@
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
+ /**
14
+ * The OAuth 2.1 authorization server for `catence serve --oauth`, built on the
15
+ * mcp-oauth-server package over the file-backed model in core/runtime/oauth.
16
+ *
17
+ * Layout of the public surface (issuer = publicUrl, resource = publicUrl/mcp):
18
+ *
19
+ * GET /.well-known/oauth-authorization-server RFC 8414 AS metadata
20
+ * GET /.well-known/oauth-protected-resource[/mcp] RFC 9728 PRM metadata
21
+ * * /oauth/authorize, /oauth/token, /oauth/register, /oauth/revoke
22
+ * (mcpAuthRouter mounted at the root with baseUrl = publicUrl + /oauth)
23
+ * GET /oauth/consent login + consent form (round-trips the authorize params)
24
+ * POST /oauth/confirm authenticateHandler: bcrypt login, then issues the code
25
+ * POST /oauth/deny redirects to the client with error=access_denied
26
+ *
27
+ * The consent round-trip is the lib's contract: the authorize handler 302s to
28
+ * authorizationUrl with the full authorize request as query params, and
29
+ * authenticateHandler re-validates those same params from the confirm POST
30
+ * body — so the consent form echoes them as hidden fields next to the
31
+ * username/password inputs. Credentials and tokens are never logged.
32
+ */
33
+ export declare function createOAuthApp(options: {
34
+ catalogRoot: string;
35
+ publicUrl: string;
36
+ }): Promise<OAuthAppHandle>;
@@ -0,0 +1,175 @@
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 { createFileOAuthServerModel, 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
+ export async function createOAuthApp(options) {
26
+ const publicUrl = options.publicUrl.replace(/\/+$/, '');
27
+ const issuerUrl = new URL(publicUrl);
28
+ const resourceServerUrl = new URL(`${publicUrl}/mcp`);
29
+ const oauthBaseUrl = new URL(`${publicUrl}/oauth`);
30
+ const accountPaths = resolveCatalogPaths(options.catalogRoot);
31
+ const provider = new OAuthServer({
32
+ issuerUrl,
33
+ authorizationUrl: new URL(`${publicUrl}/oauth/consent`),
34
+ resourceServerUrl,
35
+ scopesSupported: ['mcp:tools'],
36
+ clientIdMetadataDocuments: true,
37
+ model: createFileOAuthServerModel(path.join(options.catalogRoot, 'oauth')),
38
+ });
39
+ const oauthMetadata = createOAuthMetadata({ provider, baseUrl: oauthBaseUrl });
40
+ const protectedResourceMetadata = {
41
+ resource: resourceServerUrl.href,
42
+ authorization_servers: [issuerUrl.href],
43
+ scopes_supported: ['mcp:tools'],
44
+ bearer_methods_supported: ['header'],
45
+ };
46
+ const protectedResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl);
47
+ // Behind the tunnel the peer is the tunnel itself, so X-Forwarded-For is
48
+ // always set; express-rate-limit v8 refuses to run with it while
49
+ // `trust proxy` is false. Key the limiters on the socket address instead.
50
+ const rateLimitValidate = { xForwardedForHeader: false, forwardedHeader: false };
51
+ const app = express();
52
+ app.disable('x-powered-by');
53
+ // The issuer carries no path, so the canonical RFC 8414/RFC 9728 locations
54
+ // are the bare well-known paths; the lib's router serves the same documents
55
+ // at the /oauth-suffixed variants. These routes shadow nothing the lib needs.
56
+ const serveMetadata = (document) => (_request, response) => {
57
+ response.setHeader('Cache-Control', 'no-store');
58
+ response.status(200).json(document);
59
+ };
60
+ app.get('/.well-known/oauth-authorization-server', serveMetadata(oauthMetadata));
61
+ app.get('/.well-known/oauth-protected-resource', serveMetadata(protectedResourceMetadata));
62
+ app.get('/.well-known/oauth-protected-resource/mcp', serveMetadata(protectedResourceMetadata));
63
+ app.use(mcpAuthRouter({
64
+ provider,
65
+ baseUrl: oauthBaseUrl,
66
+ resourceServerUrl,
67
+ authorizationOptions: { rateLimit: { validate: rateLimitValidate } },
68
+ tokenOptions: { rateLimit: { validate: rateLimitValidate } },
69
+ clientRegistrationOptions: { rateLimit: { validate: rateLimitValidate } },
70
+ revocationOptions: { rateLimit: { validate: rateLimitValidate } },
71
+ }));
72
+ app.get('/oauth/consent', async (request, response) => {
73
+ response.setHeader('Cache-Control', 'no-store');
74
+ const clientId = typeof request.query.client_id === 'string' ? request.query.client_id : '';
75
+ const client = clientId ? await provider.getClient(clientId).catch(() => undefined) : undefined;
76
+ if (!client) {
77
+ response.status(400).json({ error: 'invalid_request', error_description: 'Unknown client_id.' });
78
+ return;
79
+ }
80
+ response.status(200).type('html').send(renderConsentPage(request.query, client.client_name ?? client.client_id));
81
+ });
82
+ // authenticateHandler returns an Express Router: it must be mounted with
83
+ // app.use so the mount path is stripped before the router matches its
84
+ // internal POST / route (app.post(path, router) leaves req.url intact and
85
+ // the router 404s).
86
+ app.use('/oauth/confirm', authenticateHandler({
87
+ provider,
88
+ rateLimit: { windowMs: 15 * 60 * 1000, max: 20, validate: rateLimitValidate },
89
+ getUser: async (request) => {
90
+ const username = typeof request.body?.username === 'string' ? request.body.username : '';
91
+ const password = typeof request.body?.password === 'string' ? request.body.password : '';
92
+ if (!username || !password)
93
+ return null;
94
+ const valid = await verifyAccountPassword(accountPaths, username, password);
95
+ return valid ? username : null;
96
+ },
97
+ }));
98
+ app.post('/oauth/deny', express.urlencoded({ extended: false }), async (request, response) => {
99
+ response.setHeader('Cache-Control', 'no-store');
100
+ const body = (request.body ?? {});
101
+ const clientId = typeof body.client_id === 'string' ? body.client_id : '';
102
+ const redirectUri = typeof body.redirect_uri === 'string' ? body.redirect_uri : '';
103
+ const state = typeof body.state === 'string' ? body.state : undefined;
104
+ const client = clientId ? await provider.getClient(clientId).catch(() => undefined) : undefined;
105
+ const redirectRegistered = client !== undefined && redirectUri !== '' && client.redirect_uris.some((registered) => redirectUriMatches(redirectUri, registered));
106
+ if (!client || !redirectRegistered) {
107
+ response.status(400).json({ error: 'invalid_request', error_description: 'Unknown client_id or unregistered redirect_uri.' });
108
+ return;
109
+ }
110
+ const target = new URL(redirectUri);
111
+ target.searchParams.set('error', 'access_denied');
112
+ if (state)
113
+ target.searchParams.set('state', state);
114
+ target.searchParams.set('iss', issuerUrl.href);
115
+ response.redirect(302, target.toString());
116
+ });
117
+ const verifyAccessToken = async (token) => {
118
+ try {
119
+ const authInfo = await provider.verifyAccessToken(token);
120
+ return { userId: authInfo.userId };
121
+ }
122
+ catch {
123
+ // Unknown, expired, or revoked tokens all fail closed to null.
124
+ return null;
125
+ }
126
+ };
127
+ return { app, verifyAccessToken, protectedResourceMetadataUrl };
128
+ }
129
+ /** The authorize request parameters the consent form must echo back. */
130
+ const CONSENT_PARAMETERS = ['client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'state', 'scope', 'resource'];
131
+ function escapeHtml(value) {
132
+ return value
133
+ .replace(/&/g, '&amp;')
134
+ .replace(/</g, '&lt;')
135
+ .replace(/>/g, '&gt;')
136
+ .replace(/"/g, '&quot;')
137
+ .replace(/'/g, '&#39;');
138
+ }
139
+ function renderConsentPage(query, clientName) {
140
+ const hiddenInputs = CONSENT_PARAMETERS
141
+ .filter((name) => typeof query[name] === 'string' && query[name] !== '')
142
+ .map((name) => ` <input type="hidden" name="${name}" value="${escapeHtml(query[name])}">`)
143
+ .join('\n');
144
+ const redirectUri = typeof query.redirect_uri === 'string' ? query.redirect_uri : '';
145
+ let redirectHost = redirectUri;
146
+ try {
147
+ redirectHost = new URL(redirectUri).host;
148
+ }
149
+ catch {
150
+ // Display the raw value when it does not parse; the confirm step rejects it.
151
+ }
152
+ return `<!doctype html>
153
+ <html lang="en">
154
+ <head>
155
+ <meta charset="utf-8">
156
+ <meta name="viewport" content="width=device-width, initial-scale=1">
157
+ <title>Catence — authorize ${escapeHtml(clientName)}</title>
158
+ </head>
159
+ <body>
160
+ <h1>Authorize ${escapeHtml(clientName)}</h1>
161
+ <p><strong>${escapeHtml(clientName)}</strong> is asking to access your Catence data${redirectHost ? ` (redirecting to ${escapeHtml(redirectHost)})` : ''}.</p>
162
+ <form method="post">
163
+ ${hiddenInputs}
164
+ <p><label>Username <input type="text" name="username" autocomplete="username" required></label></p>
165
+ <p><label>Password <input type="password" name="password" autocomplete="current-password" required></label></p>
166
+ <p>
167
+ <button type="submit" formaction="/oauth/confirm">Approve</button>
168
+ <button type="submit" formaction="/oauth/deny">Deny</button>
169
+ </p>
170
+ </form>
171
+ </body>
172
+ </html>
173
+ `;
174
+ }
175
+ //# sourceMappingURL=oauth.js.map
@@ -1,5 +1,5 @@
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;
@@ -21,6 +21,43 @@ export type CatenceHttpServerOptions = {
21
21
  * exposure such as a Cloudflare Tunnel to an MCP client.
22
22
  */
23
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;
24
61
  /** Internal compatibility hook for tests: override the detached sync spawner. */
25
62
  startSync?: typeof startDetachedSync;
26
63
  /** Internal compatibility hook for tests: passed into the default startDetachedSync. */
@@ -28,6 +65,13 @@ export type CatenceHttpServerOptions = {
28
65
  /** Internal compatibility hook for tests: override OpenCode Go model discovery. */
29
66
  mergeModels?: typeof mergeOpenCodeGoConsoleProfiles;
30
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
+ };
31
75
  /**
32
76
  * Create Catence's local Streamable HTTP server.
33
77
  *