@stage-labs/metro 0.1.0-beta.53 → 0.1.0-beta.55

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 (39) hide show
  1. package/dist/serve.js +56 -7
  2. package/package.json +1 -1
  3. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
  4. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +5 -5
  5. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +9 -6
  6. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +6 -13
  7. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +1 -2
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +4 -7
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/bundle-api.ts +5 -6
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +3 -7
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connect-hint.ts +4 -2
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +5 -5
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +2 -2
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-key.ts +33 -0
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-registry.ts +13 -0
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-routes.ts +54 -0
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +1 -2
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/{session-config.ts → return-to.ts} +0 -1
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +9 -7
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +2 -2
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/signed-identity.ts +36 -0
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/tunnel.ts +87 -24
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/update-api.ts +2 -5
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-api.ts +1 -2
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-api.ts +2 -32
  27. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +0 -10
  28. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +0 -6
  29. package/runtime/node_modules/@metro-labs/mcp/src/mcp/index.ts +1 -2
  30. package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +2 -28
  31. package/runtime/node_modules/@metro-labs/mcp/src/monitor/api.ts +2 -5
  32. package/runtime/runtime.json +1 -1
  33. package/runtime/stations.json +1 -0
  34. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +0 -21
  35. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +0 -96
  36. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +0 -73
  37. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +0 -43
  38. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +0 -86
  39. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +0 -41
package/dist/serve.js CHANGED
@@ -10,13 +10,33 @@ const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
10
10
  const PORT_FLAG = /^--port=(.*)$/;
11
11
  const OWNER_FLAG = /^--owner=(.*)$/;
12
12
  const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
13
- const USAGE = 'usage: metro serve [--port <n>] [--tunnel] [--owner <address>]';
13
+ const USAGE = 'usage: metro serve [--port <n>] [--tunnel [quick|tailscale]] [--owner <address>]';
14
+ const TUNNEL_FLAG = /^--tunnel=(.*)$/;
15
+ const TAILSCALE_APP = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
14
16
  function portOf(raw) {
15
17
  const n = Number(raw);
16
18
  if (raw === undefined || raw === '' || !Number.isInteger(n) || n < 1 || n > 65535)
17
19
  throw new Error(`'${raw ?? ''}' is not a port — ${USAGE}`);
18
20
  return n;
19
21
  }
22
+ function tunnelOf(raw) {
23
+ if (raw === undefined || raw === '' || raw === 'quick')
24
+ return 'quick';
25
+ if (raw === 'tailscale')
26
+ return 'tailscale';
27
+ throw new Error(`'${raw}' is not a tunnel kind (quick or tailscale) — ${USAGE}`);
28
+ }
29
+ function tunnelFlag(argv, i) {
30
+ const arg = argv[i] ?? '';
31
+ const inline = TUNNEL_FLAG.exec(arg);
32
+ if (inline)
33
+ return { kind: tunnelOf(inline[1]), consumed: 0 };
34
+ if (arg !== '--tunnel')
35
+ return null;
36
+ const next = argv[i + 1];
37
+ const takesValue = next !== undefined && !next.startsWith('-');
38
+ return { kind: tunnelOf(takesValue ? next : undefined), consumed: takesValue ? 1 : 0 };
39
+ }
20
40
  function ownerOf(raw) {
21
41
  if (raw === undefined || !ADDRESS.test(raw))
22
42
  throw new Error(`'${raw ?? ''}' is not an Ethereum address — ${USAGE}`);
@@ -24,12 +44,14 @@ function ownerOf(raw) {
24
44
  }
25
45
  export function parseServeArgs(argv) {
26
46
  let port = localPort();
27
- let tunnel = false;
47
+ let tunnel = null;
28
48
  let owner = null;
29
49
  for (let i = 0; i < argv.length; i += 1) {
30
50
  const arg = argv[i] ?? '';
31
- if (arg === '--tunnel') {
32
- tunnel = true;
51
+ const tunnelArg = tunnelFlag(argv, i);
52
+ if (tunnelArg) {
53
+ tunnel = tunnelArg.kind;
54
+ i += tunnelArg.consumed;
33
55
  continue;
34
56
  }
35
57
  const inlineOwner = OWNER_FLAG.exec(arg);
@@ -64,6 +86,31 @@ export function requireOwner(owner, dir = agentsDir()) {
64
86
  join(dir, '.owner') +
65
87
  ':\n metro serve --owner <address>');
66
88
  }
89
+ function tailscaleStatus(bin) {
90
+ const run = spawnSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
91
+ if (run.error !== undefined || run.status !== 0)
92
+ return { ok: false, state: 'not running' };
93
+ try {
94
+ const parsed = JSON.parse(run.stdout);
95
+ return parsed.BackendState === 'Running' ? { ok: true } : { ok: false, state: String(parsed.BackendState) };
96
+ }
97
+ catch {
98
+ return { ok: false, state: 'unreadable' };
99
+ }
100
+ }
101
+ export function findTailscale(candidates = ['tailscale', TAILSCALE_APP]) {
102
+ const bin = candidates.find((c) => spawnSync(c, ['version'], { stdio: 'ignore' }).status === 0);
103
+ if (bin === undefined)
104
+ throw new Error('metro serve --tunnel tailscale needs Tailscale on this machine.\n' +
105
+ 'macOS: brew install --cask tailscale (or the App Store app)\n' +
106
+ 'Linux: curl -fsSL https://tailscale.com/install.sh | sh\n' +
107
+ 'Then sign the machine in: tailscale up');
108
+ const status = tailscaleStatus(bin);
109
+ if (!status.ok)
110
+ throw new Error(`Tailscale is installed but this machine is not connected (${status.state}). Sign it in first: tailscale up\n` +
111
+ 'Funnel also has to be enabled once on your tailnet: https://tailscale.com/kb/1223/funnel');
112
+ return bin;
113
+ }
67
114
  function findCloudflared() {
68
115
  const found = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' });
69
116
  if (found.error !== undefined || found.status !== 0)
@@ -94,7 +141,8 @@ export function servePlan(opts) {
94
141
  ? {}
95
142
  : { METRO_RUNTIME_STORE: opts.runtime.dir, METRO_RUNTIME_MANIFEST: opts.runtime.manifest }),
96
143
  METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
97
- ...(opts.tunnel ? { METRO_TUNNEL: 'quick' } : {}),
144
+ ...(opts.tunnel === null ? {} : { METRO_TUNNEL: opts.tunnel }),
145
+ ...(opts.tailscaleBin === undefined ? {} : { METRO_TAILSCALE_BIN: opts.tailscaleBin }),
98
146
  ...(opts.owner === null ? {} : { METRO_OWNER: opts.owner }),
99
147
  },
100
148
  };
@@ -106,8 +154,9 @@ export function serve(argv) {
106
154
  throw new Error(`a metro serve daemon is already running on this machine (pid ${String(running)}). ` +
107
155
  'Stop it first: metro stop');
108
156
  requireOwner(owner);
109
- if (tunnel)
157
+ if (tunnel === 'quick')
110
158
  findCloudflared();
159
+ const tailscaleBin = tunnel === 'tailscale' ? findTailscale() : undefined;
111
160
  process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
112
- return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner }));
161
+ return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner, ...(tailscaleBin === undefined ? {} : { tailscaleBin }) }));
113
162
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.53",
3
+ "version": "0.1.0-beta.55",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,6 +44,7 @@
44
44
  "@metro-labs/whatsapp": "workspace:*",
45
45
  "@metro-labs/xmtp": "workspace:*",
46
46
  "@modelcontextprotocol/sdk": "^1.12.0",
47
+ "@noble/curves": "^1.9.7",
47
48
  "drizzle-kit": "^0.31.0",
48
49
  "drizzle-orm": "^0.44.0",
49
50
  "pino": "^9.5.0",
@@ -259,20 +259,20 @@ function methodAllowed(tgt: Routable, method: string | undefined): boolean {
259
259
  return ALLOWED[tgt.kind].includes(method ?? '');
260
260
  }
261
261
 
262
- function dispatch(
262
+ async function dispatch(
263
263
  req: IncomingMessage,
264
264
  res: ServerResponse,
265
265
  deps: AgentApiDeps,
266
266
  tgt: Routable,
267
267
  ): Promise<void> {
268
- const session = apiSession(req);
268
+ const session = await apiSession(req);
269
269
  if (!session) {
270
270
  sendJson(req, res, 401, { error: 'unauthorized' });
271
- return Promise.resolve();
271
+ return;
272
272
  }
273
273
  if (tgt.kind === 'accounts')
274
- return handleAccountRoute(req, res, deps, session, tgt.id, tgt.route);
275
- return routeAgent(req, res, deps, session, tgt);
274
+ await handleAccountRoute(req, res, deps, session, tgt.id, tgt.route);
275
+ else await routeAgent(req, res, deps, session, tgt);
276
276
  }
277
277
 
278
278
  export function handleAgentApiRequest(
@@ -122,12 +122,15 @@ export function handleAgentConnectorRequest(
122
122
  sendJson(req, res, 405, { error: 'method not allowed' });
123
123
  return true;
124
124
  }
125
- const session = apiSession(req);
126
- if (!session) {
127
- sendJson(req, res, 401, { error: 'unauthorized' });
128
- return true;
129
- }
130
- route(req, res, deps, session, tgt).catch((err: unknown) => {
125
+ apiSession(req)
126
+ .then((session) => {
127
+ if (!session) {
128
+ sendJson(req, res, 401, { error: 'unauthorized' });
129
+ return;
130
+ }
131
+ return route(req, res, deps, session, tgt);
132
+ })
133
+ .catch((err: unknown) => {
131
134
  log.warn({ err: errMsg(err) }, 'agent-api: unhandled error');
132
135
  if (!res.headersSent) sendJson(req, res, 500, { error: 'agent api failed' });
133
136
  });
@@ -1,8 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { ApiError } from './api-error.js';
3
3
  import { errMsg, log } from './log.js';
4
- import { verifySession } from './session.js';
5
- import { extractToken } from '../mcp/request-identity.js';
4
+ import { signedIdentity } from './signed-identity.js';
5
+ import { identitySubject } from './identity-registry.js';
6
6
 
7
7
  const BODY_MAX = 4 * 1024;
8
8
 
@@ -35,17 +35,10 @@ export function sendJson(
35
35
  res.end(JSON.stringify(body));
36
36
  }
37
37
 
38
- export function apiSession(req: IncomingMessage): ApiSession | null {
39
- const secret = process.env.METRO_SESSION_SECRET?.trim() ?? '';
40
- if (secret === '') return null;
41
- const token = extractToken(req) ?? '';
42
- if (token === '') return null;
43
- try {
44
- const { subject } = verifySession(token, secret);
45
- return { subject: subject.toLowerCase() };
46
- } catch {
47
- return null;
48
- }
38
+ export async function apiSession(req: IncomingMessage): Promise<ApiSession | null> {
39
+ const address = await signedIdentity(req);
40
+ const subject = address === null ? undefined : identitySubject(address);
41
+ return subject === undefined ? null : { subject };
49
42
  }
50
43
 
51
44
  export interface AgentIdentity {
@@ -7,7 +7,6 @@ import {
7
7
  } from '../stations/attachments.js';
8
8
  import {
9
9
  allowedAgents,
10
- authConfigFromEnv,
11
10
  authenticate,
12
11
  extractToken,
13
12
  } from '../mcp/request-identity.js';
@@ -21,7 +20,7 @@ function authorized(req: IncomingMessage, name: string): boolean {
21
20
  const owner = attachmentOwner(name);
22
21
  if (owner === undefined) return false;
23
22
  if (grantAllows(name, owner, extractToken(req) ?? '')) return true;
24
- return allowedAgents(authenticate(req, authConfigFromEnv()) ?? undefined).has(
23
+ return allowedAgents(authenticate(req) ?? undefined).has(
25
24
  owner,
26
25
  );
27
26
  }
@@ -6,7 +6,7 @@ import { errMsg, log, logFatalSync } from './log.js';
6
6
  import { acquireLock, isLocalMode, STATE_DIR, trainsDir } from './paths.js';
7
7
  import { installCrashGuard, markDaemonReady } from './crash-guard.js';
8
8
  import { METRO_VERSION } from './version.js';
9
- import { quickTunnelWanted, Tunnel, webhookPort } from './tunnel.js';
9
+ import { driverFor, Tunnel, tunnelKind, webhookPort } from './tunnel.js';
10
10
  import {
11
11
  localConnectHint,
12
12
  publicConnectHint,
@@ -20,7 +20,6 @@ import {
20
20
  } from './http.js';
21
21
  import { localAgentKey } from '../db/materialize.js';
22
22
  import { fileSource } from '../db/file-source.js';
23
- import { ensureLocalSessionSecret } from './local-secret.js';
24
23
  import { applyLocalOwner } from './local-owner.js';
25
24
  import { localOwner } from '../db/file-admin.js';
26
25
  import { ensureStationDeps } from './runtime-deps.js';
@@ -62,7 +61,8 @@ supervisor.onTrainEvent((env, train) => {
62
61
  });
63
62
 
64
63
  let webhookServer: Server | null = null;
65
- const tunnel = quickTunnelWanted() ? new Tunnel(webhookPort(), announcePublic) : null;
64
+ const tunnelWanted = tunnelKind();
65
+ const tunnel = tunnelWanted === null ? null : new Tunnel(driverFor(tunnelWanted, webhookPort()), announcePublic);
66
66
 
67
67
  function announcePublic(url: string): void {
68
68
  process.stderr.write(`\n${publicConnectHint(url, localOwner())}`);
@@ -74,7 +74,7 @@ setTrainCallBackend((train, action, args) =>
74
74
 
75
75
  function announceLocalEndpoint(): void {
76
76
  process.stderr.write(
77
- `\n${tunnel === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint()}`,
77
+ `\n${tunnelWanted === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint(tunnelWanted)}`,
78
78
  );
79
79
  const key = localAgentKey();
80
80
  if (key === null) {
@@ -119,7 +119,6 @@ async function sessionApis(): Promise<SessionApis> {
119
119
  prepareAccount,
120
120
  });
121
121
  const vault = await import('../db/vault.js');
122
- const users = await import('../db/users.js');
123
122
  return {
124
123
  vaultApi: {
125
124
  list: vault.listVaultForOwner,
@@ -127,14 +126,12 @@ async function sessionApis(): Promise<SessionApis> {
127
126
  get: vault.getVaultForOwner,
128
127
  remove: vault.deleteVaultForOwner,
129
128
  },
130
- siwe: { ensureUser: users.ensureUserByAddress },
131
129
  mode: hostedMode,
132
130
  };
133
131
  }
134
132
 
135
133
  async function main(): Promise<void> {
136
134
  if (isLocalMode()) {
137
- ensureLocalSessionSecret();
138
135
  applyLocalOwner();
139
136
  await materializeFrom(fileSource, { allowEmpty: true });
140
137
  }
@@ -92,12 +92,11 @@ export function handleBundleRequest(req: IncomingMessage, res: ServerResponse, d
92
92
  sendJson(req, res, 405, { error: 'method not allowed' });
93
93
  return true;
94
94
  }
95
- const session = apiSession(req);
96
- if (!session) {
97
- sendJson(req, res, 401, { error: 'unauthorized' });
98
- return true;
99
- }
100
- answer(req, deps, session.subject, path)
95
+ apiSession(req)
96
+ .then((session) => {
97
+ if (!session) throw new ApiError('unauthorized', 401);
98
+ return answer(req, deps, session.subject, path);
99
+ })
101
100
  .then((body) => {
102
101
  sendJson(req, res, isRestore ? 201 : 200, body);
103
102
  })
@@ -77,13 +77,9 @@ export function handleClaudeRequest(
77
77
  sendJson(req, res, 405, { error: 'method not allowed' });
78
78
  return true;
79
79
  }
80
- const session = apiSession(req);
81
- if (!session) {
82
- sendJson(req, res, 401, { error: 'unauthorized' });
83
- return true;
84
- }
85
- Promise.resolve()
86
- .then(() => {
80
+ apiSession(req)
81
+ .then((session) => {
82
+ if (!session) throw new ApiError('unauthorized', 401);
87
83
  deps.authorize(session.subject);
88
84
  return answer(req.method ?? 'GET', path, new URLSearchParams(search), (deps.dir ?? claudeDir)());
89
85
  })
@@ -35,5 +35,7 @@ export function publicConnectHint(url: string, owner: string | null): string {
35
35
  ].join('\n');
36
36
  }
37
37
 
38
- export const tunnelPendingHint = (): string =>
39
- 'Bringing up a public address through a Cloudflare quick tunnel…\n';
38
+ export const tunnelPendingHint = (kind: 'quick' | 'tailscale'): string =>
39
+ kind === 'quick'
40
+ ? 'Bringing up a public address through a Cloudflare quick tunnel…\n'
41
+ : 'Publishing this daemon through Tailscale Funnel…\n';
@@ -265,7 +265,7 @@ const ALLOWED: Record<Routable['kind'], string[]> = {
265
265
  rename: ['POST'],
266
266
  };
267
267
 
268
- function dispatch(
268
+ async function dispatch(
269
269
  req: IncomingMessage,
270
270
  res: ServerResponse,
271
271
  deps: ConnectorApiDeps,
@@ -273,14 +273,14 @@ function dispatch(
273
273
  ): Promise<void> {
274
274
  if (tgt.kind === 'callback') {
275
275
  handleCallback(req, res, deps);
276
- return Promise.resolve();
276
+ return;
277
277
  }
278
- const session = apiSession(req);
278
+ const session = await apiSession(req);
279
279
  if (!session) {
280
280
  sendJson(req, res, 401, { error: 'unauthorized' });
281
- return Promise.resolve();
281
+ return;
282
282
  }
283
- return route(req, res, deps, session, tgt);
283
+ await route(req, res, deps, session, tgt);
284
284
  }
285
285
 
286
286
  export function handleConnectorApiRequest(
@@ -4,7 +4,7 @@ import {
4
4
  type OAuthAuth,
5
5
  type OAuthTokens,
6
6
  } from './connector-verify.js';
7
- import { validateReturnTo } from './session-config.js';
7
+ import { validateReturnTo } from './return-to.js';
8
8
  import {
9
9
  authorizeUrl,
10
10
  exchangeCode,
@@ -27,7 +27,7 @@ import {
27
27
  } from './tunnel.js';
28
28
  import { attachmentEventUrl, handleAttachRequest } from './attach-serve.js';
29
29
  import { webhookEntry } from '@metro-labs/webhook';
30
- import { handleSiweAuthRequest } from './siwe-routes.js';
30
+ import { handleIdentityRequest } from './identity-routes.js';
31
31
  import { handleModeRequest } from './mode-api.js';
32
32
  import { handleUploadRequest } from './upload-api.js';
33
33
  import {
@@ -328,7 +328,7 @@ function handleSignInRoutes(
328
328
  res: ServerResponse,
329
329
  apis: SessionApis,
330
330
  ): boolean {
331
- if (apis.siwe !== undefined && handleSiweAuthRequest(req, res, apis.siwe)) return true;
331
+ if (apis.identity !== undefined && handleIdentityRequest(req, res, apis.identity)) return true;
332
332
  return apis.mode !== undefined && handleModeRequest(req, res, apis.mode);
333
333
  }
334
334
 
@@ -0,0 +1,33 @@
1
+ import { hkdfSync } from 'node:crypto';
2
+ import { secp256k1 } from '@noble/curves/secp256k1';
3
+ import { mapHashToField } from '@noble/curves/abstract/modular';
4
+ import { privateKeyToAccount } from 'viem/accounts';
5
+
6
+ export const ENCRYPTION_KEY_TYPED_DATA = {
7
+ domain: { name: 'metro', version: '1' },
8
+ types: {
9
+ EncryptionKey: [
10
+ { name: 'purpose', type: 'string' },
11
+ { name: 'keyVersion', type: 'uint256' },
12
+ ],
13
+ },
14
+ primaryType: 'EncryptionKey',
15
+ message: { purpose: 'encryption-key', keyVersion: 1n },
16
+ } as const;
17
+
18
+ const KEY_SALT = 'metro';
19
+ const IDENTITY_INFO = 'vault-secp256k1';
20
+ const KEY_HASH_BYTES = 48;
21
+
22
+ const toHex = (bytes: Uint8Array): `0x${string}` =>
23
+ `0x${[...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')}`;
24
+
25
+ export function deriveIdentityKey(signature: `0x${string}`): `0x${string}` {
26
+ const ikm = Buffer.from(signature.slice(2), 'hex');
27
+ const hash = new Uint8Array(hkdfSync('sha256', ikm, KEY_SALT, IDENTITY_INFO, KEY_HASH_BYTES));
28
+ return toHex(mapHashToField(hash, secp256k1.CURVE.n));
29
+ }
30
+
31
+ export function deriveIdentityAddress(signature: `0x${string}`): string {
32
+ return privateKeyToAccount(deriveIdentityKey(signature)).address.toLowerCase();
33
+ }
@@ -0,0 +1,13 @@
1
+ const subjects = new Map<string, string>();
2
+
3
+ export function authorizeIdentity(address: string, subject: string): void {
4
+ subjects.set(address.toLowerCase(), subject.toLowerCase());
5
+ }
6
+
7
+ export function identitySubject(address: string): string | undefined {
8
+ return subjects.get(address.toLowerCase());
9
+ }
10
+
11
+ export function resetIdentities(): void {
12
+ subjects.clear();
13
+ }
@@ -0,0 +1,54 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { verifyTypedData } from 'viem';
3
+ import { log } from './log.js';
4
+ import { ApiError } from './api-error.js';
5
+ import { apiFailure, bodyField, cors, readJsonBody, sendJson } from './api-http.js';
6
+ import { ENCRYPTION_KEY_TYPED_DATA, deriveIdentityAddress } from './identity-key.js';
7
+ import { authorizeIdentity } from './identity-registry.js';
8
+
9
+ const PATH = '/auth/identity';
10
+ const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
11
+
12
+ export interface IdentityRouteDeps {
13
+ owner: () => string | null;
14
+ }
15
+
16
+ async function register(req: IncomingMessage, deps: IdentityRouteDeps): Promise<{ address: string; owner: string }> {
17
+ const signature = bodyField(await readJsonBody(req), 'signature');
18
+ if (typeof signature !== 'string' || !SIGNATURE_RE.test(signature))
19
+ throw new ApiError('signature is required: the EncryptionKey signature of the owner wallet', 400);
20
+ const owner = deps.owner();
21
+ if (owner === null)
22
+ throw new ApiError('this machine has no owner yet: start it with metro serve --owner <address>', 403);
23
+ const ok = await verifyTypedData({
24
+ ...ENCRYPTION_KEY_TYPED_DATA,
25
+ address: owner as `0x${string}`,
26
+ signature: signature as `0x${string}`,
27
+ }).catch(() => false);
28
+ if (!ok) throw new ApiError('this machine belongs to another wallet', 403);
29
+ const address = deriveIdentityAddress(signature as `0x${string}`);
30
+ authorizeIdentity(address, owner);
31
+ log.info({ owner, identity: address }, 'identity: owner signed in');
32
+ return { address, owner };
33
+ }
34
+
35
+ export function handleIdentityRequest(req: IncomingMessage, res: ServerResponse, deps: IdentityRouteDeps): boolean {
36
+ const path = (req.url ?? '').split('?')[0] ?? '';
37
+ if (path !== PATH) return false;
38
+ if (req.method === 'OPTIONS') {
39
+ res.writeHead(204, cors(req)).end();
40
+ return true;
41
+ }
42
+ if (req.method !== 'POST') {
43
+ sendJson(req, res, 405, { error: 'method not allowed' });
44
+ return true;
45
+ }
46
+ register(req, deps)
47
+ .then((body) => {
48
+ sendJson(req, res, 200, body);
49
+ })
50
+ .catch((err: unknown) => {
51
+ apiFailure(req, res, err, 'identity');
52
+ });
53
+ return true;
54
+ }
@@ -44,7 +44,6 @@ import {
44
44
  localOwnedAgentOrThrow,
45
45
  localOwner,
46
46
  localResetAgentKey,
47
- ownerSignIn,
48
47
  readLocalAgentFile,
49
48
  } from '../db/file-admin.js';
50
49
  import { listAgentFiles, readAgentFile } from '../db/file-source.js';
@@ -191,7 +190,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
191
190
  localCli,
192
191
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
193
192
  updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
194
- siwe: { ensureUser: ownerSignIn },
193
+ identity: { owner: localOwner },
195
194
  mode: localModeInfo,
196
195
  };
197
196
  }
@@ -1,4 +1,3 @@
1
- export const SESSION_TTL_SEC = 30 * 24 * 3600;
2
1
  const PREVIEW = /^([a-z0-9-]+--)?metro-ui\.netlify\.app$/;
3
2
 
4
3
  export function allowedWebHost(host: string): boolean {
@@ -1,5 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { apiSession, cors, sendJson } from './api-http.js';
2
+ import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
3
3
 
4
4
  const PATH = '/api/session';
5
5
 
@@ -17,11 +17,13 @@ export function handleSessionApiRequest(
17
17
  sendJson(req, res, 405, { error: 'method not allowed' });
18
18
  return true;
19
19
  }
20
- const session = apiSession(req);
21
- if (!session) {
22
- sendJson(req, res, 401, { error: 'unauthorized' });
23
- return true;
24
- }
25
- sendJson(req, res, 200, { subject: session.subject });
20
+ apiSession(req)
21
+ .then((session) => {
22
+ if (!session) sendJson(req, res, 401, { error: 'unauthorized' });
23
+ else sendJson(req, res, 200, { subject: session.subject });
24
+ })
25
+ .catch((err: unknown) => {
26
+ apiFailure(req, res, err, 'session');
27
+ });
26
28
  return true;
27
29
  }
@@ -10,7 +10,7 @@ import {
10
10
  handleAgentConnectorRequest,
11
11
  type AgentConnectorApiDeps,
12
12
  } from './agent-connector-api.js';
13
- import type { SiweRouteDeps } from './siwe-routes.js';
13
+ import type { IdentityRouteDeps } from './identity-routes.js';
14
14
  import { handleLocalCliRequest, type LocalCliDeps } from './local-cli-api.js';
15
15
  import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
16
16
  import type { ModeInfo } from './mode-api.js';
@@ -28,7 +28,7 @@ export interface SessionApis {
28
28
  claudeApi?: ClaudeApiDeps;
29
29
  connectorApi?: ConnectorApiDeps;
30
30
  relayApi?: RelayApiDeps;
31
- siwe?: SiweRouteDeps;
31
+ identity?: IdentityRouteDeps;
32
32
  mode?: () => ModeInfo;
33
33
  }
34
34
 
@@ -0,0 +1,36 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { verifyMessage } from 'viem';
3
+ import { normalizeAddress } from '../db/address.js';
4
+
5
+ export const AUTH_SCHEME = 'Metro';
6
+ const SKEW_MS = 5 * 60_000;
7
+ const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
8
+
9
+ export const identityChallenge = (method: string, path: string, at: number): string =>
10
+ `metro-auth\n${method} ${path}\n${String(at)}`;
11
+
12
+ export interface SignedHeader {
13
+ address: string;
14
+ at: number;
15
+ signature: `0x${string}`;
16
+ }
17
+
18
+ export function parseIdentityHeader(header: string): SignedHeader | null {
19
+ const [scheme, rawAddress, rawAt, signature] = header.trim().split(/\s+/);
20
+ if (scheme !== AUTH_SCHEME || rawAddress === undefined || rawAt === undefined || signature === undefined) return null;
21
+ const address = normalizeAddress(rawAddress);
22
+ const at = Number(rawAt);
23
+ if (address === null || !Number.isFinite(at) || !SIGNATURE_RE.test(signature)) return null;
24
+ return { address, at, signature: signature as `0x${string}` };
25
+ }
26
+
27
+ export async function signedIdentity(req: IncomingMessage, now = Date.now()): Promise<string | null> {
28
+ const proof = parseIdentityHeader(req.headers.authorization ?? '');
29
+ if (proof === null || Math.abs(now - proof.at) > SKEW_MS) return null;
30
+ const path = (req.url ?? '').split('?')[0] ?? '';
31
+ const message = identityChallenge(req.method ?? '', path, proof.at);
32
+ const ok = await verifyMessage({ address: proof.address as `0x${string}`, message, signature: proof.signature }).catch(
33
+ () => false,
34
+ );
35
+ return ok ? proof.address : null;
36
+ }