@stage-labs/metro 0.1.0-beta.47 → 0.1.0-beta.49

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.47",
3
+ "version": "0.1.0-beta.49",
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": {
@@ -11,7 +11,6 @@ import {
11
11
  import { extractToken } from '../mcp/request-identity.js';
12
12
 
13
13
  const BODY_MAX = 4 * 1024;
14
- const DRAIN_MAX = 2 * BODY_MAX;
15
14
 
16
15
  export interface ApiSession {
17
16
  subject: string;
@@ -111,16 +110,16 @@ export function projectParam(req: IncomingMessage): string | null {
111
110
  return raw === null || raw === '' ? null : raw;
112
111
  }
113
112
 
114
- export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
113
+ export async function readJsonBody(req: IncomingMessage, max = BODY_MAX): Promise<unknown> {
115
114
  const chunks: Buffer[] = [];
116
115
  let total = 0;
117
116
  let over = false;
118
117
  for await (const c of req) {
119
118
  const buf = c as Buffer;
120
119
  total += buf.length;
121
- if (total > BODY_MAX) over = true;
120
+ if (total > max) over = true;
122
121
  if (!over) chunks.push(buf);
123
- else if (total > DRAIN_MAX) break;
122
+ else if (total > 2 * max) break;
124
123
  }
125
124
  if (over) throw new ApiError('request body too large', 413);
126
125
  const raw = Buffer.concat(chunks).toString('utf8').trim();
@@ -103,6 +103,7 @@ import { AttachSessions } from './attach-session.js';
103
103
  import { startUploadReaper } from './upload-store.js';
104
104
  import type { AgentApiDeps } from './agent-api.js';
105
105
  import type { ConnectorApiDeps } from './connector-api.js';
106
+ import { deleteVaultForOwner, getVaultForOwner, listVaultForOwner, putVaultForOwner } from '../db/vault.js';
106
107
 
107
108
  installCrashGuard();
108
109
  acquireLock(join(STATE_DIR, '.tail-lock'));
@@ -300,6 +301,7 @@ function sessionApis(): SessionApis {
300
301
  projectApi,
301
302
  runApi,
302
303
  relayApi: { target: relayTarget, fence: fenceRuntime },
304
+ vaultApi: { list: listVaultForOwner, put: putVaultForOwner, get: getVaultForOwner, remove: deleteVaultForOwner },
303
305
  mode: hostedMode,
304
306
  };
305
307
  }
@@ -0,0 +1,112 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { errMsg, log } from './log.js';
3
+ import { apiFailure, apiSession, cors, readJsonBody, sendJson } from './api-http.js';
4
+ import { ApiError } from './api-error.js';
5
+ import { isRecord } from './is-record.js';
6
+ import { parseId } from '../db/ids.js';
7
+ import type { LoadedAccount, LoadedAgent, LoadedConnector } from '../db/materialize.js';
8
+ import { STATIONS } from '../db/schema.js';
9
+
10
+ const AGENTS = '/api/agents';
11
+ const RESTORE_PATH = `${AGENTS}/restore`;
12
+ const BUNDLE_MAX = 2 * 1024 * 1024;
13
+ const STATION_NAMES = new Set<string>(STATIONS);
14
+
15
+ export interface AgentBundle {
16
+ version: 1;
17
+ agent: { id: string; name: string; key: string; stations: LoadedAccount[] };
18
+ connectors: LoadedConnector[];
19
+ }
20
+
21
+ export const loadedAgentOf = (bundle: AgentBundle): LoadedAgent => ({
22
+ id: bundle.agent.id,
23
+ name: bundle.agent.name,
24
+ key: bundle.agent.key,
25
+ accounts: bundle.agent.stations,
26
+ connectors: [],
27
+ });
28
+
29
+ export interface RestoredAgent {
30
+ id: string;
31
+ name: string;
32
+ stations: number;
33
+ connectors: number;
34
+ }
35
+
36
+ export interface BundleApiDeps {
37
+ bundle: (subject: string, agentId: string) => Promise<AgentBundle>;
38
+ restore: (subject: string, bundle: AgentBundle) => Promise<RestoredAgent>;
39
+ }
40
+
41
+ const bad = (what: string): ApiError => new ApiError(`bundle: ${what}`, 400);
42
+
43
+ function stationOf(raw: unknown): LoadedAccount {
44
+ if (!isRecord(raw) || typeof raw.station !== 'string' || !STATION_NAMES.has(raw.station))
45
+ throw bad('a station is not a known station');
46
+ if (typeof raw.id !== 'string' || !isRecord(raw.config)) throw bad('a station has no id or config');
47
+ const allowlist = Array.isArray(raw.allowlist)
48
+ ? raw.allowlist.filter((s): s is string => typeof s === 'string')
49
+ : null;
50
+ return { station: raw.station as LoadedAccount['station'], id: raw.id, allowlist, config: raw.config };
51
+ }
52
+
53
+ function connectorOf(raw: unknown): LoadedConnector {
54
+ if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.url !== 'string')
55
+ throw bad('a connector is missing its id, name or url');
56
+ if (!isRecord(raw.config)) throw bad('a connector has no config');
57
+ return { id: raw.id, name: raw.name, url: raw.url, transport: 'http', config: raw.config };
58
+ }
59
+
60
+ function agentOf(raw: unknown): AgentBundle['agent'] {
61
+ if (!isRecord(raw)) throw bad('not a v1 agent bundle');
62
+ const { id, name, key, stations } = raw;
63
+ if (typeof id !== 'string' || parseId(id) === null) throw bad('agent id is not an id');
64
+ if (typeof name !== 'string' || typeof key !== 'string' || key === '') throw bad('agent has no name or key');
65
+ if (!Array.isArray(stations)) throw bad('agent has no station list');
66
+ return { id, name, key, stations: stations.map(stationOf) };
67
+ }
68
+
69
+ export function parseBundle(raw: unknown): AgentBundle {
70
+ if (!isRecord(raw) || raw.version !== 1) throw bad('not a v1 agent bundle');
71
+ const connectors = Array.isArray(raw.connectors) ? raw.connectors : [];
72
+ return { version: 1, agent: agentOf(raw.agent), connectors: connectors.map(connectorOf) };
73
+ }
74
+
75
+ async function answer(req: IncomingMessage, deps: BundleApiDeps, subject: string, path: string): Promise<unknown> {
76
+ if (path === RESTORE_PATH) return deps.restore(subject, parseBundle(await readJsonBody(req, BUNDLE_MAX)));
77
+ const id = parseId(path.slice(AGENTS.length + 1).split('/')[0] ?? '');
78
+ if (id === null) throw new ApiError('no such agent', 404);
79
+ return deps.bundle(subject, id);
80
+ }
81
+
82
+ export function handleBundleRequest(req: IncomingMessage, res: ServerResponse, deps: BundleApiDeps): boolean {
83
+ const path = (req.url ?? '').split('?')[0] ?? '';
84
+ const isRestore = path === RESTORE_PATH;
85
+ const isBundle = /^\/api\/agents\/[^/]+\/bundle$/.test(path);
86
+ if (!isRestore && !isBundle) return false;
87
+ if (req.method === 'OPTIONS') {
88
+ res.writeHead(204, cors(req)).end();
89
+ return true;
90
+ }
91
+ if (req.method !== (isRestore ? 'POST' : 'GET')) {
92
+ sendJson(req, res, 405, { error: 'method not allowed' });
93
+ return true;
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)
101
+ .then((body) => {
102
+ sendJson(req, res, isRestore ? 201 : 200, body);
103
+ })
104
+ .catch((err: unknown) => {
105
+ if (err instanceof ApiError) apiFailure(req, res, err, 'bundle-api');
106
+ else {
107
+ log.warn({ err: errMsg(err) }, 'bundle-api: request failed');
108
+ if (!res.headersSent) sendJson(req, res, 500, { error: 'bundle api failed' });
109
+ }
110
+ });
111
+ return true;
112
+ }
@@ -15,6 +15,7 @@ const PATH = '/api/agents/import';
15
15
 
16
16
  export interface ImportedAgent extends CreatedAgent {
17
17
  stations: number;
18
+ connectors: number;
18
19
  }
19
20
 
20
21
  export interface ImportApiDeps {
@@ -31,7 +32,7 @@ async function handle(
31
32
  const code = parsePairingCode(bodyField(await readJsonBody(req), 'code'));
32
33
  const made = await deps.importAgent(subject, code);
33
34
  log.info(
34
- { agent: made.name, id: made.id, stations: made.stations },
35
+ { agent: made.name, id: made.id, stations: made.stations, connectors: made.connectors },
35
36
  'import-api: agent imported from metro',
36
37
  );
37
38
  sendJson(req, res, 201, made);
@@ -8,12 +8,15 @@ import type { AgentConnectorApiDeps } from './agent-connector-api.js';
8
8
  import type { SessionApis } from './session-apis.js';
9
9
  import type { ModeInfo } from './mode-api.js';
10
10
  import type { ImportApiDeps } from './import-api.js';
11
+ import { loadedAgentOf, type AgentBundle, type BundleApiDeps } from './bundle-api.js';
11
12
  import { fetchAgentWithCode } from './agent-import.js';
12
13
  import type { ConnectorApiDeps } from './connector-api.js';
13
14
  import { allowLocalConnectors } from './connector-url.js';
14
15
  import { keyIdentity, type LocalCliDeps } from './local-cli-api.js';
15
16
  import type { RelayApiDeps } from './relay.js';
16
17
  import {
18
+ readLocalConnectors,
19
+ localImportConnectors,
17
20
  localAddConnector,
18
21
  localAgentConnectors,
19
22
  localConnectorNamesByIds,
@@ -32,7 +35,6 @@ import {
32
35
  } from '../db/local-connectors.js';
33
36
  import {
34
37
  assertLocalOwner,
35
- ownerSignIn,
36
38
  connectorIdsOfLocalAgent,
37
39
  LOCAL_PROJECT_ID,
38
40
  localAttachAccount,
@@ -44,6 +46,8 @@ import {
44
46
  localOwnedAgentOrThrow,
45
47
  localOwner,
46
48
  localResetAgentKey,
49
+ ownerSignIn,
50
+ readLocalAgentFile,
47
51
  } from '../db/file-admin.js';
48
52
  import { listAgentFiles, readAgentFile } from '../db/file-source.js';
49
53
  import type { StationName } from '../db/schema.js';
@@ -157,6 +161,35 @@ const localCli: LocalCliDeps = {
157
161
  connectorSummaries: (agentId) => localConnectorSummariesByIds(connectorIdsOfLocalAgent(agentId) ?? []),
158
162
  };
159
163
 
164
+ function bundleApi(deps: LocalModeDeps): BundleApiDeps {
165
+ return {
166
+ bundle: async (subject, agentId) => {
167
+ const { agent } = await localOwnedAgentOrThrow(subject, agentId);
168
+ const file = readLocalAgentFile(agentId);
169
+ const held = new Set(file.connectors);
170
+ const bundle: AgentBundle = {
171
+ version: 1,
172
+ agent: { id: file.id, name: file.name, key: file.key ?? '', stations: file.stations },
173
+ connectors: readLocalConnectors()
174
+ .filter((c) => held.has(c.id))
175
+ .map((c) => ({ id: c.id, name: c.name, url: c.url, transport: c.transport, config: { ...c.config } })),
176
+ };
177
+ if (bundle.agent.key === '') throw new ApiError(`agent '${agent.name}' has no key to bundle`, 400);
178
+ return bundle;
179
+ },
180
+ restore: async (subject, bundle) => {
181
+ assertLocalOwner(subject);
182
+ const made = await localImportAgent(subject, loadedAgentOf(bundle));
183
+ const connectors = localImportConnectors(bundle.connectors);
184
+ for (const station of new Set(bundle.agent.stations.map((a) => a.station)))
185
+ await deps.syncStations(station).catch((err: unknown) => {
186
+ log.warn({ station, err: errMsg(err) }, 'restore: station reload failed, the change lands at the next boot');
187
+ });
188
+ return { id: made.id, name: made.name, stations: made.stations, connectors };
189
+ },
190
+ };
191
+ }
192
+
160
193
  function importApi(deps: LocalModeDeps): ImportApiDeps {
161
194
  const fetchAgent = deps.fetchAgent ?? fetchAgentWithCode;
162
195
  return {
@@ -164,6 +197,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
164
197
  assertLocalOwner(subject);
165
198
  const agent = await fetchAgent(code, hostname());
166
199
  const made = await localImportAgent(subject, agent);
200
+ const connectors = localImportConnectors(agent.connectors ?? []);
167
201
  for (const station of new Set(agent.accounts.map((a) => a.station)))
168
202
  await deps.syncStations(station).catch((err: unknown) => {
169
203
  log.warn(
@@ -171,7 +205,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
171
205
  'import: station reload failed, the change lands at the next boot',
172
206
  );
173
207
  });
174
- return made;
208
+ return { ...made, connectors };
175
209
  },
176
210
  };
177
211
  }
@@ -186,6 +220,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
186
220
  agentApi: agentApi(deps),
187
221
  agentConnectorApi,
188
222
  importApi: importApi(deps),
223
+ bundleApi: bundleApi(deps),
189
224
  connectorApi,
190
225
  relayApi,
191
226
  localCli,
@@ -21,11 +21,15 @@ import { handleImportRequest, type ImportApiDeps } from './import-api.js';
21
21
  import { handleLocalCliRequest, type LocalCliDeps } from './local-cli-api.js';
22
22
  import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
23
23
  import type { ModeInfo } from './mode-api.js';
24
+ import { handleVaultApiRequest, type VaultApiDeps } from './vault-api.js';
25
+ import { handleBundleRequest, type BundleApiDeps } from './bundle-api.js';
24
26
 
25
27
  export interface SessionApis {
26
28
  agentApi?: AgentApiDeps;
27
29
  agentConnectorApi?: AgentConnectorApiDeps;
28
30
  importApi?: ImportApiDeps;
31
+ bundleApi?: BundleApiDeps;
32
+ vaultApi?: VaultApiDeps;
29
33
  localCli?: LocalCliDeps;
30
34
  claudeApi?: ClaudeApiDeps;
31
35
  connectorApi?: ConnectorApiDeps;
@@ -36,27 +40,27 @@ export interface SessionApis {
36
40
  mode?: () => ModeInfo;
37
41
  }
38
42
 
43
+ const when = <T>(dep: T | undefined, run: (dep: T) => boolean): (() => boolean)[] =>
44
+ dep === undefined ? [] : [() => run(dep)];
45
+
39
46
  export function handleSessionApis(
40
47
  req: IncomingMessage,
41
48
  res: ServerResponse,
42
49
  apis: SessionApis,
43
50
  ): boolean {
44
- const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi, importApi } = apis;
45
- const { localCli, claudeApi } = apis;
46
- const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
47
- if (localCli) routes.push(() => handleLocalCliRequest(req, res, localCli));
48
- if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
49
- if (projectApi)
50
- routes.push(() => handleProjectApiRequest(req, res, projectApi));
51
- if (connectorApi)
52
- routes.push(
53
- () => handleCliPairRequest(req, res, connectorApi),
54
- () => handleConnectorApiRequest(req, res, connectorApi),
55
- );
56
- if (importApi) routes.push(() => handleImportRequest(req, res, importApi));
57
- if (claudeApi) routes.push(() => handleClaudeRequest(req, res, claudeApi));
58
- if (agentConnectorApi)
59
- routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
60
- if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
51
+ const routes: (() => boolean)[] = [
52
+ () => handleSessionApiRequest(req, res),
53
+ ...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
+ ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
58
+ ...when(apis.importApi, (d) => handleImportRequest(req, res, d)),
59
+ ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
60
+ ...when(apis.vaultApi, (d) => handleVaultApiRequest(req, res, d)),
61
+ ...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
62
+ ...when(apis.agentConnectorApi, (d) => handleAgentConnectorRequest(req, res, d)),
63
+ ...when(apis.agentApi, (d) => handleAgentApiRequest(req, res, d)),
64
+ ];
61
65
  return routes.some((run) => run());
62
66
  }
@@ -0,0 +1,78 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { errMsg, log } from './log.js';
3
+ import { apiFailure, apiSession, cors, readJsonBody, sendJson, type ApiSession } from './api-http.js';
4
+ import { parseId } from '../db/ids.js';
5
+ import { ENVELOPE_MAX, type VaultBundle, type VaultEntry } from '../db/vault.js';
6
+
7
+ const PREFIX = '/api/vault';
8
+ const BODY_MAX = ENVELOPE_MAX + 64 * 1024;
9
+
10
+ export interface VaultApiDeps {
11
+ list: (subject: string) => Promise<VaultEntry[]>;
12
+ put: (subject: string, id: string, body: unknown) => Promise<VaultEntry>;
13
+ get: (subject: string, id: string) => Promise<VaultBundle>;
14
+ remove: (subject: string, id: string) => Promise<{ id: string; name: string }>;
15
+ }
16
+
17
+ type Target = { kind: 'index' } | { kind: 'bundle'; id: string } | { kind: 'unknown' } | null;
18
+
19
+ function target(path: string): Target {
20
+ if (path === PREFIX || path === `${PREFIX}/`) return { kind: 'index' };
21
+ if (!path.startsWith(`${PREFIX}/`)) return null;
22
+ const segments = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
23
+ const id = parseId(segments[0] ?? '');
24
+ return id === null || segments.length !== 1 ? { kind: 'unknown' } : { kind: 'bundle', id };
25
+ }
26
+
27
+ const ALLOWED: Record<'index' | 'bundle', string[]> = { index: ['GET'], bundle: ['GET', 'PUT', 'DELETE'] };
28
+
29
+ async function route(
30
+ req: IncomingMessage,
31
+ res: ServerResponse,
32
+ deps: VaultApiDeps,
33
+ session: ApiSession,
34
+ tgt: { kind: 'index' } | { kind: 'bundle'; id: string },
35
+ ): Promise<void> {
36
+ try {
37
+ if (tgt.kind === 'index') {
38
+ sendJson(req, res, 200, { entries: await deps.list(session.subject) });
39
+ return;
40
+ }
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));
43
+ else {
44
+ const saved = await deps.put(session.subject, tgt.id, await readJsonBody(req, BODY_MAX));
45
+ log.info({ id: saved.id, name: saved.name }, 'vault-api: bundle stored');
46
+ sendJson(req, res, 200, saved);
47
+ }
48
+ } catch (err) {
49
+ apiFailure(req, res, err, 'vault-api');
50
+ }
51
+ }
52
+
53
+ export function handleVaultApiRequest(req: IncomingMessage, res: ServerResponse, deps: VaultApiDeps): boolean {
54
+ const tgt = target((req.url ?? '').split('?')[0] ?? '');
55
+ if (tgt === null) return false;
56
+ if (req.method === 'OPTIONS') {
57
+ res.writeHead(204, cors(req)).end();
58
+ return true;
59
+ }
60
+ if (tgt.kind === 'unknown') {
61
+ sendJson(req, res, 404, { error: 'no such bundle' });
62
+ return true;
63
+ }
64
+ if (!ALLOWED[tgt.kind].includes(req.method ?? '')) {
65
+ sendJson(req, res, 405, { error: 'method not allowed' });
66
+ return true;
67
+ }
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) => {
74
+ log.warn({ err: errMsg(err) }, 'vault-api: unhandled error');
75
+ if (!res.headersSent) sendJson(req, res, 500, { error: 'vault api failed' });
76
+ });
77
+ return true;
78
+ }
@@ -187,12 +187,7 @@ export function assertLocalOwner(subject: string, dir = agentsDir()): void {
187
187
  if (!isOwner(subject, dir)) throw new AgentAdminError('no such project', 404);
188
188
  }
189
189
 
190
- export async function localImportAgent(
191
- subject: string,
192
- agent: LoadedAgent,
193
- dir = agentsDir(),
194
- ): Promise<{ id: string; name: string; key: string; stations: number }> {
195
- assertLocalOwner(subject, dir);
190
+ function assertImportable(agent: LoadedAgent): asserts agent is LoadedAgent & { key: string } {
196
191
  const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
197
192
  if (immovable !== undefined)
198
193
  throw new AgentAdminError(
@@ -201,37 +196,71 @@ export async function localImportAgent(
201
196
  );
202
197
  if (agent.key === null)
203
198
  throw new AgentAdminError('that agent has no key; reset it on metro.box first', 400);
204
- const folder = join(dir, agent.name);
205
- const path = join(folder, AGENT_FILE);
206
- if (existsSync(path))
199
+ }
200
+
201
+ interface ImportTarget {
202
+ path: string;
203
+ previous: AgentFile | undefined;
204
+ }
205
+
206
+ function importTarget(dir: string, agent: LoadedAgent & { key: string }): ImportTarget {
207
+ const existing = storedAgents(dir);
208
+ const same = existing.find((s) => s.file.id === agent.id);
209
+ const fresh = join(dir, agent.name, AGENT_FILE);
210
+ if (same === undefined && existsSync(fresh))
207
211
  throw new AgentAdminError(
208
212
  `an agent named '${agent.name}' already exists on this machine`,
209
213
  409,
210
214
  );
211
- assertUnclaimed(storedAgents(dir), agent);
212
- const file = fileFor(agent, localOwner(dir), path);
213
- ensureSecureDir(folder);
215
+ assertUnclaimed(existing.filter((s) => s !== same), agent);
216
+ return { path: same?.path ?? fresh, previous: same?.file };
217
+ }
218
+
219
+ export async function localImportAgent(
220
+ subject: string,
221
+ agent: LoadedAgent,
222
+ dir = agentsDir(),
223
+ ): Promise<{ id: string; name: string; key: string; stations: number }> {
224
+ assertLocalOwner(subject, dir);
225
+ assertImportable(agent);
226
+ const { path, previous } = importTarget(dir, agent);
227
+ const file = fileFor(agent, localOwner(dir), path, previous);
228
+ ensureSecureDir(join(path, '..'));
214
229
  save({ path, file });
230
+ if (previous !== undefined && previous.key !== agent.key) unregisterAgentKey(agent.id);
215
231
  registerKey(agent.key, agent.id);
216
232
  return Promise.resolve({
217
233
  id: agent.id,
218
234
  name: agent.name,
219
235
  key: agent.key,
220
- stations: agent.accounts.length,
236
+ stations: file.stations.length,
221
237
  });
222
238
  }
223
239
 
224
- function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentFile {
240
+ function mergedStations(agent: LoadedAgent, previous: AgentFile | undefined): AgentFile['stations'] {
241
+ const fromMetro = agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] }));
242
+ const kept = (previous?.stations ?? []).filter(
243
+ (s) => !fromMetro.some((m) => m.station === s.station && m.id === s.id),
244
+ );
245
+ return [...fromMetro, ...kept];
246
+ }
247
+
248
+ function fileFor(
249
+ agent: LoadedAgent,
250
+ owner: string | null,
251
+ path: string,
252
+ previous?: AgentFile,
253
+ ): AgentFile {
225
254
  try {
226
255
  return parseAgentFile(
227
256
  JSON.stringify({
228
257
  version: 1,
229
258
  id: agent.id,
230
- name: agent.name,
259
+ name: previous?.name ?? agent.name,
231
260
  key: agent.key,
232
261
  owner,
233
- stations: agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] })),
234
- connectors: [],
262
+ stations: mergedStations(agent, previous),
263
+ connectors: previous?.connectors ?? [],
235
264
  }),
236
265
  path,
237
266
  );
@@ -242,6 +271,12 @@ function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentF
242
271
  }
243
272
  }
244
273
 
274
+ export function readLocalAgentFile(agentId: string, dir = agentsDir()): AgentFile {
275
+ const found = storedAgents(dir).find((s) => s.file.id === agentId);
276
+ if (found === undefined) throw missing();
277
+ return found.file;
278
+ }
279
+
245
280
  export function connectorIdsOfLocalAgent(agentId: string, dir = agentsDir()): string[] | null {
246
281
  const found = storedAgents(dir).find((s) => s.file.id === agentId);
247
282
  return found === undefined ? null : found.file.connectors;
@@ -48,6 +48,7 @@ import {
48
48
  LOCAL_PROJECT_ID,
49
49
  } from './file-admin.js';
50
50
  import { newId } from './ids.js';
51
+ import type { LoadedConnector } from './materialize.js';
51
52
 
52
53
  export interface LocalConnectorRow {
53
54
  id: string;
@@ -70,7 +71,7 @@ const isRow = (v: unknown): v is { id: string; name: string; url: string; config
70
71
  const asList = (value: unknown): unknown[] =>
71
72
  Array.isArray(value) ? value.map((item: unknown) => item) : [];
72
73
 
73
- function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
74
+ export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
74
75
  const raw = readJson<{ connectors?: unknown }>(filePath(dir), {}, {
75
76
  warn: 'connectors.json: malformed, ignoring',
76
77
  });
@@ -113,6 +114,26 @@ async function oauthCapable(url: URL, auth: ConnectorAuth): Promise<boolean> {
113
114
  return advertisesOAuth(url);
114
115
  }
115
116
 
117
+ export function localImportConnectors(rows: LoadedConnector[], dir = agentsDir()): number {
118
+ const current = readLocalConnectors(dir);
119
+ for (const row of rows) {
120
+ const other = current.find((r) => r.id !== row.id && r.name === row.name);
121
+ if (other !== undefined)
122
+ throw new ConnectorError(`a connector named '${row.name}' already exists on this daemon; rename it first`, 409);
123
+ }
124
+ const imported: LocalConnectorRow[] = rows.map((row) => ({
125
+ id: row.id,
126
+ name: row.name,
127
+ url: row.url,
128
+ transport: 'http',
129
+ config: readConfig(row.config),
130
+ }));
131
+ const ids = new Set(imported.map((r) => r.id));
132
+ writeRows(dir, [...current.filter((r) => !ids.has(r.id)), ...imported]);
133
+ for (const row of imported) localHoldEverywhere(row.id, dir);
134
+ return imported.length;
135
+ }
136
+
116
137
  export async function localListConnectors(
117
138
  subject: string,
118
139
  project: string,
@@ -21,7 +21,14 @@ import {
21
21
  type AllowlistMap,
22
22
  } from './agent-map.js';
23
23
  import { setKeyMap } from './key-map.js';
24
- import { STATIONS, stations, agents, type StationName } from './schema.js';
24
+ import {
25
+ agentConnectors,
26
+ agents,
27
+ connectors,
28
+ stations,
29
+ STATIONS,
30
+ type StationName,
31
+ } from './schema.js';
25
32
 
26
33
  interface StationTarget {
27
34
  file: string;
@@ -36,11 +43,20 @@ export interface LoadedAccount {
36
43
  config: Record<string, unknown>;
37
44
  }
38
45
 
46
+ export interface LoadedConnector {
47
+ id: string;
48
+ name: string;
49
+ url: string;
50
+ transport: 'http';
51
+ config: Record<string, unknown>;
52
+ }
53
+
39
54
  export interface LoadedAgent {
40
55
  id: string;
41
56
  name: string;
42
57
  accounts: LoadedAccount[];
43
58
  key: string | null;
59
+ connectors?: LoadedConnector[];
44
60
  }
45
61
 
46
62
  const METRO_DIR = join(homedir(), '.metro');
@@ -133,10 +149,22 @@ export async function loadAgentForRuntime(
133
149
  .select()
134
150
  .from(stations)
135
151
  .where(eq(stations.agentId, a.id));
152
+ const held = await db
153
+ .select({ id: connectors.id, name: connectors.name, url: connectors.url, config: connectors.config })
154
+ .from(agentConnectors)
155
+ .innerJoin(connectors, eq(connectors.id, agentConnectors.connectorId))
156
+ .where(eq(agentConnectors.agentId, a.id));
136
157
  return {
137
158
  id: a.id,
138
159
  name: a.name,
139
160
  key: a.key,
161
+ connectors: held.map((c) => ({
162
+ id: c.id,
163
+ name: c.name,
164
+ url: c.url,
165
+ transport: 'http' as const,
166
+ config: c.config as Record<string, unknown>,
167
+ })),
140
168
  accounts: acctRows
141
169
  .filter((r) => MOVABLE_STATIONS.has(r.station))
142
170
  .map((r) => ({
@@ -1,10 +1,4 @@
1
- import {
2
- boolean,
3
- jsonb,
4
- pgTable,
5
- text,
6
- unique,
7
- } from 'drizzle-orm/pg-core';
1
+ import { boolean, index, jsonb, pgTable, text, unique } from 'drizzle-orm/pg-core';
8
2
 
9
3
  export const STATIONS = [
10
4
  'xmtp',
@@ -114,3 +108,16 @@ export const agentConnectors = pgTable(
114
108
  ),
115
109
  ],
116
110
  );
111
+
112
+ export const vault = pgTable(
113
+ 'vault',
114
+ {
115
+ id: text('id').primaryKey(),
116
+ owner: text('owner').notNull(),
117
+ name: text('name').notNull(),
118
+ stations: jsonb('stations').notNull(),
119
+ envelope: jsonb('envelope').notNull(),
120
+ syncedAt: text('synced_at').notNull(),
121
+ },
122
+ (t) => [index('vault_owner_idx').on(t.owner)],
123
+ );
@@ -0,0 +1,119 @@
1
+ import { and, asc, eq } from 'drizzle-orm';
2
+ import { ApiError } from '../daemon/api-error.js';
3
+ import { isRecord } from '../daemon/is-record.js';
4
+ import { getDb } from './client.js';
5
+ import { AGENT_NAME_RE, parseId } from './ids.js';
6
+ import { STATIONS, vault } from './schema.js';
7
+ import { normalizeAddress } from './users.js';
8
+
9
+ export class VaultError extends ApiError {}
10
+
11
+ export interface VaultEntry {
12
+ id: string;
13
+ name: string;
14
+ stations: string[];
15
+ syncedAt: string;
16
+ }
17
+
18
+ export interface VaultBundle extends VaultEntry {
19
+ envelope: Record<string, unknown>;
20
+ }
21
+
22
+ export interface VaultInput {
23
+ name: string;
24
+ stations: string[];
25
+ envelope: Record<string, unknown>;
26
+ }
27
+
28
+ export const ENVELOPE_MAX = 2 * 1024 * 1024;
29
+ const STATION_NAMES = new Set<string>(STATIONS);
30
+
31
+ const missing = (): VaultError => new VaultError('no such bundle', 404);
32
+
33
+ function ownerOf(subject: string): string {
34
+ const address = normalizeAddress(subject);
35
+ if (address === null) throw new VaultError('the vault needs a wallet sign-in', 403);
36
+ return address;
37
+ }
38
+
39
+ function stationsOf(raw: unknown): string[] {
40
+ if (!Array.isArray(raw)) throw new VaultError('stations must be a list of station names', 400);
41
+ const list = raw.map((s: unknown) => (typeof s === 'string' ? s : ''));
42
+ const bad = list.find((s) => !STATION_NAMES.has(s));
43
+ if (bad !== undefined) throw new VaultError(`'${bad}' is not a station`, 400);
44
+ return [...new Set(list)];
45
+ }
46
+
47
+ function envelopeOf(raw: unknown, id: string, owner: string): Record<string, unknown> {
48
+ if (!isRecord(raw) || raw.v !== 1 || raw.agentId !== id)
49
+ throw new VaultError('envelope must be a v1 envelope for this agent', 400);
50
+ const key = raw.key;
51
+ if (!isRecord(key) || key.recipient !== owner)
52
+ throw new VaultError('envelope must be sealed to the signed-in wallet', 400);
53
+ if (typeof raw.ciphertext !== 'string' || typeof raw.nonce !== 'string')
54
+ throw new VaultError('envelope carries no ciphertext', 400);
55
+ if (JSON.stringify(raw).length > ENVELOPE_MAX)
56
+ throw new VaultError(`envelope is over ${String(ENVELOPE_MAX)} bytes`, 413);
57
+ return raw;
58
+ }
59
+
60
+ export function parseVaultInput(subject: string, id: string, body: unknown): VaultInput & { owner: string } {
61
+ const owner = ownerOf(subject);
62
+ if (parseId(id) === null) throw missing();
63
+ if (!isRecord(body)) throw new VaultError('body must be an object', 400);
64
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
65
+ if (!AGENT_NAME_RE.test(name)) throw new VaultError('name is not an agent name', 400);
66
+ return { owner, name, stations: stationsOf(body.stations), envelope: envelopeOf(body.envelope, id, owner) };
67
+ }
68
+
69
+ const entryOf = (row: { id: string; name: string; stations: unknown; syncedAt: string }): VaultEntry => ({
70
+ id: row.id,
71
+ name: row.name,
72
+ stations: Array.isArray(row.stations) ? row.stations.filter((s): s is string => typeof s === 'string') : [],
73
+ syncedAt: row.syncedAt,
74
+ });
75
+
76
+ export async function listVaultForOwner(subject: string): Promise<VaultEntry[]> {
77
+ const owner = ownerOf(subject);
78
+ const rows = await getDb()
79
+ .select({ id: vault.id, name: vault.name, stations: vault.stations, syncedAt: vault.syncedAt })
80
+ .from(vault)
81
+ .where(eq(vault.owner, owner))
82
+ .orderBy(asc(vault.name));
83
+ return rows.map(entryOf);
84
+ }
85
+
86
+ export async function putVaultForOwner(subject: string, id: string, body: unknown): Promise<VaultEntry> {
87
+ const input = parseVaultInput(subject, id, body);
88
+ const syncedAt = new Date().toISOString();
89
+ const values = { id, owner: input.owner, name: input.name, stations: input.stations, envelope: input.envelope, syncedAt };
90
+ const db = getDb();
91
+ const held = await db.select({ owner: vault.owner }).from(vault).where(eq(vault.id, id));
92
+ if (held[0] !== undefined && held[0].owner !== input.owner) throw missing();
93
+ await db
94
+ .insert(vault)
95
+ .values(values)
96
+ .onConflictDoUpdate({ target: vault.id, set: { name: input.name, stations: input.stations, envelope: input.envelope, syncedAt } });
97
+ return entryOf(values);
98
+ }
99
+
100
+ export async function getVaultForOwner(subject: string, id: string): Promise<VaultBundle> {
101
+ const owner = ownerOf(subject);
102
+ if (parseId(id) === null) throw missing();
103
+ const rows = await getDb().select().from(vault).where(and(eq(vault.id, id), eq(vault.owner, owner)));
104
+ const row = rows[0];
105
+ if (row === undefined) throw missing();
106
+ return { ...entryOf(row), envelope: isRecord(row.envelope) ? row.envelope : {} };
107
+ }
108
+
109
+ export async function deleteVaultForOwner(subject: string, id: string): Promise<{ id: string; name: string }> {
110
+ const owner = ownerOf(subject);
111
+ if (parseId(id) === null) throw missing();
112
+ const gone = await getDb()
113
+ .delete(vault)
114
+ .where(and(eq(vault.id, id), eq(vault.owner, owner)))
115
+ .returning({ id: vault.id, name: vault.name });
116
+ const row = gone[0];
117
+ if (row === undefined) throw missing();
118
+ return row;
119
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.47"
2
+ "version": "0.1.0-beta.49"
3
3
  }