@stage-labs/metro 0.1.0-beta.50 → 0.1.0-beta.52

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 (51) hide show
  1. package/dist/api.js +8 -142
  2. package/dist/cli.js +27 -131
  3. package/dist/control.js +5 -104
  4. package/dist/plugin.js +2 -4
  5. package/dist/runtime.js +18 -72
  6. package/dist/serve.js +3 -2
  7. package/dist/tail.js +7 -8
  8. package/dist/update.js +13 -1
  9. package/package.json +1 -1
  10. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -2
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +3 -35
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +7 -22
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +4 -54
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +1 -2
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +29 -208
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +0 -4
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +2 -37
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +0 -3
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +4 -6
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +3 -14
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +0 -81
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +2 -2
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/update-api.ts +119 -0
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-api.ts +42 -12
  25. package/runtime/node_modules/@metro-labs/mcp/src/db/account-attach.ts +1 -73
  26. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +1 -172
  27. package/runtime/node_modules/@metro-labs/mcp/src/db/client.ts +0 -5
  28. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +2 -89
  29. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +3 -262
  30. package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +1 -1
  31. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +2 -140
  32. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +1 -89
  33. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +14 -41
  34. package/runtime/node_modules/@metro-labs/mcp/src/db/vault.ts +5 -5
  35. package/runtime/node_modules/@metro-labs/whatsapp/package.json +0 -2
  36. package/runtime/runtime.json +1 -1
  37. package/dist/prompt.js +0 -61
  38. package/dist/store.js +0 -93
  39. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-import.ts +0 -76
  40. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +0 -20
  41. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +0 -141
  42. package/runtime/node_modules/@metro-labs/mcp/src/daemon/import-api.ts +0 -68
  43. package/runtime/node_modules/@metro-labs/mcp/src/daemon/pair-codes.ts +0 -58
  44. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +0 -214
  45. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +0 -139
  46. package/runtime/node_modules/@metro-labs/mcp/src/daemon/runtime-source.ts +0 -105
  47. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +0 -143
  48. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +0 -309
  49. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +0 -24
  50. package/runtime/node_modules/@metro-labs/mcp/src/db/runtimes.ts +0 -105
  51. package/runtime/node_modules/@metro-labs/mcp/src/db/whatsapp-login.ts +0 -18
@@ -10,10 +10,8 @@ import {
10
10
  readJsonBody,
11
11
  sendJson,
12
12
  type ApiSession,
13
- type Fence,
14
13
  } from './api-http.js';
15
14
  import { parseId } from '../db/ids.js';
16
- import type { AgentConnectors } from '../db/agent-connectors.js';
17
15
  import type { RelayServerEntry } from './connector-json.js';
18
16
  import {
19
17
  handleCallback,
@@ -39,8 +37,6 @@ export interface ConnectorApiDeps extends OAuthRouteDeps {
39
37
  listConnectors: (subject: string, project: string) => Promise<Connector[]>;
40
38
  connectorSummariesByIds: (ids: string[]) => Promise<ConnectorSummary[]>;
41
39
  connectorNamesByIds: (ids: string[]) => Promise<RelayServerEntry[]>;
42
- agentConnectors: (subject: string, agentId: string) => Promise<AgentConnectors>;
43
- fenceRuntime: Fence;
44
40
  createConnector: (
45
41
  subject: string,
46
42
  project: string,
@@ -1,4 +1,3 @@
1
- import { hostname } from 'node:os';
2
1
  import { ApiError } from './api-error.js';
3
2
  import { errMsg, log } from './log.js';
4
3
  import { AttachSessions } from './attach-session.js';
@@ -7,10 +6,8 @@ import { ATTACHABLE, type AccountApiDeps } from './account-api.js';
7
6
  import type { AgentConnectorApiDeps } from './agent-connector-api.js';
8
7
  import type { SessionApis } from './session-apis.js';
9
8
  import type { ModeInfo } from './mode-api.js';
10
- import type { ImportApiDeps } from './import-api.js';
11
9
  import { loadedAgentOf, type AgentBundle, type BundleApiDeps } from './bundle-api.js';
12
10
  import { METRO_VERSION } from './version.js';
13
- import { fetchAgentWithCode } from './agent-import.js';
14
11
  import type { ConnectorApiDeps } from './connector-api.js';
15
12
  import { allowLocalConnectors } from './connector-url.js';
16
13
  import { keyIdentity, type LocalCliDeps } from './local-cli-api.js';
@@ -55,7 +52,7 @@ import type { StationName } from '../db/schema.js';
55
52
 
56
53
  export interface LocalModeDeps {
57
54
  syncStations: (station: StationName) => Promise<void>;
58
- fetchAgent?: (code: string, label: string) => ReturnType<typeof fetchAgentWithCode>;
55
+ restart: () => void;
59
56
  closeAgentSession: (id: string) => Promise<boolean>;
60
57
  gatherAccounts: AgentApiDeps['gatherAccounts'];
61
58
  capabilities: AgentApiDeps['capabilities'];
@@ -63,11 +60,6 @@ export interface LocalModeDeps {
63
60
  prepareAccount: AccountApiDeps['prepareAccount'];
64
61
  }
65
62
 
66
- const notHere = (what: string): Promise<never> =>
67
- Promise.reject(
68
- new ApiError(`${what} are managed on metro.box, not on a local daemon`, 400),
69
- );
70
-
71
63
  function attachSessions(deps: LocalModeDeps): AttachSessions {
72
64
  return new AttachSessions({
73
65
  authorize: async (owner) => {
@@ -106,8 +98,6 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
106
98
  capabilities: deps.capabilities,
107
99
  attachable: ATTACHABLE.filter((s) => s !== 'webhook'),
108
100
  liveness: deps.liveness,
109
- releaseRuntime: () => notHere('runtime leases'),
110
- runtimes: () => Promise.resolve(new Map()),
111
101
  connectorIds: connectorIdsOfLocalAgents,
112
102
  prepareAccount: deps.prepareAccount,
113
103
  attachAccount: localAttachAccount,
@@ -124,16 +114,12 @@ const agentConnectorApi: AgentConnectorApiDeps = {
124
114
  agentConnectors: localAgentConnectors,
125
115
  addConnector: localAddConnector,
126
116
  removeConnector: localRemoveConnector,
127
- mintCode: () =>
128
- Promise.reject(new ApiError('a local daemon has no pairing codes', 400)),
129
117
  };
130
118
 
131
119
  const connectorApi: ConnectorApiDeps = {
132
120
  listConnectors: localListConnectors,
133
121
  connectorSummariesByIds: (ids) => localConnectorSummariesByIds(ids),
134
122
  connectorNamesByIds: (ids) => localConnectorNamesByIds(ids),
135
- agentConnectors: localAgentConnectors,
136
- fenceRuntime: () => Promise.resolve(),
137
123
  createConnector: localCreateConnector,
138
124
  verifyConnector: localVerifyConnector,
139
125
  disconnectConnector: localDisconnectConnector,
@@ -146,7 +132,6 @@ const connectorApi: ConnectorApiDeps = {
146
132
 
147
133
  const relayApi: RelayApiDeps = {
148
134
  target: (agentId, connectorId, force) => localRelayTarget(agentId, connectorId, force),
149
- fence: () => Promise.resolve(),
150
135
  identify: keyIdentity,
151
136
  };
152
137
 
@@ -191,26 +176,6 @@ function bundleApi(deps: LocalModeDeps): BundleApiDeps {
191
176
  };
192
177
  }
193
178
 
194
- function importApi(deps: LocalModeDeps): ImportApiDeps {
195
- const fetchAgent = deps.fetchAgent ?? fetchAgentWithCode;
196
- return {
197
- importAgent: async (subject, code) => {
198
- assertLocalOwner(subject);
199
- const agent = await fetchAgent(code, hostname());
200
- const made = await localImportAgent(subject, agent);
201
- const connectors = localImportConnectors(agent.connectors ?? []);
202
- for (const station of new Set(agent.accounts.map((a) => a.station)))
203
- await deps.syncStations(station).catch((err: unknown) => {
204
- log.warn(
205
- { station, err: errMsg(err) },
206
- 'import: station reload failed, the change lands at the next boot',
207
- );
208
- });
209
- return { ...made, connectors };
210
- },
211
- };
212
- }
213
-
214
179
  function localModeInfo(): ModeInfo {
215
180
  return { mode: 'local', owner: localOwner(), project: LOCAL_PROJECT_ID, version: METRO_VERSION };
216
181
  }
@@ -220,12 +185,12 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
220
185
  return {
221
186
  agentApi: agentApi(deps),
222
187
  agentConnectorApi,
223
- importApi: importApi(deps),
224
188
  bundleApi: bundleApi(deps),
225
189
  connectorApi,
226
190
  relayApi,
227
191
  localCli,
228
192
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
193
+ updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
229
194
  siwe: { ensureUser: ownerSignIn },
230
195
  mode: localModeInfo,
231
196
  };
@@ -16,9 +16,6 @@ export const STATE_DIR =
16
16
  process.env.METRO_STATE_DIR ?? join(homedir(), '.cache', 'metro');
17
17
  mkdirSync(STATE_DIR, { recursive: true });
18
18
 
19
- export const configDir = (): string =>
20
- process.env.METRO_CONFIG_DIR ??
21
- join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'metro');
22
19
 
23
20
  export function trainsDir(): string {
24
21
  const env = process.env.METRO_TRAINS_DIR?.trim();
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { RelayTarget } from '../db/connector-relay.js';
3
- import { agentIdentity, assertLease, type AgentIdentity, type Fence } from './api-http.js';
3
+ import type { AgentIdentity } from './api-http.js';
4
4
  import { ApiError } from './api-error.js';
5
5
  import { errMsg, log } from './log.js';
6
6
 
@@ -10,8 +10,7 @@ export interface RelayApiDeps {
10
10
  connectorId: string,
11
11
  force: boolean,
12
12
  ) => Promise<RelayTarget>;
13
- fence: Fence;
14
- identify?: (req: IncomingMessage) => AgentIdentity | null;
13
+ identify: (req: IncomingMessage) => AgentIdentity | null;
15
14
  }
16
15
 
17
16
  const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
@@ -264,13 +263,12 @@ function dispatch(
264
263
  connectorId: string,
265
264
  deps: RelayApiDeps,
266
265
  ): void {
267
- const who = (deps.identify ?? agentIdentity)(req);
266
+ const who = deps.identify(req);
268
267
  if (who === null) {
269
268
  answer(res, 401, { error: 'unauthorized' });
270
269
  return;
271
270
  }
272
- assertLease(who, deps.fence)
273
- .then(() => relayExchange(req, res, who.agentId, connectorId, deps))
271
+ relayExchange(req, res, who.agentId, connectorId, deps)
274
272
  .catch((err: unknown) => {
275
273
  if (err instanceof ApiError) {
276
274
  answer(res, err.status, { error: err.message });
@@ -5,36 +5,28 @@ import {
5
5
  handleConnectorApiRequest,
6
6
  type ConnectorApiDeps,
7
7
  } from './connector-api.js';
8
- import { handleCliPairRequest } from './cli-pair-api.js';
9
- import { handleRunApiRequest, type RunApiDeps } from './run-api.js';
10
8
  import type { RelayApiDeps } from './relay.js';
11
- import {
12
- handleProjectApiRequest,
13
- type ProjectApiDeps,
14
- } from './project-api.js';
15
9
  import {
16
10
  handleAgentConnectorRequest,
17
11
  type AgentConnectorApiDeps,
18
12
  } from './agent-connector-api.js';
19
13
  import type { SiweRouteDeps } from './siwe-routes.js';
20
- import { handleImportRequest, type ImportApiDeps } from './import-api.js';
21
14
  import { handleLocalCliRequest, type LocalCliDeps } from './local-cli-api.js';
22
15
  import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
23
16
  import type { ModeInfo } from './mode-api.js';
24
17
  import { handleVaultApiRequest, type VaultApiDeps } from './vault-api.js';
25
18
  import { handleBundleRequest, type BundleApiDeps } from './bundle-api.js';
19
+ import { handleUpdateRequest, type UpdateApiDeps } from './update-api.js';
26
20
 
27
21
  export interface SessionApis {
28
22
  agentApi?: AgentApiDeps;
29
23
  agentConnectorApi?: AgentConnectorApiDeps;
30
- importApi?: ImportApiDeps;
31
24
  bundleApi?: BundleApiDeps;
32
25
  vaultApi?: VaultApiDeps;
26
+ updateApi?: UpdateApiDeps;
33
27
  localCli?: LocalCliDeps;
34
28
  claudeApi?: ClaudeApiDeps;
35
29
  connectorApi?: ConnectorApiDeps;
36
- projectApi?: ProjectApiDeps;
37
- runApi?: RunApiDeps;
38
30
  relayApi?: RelayApiDeps;
39
31
  siwe?: SiweRouteDeps;
40
32
  mode?: () => ModeInfo;
@@ -51,13 +43,10 @@ export function handleSessionApis(
51
43
  const routes: (() => boolean)[] = [
52
44
  () => handleSessionApiRequest(req, res),
53
45
  ...when(apis.localCli, (d) => handleLocalCliRequest(req, res, d)),
54
- ...when(apis.runApi, (d) => handleRunApiRequest(req, res, d)),
55
- ...when(apis.projectApi, (d) => handleProjectApiRequest(req, res, d)),
56
- ...when(apis.connectorApi, (d) => handleCliPairRequest(req, res, d)),
57
46
  ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
58
- ...when(apis.importApi, (d) => handleImportRequest(req, res, d)),
59
47
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
60
48
  ...when(apis.vaultApi, (d) => handleVaultApiRequest(req, res, d)),
49
+ ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
61
50
  ...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
62
51
  ...when(apis.agentConnectorApi, (d) => handleAgentConnectorRequest(req, res, d)),
63
52
  ...when(apis.agentApi, (d) => handleAgentApiRequest(req, res, d)),
@@ -58,17 +58,6 @@ interface SessionClaims {
58
58
  agentIds: string[];
59
59
  }
60
60
 
61
- export interface AgentClaims {
62
- subject: string;
63
- agentId: string;
64
- }
65
-
66
- export interface RunClaims {
67
- subject: string;
68
- agentId: string;
69
- runtimeId: string;
70
- }
71
-
72
61
  export function signSession(
73
62
  claims: SessionClaims,
74
63
  secret: string,
@@ -105,73 +94,3 @@ export function verifySession(
105
94
  throw new SessionError('malformed session');
106
95
  return { subject: p.sub, agentIds: ids as string[] };
107
96
  }
108
-
109
- export function signAgentToken(
110
- claims: AgentClaims,
111
- secret: string,
112
- opts: { ttlSec?: number; now?: number } = {},
113
- ): string {
114
- const iat = nowSec(opts.now);
115
- return sign(
116
- {
117
- typ: 'agent',
118
- sub: claims.subject,
119
- agent: claims.agentId,
120
- iat,
121
- exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
122
- },
123
- secret,
124
- );
125
- }
126
-
127
- export function verifyAgentToken(
128
- token: string,
129
- secret: string,
130
- now?: number,
131
- ): AgentClaims {
132
- const p = verify(token, secret);
133
- if (p.typ !== 'agent') throw new SessionError('wrong token type');
134
- if (typeof p.exp !== 'number' || p.exp < nowSec(now))
135
- throw new SessionError('agent token expired');
136
- if (typeof p.sub !== 'string' || typeof p.agent !== 'string')
137
- throw new SessionError('malformed agent token');
138
- return { subject: p.sub, agentId: p.agent };
139
- }
140
-
141
- export function signRunToken(
142
- claims: RunClaims,
143
- secret: string,
144
- opts: { ttlSec?: number; now?: number } = {},
145
- ): string {
146
- const iat = nowSec(opts.now);
147
- return sign(
148
- {
149
- typ: 'run',
150
- sub: claims.subject,
151
- agent: claims.agentId,
152
- rt: claims.runtimeId,
153
- iat,
154
- exp: iat + (opts.ttlSec ?? 365 * 24 * 3600),
155
- },
156
- secret,
157
- );
158
- }
159
-
160
- export function verifyRunToken(
161
- token: string,
162
- secret: string,
163
- now?: number,
164
- ): RunClaims {
165
- const p = verify(token, secret);
166
- if (p.typ !== 'run') throw new SessionError('wrong token type');
167
- if (typeof p.exp !== 'number' || p.exp < nowSec(now))
168
- throw new SessionError('run token expired');
169
- if (
170
- typeof p.sub !== 'string' ||
171
- typeof p.agent !== 'string' ||
172
- typeof p.rt !== 'string' ||
173
- !ID_RE.test(p.agent)
174
- )
175
- throw new SessionError('malformed run token');
176
- return { subject: p.sub, agentId: p.agent, runtimeId: p.rt };
177
- }
@@ -11,7 +11,7 @@ import { mintNonce, takeNonce } from './siwe-nonces.js';
11
11
  import { verifySiweLogin } from './siwe-auth.js';
12
12
  import { signSession } from './session.js';
13
13
  import { SESSION_TTL_SEC } from './session-config.js';
14
- import { ensureUserWithProject } from '../db/projects.js';
14
+ import { ensureUserByAddress } from '../db/users.js';
15
15
 
16
16
  const NONCE_PATH = '/auth/siwe/nonce';
17
17
  const VERIFY_PATH = '/auth/siwe/verify';
@@ -20,7 +20,7 @@ export interface SiweRouteDeps {
20
20
  ensureUser: (address: string) => Promise<string>;
21
21
  }
22
22
 
23
- const defaultDeps: SiweRouteDeps = { ensureUser: ensureUserWithProject };
23
+ const defaultDeps: SiweRouteDeps = { ensureUser: ensureUserByAddress };
24
24
 
25
25
  async function handleVerify(
26
26
  req: IncomingMessage,
@@ -0,0 +1,119 @@
1
+ import { execFile } from 'node:child_process';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { errMsg, log } from './log.js';
4
+ import { ApiError } from './api-error.js';
5
+ import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
6
+ import { isRecord } from './is-record.js';
7
+ import { METRO_VERSION } from './version.js';
8
+
9
+ const PATH = '/api/update';
10
+ const CHECK_TTL_MS = 10 * 60_000;
11
+ const CHECK_TIMEOUT_MS = 30_000;
12
+ const UPDATE_TIMEOUT_MS = 5 * 60_000;
13
+ const RESTART_DELAY_MS = 500;
14
+ const OUTPUT_TAIL = 800;
15
+
16
+ export interface UpdateCheck {
17
+ current: string;
18
+ latest: string;
19
+ newer: boolean;
20
+ }
21
+
22
+ export interface UpdateResult {
23
+ updated: boolean;
24
+ version: string;
25
+ restarting: boolean;
26
+ }
27
+
28
+ export interface UpdateApiDeps {
29
+ authorize: (subject: string) => void;
30
+ restart: () => void;
31
+ cliBin?: () => string;
32
+ }
33
+
34
+ const cliBinFromEnv = (): string => process.env.METRO_CLI_BIN?.trim() ?? '';
35
+
36
+ function runCli(bin: string, args: string[], timeout: number): Promise<{ stdout: string; stderr: string }> {
37
+ return new Promise((resolve, reject) => {
38
+ execFile(process.execPath, [bin, ...args], { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
39
+ if (err === null) resolve({ stdout, stderr });
40
+ else reject(new ApiError(`metro update failed: ${(stderr || err.message).trim().slice(-OUTPUT_TAIL)}`, 502));
41
+ });
42
+ });
43
+ }
44
+
45
+ let cached: { at: number; check: UpdateCheck } | null = null;
46
+
47
+ function parseCheck(raw: string): UpdateCheck {
48
+ let body: unknown;
49
+ try {
50
+ body = JSON.parse(raw.trim().split('\n').at(-1) ?? '');
51
+ } catch {
52
+ throw new ApiError('metro update --check returned no report', 502);
53
+ }
54
+ if (!isRecord(body) || typeof body.current !== 'string' || typeof body.latest !== 'string')
55
+ throw new ApiError('metro update --check returned an unexpected report', 502);
56
+ return { current: body.current, latest: body.latest, newer: body.newer === true };
57
+ }
58
+
59
+ async function check(bin: string, now = Date.now()): Promise<UpdateCheck> {
60
+ if (cached !== null && now - cached.at < CHECK_TTL_MS) return cached.check;
61
+ const out = await runCli(bin, ['update', '--check'], CHECK_TIMEOUT_MS);
62
+ cached = { at: now, check: parseCheck(out.stdout) };
63
+ return cached.check;
64
+ }
65
+
66
+ async function apply(bin: string, deps: UpdateApiDeps): Promise<UpdateResult> {
67
+ const before = await check(bin);
68
+ if (!before.newer) return { updated: false, version: before.current, restarting: false };
69
+ const out = await runCli(bin, ['update'], UPDATE_TIMEOUT_MS);
70
+ cached = null;
71
+ log.info({ from: before.current, to: before.latest, output: out.stderr.trim().slice(-OUTPUT_TAIL) }, 'update-api: metro updated, restarting');
72
+ setTimeout(() => {
73
+ deps.restart();
74
+ }, RESTART_DELAY_MS).unref();
75
+ return { updated: true, version: before.latest, restarting: true };
76
+ }
77
+
78
+ function binOrThrow(deps: UpdateApiDeps): string {
79
+ const bin = (deps.cliBin ?? cliBinFromEnv)();
80
+ if (bin === '')
81
+ throw new ApiError('this daemon was not started by metro serve, so it cannot update itself: run metro update on the machine', 400);
82
+ return bin;
83
+ }
84
+
85
+ export function handleUpdateRequest(req: IncomingMessage, res: ServerResponse, deps: UpdateApiDeps): boolean {
86
+ const path = (req.url ?? '').split('?')[0] ?? '';
87
+ if (path !== PATH) return false;
88
+ if (req.method === 'OPTIONS') {
89
+ res.writeHead(204, cors(req)).end();
90
+ return true;
91
+ }
92
+ if (req.method !== 'GET' && req.method !== 'POST') {
93
+ sendJson(req, res, 405, { error: 'method not allowed' });
94
+ return true;
95
+ }
96
+ const session = apiSession(req);
97
+ if (!session) {
98
+ sendJson(req, res, 401, { error: 'unauthorized' });
99
+ return true;
100
+ }
101
+ Promise.resolve()
102
+ .then(async (): Promise<unknown> => {
103
+ deps.authorize(session.subject);
104
+ const bin = binOrThrow(deps);
105
+ if (req.method === 'GET') return { ...(await check(bin)), running: METRO_VERSION };
106
+ return apply(bin, deps);
107
+ })
108
+ .then((body) => {
109
+ sendJson(req, res, 200, body);
110
+ })
111
+ .catch((err: unknown) => {
112
+ if (err instanceof ApiError) apiFailure(req, res, err, 'update-api');
113
+ else {
114
+ log.warn({ err: errMsg(err) }, 'update-api: request failed');
115
+ if (!res.headersSent) sendJson(req, res, 500, { error: 'update api failed' });
116
+ }
117
+ });
118
+ return true;
119
+ }
@@ -1,11 +1,42 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from './log.js';
3
- import { apiFailure, apiSession, cors, readJsonBody, sendJson, type ApiSession } from './api-http.js';
3
+ import { verifyMessage } from 'viem';
4
+ import { apiFailure, cors, readJsonBody, sendJson } from './api-http.js';
5
+ import { normalizeAddress } from '../db/users.js';
4
6
  import { parseId } from '../db/ids.js';
5
7
  import { ENVELOPE_MAX, type VaultBundle, type VaultEntry } from '../db/vault.js';
6
8
 
7
9
  const PREFIX = '/api/vault';
8
10
  const BODY_MAX = ENVELOPE_MAX + 64 * 1024;
11
+ const SKEW_MS = 5 * 60_000;
12
+ const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
13
+
14
+ export const vaultChallenge = (method: string, path: string, at: number): string =>
15
+ `metro-vault\n${method} ${path}\n${String(at)}`;
16
+
17
+ interface VaultProof {
18
+ address: string;
19
+ at: number;
20
+ signature: `0x${string}`;
21
+ }
22
+
23
+ function parseVaultHeader(header: string): VaultProof | null {
24
+ const [scheme, rawAddress, rawAt, signature] = header.trim().split(/\s+/);
25
+ if (scheme !== 'Vault' || rawAddress === undefined || rawAt === undefined || signature === undefined) return null;
26
+ const address = normalizeAddress(rawAddress);
27
+ const at = Number(rawAt);
28
+ if (address === null || !Number.isFinite(at) || !SIGNATURE_RE.test(signature)) return null;
29
+ return { address, at, signature: signature as `0x${string}` };
30
+ }
31
+
32
+ export async function vaultIdentity(req: IncomingMessage, now = Date.now()): Promise<string | null> {
33
+ const proof = parseVaultHeader(req.headers.authorization ?? '');
34
+ if (proof === null || Math.abs(now - proof.at) > SKEW_MS) return null;
35
+ const path = (req.url ?? '').split('?')[0] ?? '';
36
+ const message = vaultChallenge(req.method ?? '', path, proof.at);
37
+ const ok = await verifyMessage({ address: proof.address as `0x${string}`, message, signature: proof.signature }).catch(() => false);
38
+ return ok ? proof.address : null;
39
+ }
9
40
 
10
41
  export interface VaultApiDeps {
11
42
  list: (subject: string) => Promise<VaultEntry[]>;
@@ -30,18 +61,22 @@ async function route(
30
61
  req: IncomingMessage,
31
62
  res: ServerResponse,
32
63
  deps: VaultApiDeps,
33
- session: ApiSession,
34
64
  tgt: { kind: 'index' } | { kind: 'bundle'; id: string },
35
65
  ): Promise<void> {
36
66
  try {
67
+ const owner = await vaultIdentity(req);
68
+ if (owner === null) {
69
+ sendJson(req, res, 401, { error: 'unauthorized' });
70
+ return;
71
+ }
37
72
  if (tgt.kind === 'index') {
38
- sendJson(req, res, 200, { entries: await deps.list(session.subject) });
73
+ sendJson(req, res, 200, { entries: await deps.list(owner) });
39
74
  return;
40
75
  }
41
- if (req.method === 'GET') sendJson(req, res, 200, await deps.get(session.subject, tgt.id));
42
- else if (req.method === 'DELETE') sendJson(req, res, 200, await deps.remove(session.subject, tgt.id));
76
+ if (req.method === 'GET') sendJson(req, res, 200, await deps.get(owner, tgt.id));
77
+ else if (req.method === 'DELETE') sendJson(req, res, 200, await deps.remove(owner, tgt.id));
43
78
  else {
44
- const saved = await deps.put(session.subject, tgt.id, await readJsonBody(req, BODY_MAX));
79
+ const saved = await deps.put(owner, tgt.id, await readJsonBody(req, BODY_MAX));
45
80
  log.info({ id: saved.id, name: saved.name }, 'vault-api: bundle stored');
46
81
  sendJson(req, res, 200, saved);
47
82
  }
@@ -65,12 +100,7 @@ export function handleVaultApiRequest(req: IncomingMessage, res: ServerResponse,
65
100
  sendJson(req, res, 405, { error: 'method not allowed' });
66
101
  return true;
67
102
  }
68
- const session = apiSession(req);
69
- if (!session) {
70
- sendJson(req, res, 401, { error: 'unauthorized' });
71
- return true;
72
- }
73
- route(req, res, deps, session, tgt).catch((err: unknown) => {
103
+ route(req, res, deps, tgt).catch((err: unknown) => {
74
104
  log.warn({ err: errMsg(err) }, 'vault-api: unhandled error');
75
105
  if (!res.headersSent) sendJson(req, res, 500, { error: 'vault api failed' });
76
106
  });
@@ -1,12 +1,6 @@
1
- import { isUniqueViolation } from './users.js';
2
- import { and, eq, sql } from 'drizzle-orm';
3
- import { newId } from './ids.js';
4
- import { getDb } from './client.js';
5
- import { AgentAdminError, ownedAgentOrThrow, } from './agent-admin.js';
6
- import { stations, STATIONS, type StationName } from './schema.js';
1
+ import { STATIONS, type StationName } from './schema.js';
7
2
 
8
3
  const ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
9
- const ID_ATTEMPTS = 5;
10
4
 
11
5
  export interface AccountRef {
12
6
  agentId: string;
@@ -23,69 +17,3 @@ export function isStationName(raw: unknown): raw is StationName {
23
17
  export function parseAccountId(raw: string): string | null {
24
18
  return ACCOUNT_ID_RE.test(raw) ? raw : null;
25
19
  }
26
-
27
- async function assertTokenFree(
28
- station: StationName,
29
- token: string,
30
- ): Promise<void> {
31
- const rows = await getDb()
32
- .select({ id: stations.id })
33
- .from(stations)
34
- .where(
35
- and(
36
- eq(stations.station, station),
37
- sql`${stations.config}->>'token' = ${token}`,
38
- ),
39
- );
40
- if (rows.length > 0)
41
- throw new AgentAdminError(
42
- 'that bot token is already attached to a Metro account',
43
- 409,
44
- );
45
- }
46
-
47
- export async function attachAccountToAgent(
48
- email: string,
49
- agentId: string,
50
- station: StationName,
51
- config: Record<string, unknown>,
52
- ): Promise<AccountRef> {
53
- const { agent } = await ownedAgentOrThrow(email, agentId);
54
- const token = config.token;
55
- if (typeof token === 'string') await assertTokenFree(station, token);
56
- const db = getDb();
57
- for (let attempt = 0; attempt < ID_ATTEMPTS; attempt++) {
58
- const accountId = newId();
59
- try {
60
- await db
61
- .insert(stations)
62
- .values({ id: accountId, agentId: agent.id, station, config });
63
- return { agentId: agent.id, station, accountId };
64
- } catch (err) {
65
- if (!isUniqueViolation(err)) throw err;
66
- }
67
- }
68
- throw new AgentAdminError('could not allocate a free account id', 500);
69
- }
70
-
71
- export async function detachAccountFromAgent(
72
- email: string,
73
- agentId: string,
74
- station: StationName,
75
- accountId: string,
76
- ): Promise<AccountRef> {
77
- const { agent } = await ownedAgentOrThrow(email, agentId);
78
- const gone = await getDb()
79
- .delete(stations)
80
- .where(
81
- and(
82
- eq(stations.agentId, agent.id),
83
- eq(stations.station, station),
84
- eq(stations.id, accountId),
85
- ),
86
- )
87
- .returning({ id: stations.id });
88
- if (gone.length === 0)
89
- throw new AgentAdminError('no such account on this agent', 404);
90
- return { agentId: agent.id, station, accountId };
91
- }