@stage-labs/metro 0.1.0-beta.31 → 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 (50) hide show
  1. package/dist/cli.js +5 -0
  2. package/dist/runtime.js +4 -1
  3. package/dist/serve.js +57 -0
  4. package/package.json +1 -1
  5. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
  6. package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +5 -5
  7. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +16 -15
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +9 -9
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +1 -1
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +8 -7
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +5 -3
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +2 -2
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +109 -71
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +16 -7
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +14 -14
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +8 -8
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +3 -3
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -2
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +11 -4
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +158 -0
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +1 -1
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +10 -0
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +17 -17
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +2 -2
  27. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +1 -1
  28. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +4 -0
  29. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
  30. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +9 -9
  31. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
  32. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
  33. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
  34. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +2 -5
  35. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +15 -16
  36. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +9 -9
  37. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +31 -31
  38. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +250 -0
  39. package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
  40. package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +2 -0
  41. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +16 -7
  42. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +48 -39
  43. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +7 -7
  44. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +2 -1
  45. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +22 -12
  46. package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +4 -4
  47. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +1 -1
  48. package/runtime/runtime.json +1 -1
  49. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +0 -164
  50. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
@@ -0,0 +1,158 @@
1
+ import { hostname } from 'node:os';
2
+ import { ApiError } from './api-error.js';
3
+ import { errMsg, log } from './log.js';
4
+ import { AttachSessions } from './attach-session.js';
5
+ import type { AgentApiDeps } from './agent-api.js';
6
+ import { ATTACHABLE, type AccountApiDeps } from './account-api.js';
7
+ import type { AgentConnectorApiDeps } from './agent-connector-api.js';
8
+ import type { ProjectApiDeps } from './project-api.js';
9
+ import type { SessionApis } from './session-apis.js';
10
+ import type { ModeInfo } from './mode-api.js';
11
+ import {
12
+ claimLocalOwner,
13
+ LOCAL_PROJECT_ID,
14
+ localAttachAccount,
15
+ localCreateAgent,
16
+ localDeleteAgent,
17
+ localDetachAccount,
18
+ localListAgents,
19
+ localOwnedAgentOrThrow,
20
+ localOwner,
21
+ localResetAgentKey,
22
+ } from '../db/file-admin.js';
23
+ import { normalizeAddress } from '../db/users.js';
24
+ import type { Project } from '../db/projects.js';
25
+ import type { StationName } from '../db/schema.js';
26
+
27
+ export interface LocalModeDeps {
28
+ syncStations: (station: StationName) => Promise<void>;
29
+ closeAgentSession: (id: string) => Promise<boolean>;
30
+ gatherAccounts: AgentApiDeps['gatherAccounts'];
31
+ capabilities: AgentApiDeps['capabilities'];
32
+ liveness: AgentApiDeps['liveness'];
33
+ prepareAccount: AccountApiDeps['prepareAccount'];
34
+ }
35
+
36
+ const notHere = (what: string): Promise<never> =>
37
+ Promise.reject(
38
+ new ApiError(`${what} are managed on metro.box, not on a local daemon`, 400),
39
+ );
40
+
41
+ function localProject(): Project {
42
+ return {
43
+ id: LOCAL_PROJECT_ID,
44
+ name: hostname(),
45
+ isDefault: true,
46
+ owner: true,
47
+ role: 'admin',
48
+ };
49
+ }
50
+
51
+ function attachSessions(deps: LocalModeDeps): AttachSessions {
52
+ return new AttachSessions({
53
+ authorize: async (owner) => {
54
+ await localOwnedAgentOrThrow(owner.subject, owner.agentId);
55
+ },
56
+ complete: async (owner, station, config) => {
57
+ const ref = await localAttachAccount(owner.subject, owner.agentId, station, config);
58
+ const activated = await deps.syncStations(station).then(
59
+ () => true,
60
+ (err: unknown) => {
61
+ log.warn(
62
+ { station, err: errMsg(err) },
63
+ 'attach-session: station reload failed, the change lands at the next boot',
64
+ );
65
+ return false;
66
+ },
67
+ );
68
+ return { accountId: ref.accountId, activated };
69
+ },
70
+ });
71
+ }
72
+
73
+ function agentApi(deps: LocalModeDeps): AgentApiDeps {
74
+ return {
75
+ attachSessions: attachSessions(deps),
76
+ listAgents: localListAgents,
77
+ createAgent: localCreateAgent,
78
+ deleteAgent: localDeleteAgent,
79
+ resetKey: async (subject, id) => {
80
+ const reset = await localResetAgentKey(subject, id);
81
+ const closed = await deps.closeAgentSession(id);
82
+ log.info({ agent: reset.name, id, sessionClosed: closed }, 'local: key rotated');
83
+ return reset;
84
+ },
85
+ gatherAccounts: deps.gatherAccounts,
86
+ capabilities: deps.capabilities,
87
+ attachable: ATTACHABLE.filter((s) => s !== 'webhook'),
88
+ liveness: deps.liveness,
89
+ releaseRuntime: () => notHere('runtime leases'),
90
+ runtimes: () => Promise.resolve(new Map()),
91
+ connectorIds: () => Promise.resolve(new Map()),
92
+ prepareAccount: deps.prepareAccount,
93
+ attachAccount: localAttachAccount,
94
+ detachAccount: localDetachAccount,
95
+ syncStations: deps.syncStations,
96
+ };
97
+ }
98
+
99
+ const projectApi: ProjectApiDeps = {
100
+ listProjects: (subject) => {
101
+ const owner = localOwner();
102
+ const mine = owner !== null && owner === normalizeAddress(subject);
103
+ return Promise.resolve(mine ? [localProject()] : []);
104
+ },
105
+ createProject: () => notHere('projects'),
106
+ renameProject: () => notHere('projects'),
107
+ deleteProject: () => notHere('projects'),
108
+ listMembers: (subject, id) => {
109
+ const owner = localOwner();
110
+ if (id !== LOCAL_PROJECT_ID || owner === null || owner !== normalizeAddress(subject))
111
+ return Promise.reject(new ApiError('no such project', 404));
112
+ return Promise.resolve([
113
+ { id: 'owner', address: owner, email: null, role: 'admin', owner: true },
114
+ ]);
115
+ },
116
+ addMember: () => notHere('members'),
117
+ setMemberRole: () => notHere('members'),
118
+ removeMember: () => notHere('members'),
119
+ };
120
+
121
+ const agentConnectorApi: AgentConnectorApiDeps = {
122
+ agentConnectors: async (subject, id) => {
123
+ const { agent } = await localOwnedAgentOrThrow(subject, id);
124
+ return { ...agent, connectorIds: [] };
125
+ },
126
+ addConnector: () => notHere('connectors'),
127
+ removeConnector: () => notHere('connectors'),
128
+ mintCode: () =>
129
+ Promise.reject(new ApiError('a local daemon has no pairing codes', 400)),
130
+ };
131
+
132
+ export function localConnectHint(port: number): string {
133
+ const ui = process.env.METRO_UI_URL ?? 'https://metro.box';
134
+ const here = `http://127.0.0.1:${String(port)}`;
135
+ const forward = `ssh -L ${String(port)}:127.0.0.1:${String(port)} ${hostname()}`;
136
+ return [
137
+ 'Manage this machine from the web UI:',
138
+ '',
139
+ ` ${ui}/#/connect/${encodeURIComponent(here)}`,
140
+ '',
141
+ `From another computer, forward the port first: ${forward}`,
142
+ '',
143
+ ].join('\n');
144
+ }
145
+
146
+ export function localModeInfo(): ModeInfo {
147
+ return { mode: 'local', owner: localOwner(), project: LOCAL_PROJECT_ID };
148
+ }
149
+
150
+ export function localSessionApis(deps: LocalModeDeps): SessionApis {
151
+ return {
152
+ agentApi: agentApi(deps),
153
+ agentConnectorApi,
154
+ projectApi,
155
+ siwe: { ensureUser: claimLocalOwner },
156
+ mode: localModeInfo,
157
+ };
158
+ }
@@ -0,0 +1,21 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { agentsDir } from '../db/file-source.js';
5
+ import { ensureSecureDir, writeSecure } from './secure-fs.js';
6
+
7
+ const SECRET_FILE = '.session-secret';
8
+
9
+ export function ensureLocalSessionSecret(dir = agentsDir()): string {
10
+ const configured = process.env.METRO_SESSION_SECRET?.trim() ?? '';
11
+ if (configured !== '') return configured;
12
+ const path = join(dir, SECRET_FILE);
13
+ let secret = existsSync(path) ? readFileSync(path, 'utf8').trim() : '';
14
+ if (secret === '') {
15
+ secret = randomBytes(32).toString('base64url');
16
+ ensureSecureDir(dir);
17
+ writeSecure(path, `${secret}\n`);
18
+ }
19
+ process.env.METRO_SESSION_SECRET = secret;
20
+ return secret;
21
+ }
@@ -0,0 +1,31 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { cors, sendJson } from './api-http.js';
3
+
4
+ const PATH = '/api/mode';
5
+
6
+ export type DaemonMode = 'hosted' | 'linked' | 'local';
7
+
8
+ export interface ModeInfo {
9
+ mode: DaemonMode;
10
+ owner: string | null;
11
+ project: string | null;
12
+ }
13
+
14
+ export function handleModeRequest(
15
+ req: IncomingMessage,
16
+ res: ServerResponse,
17
+ info: () => ModeInfo,
18
+ ): boolean {
19
+ const path = (req.url ?? '').split('?')[0] ?? '';
20
+ if (path !== PATH) return false;
21
+ if (req.method === 'OPTIONS') {
22
+ res.writeHead(204, cors(req)).end();
23
+ return true;
24
+ }
25
+ if (req.method !== 'GET') {
26
+ sendJson(req, res, 405, { error: 'method not allowed' });
27
+ return true;
28
+ }
29
+ sendJson(req, res, 200, info());
30
+ return true;
31
+ }
@@ -7,7 +7,7 @@ const SWEEP_MS = 60_000;
7
7
  const MAX_PENDING = 100;
8
8
 
9
9
  export interface PendingAuth {
10
- email: string;
10
+ subject: string;
11
11
  name: string;
12
12
  url: string;
13
13
  resource: string;
@@ -10,6 +10,7 @@ import {
10
10
  } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
13
14
  import { log } from './log.js';
14
15
 
15
16
  export const STATE_DIR =
@@ -23,6 +24,15 @@ export const CONFIG_ENV_FILE = join(CONFIG_DIR, '.env');
23
24
 
24
25
  export const TRAINS_ENV_FILE = join(homedir(), '.metro', '.env');
25
26
 
27
+ export function trainsDir(): string {
28
+ const env = process.env.METRO_TRAINS_DIR?.trim();
29
+ if (env !== undefined && env !== '') return env;
30
+ return fileURLToPath(new URL('../../trains', import.meta.url));
31
+ }
32
+
33
+ export const isLocalMode = (): boolean =>
34
+ process.env.METRO_MODE?.trim() === 'local';
35
+
26
36
  export const envSources = (): readonly string[] => [
27
37
  join(process.cwd(), '.env'),
28
38
  TRAINS_ENV_FILE,
@@ -15,32 +15,32 @@ import type { Member, Project } from '../db/projects.js';
15
15
  const PREFIX = '/api/projects';
16
16
 
17
17
  export interface ProjectApiDeps {
18
- listProjects: (email: string) => Promise<Project[]>;
19
- createProject: (email: string, name: unknown) => Promise<Project>;
18
+ listProjects: (subject: string) => Promise<Project[]>;
19
+ createProject: (subject: string, name: unknown) => Promise<Project>;
20
20
  renameProject: (
21
- email: string,
21
+ subject: string,
22
22
  id: string,
23
23
  name: unknown,
24
24
  ) => Promise<Project>;
25
25
  deleteProject: (
26
- email: string,
26
+ subject: string,
27
27
  id: string,
28
28
  ) => Promise<{ id: string; name: string }>;
29
- listMembers: (email: string, id: string) => Promise<Member[]>;
29
+ listMembers: (subject: string, id: string) => Promise<Member[]>;
30
30
  addMember: (
31
- email: string,
31
+ subject: string,
32
32
  id: string,
33
33
  invited: unknown,
34
34
  role: unknown,
35
35
  ) => Promise<Member[]>;
36
36
  setMemberRole: (
37
- email: string,
37
+ subject: string,
38
38
  id: string,
39
39
  memberId: string,
40
40
  role: unknown,
41
41
  ) => Promise<Member[]>;
42
42
  removeMember: (
43
- email: string,
43
+ subject: string,
44
44
  id: string,
45
45
  memberId: string,
46
46
  ) => Promise<Member[]>;
@@ -98,11 +98,11 @@ async function handleIndex(
98
98
  session: ApiSession,
99
99
  ): Promise<void> {
100
100
  if (req.method === 'GET') {
101
- sendJson(req, res, 200, { projects: await deps.listProjects(session.email) });
101
+ sendJson(req, res, 200, { projects: await deps.listProjects(session.subject) });
102
102
  return;
103
103
  }
104
104
  const made = await deps.createProject(
105
- session.email,
105
+ session.subject,
106
106
  bodyField(await readJsonBody(req), 'name'),
107
107
  );
108
108
  log.info({ id: made.id, name: made.name }, 'project-api: created project');
@@ -117,14 +117,14 @@ async function handleMembers(
117
117
  id: string,
118
118
  ): Promise<void> {
119
119
  if (req.method === 'GET') {
120
- sendJson(req, res, 200, { members: await deps.listMembers(session.email, id) });
120
+ sendJson(req, res, 200, { members: await deps.listMembers(session.subject, id) });
121
121
  return;
122
122
  }
123
123
  const body = await readJsonBody(req);
124
124
  const members = await deps.addMember(
125
- session.email,
125
+ session.subject,
126
126
  id,
127
- bodyField(body, 'email'),
127
+ bodyField(body, 'address'),
128
128
  bodyField(body, 'role'),
129
129
  );
130
130
  sendJson(req, res, 200, { members });
@@ -138,12 +138,12 @@ async function handleMember(
138
138
  tgt: { id: string; memberId: string },
139
139
  ): Promise<void> {
140
140
  if (req.method === 'DELETE') {
141
- const members = await deps.removeMember(session.email, tgt.id, tgt.memberId);
141
+ const members = await deps.removeMember(session.subject, tgt.id, tgt.memberId);
142
142
  sendJson(req, res, 200, { members });
143
143
  return;
144
144
  }
145
145
  const members = await deps.setMemberRole(
146
- session.email,
146
+ session.subject,
147
147
  tgt.id,
148
148
  tgt.memberId,
149
149
  bodyField(await readJsonBody(req), 'role'),
@@ -170,12 +170,12 @@ async function route(
170
170
  res,
171
171
  200,
172
172
  await deps.renameProject(
173
- session.email,
173
+ session.subject,
174
174
  tgt.id,
175
175
  bodyField(await readJsonBody(req), 'name'),
176
176
  ),
177
177
  );
178
- else sendJson(req, res, 200, await deps.deleteProject(session.email, tgt.id));
178
+ else sendJson(req, res, 200, await deps.deleteProject(session.subject, tgt.id));
179
179
  } catch (err) {
180
180
  apiFailure(req, res, err, 'project-api');
181
181
  }
@@ -9,7 +9,7 @@ import {
9
9
  sendJson,
10
10
  } from './api-http.js';
11
11
  import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
12
- import { sessionTtlFromEnv } from './google-oauth.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';
@@ -78,7 +78,7 @@ async function handleClaim(
78
78
  }
79
79
  const lease = await deps.claimRuntime(taken.agentId, labelOf(body));
80
80
  const token = signRunToken(
81
- { email: taken.email, agentId: lease.agentId, runtimeId: lease.runtimeId },
81
+ { subject: taken.subject, agentId: lease.agentId, runtimeId: lease.runtimeId },
82
82
  secret,
83
83
  { ttlSec: sessionTtlFromEnv() },
84
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
  }
@@ -16,6 +16,8 @@ import {
16
16
  handleAgentConnectorRequest,
17
17
  type AgentConnectorApiDeps,
18
18
  } from './agent-connector-api.js';
19
+ import type { SiweRouteDeps } from './siwe-routes.js';
20
+ import type { ModeInfo } from './mode-api.js';
19
21
 
20
22
  export interface SessionApis {
21
23
  agentApi?: AgentApiDeps;
@@ -24,6 +26,8 @@ export interface SessionApis {
24
26
  projectApi?: ProjectApiDeps;
25
27
  runApi?: RunApiDeps;
26
28
  relayApi?: RelayApiDeps;
29
+ siwe?: SiweRouteDeps;
30
+ mode?: () => ModeInfo;
27
31
  }
28
32
 
29
33
  export function handleSessionApis(
@@ -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
102
  export interface AgentClaims {
103
- email: string;
103
+ subject: string;
104
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,7 +144,7 @@ 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
150
  export function signAgentToken(
@@ -156,7 +156,7 @@ export function signAgentToken(
156
156
  return sign(
157
157
  {
158
158
  typ: 'agent',
159
- sub: claims.email,
159
+ sub: claims.subject,
160
160
  agent: claims.agentId,
161
161
  iat,
162
162
  exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
@@ -176,7 +176,7 @@ export function verifyAgentToken(
176
176
  throw new SessionError('agent token expired');
177
177
  if (typeof p.sub !== 'string' || typeof p.agent !== 'string')
178
178
  throw new SessionError('malformed agent token');
179
- return { email: p.sub, agentId: p.agent };
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
+ }