@stage-labs/metro 0.1.0-beta.30 → 0.1.0-beta.32

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 (59) hide show
  1. package/dist/api.js +12 -12
  2. package/dist/bedrock.js +5 -3
  3. package/dist/cli.js +23 -11
  4. package/dist/runtime.js +4 -1
  5. package/dist/serve.js +57 -0
  6. package/dist/store.js +26 -3
  7. package/package.json +1 -1
  8. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +5 -5
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +24 -28
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +34 -10
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +5 -3
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +2 -2
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +119 -72
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +31 -20
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +17 -43
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +8 -8
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +3 -3
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -2
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +11 -4
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +158 -0
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +1 -1
  27. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +10 -0
  28. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +17 -17
  29. package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
  30. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +15 -6
  31. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +1 -1
  32. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +12 -3
  33. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
  34. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +21 -21
  35. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
  36. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
  37. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
  38. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +2 -5
  39. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +15 -16
  40. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
  41. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
  42. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +32 -32
  43. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +250 -0
  44. package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
  45. package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +2 -0
  46. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +16 -7
  47. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +49 -40
  48. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +13 -20
  49. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +8 -21
  50. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +32 -17
  51. package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +4 -4
  52. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +1 -1
  53. package/runtime/runtime.json +1 -1
  54. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
  55. package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
  56. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +0 -164
  57. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
  58. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
  59. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-collections.ts +0 -218
@@ -1,20 +0,0 @@
1
- import { makeCodeStore } from './pair-codes.js';
2
-
3
- export interface CliCode {
4
- email: string;
5
- collectionId: string;
6
- }
7
-
8
- const store = makeCodeStore<CliCode>('mc');
9
-
10
- export const CLI_CODE_RE = store.pattern;
11
-
12
- export const mintCliCode = (
13
- entry: CliCode,
14
- now?: number,
15
- ): { code: string; expiresAt: number } => store.mint(entry, now);
16
-
17
- export const takeCliCode = (code: string, now?: number): CliCode | undefined =>
18
- store.take(code, now);
19
-
20
- export const cliCodeCount = (): number => store.count();
@@ -1,202 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { errMsg, log } from './log.js';
3
- import {
4
- apiFailure,
5
- apiSession,
6
- projectParam,
7
- bodyField,
8
- cors,
9
- readJsonBody,
10
- sendJson,
11
- type ApiSession,
12
- } from './api-http.js';
13
- import { parseId } from '../db/ids.js';
14
- import { mintCliCode } from './cli-pair.js';
15
- import type { ConnectorApiDeps } from './connector-api.js';
16
-
17
- const PREFIX = '/api/collections';
18
-
19
- type Routable =
20
- | { kind: 'index' }
21
- | { kind: 'collection'; id: string }
22
- | { kind: 'rename'; id: string }
23
- | { kind: 'code'; id: string }
24
- | { kind: 'items'; id: string }
25
- | { kind: 'item'; id: string; connectorId: string };
26
-
27
- type Target = Routable | { kind: 'unknown' } | null;
28
-
29
- const ALLOWED: Record<Routable['kind'], string[]> = {
30
- index: ['GET', 'POST'],
31
- collection: ['GET', 'DELETE'],
32
- rename: ['POST'],
33
- code: ['POST'],
34
- items: ['POST'],
35
- item: ['DELETE'],
36
- };
37
-
38
- function itemTarget(id: string, rest: string[]): Target {
39
- if (rest.length === 1) return { kind: 'items', id };
40
- if (rest.length > 2) return { kind: 'unknown' };
41
- const connectorId = parseId(rest[1] ?? '');
42
- return connectorId === null
43
- ? { kind: 'unknown' }
44
- : { kind: 'item', id, connectorId };
45
- }
46
-
47
- function subTarget(id: string, rest: string[]): Target {
48
- if (rest.length === 0) return { kind: 'collection', id };
49
- const head = rest[0];
50
- if (head === 'items') return itemTarget(id, rest);
51
- if (rest.length > 1) return { kind: 'unknown' };
52
- if (head === 'rename') return { kind: 'rename', id };
53
- if (head === 'code') return { kind: 'code', id };
54
- return { kind: 'unknown' };
55
- }
56
-
57
- function target(path: string): Target {
58
- if (path === PREFIX || path === `${PREFIX}/`) return { kind: 'index' };
59
- if (!path.startsWith(`${PREFIX}/`)) return null;
60
- const segments = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
61
- const head = segments[0];
62
- if (head === undefined) return { kind: 'index' };
63
- const id = parseId(head);
64
- return id === null ? { kind: 'unknown' } : subTarget(id, segments.slice(1));
65
- }
66
-
67
- async function nameFrom(req: IncomingMessage): Promise<string | null> {
68
- const name = bodyField(await readJsonBody(req), 'name');
69
- return typeof name === 'string' ? name : null;
70
- }
71
-
72
- async function handleIndex(
73
- req: IncomingMessage,
74
- res: ServerResponse,
75
- deps: ConnectorApiDeps,
76
- session: ApiSession,
77
- ): Promise<void> {
78
- const project = projectParam(req);
79
- if (project === null) {
80
- sendJson(req, res, 400, { error: 'a project is required' });
81
- return;
82
- }
83
- if (req.method === 'GET') {
84
- sendJson(req, res, 200, {
85
- collections: await deps.listCollections(session.email, project),
86
- });
87
- return;
88
- }
89
- const name = await nameFrom(req);
90
- if (name === null) {
91
- sendJson(req, res, 400, { error: 'name is required' });
92
- return;
93
- }
94
- const list = await deps.createCollection(session.email, project, name);
95
- log.info({ id: list.id, name: list.name }, 'collection-api: created collection');
96
- sendJson(req, res, 201, list);
97
- }
98
-
99
- async function handleRename(
100
- req: IncomingMessage,
101
- res: ServerResponse,
102
- deps: ConnectorApiDeps,
103
- session: ApiSession,
104
- id: string,
105
- ): Promise<void> {
106
- const name = await nameFrom(req);
107
- if (name === null) {
108
- sendJson(req, res, 400, { error: 'name is required' });
109
- return;
110
- }
111
- sendJson(req, res, 200, await deps.renameCollection(session.email, id, name));
112
- }
113
-
114
- async function handleItems(
115
- req: IncomingMessage,
116
- res: ServerResponse,
117
- deps: ConnectorApiDeps,
118
- session: ApiSession,
119
- id: string,
120
- ): Promise<void> {
121
- const connectorId = bodyField(await readJsonBody(req), 'connectorId');
122
- if (typeof connectorId !== 'string' || parseId(connectorId) === null) {
123
- sendJson(req, res, 400, { error: 'connectorId is required' });
124
- return;
125
- }
126
- sendJson(req, res, 200, await deps.addToCollection(session.email, id, connectorId));
127
- }
128
-
129
- async function handleCode(
130
- req: IncomingMessage,
131
- res: ServerResponse,
132
- deps: ConnectorApiDeps,
133
- session: ApiSession,
134
- id: string,
135
- ): Promise<void> {
136
- const collection = await deps.getCollection(session.email, id);
137
- const minted = mintCliCode({ email: session.email, collectionId: collection.id });
138
- log.info({ id: collection.id, email: session.email }, 'collection-api: minted cli code');
139
- sendJson(req, res, 200, { ...minted, collection: collection.name });
140
- }
141
-
142
- async function route(
143
- req: IncomingMessage,
144
- res: ServerResponse,
145
- deps: ConnectorApiDeps,
146
- session: ApiSession,
147
- tgt: Routable,
148
- ): Promise<void> {
149
- try {
150
- if (tgt.kind === 'index')
151
- await handleIndex(req, res, deps, session);
152
- else if (tgt.kind === 'rename')
153
- await handleRename(req, res, deps, session, tgt.id);
154
- else if (tgt.kind === 'code')
155
- await handleCode(req, res, deps, session, tgt.id);
156
- else if (tgt.kind === 'items')
157
- await handleItems(req, res, deps, session, tgt.id);
158
- else if (tgt.kind === 'item')
159
- sendJson(
160
- req,
161
- res,
162
- 200,
163
- await deps.removeFromCollection(session.email, tgt.id, tgt.connectorId),
164
- );
165
- else if (req.method === 'DELETE')
166
- sendJson(req, res, 200, await deps.deleteCollection(session.email, tgt.id));
167
- else sendJson(req, res, 200, await deps.getCollection(session.email, tgt.id));
168
- } catch (err) {
169
- apiFailure(req, res, err, 'collection-api');
170
- }
171
- }
172
-
173
- export function handleCollectionApiRequest(
174
- req: IncomingMessage,
175
- res: ServerResponse,
176
- deps: ConnectorApiDeps,
177
- ): boolean {
178
- const tgt = target((req.url ?? '').split('?')[0] ?? '');
179
- if (tgt === null) return false;
180
- if (req.method === 'OPTIONS') {
181
- res.writeHead(204, cors(req)).end();
182
- return true;
183
- }
184
- if (tgt.kind === 'unknown') {
185
- sendJson(req, res, 404, { error: 'no such collection' });
186
- return true;
187
- }
188
- if (!ALLOWED[tgt.kind].includes(req.method ?? '')) {
189
- sendJson(req, res, 405, { error: 'method not allowed' });
190
- return true;
191
- }
192
- const session = apiSession(req);
193
- if (!session) {
194
- sendJson(req, res, 401, { error: 'unauthorized' });
195
- return true;
196
- }
197
- route(req, res, deps, session, tgt).catch((err: unknown) => {
198
- log.warn({ err: errMsg(err) }, 'list-api: unhandled error');
199
- if (!res.headersSent) sendJson(req, res, 500, { error: 'list api failed' });
200
- });
201
- return true;
202
- }
@@ -1,164 +0,0 @@
1
- import {
2
- createPublicKey,
3
- verify as cryptoVerify,
4
- type JsonWebKey as CryptoJsonWebKey,
5
- } from 'node:crypto';
6
-
7
- export class GoogleAuthError extends Error {}
8
-
9
- interface GoogleClaims {
10
- email: string;
11
- emailVerified: boolean;
12
- aud: string;
13
- iss: string;
14
- exp: number;
15
- }
16
-
17
- interface Jwk {
18
- kid: string;
19
- kty: string;
20
- alg?: string;
21
- n: string;
22
- e: string;
23
- }
24
-
25
- interface VerifyOptions {
26
- clientId: string;
27
- now?: number;
28
- fetchCerts?: () => Promise<Jwk[]>;
29
- clockToleranceSec?: number;
30
- expectedNonce?: string;
31
- }
32
-
33
- const DEFAULT_CERTS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
34
- const certsUrl = (): string => {
35
- const v = process.env.GOOGLE_OAUTH_JWKS_URL?.trim();
36
- return v !== undefined && v !== '' ? v : DEFAULT_CERTS_URL;
37
- };
38
- const GOOGLE_ISSUERS = new Set([
39
- 'https://accounts.google.com',
40
- 'accounts.google.com',
41
- ]);
42
-
43
- const decodeSegment = (seg: string): Buffer =>
44
- Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
45
-
46
- const decodeJson = (seg: string): unknown => {
47
- try {
48
- return JSON.parse(decodeSegment(seg).toString('utf8'));
49
- } catch {
50
- throw new GoogleAuthError('malformed token segment');
51
- }
52
- };
53
-
54
- let certCache: { keys: Jwk[]; expiresAt: number } | null = null;
55
-
56
- async function fetchGoogleCerts(): Promise<Jwk[]> {
57
- const res = await fetch(certsUrl());
58
- if (!res.ok)
59
- throw new GoogleAuthError(`google certs fetch failed: ${res.status}`);
60
- const body = (await res.json()) as { keys?: Jwk[] };
61
- const maxAge = /max-age=(\d+)/.exec(res.headers.get('cache-control') ?? '');
62
- const ttlMs = maxAge ? Number(maxAge[1]) * 1000 : 3600_000;
63
- const keys = body.keys ?? [];
64
- certCache = { keys, expiresAt: Date.now() + ttlMs };
65
- return keys;
66
- }
67
-
68
- async function loadCerts(
69
- fetcher: () => Promise<Jwk[]>,
70
- ): Promise<Jwk[]> {
71
- if (certCache && certCache.expiresAt > Date.now()) return certCache.keys;
72
- return fetcher();
73
- }
74
-
75
- function jwkPublicKey(jwk: Jwk): ReturnType<typeof createPublicKey> {
76
- return createPublicKey({
77
- key: jwk as unknown as CryptoJsonWebKey,
78
- format: 'jwk',
79
- });
80
- }
81
-
82
- function verifySignature(token: string, jwk: Jwk): boolean {
83
- const parts = token.split('.');
84
- const signingInput = `${parts[0]}.${parts[1]}`;
85
- const signature = decodeSegment(parts[2] ?? '');
86
- return cryptoVerify(
87
- 'RSA-SHA256',
88
- Buffer.from(signingInput),
89
- jwkPublicKey(jwk),
90
- signature,
91
- );
92
- }
93
-
94
- interface Header {
95
- alg?: string;
96
- kid?: string;
97
- }
98
-
99
- interface Payload {
100
- iss?: string;
101
- aud?: string;
102
- exp?: number;
103
- email?: string;
104
- email_verified?: boolean | string;
105
- nonce?: string;
106
- }
107
-
108
- function assertHeader(header: Header): asserts header is Header & { kid: string } {
109
- if (header.alg !== 'RS256')
110
- throw new GoogleAuthError(`unsupported alg ${String(header.alg)}`);
111
- if (!header.kid) throw new GoogleAuthError('missing kid');
112
- }
113
-
114
- function assertNotExpired(payload: Payload, opts: VerifyOptions): number {
115
- const now = Math.floor((opts.now ?? Date.now()) / 1000);
116
- const skew = opts.clockToleranceSec ?? 60;
117
- if (typeof payload.exp !== 'number' || payload.exp + skew < now)
118
- throw new GoogleAuthError('token expired');
119
- return payload.exp;
120
- }
121
-
122
- function verifiedEmail(payload: Payload): string {
123
- const verified =
124
- payload.email_verified === true || payload.email_verified === 'true';
125
- if (!payload.email || !verified)
126
- throw new GoogleAuthError('email not verified');
127
- return payload.email.toLowerCase();
128
- }
129
-
130
- function validateClaims(payload: Payload, opts: VerifyOptions): GoogleClaims {
131
- if (!payload.iss || !GOOGLE_ISSUERS.has(payload.iss))
132
- throw new GoogleAuthError(`unexpected issuer ${String(payload.iss)}`);
133
- if (payload.aud !== opts.clientId)
134
- throw new GoogleAuthError('audience mismatch');
135
- if (opts.expectedNonce !== undefined && payload.nonce !== opts.expectedNonce)
136
- throw new GoogleAuthError('nonce mismatch');
137
- const exp = assertNotExpired(payload, opts);
138
- return {
139
- email: verifiedEmail(payload),
140
- emailVerified: true,
141
- aud: payload.aud,
142
- iss: payload.iss,
143
- exp,
144
- };
145
- }
146
-
147
- export async function verifyGoogleIdToken(
148
- token: string,
149
- opts: VerifyOptions,
150
- ): Promise<GoogleClaims> {
151
- const parts = token.split('.');
152
- if (parts.length !== 3) throw new GoogleAuthError('not a JWT');
153
- const header = decodeJson(parts[0] ?? '') as Header;
154
- const payload = decodeJson(parts[1] ?? '') as Payload;
155
- assertHeader(header);
156
-
157
- const keys = await loadCerts(opts.fetchCerts ?? fetchGoogleCerts);
158
- const jwk = keys.find((k) => k.kid === header.kid);
159
- if (!jwk) throw new GoogleAuthError('signing key not found');
160
- if (!verifySignature(token, jwk))
161
- throw new GoogleAuthError('invalid signature');
162
-
163
- return validateClaims(payload, opts);
164
- }
@@ -1,208 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { errMsg, log } from './log.js';
3
- import { verifyGoogleIdToken } from './google-auth.js';
4
- import { newNonce, signSession, signState, verifyState } from './session.js';
5
- import { ensureUserWithProject } from '../db/projects.js';
6
-
7
- export interface OAuthConfig {
8
- clientId: string;
9
- clientSecret: string;
10
- redirectUri: string;
11
- authUrl: string;
12
- tokenUrl: string;
13
- sessionSecret: string;
14
- sessionTtlSec: number;
15
- }
16
-
17
- const DEFAULT_REDIRECT_URI = 'https://mcp.metro.box/auth/google/callback';
18
- const DEFAULT_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
19
- const DEFAULT_TOKEN_URL = 'https://oauth2.googleapis.com/token';
20
- const PREVIEW = /^([a-z0-9-]+--)?metro-ui\.netlify\.app$/;
21
-
22
- const DEFAULT_SESSION_TTL_SEC = 30 * 24 * 3600;
23
-
24
- const envOr = (name: string, def: string): string => {
25
- const v = process.env[name]?.trim();
26
- return v !== undefined && v !== '' ? v : def;
27
- };
28
-
29
- export function sessionTtlFromEnv(): number {
30
- const ttl = Number(process.env.METRO_SESSION_TTL_SEC);
31
- return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_SESSION_TTL_SEC;
32
- }
33
-
34
- function oauthConfigFromEnv(): OAuthConfig | null {
35
- const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID?.trim() ?? '';
36
- const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim() ?? '';
37
- const sessionSecret = process.env.METRO_SESSION_SECRET?.trim() ?? '';
38
- if (clientId === '' || clientSecret === '' || sessionSecret === '') return null;
39
- return {
40
- clientId,
41
- clientSecret,
42
- sessionSecret,
43
- redirectUri: envOr('GOOGLE_OAUTH_REDIRECT_URI', DEFAULT_REDIRECT_URI),
44
- authUrl: envOr('GOOGLE_OAUTH_AUTH_URL', DEFAULT_AUTH_URL),
45
- tokenUrl: envOr('GOOGLE_OAUTH_TOKEN_URL', DEFAULT_TOKEN_URL),
46
- sessionTtlSec: sessionTtlFromEnv(),
47
- };
48
- }
49
-
50
- export function validateReturnTo(returnTo: string): boolean {
51
- let url: URL;
52
- try {
53
- url = new URL(returnTo);
54
- } catch {
55
- return false;
56
- }
57
- const host = url.hostname;
58
- if (url.protocol === 'http:' && (host === 'localhost' || host === '127.0.0.1'))
59
- return true;
60
- if (url.protocol !== 'https:') return false;
61
- if (host === 'metro.box' || host === 'localhost') return true;
62
- return PREVIEW.test(host);
63
- }
64
-
65
- export function buildStartRedirect(
66
- cfg: OAuthConfig,
67
- returnTo: string,
68
- now?: number,
69
- ): string {
70
- if (!validateReturnTo(returnTo)) throw new Error('invalid return_to');
71
- const nonce = newNonce();
72
- const state = signState(
73
- { return_to: returnTo, nonce },
74
- cfg.sessionSecret,
75
- { now },
76
- );
77
- const u = new URL(cfg.authUrl);
78
- u.searchParams.set('client_id', cfg.clientId);
79
- u.searchParams.set('redirect_uri', cfg.redirectUri);
80
- u.searchParams.set('response_type', 'code');
81
- u.searchParams.set('scope', 'openid email');
82
- u.searchParams.set('state', state);
83
- u.searchParams.set('nonce', nonce);
84
- u.searchParams.set('prompt', 'select_account');
85
- return u.toString();
86
- }
87
-
88
- function withFragment(returnTo: string, frag: string): string {
89
- return `${returnTo.split('#')[0]}#${frag}`;
90
- }
91
-
92
- export interface CallbackDeps {
93
- exchangeCode: (code: string) => Promise<{ id_token?: string }>;
94
- verifyIdToken: (idToken: string, nonce: string) => Promise<{ email: string }>;
95
- ensureUser: (email: string) => Promise<string>;
96
- }
97
-
98
- export async function completeCallback(
99
- cfg: OAuthConfig,
100
- params: { code?: string; state?: string },
101
- deps: CallbackDeps,
102
- now?: number,
103
- ): Promise<string> {
104
- if (!params.state) throw new Error('missing state');
105
- const { return_to, nonce } = verifyState(params.state, cfg.sessionSecret, now);
106
- if (!validateReturnTo(return_to)) throw new Error('invalid return_to');
107
- if (!params.code) return withFragment(return_to, 'error=exchange');
108
-
109
- const tokens = await deps.exchangeCode(params.code);
110
- if (!tokens.id_token) return withFragment(return_to, 'error=exchange');
111
-
112
- let email: string;
113
- try {
114
- ({ email } = await deps.verifyIdToken(tokens.id_token, nonce));
115
- } catch {
116
- return withFragment(return_to, 'error=verify');
117
- }
118
-
119
- const userId = await deps.ensureUser(email);
120
- const agentIds: string[] = [];
121
- log.info({ userId }, 'google auth: session issued');
122
-
123
- const session = signSession({ email, agentIds }, cfg.sessionSecret, {
124
- ttlSec: cfg.sessionTtlSec,
125
- now,
126
- });
127
- return withFragment(return_to, `session=${encodeURIComponent(session)}`);
128
- }
129
-
130
- async function exchangeCode(
131
- cfg: OAuthConfig,
132
- code: string,
133
- ): Promise<{ id_token?: string }> {
134
- const res = await fetch(cfg.tokenUrl, {
135
- method: 'POST',
136
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
137
- body: new URLSearchParams({
138
- code,
139
- client_id: cfg.clientId,
140
- client_secret: cfg.clientSecret,
141
- redirect_uri: cfg.redirectUri,
142
- grant_type: 'authorization_code',
143
- }),
144
- });
145
- if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
146
- return (await res.json()) as { id_token?: string };
147
- }
148
-
149
- function defaultDeps(cfg: OAuthConfig): CallbackDeps {
150
- return {
151
- exchangeCode: (code) => exchangeCode(cfg, code),
152
- verifyIdToken: (idToken, nonce) =>
153
- verifyGoogleIdToken(idToken, {
154
- clientId: cfg.clientId,
155
- expectedNonce: nonce,
156
- }).then((c) => ({ email: c.email })),
157
- ensureUser: ensureUserWithProject,
158
-
159
- };
160
- }
161
-
162
- function serveStart(cfg: OAuthConfig, url: URL, res: ServerResponse): void {
163
- const redirect = buildStartRedirect(cfg, url.searchParams.get('return_to') ?? '');
164
- res.writeHead(302, { location: redirect }).end();
165
- }
166
-
167
- async function serveCallback(
168
- cfg: OAuthConfig,
169
- url: URL,
170
- res: ServerResponse,
171
- ): Promise<void> {
172
- const redirect = await completeCallback(
173
- cfg,
174
- {
175
- code: url.searchParams.get('code') ?? undefined,
176
- state: url.searchParams.get('state') ?? undefined,
177
- },
178
- defaultDeps(cfg),
179
- );
180
- res.writeHead(302, { location: redirect }).end();
181
- }
182
-
183
- export async function handleGoogleAuthRequest(
184
- req: IncomingMessage,
185
- res: ServerResponse,
186
- ): Promise<boolean> {
187
- const url = new URL(req.url ?? '/', 'http://localhost');
188
- const path = url.pathname;
189
- if (path !== '/auth/google/start' && path !== '/auth/google/callback')
190
- return false;
191
- if (req.method !== 'GET') {
192
- res.writeHead(405).end();
193
- return true;
194
- }
195
- const cfg = oauthConfigFromEnv();
196
- if (!cfg) {
197
- res.writeHead(503).end('google login not configured');
198
- return true;
199
- }
200
- try {
201
- if (path === '/auth/google/start') serveStart(cfg, url, res);
202
- else await serveCallback(cfg, url, res);
203
- } catch (e) {
204
- log.warn({ err: errMsg(e) }, 'google auth request failed');
205
- res.writeHead(400).end('bad request');
206
- }
207
- return true;
208
- }
@@ -1,18 +0,0 @@
1
- import { makeCodeStore } from './pair-codes.js';
2
-
3
- export interface RunCode {
4
- email: string;
5
- agentId: string;
6
- }
7
-
8
- const store = makeCodeStore<RunCode>('mr');
9
-
10
- export const RUN_CODE_RE = store.pattern;
11
-
12
- export const mintRunCode = (
13
- entry: RunCode,
14
- now?: number,
15
- ): { code: string; expiresAt: number } => store.mint(entry, now);
16
-
17
- export const takeRunCode = (code: string, now?: number): RunCode | undefined =>
18
- store.take(code, now);