@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 +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +3 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/bundle-api.ts +112 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +34 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +21 -17
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-api.ts +78 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +6 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +14 -7
- package/runtime/node_modules/@metro-labs/mcp/src/db/vault.ts +119 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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 >
|
|
120
|
+
if (total > max) over = true;
|
|
122
121
|
if (!over) chunks.push(buf);
|
|
123
|
-
else if (total >
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
package/runtime/runtime.json
CHANGED