@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,15 +1,16 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { RelayTarget } from '../db/connector-relay.js';
3
- import { cliIdentity } from './api-http.js';
3
+ import { agentIdentity, assertLease, type Fence } from './api-http.js';
4
4
  import { ApiError } from './api-error.js';
5
5
  import { errMsg, log } from './log.js';
6
6
 
7
7
  export interface RelayApiDeps {
8
8
  target: (
9
- collectionId: string,
9
+ agentId: string,
10
10
  connectorId: string,
11
11
  force: boolean,
12
12
  ) => Promise<RelayTarget>;
13
+ fence: Fence;
13
14
  }
14
15
 
15
16
  const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
@@ -196,18 +197,18 @@ type Exchanged =
196
197
 
197
198
  async function exchange(
198
199
  req: IncomingMessage,
199
- collectionId: string,
200
+ agentId: string,
200
201
  connectorId: string,
201
202
  deps: RelayApiDeps,
202
203
  body: Uint8Array<ArrayBuffer> | null,
203
204
  signal: AbortSignal,
204
205
  ): Promise<Exchanged> {
205
- const target = await deps.target(collectionId, connectorId, false);
206
+ const target = await deps.target(agentId, connectorId, false);
206
207
  if (target.kind !== 'ok') return { kind: target.kind };
207
208
  let upstream = await forward(req, target, body, signal);
208
209
  if (!authFailed(upstream.status)) return { kind: 'response', upstream };
209
210
  await upstream.body?.cancel();
210
- const fresh = await deps.target(collectionId, connectorId, true);
211
+ const fresh = await deps.target(agentId, connectorId, true);
211
212
  if (fresh.kind !== 'ok') return { kind: 'signin' };
212
213
  upstream = await forward(req, fresh, body, signal);
213
214
  if (!authFailed(upstream.status)) return { kind: 'response', upstream };
@@ -218,7 +219,7 @@ async function exchange(
218
219
  async function relayExchange(
219
220
  req: IncomingMessage,
220
221
  res: ServerResponse,
221
- collectionId: string,
222
+ agentId: string,
222
223
  connectorId: string,
223
224
  deps: RelayApiDeps,
224
225
  ): Promise<void> {
@@ -234,7 +235,7 @@ async function relayExchange(
234
235
  const body = req.method === 'POST' ? await readCapped(req) : null;
235
236
  const out = await exchange(
236
237
  req,
237
- collectionId,
238
+ agentId,
238
239
  connectorId,
239
240
  deps,
240
241
  body,
@@ -262,13 +263,14 @@ function dispatch(
262
263
  connectorId: string,
263
264
  deps: RelayApiDeps,
264
265
  ): void {
265
- const who = cliIdentity(req);
266
+ const who = agentIdentity(req);
266
267
  if (who === null) {
267
268
  answer(res, 401, { error: 'unauthorized' });
268
269
  return;
269
270
  }
270
- relayExchange(req, res, who.collectionId, connectorId, deps).catch(
271
- (err: unknown) => {
271
+ assertLease(who, deps.fence)
272
+ .then(() => relayExchange(req, res, who.agentId, connectorId, deps))
273
+ .catch((err: unknown) => {
272
274
  if (err instanceof ApiError) {
273
275
  answer(res, err.status, { error: err.message });
274
276
  } else {
@@ -277,8 +279,7 @@ function dispatch(
277
279
  if (!res.writableEnded) res.end();
278
280
  }
279
281
  closeIfBodyUnread(req, res);
280
- },
281
- );
282
+ });
282
283
  }
283
284
 
284
285
  export function handleRelayRequest(
@@ -8,8 +8,8 @@ import {
8
8
  runIdentity,
9
9
  sendJson,
10
10
  } from './api-http.js';
11
- import { RUN_CODE_RE, takeRunCode } from './run-pair.js';
12
- import { sessionTtlFromEnv } from './google-oauth.js';
11
+ import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
12
+ import { sessionTtlFromEnv } from './session-config.js';
13
13
  import { signRunToken } from './session.js';
14
14
  import type { LoadedAgent } from '../db/materialize.js';
15
15
  import type { RuntimeLease } from '../db/runtimes.js';
@@ -27,6 +27,7 @@ export interface RunApiDeps {
27
27
  fenceRuntime: (runtimeId: string, agentId: string) => Promise<void>;
28
28
  touchRuntime: (runtimeId: string) => Promise<void>;
29
29
  loadAgent: (agentId: string) => Promise<LoadedAgent>;
30
+ blockedStations: (agentId: string) => Promise<string[]>;
30
31
  }
31
32
 
32
33
  function secretOrNull(): string | null {
@@ -53,23 +54,31 @@ async function handleClaim(
53
54
  const body = await readJsonBody(req);
54
55
  const raw = bodyField(body, 'code');
55
56
  const code = typeof raw === 'string' ? raw.trim() : '';
56
- if (!RUN_CODE_RE.test(code)) {
57
+ if (!AGENT_CODE_RE.test(code)) {
57
58
  sendJson(req, res, 400, {
58
59
  error:
59
- 'that does not look like a runtime code — metro start wants the mr_… code from the agent page',
60
+ 'that does not look like an agent code — metro start wants the ma_… code from the agent page',
60
61
  });
61
62
  return;
62
63
  }
63
- const taken = takeRunCode(code);
64
+ const taken = takeAgentCode(code);
64
65
  if (taken === undefined) {
65
66
  sendJson(req, res, 400, {
66
67
  error: 'that code has expired or was already used',
67
68
  });
68
69
  return;
69
70
  }
71
+ const blocked = await deps.blockedStations(taken.agentId);
72
+ if (blocked.length > 0) {
73
+ sendJson(req, res, 409, {
74
+ error:
75
+ `this agent holds ${blocked.join(', ')}, which only runs on metro because a webhook url has to be publicly reachable. Detach it, or move it to a second agent that stays on metro, then start this one.`,
76
+ });
77
+ return;
78
+ }
70
79
  const lease = await deps.claimRuntime(taken.agentId, labelOf(body));
71
80
  const token = signRunToken(
72
- { email: taken.email, agentId: lease.agentId, runtimeId: lease.runtimeId },
81
+ { subject: taken.subject, agentId: lease.agentId, runtimeId: lease.runtimeId },
73
82
  secret,
74
83
  { ttlSec: sessionTtlFromEnv() },
75
84
  );
@@ -22,6 +22,6 @@ export function handleSessionApiRequest(
22
22
  sendJson(req, res, 401, { error: 'unauthorized' });
23
23
  return true;
24
24
  }
25
- sendJson(req, res, 200, { email: session.email });
25
+ sendJson(req, res, 200, { subject: session.subject });
26
26
  return true;
27
27
  }
@@ -5,7 +5,6 @@ import {
5
5
  handleConnectorApiRequest,
6
6
  type ConnectorApiDeps,
7
7
  } from './connector-api.js';
8
- import { handleCollectionApiRequest } from './collection-api.js';
9
8
  import { handleCliPairRequest } from './cli-pair-api.js';
10
9
  import { handleRunApiRequest, type RunApiDeps } from './run-api.js';
11
10
  import type { RelayApiDeps } from './relay.js';
@@ -13,13 +12,22 @@ import {
13
12
  handleProjectApiRequest,
14
13
  type ProjectApiDeps,
15
14
  } from './project-api.js';
15
+ import {
16
+ handleAgentConnectorRequest,
17
+ type AgentConnectorApiDeps,
18
+ } from './agent-connector-api.js';
19
+ import type { SiweRouteDeps } from './siwe-routes.js';
20
+ import type { ModeInfo } from './mode-api.js';
16
21
 
17
22
  export interface SessionApis {
18
23
  agentApi?: AgentApiDeps;
24
+ agentConnectorApi?: AgentConnectorApiDeps;
19
25
  connectorApi?: ConnectorApiDeps;
20
26
  projectApi?: ProjectApiDeps;
21
27
  runApi?: RunApiDeps;
22
28
  relayApi?: RelayApiDeps;
29
+ siwe?: SiweRouteDeps;
30
+ mode?: () => ModeInfo;
23
31
  }
24
32
 
25
33
  export function handleSessionApis(
@@ -27,7 +35,7 @@ export function handleSessionApis(
27
35
  res: ServerResponse,
28
36
  apis: SessionApis,
29
37
  ): boolean {
30
- const { agentApi, connectorApi, projectApi, runApi } = apis;
38
+ const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi } = apis;
31
39
  const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
32
40
  if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
33
41
  if (projectApi)
@@ -35,9 +43,10 @@ export function handleSessionApis(
35
43
  if (connectorApi)
36
44
  routes.push(
37
45
  () => handleCliPairRequest(req, res, connectorApi),
38
- () => handleCollectionApiRequest(req, res, connectorApi),
39
46
  () => handleConnectorApiRequest(req, res, connectorApi),
40
47
  );
48
+ if (agentConnectorApi)
49
+ routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
41
50
  if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
42
51
  return routes.some((run) => run());
43
52
  }
@@ -0,0 +1,27 @@
1
+ const DEFAULT_SESSION_TTL_SEC = 30 * 24 * 3600;
2
+ const PREVIEW = /^([a-z0-9-]+--)?metro-ui\.netlify\.app$/;
3
+
4
+ export function sessionTtlFromEnv(): number {
5
+ const ttl = Number(process.env.METRO_SESSION_TTL_SEC);
6
+ return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_SESSION_TTL_SEC;
7
+ }
8
+
9
+ export function allowedWebHost(host: string): boolean {
10
+ if (host === 'metro.box' || host === 'localhost' || host === '127.0.0.1')
11
+ return true;
12
+ return PREVIEW.test(host);
13
+ }
14
+
15
+ export function validateReturnTo(returnTo: string): boolean {
16
+ let url: URL;
17
+ try {
18
+ url = new URL(returnTo);
19
+ } catch {
20
+ return false;
21
+ }
22
+ const host = url.hostname;
23
+ if (url.protocol === 'http:' && (host === 'localhost' || host === '127.0.0.1'))
24
+ return true;
25
+ if (url.protocol !== 'https:') return false;
26
+ return allowedWebHost(host);
27
+ }
@@ -95,17 +95,17 @@ export function verifyState(
95
95
  }
96
96
 
97
97
  interface SessionClaims {
98
- email: string;
98
+ subject: string;
99
99
  agentIds: string[];
100
100
  }
101
101
 
102
- export interface CliClaims {
103
- email: string;
104
- collectionId: string;
102
+ export interface AgentClaims {
103
+ subject: string;
104
+ agentId: string;
105
105
  }
106
106
 
107
107
  export interface RunClaims {
108
- email: string;
108
+ subject: string;
109
109
  agentId: string;
110
110
  runtimeId: string;
111
111
  }
@@ -119,7 +119,7 @@ export function signSession(
119
119
  return sign(
120
120
  {
121
121
  typ: 'session',
122
- sub: claims.email,
122
+ sub: claims.subject,
123
123
  agent_ids: claims.agentIds,
124
124
  iat,
125
125
  exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
@@ -144,20 +144,20 @@ export function verifySession(
144
144
  ids.some((a) => typeof a !== 'string' || !ID_RE.test(a))
145
145
  )
146
146
  throw new SessionError('malformed session');
147
- return { email: p.sub, agentIds: ids as string[] };
147
+ return { subject: p.sub, agentIds: ids as string[] };
148
148
  }
149
149
 
150
- export function signCliToken(
151
- claims: CliClaims,
150
+ export function signAgentToken(
151
+ claims: AgentClaims,
152
152
  secret: string,
153
153
  opts: { ttlSec?: number; now?: number } = {},
154
154
  ): string {
155
155
  const iat = nowSec(opts.now);
156
156
  return sign(
157
157
  {
158
- typ: 'cli',
159
- sub: claims.email,
160
- collection: claims.collectionId,
158
+ typ: 'agent',
159
+ sub: claims.subject,
160
+ agent: claims.agentId,
161
161
  iat,
162
162
  exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
163
163
  },
@@ -165,18 +165,18 @@ export function signCliToken(
165
165
  );
166
166
  }
167
167
 
168
- export function verifyCliToken(
168
+ export function verifyAgentToken(
169
169
  token: string,
170
170
  secret: string,
171
171
  now?: number,
172
- ): CliClaims {
172
+ ): AgentClaims {
173
173
  const p = verify(token, secret);
174
- if (p.typ !== 'cli') throw new SessionError('wrong token type');
174
+ if (p.typ !== 'agent') throw new SessionError('wrong token type');
175
175
  if (typeof p.exp !== 'number' || p.exp < nowSec(now))
176
- throw new SessionError('cli token expired');
177
- if (typeof p.sub !== 'string' || typeof p.collection !== 'string')
178
- throw new SessionError('malformed cli token');
179
- return { email: p.sub, collectionId: p.collection };
176
+ throw new SessionError('agent token expired');
177
+ if (typeof p.sub !== 'string' || typeof p.agent !== 'string')
178
+ throw new SessionError('malformed agent token');
179
+ return { subject: p.sub, agentId: p.agent };
180
180
  }
181
181
 
182
182
  export function signRunToken(
@@ -188,7 +188,7 @@ export function signRunToken(
188
188
  return sign(
189
189
  {
190
190
  typ: 'run',
191
- sub: claims.email,
191
+ sub: claims.subject,
192
192
  agent: claims.agentId,
193
193
  rt: claims.runtimeId,
194
194
  iat,
@@ -214,5 +214,5 @@ export function verifyRunToken(
214
214
  !ID_RE.test(p.agent)
215
215
  )
216
216
  throw new SessionError('malformed run token');
217
- return { email: p.sub, agentId: p.agent, runtimeId: p.rt };
217
+ return { subject: p.sub, agentId: p.agent, runtimeId: p.rt };
218
218
  }
@@ -0,0 +1,82 @@
1
+ import { verifyMessage, type Hex } from 'viem';
2
+ import { parseSiweMessage, validateSiweMessage } from 'viem/siwe';
3
+ import { ApiError } from './api-error.js';
4
+ import { allowedWebHost } from './session-config.js';
5
+
6
+ export class SiweError extends ApiError {}
7
+
8
+ export interface SiweLogin {
9
+ message: string;
10
+ signature: string;
11
+ }
12
+
13
+ export interface SiweVerifyArgs {
14
+ address: `0x${string}`;
15
+ message: string;
16
+ signature: Hex;
17
+ }
18
+
19
+ export interface SiweDeps {
20
+ takeNonce: (nonce: string) => boolean;
21
+ now?: number;
22
+ verify?: (args: SiweVerifyArgs) => Promise<boolean>;
23
+ }
24
+
25
+ const HEX_SIGNATURE = /^0x[0-9a-fA-F]{130}$/;
26
+
27
+ export function allowedSiweDomain(domain: string): boolean {
28
+ return allowedWebHost(domain.replace(/:\d+$/, ''));
29
+ }
30
+
31
+ function hostOf(uri: string): string | null {
32
+ try {
33
+ return new URL(uri).host;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function fieldsOrThrow(message: string): {
40
+ address: `0x${string}`;
41
+ domain: string;
42
+ nonce: string;
43
+ parsed: ReturnType<typeof parseSiweMessage>;
44
+ } {
45
+ const parsed = parseSiweMessage(message);
46
+ const { address, domain, nonce, uri, version } = parsed;
47
+ if (
48
+ address === undefined ||
49
+ domain === undefined ||
50
+ nonce === undefined ||
51
+ uri === undefined ||
52
+ version !== '1'
53
+ )
54
+ throw new SiweError('that is not a sign-in message', 400);
55
+ if (!allowedSiweDomain(domain))
56
+ throw new SiweError(`sign-in messages for ${domain} are not accepted here`, 400);
57
+ if (hostOf(uri) !== domain)
58
+ throw new SiweError('the sign-in message names a different site', 400);
59
+ return { address, domain, nonce, parsed };
60
+ }
61
+
62
+ export async function verifySiweLogin(
63
+ input: SiweLogin,
64
+ deps: SiweDeps,
65
+ ): Promise<string> {
66
+ if (!HEX_SIGNATURE.test(input.signature))
67
+ throw new SiweError('that is not an Ethereum signature', 400);
68
+ const { address, domain, nonce, parsed } = fieldsOrThrow(input.message);
69
+ const time = new Date(deps.now ?? Date.now());
70
+ if (!validateSiweMessage({ message: parsed, domain, nonce, time }))
71
+ throw new SiweError('this sign-in message is expired or does not match', 400);
72
+ if (!deps.takeNonce(nonce))
73
+ throw new SiweError('that sign-in has expired or was already used', 400);
74
+ const verify = deps.verify ?? verifyMessage;
75
+ const signature = input.signature as Hex;
76
+ if (!(await verify({ address, message: input.message, signature })))
77
+ throw new SiweError(
78
+ 'the signature does not match the address; smart-contract wallets are not supported yet',
79
+ 401,
80
+ );
81
+ return address.toLowerCase();
82
+ }
@@ -0,0 +1,43 @@
1
+ import { generateSiweNonce } from 'viem/siwe';
2
+
3
+ const TTL_MS = 10 * 60_000;
4
+ const SWEEP_MS = 60_000;
5
+ const MAX_LIVE = 500;
6
+
7
+ const live = new Map<string, number>();
8
+ let sweeper: ReturnType<typeof setInterval> | null = null;
9
+
10
+ function sweep(now = Date.now()): void {
11
+ for (const [nonce, expiresAt] of live) if (expiresAt <= now) live.delete(nonce);
12
+ }
13
+
14
+ function ensureSweeper(): void {
15
+ if (sweeper !== null) return;
16
+ sweeper = setInterval(sweep, SWEEP_MS);
17
+ sweeper.unref?.();
18
+ }
19
+
20
+ export function mintNonce(now = Date.now()): string {
21
+ sweep(now);
22
+ while (live.size >= MAX_LIVE) {
23
+ const oldest = live.keys().next();
24
+ if (oldest.done) break;
25
+ live.delete(oldest.value);
26
+ }
27
+ const nonce = generateSiweNonce();
28
+ live.set(nonce, now + TTL_MS);
29
+ ensureSweeper();
30
+ return nonce;
31
+ }
32
+
33
+ export function takeNonce(nonce: string, now = Date.now()): boolean {
34
+ const expiresAt = live.get(nonce);
35
+ if (expiresAt === undefined) return false;
36
+ live.delete(nonce);
37
+ return expiresAt > now;
38
+ }
39
+
40
+ export function nonceCount(): number {
41
+ sweep();
42
+ return live.size;
43
+ }
@@ -0,0 +1,89 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { log } from './log.js';
3
+ import {
4
+ apiFailure,
5
+ bodyField,
6
+ cors,
7
+ readJsonBody,
8
+ sendJson,
9
+ } from './api-http.js';
10
+ import { mintNonce, takeNonce } from './siwe-nonces.js';
11
+ import { verifySiweLogin } from './siwe-auth.js';
12
+ import { signSession } from './session.js';
13
+ import { sessionTtlFromEnv } from './session-config.js';
14
+ import { ensureUserWithProject } from '../db/projects.js';
15
+
16
+ const NONCE_PATH = '/auth/siwe/nonce';
17
+ const VERIFY_PATH = '/auth/siwe/verify';
18
+
19
+ export interface SiweRouteDeps {
20
+ ensureUser: (address: string) => Promise<string>;
21
+ }
22
+
23
+ const defaultDeps: SiweRouteDeps = { ensureUser: ensureUserWithProject };
24
+
25
+ async function handleVerify(
26
+ req: IncomingMessage,
27
+ res: ServerResponse,
28
+ secret: string,
29
+ deps: SiweRouteDeps,
30
+ ): Promise<void> {
31
+ const body = await readJsonBody(req);
32
+ const message = bodyField(body, 'message');
33
+ const signature = bodyField(body, 'signature');
34
+ if (typeof message !== 'string' || typeof signature !== 'string') {
35
+ sendJson(req, res, 400, { error: 'message and signature are required' });
36
+ return;
37
+ }
38
+ const address = await verifySiweLogin({ message, signature }, { takeNonce });
39
+ const userId = await deps.ensureUser(address);
40
+ log.info({ userId, address }, 'siwe: session issued');
41
+ const session = signSession({ subject: address, agentIds: [] }, secret, {
42
+ ttlSec: sessionTtlFromEnv(),
43
+ });
44
+ sendJson(req, res, 200, { session, address });
45
+ }
46
+
47
+ const ROUTES: Record<string, string> = {
48
+ [NONCE_PATH]: 'GET',
49
+ [VERIFY_PATH]: 'POST',
50
+ };
51
+
52
+ function refused(
53
+ req: IncomingMessage,
54
+ res: ServerResponse,
55
+ path: string,
56
+ ): boolean {
57
+ if (req.method === 'OPTIONS') {
58
+ res.writeHead(204, cors(req)).end();
59
+ return true;
60
+ }
61
+ if (req.method !== ROUTES[path]) {
62
+ sendJson(req, res, 405, { error: 'method not allowed' });
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ export function handleSiweAuthRequest(
69
+ req: IncomingMessage,
70
+ res: ServerResponse,
71
+ deps: SiweRouteDeps = defaultDeps,
72
+ ): boolean {
73
+ const path = (req.url ?? '').split('?')[0] ?? '';
74
+ if (ROUTES[path] === undefined) return false;
75
+ if (refused(req, res, path)) return true;
76
+ const secret = process.env.METRO_SESSION_SECRET?.trim() ?? '';
77
+ if (secret === '') {
78
+ sendJson(req, res, 503, { error: 'sign-in is not configured' });
79
+ return true;
80
+ }
81
+ if (path === NONCE_PATH) {
82
+ sendJson(req, res, 200, { nonce: mintNonce() });
83
+ return true;
84
+ }
85
+ handleVerify(req, res, secret, deps).catch((err: unknown) => {
86
+ apiFailure(req, res, err, 'siwe');
87
+ });
88
+ return true;
89
+ }
@@ -1,5 +1,4 @@
1
1
  import { mkdirSync, statSync, type FSWatcher } from 'node:fs';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
3
  import { errMsg, log } from './log.js';
5
4
  import { daemonSelf } from './events.js';
@@ -22,9 +21,7 @@ import {
22
21
  startWatcher,
23
22
  type TrainState,
24
23
  } from './supervisor-io.js';
25
-
26
- export const TRAINS_DIR =
27
- process.env.METRO_TRAINS_DIR ?? join(homedir(), '.metro', 'trains');
24
+ import { trainsDir } from './paths.js';
28
25
 
29
26
  const DYLD_FALLBACK_ENV: Record<string, string> =
30
27
  process.platform === 'darwin'
@@ -45,7 +42,7 @@ export class TrainSupervisor {
45
42
  private watcher: FSWatcher | null = null;
46
43
  private reloadTimers = new Map<string, ReturnType<typeof setTimeout>>();
47
44
 
48
- constructor(private dir: string = TRAINS_DIR) {}
45
+ constructor(private dir: string = trainsDir()) {}
49
46
 
50
47
  running(): string[] {
51
48
  return [...this.trains.keys()];
@@ -3,12 +3,11 @@ import { and, asc, eq, inArray } from 'drizzle-orm';
3
3
  import { ApiError } from '../daemon/api-error.js';
4
4
  import { getDb } from './client.js';
5
5
  import { registerKey, rotateAgentKey, unregisterAgentKey } from './key-map.js';
6
- import { newId, parseId } from './ids.js';
6
+ import { AGENT_NAME_RE, newId, parseId } from './ids.js';
7
7
  import { agents, stations } from './schema.js';
8
8
  import { isUniqueViolation } from './users.js';
9
9
  import { projectIdOrThrow } from './projects.js';
10
10
 
11
- export const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{1,31}$/;
12
11
  const KEY_ATTEMPTS = 5;
13
12
 
14
13
  export class AgentAdminError extends ApiError {}
@@ -93,11 +92,11 @@ async function selectKeyRows(ids: string[]): Promise<KeyRow[]> {
93
92
  .where(inArray(agents.id, ids));
94
93
  }
95
94
 
96
- export async function listAgentsForEmail(
97
- email: string,
95
+ export async function listAgentsForUser(
96
+ subject: string,
98
97
  project: string,
99
98
  ): Promise<AgentSummary[]> {
100
- const projectId = await projectIdOrThrow(email, project);
99
+ const projectId = await projectIdOrThrow(subject, project);
101
100
  const rows = await getDb()
102
101
  .select({ id: agents.id, name: agents.name, projectId: agents.projectId })
103
102
  .from(agents)
@@ -123,14 +122,14 @@ async function insertAgent(
123
122
  return id;
124
123
  }
125
124
 
126
- export async function createAgentForEmail(
127
- email: string,
125
+ export async function createAgentForUser(
126
+ subject: string,
128
127
  project: string,
129
128
  rawName: string,
130
129
  ): Promise<CreatedAgent> {
131
130
  const name = normalizeAgentName(rawName);
132
131
  const key = newApiKey();
133
- const id = await insertAgent(await projectIdOrThrow(email, project), name, key);
132
+ const id = await insertAgent(await projectIdOrThrow(subject, project), name, key);
134
133
  if (servesEveryAgent()) registerKey(key, id);
135
134
  return { id, name, key };
136
135
  }
@@ -145,7 +144,7 @@ interface Deletable {
145
144
  }
146
145
 
147
146
  export async function ownedAgentOrThrow(
148
- email: string,
147
+ subject: string,
149
148
  id: string,
150
149
  ): Promise<Deletable> {
151
150
  const rows = await getDb().select().from(agents).where(eq(agents.id, id));
@@ -153,7 +152,7 @@ export async function ownedAgentOrThrow(
153
152
  const missing = new AgentAdminError('no such agent', 404);
154
153
  if (!row) throw missing;
155
154
  try {
156
- await projectIdOrThrow(email, row.projectId);
155
+ await projectIdOrThrow(subject, row.projectId);
157
156
  } catch {
158
157
  throw missing;
159
158
  }
@@ -179,21 +178,21 @@ async function writeNewKey(id: string, projectId: string): Promise<string> {
179
178
  throw new AgentAdminError('could not allocate a free api key', 500);
180
179
  }
181
180
 
182
- export async function resetAgentKeyForEmail(
183
- email: string,
181
+ export async function resetAgentKeyForUser(
182
+ subject: string,
184
183
  id: string,
185
184
  ): Promise<ResetAgentKey> {
186
- const { agent, projectId } = await ownedAgentOrThrow(email, id);
185
+ const { agent, projectId } = await ownedAgentOrThrow(subject, id);
187
186
  const key = await writeNewKey(agent.id, projectId);
188
187
  rotateAgentKey(agent.id, daemonServesAgent(agent.id) ? key : null);
189
188
  return { id: agent.id, name: agent.name, key };
190
189
  }
191
190
 
192
- export async function deleteAgentForEmail(
193
- email: string,
191
+ export async function deleteAgentForUser(
192
+ subject: string,
194
193
  id: string,
195
194
  ): Promise<DeletedAgent> {
196
- const { agent, projectId } = await ownedAgentOrThrow(email, id);
195
+ const { agent, projectId } = await ownedAgentOrThrow(subject, id);
197
196
  await getDb().transaction(async (tx) => {
198
197
  const attached = await tx
199
198
  .select({ id: stations.id })