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