@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 +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/import-api.ts +2 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +37 -2
- 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 +52 -17
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +22 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +29 -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
|
+
}
|
|
@@ -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
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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(
|
|
212
|
-
|
|
213
|
-
|
|
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:
|
|
236
|
+
stations: file.stations.length,
|
|
221
237
|
});
|
|
222
238
|
}
|
|
223
239
|
|
|
224
|
-
function
|
|
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
|
|
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 {
|
|
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
|
+
}
|
package/runtime/runtime.json
CHANGED