@stage-labs/metro 0.1.0-beta.48 → 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.48",
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
+ }
@@ -8,12 +8,14 @@ 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,
17
19
  localImportConnectors,
18
20
  localAddConnector,
19
21
  localAgentConnectors,
@@ -33,7 +35,6 @@ import {
33
35
  } from '../db/local-connectors.js';
34
36
  import {
35
37
  assertLocalOwner,
36
- ownerSignIn,
37
38
  connectorIdsOfLocalAgent,
38
39
  LOCAL_PROJECT_ID,
39
40
  localAttachAccount,
@@ -45,6 +46,8 @@ import {
45
46
  localOwnedAgentOrThrow,
46
47
  localOwner,
47
48
  localResetAgentKey,
49
+ ownerSignIn,
50
+ readLocalAgentFile,
48
51
  } from '../db/file-admin.js';
49
52
  import { listAgentFiles, readAgentFile } from '../db/file-source.js';
50
53
  import type { StationName } from '../db/schema.js';
@@ -158,6 +161,35 @@ const localCli: LocalCliDeps = {
158
161
  connectorSummaries: (agentId) => localConnectorSummariesByIds(connectorIdsOfLocalAgent(agentId) ?? []),
159
162
  };
160
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
+
161
193
  function importApi(deps: LocalModeDeps): ImportApiDeps {
162
194
  const fetchAgent = deps.fetchAgent ?? fetchAgentWithCode;
163
195
  return {
@@ -188,6 +220,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
188
220
  agentApi: agentApi(deps),
189
221
  agentConnectorApi,
190
222
  importApi: importApi(deps),
223
+ bundleApi: bundleApi(deps),
191
224
  connectorApi,
192
225
  relayApi,
193
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
+ }
@@ -271,6 +271,12 @@ function fileFor(
271
271
  }
272
272
  }
273
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
+
274
280
  export function connectorIdsOfLocalAgent(agentId: string, dir = agentsDir()): string[] | null {
275
281
  const found = storedAgents(dir).find((s) => s.file.id === agentId);
276
282
  return found === undefined ? null : found.file.connectors;
@@ -71,7 +71,7 @@ const isRow = (v: unknown): v is { id: string; name: string; url: string; config
71
71
  const asList = (value: unknown): unknown[] =>
72
72
  Array.isArray(value) ? value.map((item: unknown) => item) : [];
73
73
 
74
- function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
74
+ export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
75
75
  const raw = readJson<{ connectors?: unknown }>(filePath(dir), {}, {
76
76
  warn: 'connectors.json: malformed, ignoring',
77
77
  });
@@ -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.48"
2
+ "version": "0.1.0-beta.49"
3
3
  }