@stage-labs/metro 0.1.0-beta.30 → 0.1.0-beta.32
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 +12 -12
- package/dist/bedrock.js +5 -3
- package/dist/cli.js +23 -11
- package/dist/runtime.js +4 -1
- package/dist/serve.js +57 -0
- package/dist/store.js +26 -3
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +5 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +24 -28
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +34 -10
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +5 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +119 -72
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +31 -20
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +17 -43
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +8 -8
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +3 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +11 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +158 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +10 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +17 -17
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +15 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +12 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +21 -21
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +2 -5
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +15 -16
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +32 -32
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +250 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +16 -7
- package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +49 -40
- package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +13 -20
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +8 -21
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +32 -17
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +4 -4
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +1 -1
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +0 -164
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-collections.ts +0 -218
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { RelayTarget } from '../db/connector-relay.js';
|
|
3
|
-
import {
|
|
3
|
+
import { agentIdentity, assertLease, type Fence } from './api-http.js';
|
|
4
4
|
import { ApiError } from './api-error.js';
|
|
5
5
|
import { errMsg, log } from './log.js';
|
|
6
6
|
|
|
7
7
|
export interface RelayApiDeps {
|
|
8
8
|
target: (
|
|
9
|
-
|
|
9
|
+
agentId: string,
|
|
10
10
|
connectorId: string,
|
|
11
11
|
force: boolean,
|
|
12
12
|
) => Promise<RelayTarget>;
|
|
13
|
+
fence: Fence;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
@@ -196,18 +197,18 @@ type Exchanged =
|
|
|
196
197
|
|
|
197
198
|
async function exchange(
|
|
198
199
|
req: IncomingMessage,
|
|
199
|
-
|
|
200
|
+
agentId: string,
|
|
200
201
|
connectorId: string,
|
|
201
202
|
deps: RelayApiDeps,
|
|
202
203
|
body: Uint8Array<ArrayBuffer> | null,
|
|
203
204
|
signal: AbortSignal,
|
|
204
205
|
): Promise<Exchanged> {
|
|
205
|
-
const target = await deps.target(
|
|
206
|
+
const target = await deps.target(agentId, connectorId, false);
|
|
206
207
|
if (target.kind !== 'ok') return { kind: target.kind };
|
|
207
208
|
let upstream = await forward(req, target, body, signal);
|
|
208
209
|
if (!authFailed(upstream.status)) return { kind: 'response', upstream };
|
|
209
210
|
await upstream.body?.cancel();
|
|
210
|
-
const fresh = await deps.target(
|
|
211
|
+
const fresh = await deps.target(agentId, connectorId, true);
|
|
211
212
|
if (fresh.kind !== 'ok') return { kind: 'signin' };
|
|
212
213
|
upstream = await forward(req, fresh, body, signal);
|
|
213
214
|
if (!authFailed(upstream.status)) return { kind: 'response', upstream };
|
|
@@ -218,7 +219,7 @@ async function exchange(
|
|
|
218
219
|
async function relayExchange(
|
|
219
220
|
req: IncomingMessage,
|
|
220
221
|
res: ServerResponse,
|
|
221
|
-
|
|
222
|
+
agentId: string,
|
|
222
223
|
connectorId: string,
|
|
223
224
|
deps: RelayApiDeps,
|
|
224
225
|
): Promise<void> {
|
|
@@ -234,7 +235,7 @@ async function relayExchange(
|
|
|
234
235
|
const body = req.method === 'POST' ? await readCapped(req) : null;
|
|
235
236
|
const out = await exchange(
|
|
236
237
|
req,
|
|
237
|
-
|
|
238
|
+
agentId,
|
|
238
239
|
connectorId,
|
|
239
240
|
deps,
|
|
240
241
|
body,
|
|
@@ -262,13 +263,14 @@ function dispatch(
|
|
|
262
263
|
connectorId: string,
|
|
263
264
|
deps: RelayApiDeps,
|
|
264
265
|
): void {
|
|
265
|
-
const who =
|
|
266
|
+
const who = agentIdentity(req);
|
|
266
267
|
if (who === null) {
|
|
267
268
|
answer(res, 401, { error: 'unauthorized' });
|
|
268
269
|
return;
|
|
269
270
|
}
|
|
270
|
-
|
|
271
|
-
(
|
|
271
|
+
assertLease(who, deps.fence)
|
|
272
|
+
.then(() => relayExchange(req, res, who.agentId, connectorId, deps))
|
|
273
|
+
.catch((err: unknown) => {
|
|
272
274
|
if (err instanceof ApiError) {
|
|
273
275
|
answer(res, err.status, { error: err.message });
|
|
274
276
|
} else {
|
|
@@ -277,8 +279,7 @@ function dispatch(
|
|
|
277
279
|
if (!res.writableEnded) res.end();
|
|
278
280
|
}
|
|
279
281
|
closeIfBodyUnread(req, res);
|
|
280
|
-
}
|
|
281
|
-
);
|
|
282
|
+
});
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
export function handleRelayRequest(
|
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
runIdentity,
|
|
9
9
|
sendJson,
|
|
10
10
|
} from './api-http.js';
|
|
11
|
-
import {
|
|
12
|
-
import { sessionTtlFromEnv } from './
|
|
11
|
+
import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
|
|
12
|
+
import { sessionTtlFromEnv } from './session-config.js';
|
|
13
13
|
import { signRunToken } from './session.js';
|
|
14
14
|
import type { LoadedAgent } from '../db/materialize.js';
|
|
15
15
|
import type { RuntimeLease } from '../db/runtimes.js';
|
|
@@ -27,6 +27,7 @@ export interface RunApiDeps {
|
|
|
27
27
|
fenceRuntime: (runtimeId: string, agentId: string) => Promise<void>;
|
|
28
28
|
touchRuntime: (runtimeId: string) => Promise<void>;
|
|
29
29
|
loadAgent: (agentId: string) => Promise<LoadedAgent>;
|
|
30
|
+
blockedStations: (agentId: string) => Promise<string[]>;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function secretOrNull(): string | null {
|
|
@@ -53,23 +54,31 @@ async function handleClaim(
|
|
|
53
54
|
const body = await readJsonBody(req);
|
|
54
55
|
const raw = bodyField(body, 'code');
|
|
55
56
|
const code = typeof raw === 'string' ? raw.trim() : '';
|
|
56
|
-
if (!
|
|
57
|
+
if (!AGENT_CODE_RE.test(code)) {
|
|
57
58
|
sendJson(req, res, 400, {
|
|
58
59
|
error:
|
|
59
|
-
'that does not look like
|
|
60
|
+
'that does not look like an agent code — metro start wants the ma_… code from the agent page',
|
|
60
61
|
});
|
|
61
62
|
return;
|
|
62
63
|
}
|
|
63
|
-
const taken =
|
|
64
|
+
const taken = takeAgentCode(code);
|
|
64
65
|
if (taken === undefined) {
|
|
65
66
|
sendJson(req, res, 400, {
|
|
66
67
|
error: 'that code has expired or was already used',
|
|
67
68
|
});
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
71
|
+
const blocked = await deps.blockedStations(taken.agentId);
|
|
72
|
+
if (blocked.length > 0) {
|
|
73
|
+
sendJson(req, res, 409, {
|
|
74
|
+
error:
|
|
75
|
+
`this agent holds ${blocked.join(', ')}, which only runs on metro because a webhook url has to be publicly reachable. Detach it, or move it to a second agent that stays on metro, then start this one.`,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
70
79
|
const lease = await deps.claimRuntime(taken.agentId, labelOf(body));
|
|
71
80
|
const token = signRunToken(
|
|
72
|
-
{
|
|
81
|
+
{ subject: taken.subject, agentId: lease.agentId, runtimeId: lease.runtimeId },
|
|
73
82
|
secret,
|
|
74
83
|
{ ttlSec: sessionTtlFromEnv() },
|
|
75
84
|
);
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
handleConnectorApiRequest,
|
|
6
6
|
type ConnectorApiDeps,
|
|
7
7
|
} from './connector-api.js';
|
|
8
|
-
import { handleCollectionApiRequest } from './collection-api.js';
|
|
9
8
|
import { handleCliPairRequest } from './cli-pair-api.js';
|
|
10
9
|
import { handleRunApiRequest, type RunApiDeps } from './run-api.js';
|
|
11
10
|
import type { RelayApiDeps } from './relay.js';
|
|
@@ -13,13 +12,22 @@ import {
|
|
|
13
12
|
handleProjectApiRequest,
|
|
14
13
|
type ProjectApiDeps,
|
|
15
14
|
} from './project-api.js';
|
|
15
|
+
import {
|
|
16
|
+
handleAgentConnectorRequest,
|
|
17
|
+
type AgentConnectorApiDeps,
|
|
18
|
+
} from './agent-connector-api.js';
|
|
19
|
+
import type { SiweRouteDeps } from './siwe-routes.js';
|
|
20
|
+
import type { ModeInfo } from './mode-api.js';
|
|
16
21
|
|
|
17
22
|
export interface SessionApis {
|
|
18
23
|
agentApi?: AgentApiDeps;
|
|
24
|
+
agentConnectorApi?: AgentConnectorApiDeps;
|
|
19
25
|
connectorApi?: ConnectorApiDeps;
|
|
20
26
|
projectApi?: ProjectApiDeps;
|
|
21
27
|
runApi?: RunApiDeps;
|
|
22
28
|
relayApi?: RelayApiDeps;
|
|
29
|
+
siwe?: SiweRouteDeps;
|
|
30
|
+
mode?: () => ModeInfo;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
export function handleSessionApis(
|
|
@@ -27,7 +35,7 @@ export function handleSessionApis(
|
|
|
27
35
|
res: ServerResponse,
|
|
28
36
|
apis: SessionApis,
|
|
29
37
|
): boolean {
|
|
30
|
-
const { agentApi, connectorApi, projectApi, runApi } = apis;
|
|
38
|
+
const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi } = apis;
|
|
31
39
|
const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
|
|
32
40
|
if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
|
|
33
41
|
if (projectApi)
|
|
@@ -35,9 +43,10 @@ export function handleSessionApis(
|
|
|
35
43
|
if (connectorApi)
|
|
36
44
|
routes.push(
|
|
37
45
|
() => handleCliPairRequest(req, res, connectorApi),
|
|
38
|
-
() => handleCollectionApiRequest(req, res, connectorApi),
|
|
39
46
|
() => handleConnectorApiRequest(req, res, connectorApi),
|
|
40
47
|
);
|
|
48
|
+
if (agentConnectorApi)
|
|
49
|
+
routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
|
|
41
50
|
if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
|
|
42
51
|
return routes.some((run) => run());
|
|
43
52
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const DEFAULT_SESSION_TTL_SEC = 30 * 24 * 3600;
|
|
2
|
+
const PREVIEW = /^([a-z0-9-]+--)?metro-ui\.netlify\.app$/;
|
|
3
|
+
|
|
4
|
+
export function sessionTtlFromEnv(): number {
|
|
5
|
+
const ttl = Number(process.env.METRO_SESSION_TTL_SEC);
|
|
6
|
+
return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_SESSION_TTL_SEC;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function allowedWebHost(host: string): boolean {
|
|
10
|
+
if (host === 'metro.box' || host === 'localhost' || host === '127.0.0.1')
|
|
11
|
+
return true;
|
|
12
|
+
return PREVIEW.test(host);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateReturnTo(returnTo: string): boolean {
|
|
16
|
+
let url: URL;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(returnTo);
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const host = url.hostname;
|
|
23
|
+
if (url.protocol === 'http:' && (host === 'localhost' || host === '127.0.0.1'))
|
|
24
|
+
return true;
|
|
25
|
+
if (url.protocol !== 'https:') return false;
|
|
26
|
+
return allowedWebHost(host);
|
|
27
|
+
}
|
|
@@ -95,17 +95,17 @@ export function verifyState(
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
interface SessionClaims {
|
|
98
|
-
|
|
98
|
+
subject: string;
|
|
99
99
|
agentIds: string[];
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export interface
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
export interface AgentClaims {
|
|
103
|
+
subject: string;
|
|
104
|
+
agentId: string;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export interface RunClaims {
|
|
108
|
-
|
|
108
|
+
subject: string;
|
|
109
109
|
agentId: string;
|
|
110
110
|
runtimeId: string;
|
|
111
111
|
}
|
|
@@ -119,7 +119,7 @@ export function signSession(
|
|
|
119
119
|
return sign(
|
|
120
120
|
{
|
|
121
121
|
typ: 'session',
|
|
122
|
-
sub: claims.
|
|
122
|
+
sub: claims.subject,
|
|
123
123
|
agent_ids: claims.agentIds,
|
|
124
124
|
iat,
|
|
125
125
|
exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
|
|
@@ -144,20 +144,20 @@ export function verifySession(
|
|
|
144
144
|
ids.some((a) => typeof a !== 'string' || !ID_RE.test(a))
|
|
145
145
|
)
|
|
146
146
|
throw new SessionError('malformed session');
|
|
147
|
-
return {
|
|
147
|
+
return { subject: p.sub, agentIds: ids as string[] };
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
export function
|
|
151
|
-
claims:
|
|
150
|
+
export function signAgentToken(
|
|
151
|
+
claims: AgentClaims,
|
|
152
152
|
secret: string,
|
|
153
153
|
opts: { ttlSec?: number; now?: number } = {},
|
|
154
154
|
): string {
|
|
155
155
|
const iat = nowSec(opts.now);
|
|
156
156
|
return sign(
|
|
157
157
|
{
|
|
158
|
-
typ: '
|
|
159
|
-
sub: claims.
|
|
160
|
-
|
|
158
|
+
typ: 'agent',
|
|
159
|
+
sub: claims.subject,
|
|
160
|
+
agent: claims.agentId,
|
|
161
161
|
iat,
|
|
162
162
|
exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
|
|
163
163
|
},
|
|
@@ -165,18 +165,18 @@ export function signCliToken(
|
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
export function
|
|
168
|
+
export function verifyAgentToken(
|
|
169
169
|
token: string,
|
|
170
170
|
secret: string,
|
|
171
171
|
now?: number,
|
|
172
|
-
):
|
|
172
|
+
): AgentClaims {
|
|
173
173
|
const p = verify(token, secret);
|
|
174
|
-
if (p.typ !== '
|
|
174
|
+
if (p.typ !== 'agent') throw new SessionError('wrong token type');
|
|
175
175
|
if (typeof p.exp !== 'number' || p.exp < nowSec(now))
|
|
176
|
-
throw new SessionError('
|
|
177
|
-
if (typeof p.sub !== 'string' || typeof p.
|
|
178
|
-
throw new SessionError('malformed
|
|
179
|
-
return {
|
|
176
|
+
throw new SessionError('agent token expired');
|
|
177
|
+
if (typeof p.sub !== 'string' || typeof p.agent !== 'string')
|
|
178
|
+
throw new SessionError('malformed agent token');
|
|
179
|
+
return { subject: p.sub, agentId: p.agent };
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
export function signRunToken(
|
|
@@ -188,7 +188,7 @@ export function signRunToken(
|
|
|
188
188
|
return sign(
|
|
189
189
|
{
|
|
190
190
|
typ: 'run',
|
|
191
|
-
sub: claims.
|
|
191
|
+
sub: claims.subject,
|
|
192
192
|
agent: claims.agentId,
|
|
193
193
|
rt: claims.runtimeId,
|
|
194
194
|
iat,
|
|
@@ -214,5 +214,5 @@ export function verifyRunToken(
|
|
|
214
214
|
!ID_RE.test(p.agent)
|
|
215
215
|
)
|
|
216
216
|
throw new SessionError('malformed run token');
|
|
217
|
-
return {
|
|
217
|
+
return { subject: p.sub, agentId: p.agent, runtimeId: p.rt };
|
|
218
218
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { verifyMessage, type Hex } from 'viem';
|
|
2
|
+
import { parseSiweMessage, validateSiweMessage } from 'viem/siwe';
|
|
3
|
+
import { ApiError } from './api-error.js';
|
|
4
|
+
import { allowedWebHost } from './session-config.js';
|
|
5
|
+
|
|
6
|
+
export class SiweError extends ApiError {}
|
|
7
|
+
|
|
8
|
+
export interface SiweLogin {
|
|
9
|
+
message: string;
|
|
10
|
+
signature: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SiweVerifyArgs {
|
|
14
|
+
address: `0x${string}`;
|
|
15
|
+
message: string;
|
|
16
|
+
signature: Hex;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SiweDeps {
|
|
20
|
+
takeNonce: (nonce: string) => boolean;
|
|
21
|
+
now?: number;
|
|
22
|
+
verify?: (args: SiweVerifyArgs) => Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HEX_SIGNATURE = /^0x[0-9a-fA-F]{130}$/;
|
|
26
|
+
|
|
27
|
+
export function allowedSiweDomain(domain: string): boolean {
|
|
28
|
+
return allowedWebHost(domain.replace(/:\d+$/, ''));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hostOf(uri: string): string | null {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(uri).host;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fieldsOrThrow(message: string): {
|
|
40
|
+
address: `0x${string}`;
|
|
41
|
+
domain: string;
|
|
42
|
+
nonce: string;
|
|
43
|
+
parsed: ReturnType<typeof parseSiweMessage>;
|
|
44
|
+
} {
|
|
45
|
+
const parsed = parseSiweMessage(message);
|
|
46
|
+
const { address, domain, nonce, uri, version } = parsed;
|
|
47
|
+
if (
|
|
48
|
+
address === undefined ||
|
|
49
|
+
domain === undefined ||
|
|
50
|
+
nonce === undefined ||
|
|
51
|
+
uri === undefined ||
|
|
52
|
+
version !== '1'
|
|
53
|
+
)
|
|
54
|
+
throw new SiweError('that is not a sign-in message', 400);
|
|
55
|
+
if (!allowedSiweDomain(domain))
|
|
56
|
+
throw new SiweError(`sign-in messages for ${domain} are not accepted here`, 400);
|
|
57
|
+
if (hostOf(uri) !== domain)
|
|
58
|
+
throw new SiweError('the sign-in message names a different site', 400);
|
|
59
|
+
return { address, domain, nonce, parsed };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function verifySiweLogin(
|
|
63
|
+
input: SiweLogin,
|
|
64
|
+
deps: SiweDeps,
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
if (!HEX_SIGNATURE.test(input.signature))
|
|
67
|
+
throw new SiweError('that is not an Ethereum signature', 400);
|
|
68
|
+
const { address, domain, nonce, parsed } = fieldsOrThrow(input.message);
|
|
69
|
+
const time = new Date(deps.now ?? Date.now());
|
|
70
|
+
if (!validateSiweMessage({ message: parsed, domain, nonce, time }))
|
|
71
|
+
throw new SiweError('this sign-in message is expired or does not match', 400);
|
|
72
|
+
if (!deps.takeNonce(nonce))
|
|
73
|
+
throw new SiweError('that sign-in has expired or was already used', 400);
|
|
74
|
+
const verify = deps.verify ?? verifyMessage;
|
|
75
|
+
const signature = input.signature as Hex;
|
|
76
|
+
if (!(await verify({ address, message: input.message, signature })))
|
|
77
|
+
throw new SiweError(
|
|
78
|
+
'the signature does not match the address; smart-contract wallets are not supported yet',
|
|
79
|
+
401,
|
|
80
|
+
);
|
|
81
|
+
return address.toLowerCase();
|
|
82
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { generateSiweNonce } from 'viem/siwe';
|
|
2
|
+
|
|
3
|
+
const TTL_MS = 10 * 60_000;
|
|
4
|
+
const SWEEP_MS = 60_000;
|
|
5
|
+
const MAX_LIVE = 500;
|
|
6
|
+
|
|
7
|
+
const live = new Map<string, number>();
|
|
8
|
+
let sweeper: ReturnType<typeof setInterval> | null = null;
|
|
9
|
+
|
|
10
|
+
function sweep(now = Date.now()): void {
|
|
11
|
+
for (const [nonce, expiresAt] of live) if (expiresAt <= now) live.delete(nonce);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ensureSweeper(): void {
|
|
15
|
+
if (sweeper !== null) return;
|
|
16
|
+
sweeper = setInterval(sweep, SWEEP_MS);
|
|
17
|
+
sweeper.unref?.();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function mintNonce(now = Date.now()): string {
|
|
21
|
+
sweep(now);
|
|
22
|
+
while (live.size >= MAX_LIVE) {
|
|
23
|
+
const oldest = live.keys().next();
|
|
24
|
+
if (oldest.done) break;
|
|
25
|
+
live.delete(oldest.value);
|
|
26
|
+
}
|
|
27
|
+
const nonce = generateSiweNonce();
|
|
28
|
+
live.set(nonce, now + TTL_MS);
|
|
29
|
+
ensureSweeper();
|
|
30
|
+
return nonce;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function takeNonce(nonce: string, now = Date.now()): boolean {
|
|
34
|
+
const expiresAt = live.get(nonce);
|
|
35
|
+
if (expiresAt === undefined) return false;
|
|
36
|
+
live.delete(nonce);
|
|
37
|
+
return expiresAt > now;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function nonceCount(): number {
|
|
41
|
+
sweep();
|
|
42
|
+
return live.size;
|
|
43
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { log } from './log.js';
|
|
3
|
+
import {
|
|
4
|
+
apiFailure,
|
|
5
|
+
bodyField,
|
|
6
|
+
cors,
|
|
7
|
+
readJsonBody,
|
|
8
|
+
sendJson,
|
|
9
|
+
} from './api-http.js';
|
|
10
|
+
import { mintNonce, takeNonce } from './siwe-nonces.js';
|
|
11
|
+
import { verifySiweLogin } from './siwe-auth.js';
|
|
12
|
+
import { signSession } from './session.js';
|
|
13
|
+
import { sessionTtlFromEnv } from './session-config.js';
|
|
14
|
+
import { ensureUserWithProject } from '../db/projects.js';
|
|
15
|
+
|
|
16
|
+
const NONCE_PATH = '/auth/siwe/nonce';
|
|
17
|
+
const VERIFY_PATH = '/auth/siwe/verify';
|
|
18
|
+
|
|
19
|
+
export interface SiweRouteDeps {
|
|
20
|
+
ensureUser: (address: string) => Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const defaultDeps: SiweRouteDeps = { ensureUser: ensureUserWithProject };
|
|
24
|
+
|
|
25
|
+
async function handleVerify(
|
|
26
|
+
req: IncomingMessage,
|
|
27
|
+
res: ServerResponse,
|
|
28
|
+
secret: string,
|
|
29
|
+
deps: SiweRouteDeps,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const body = await readJsonBody(req);
|
|
32
|
+
const message = bodyField(body, 'message');
|
|
33
|
+
const signature = bodyField(body, 'signature');
|
|
34
|
+
if (typeof message !== 'string' || typeof signature !== 'string') {
|
|
35
|
+
sendJson(req, res, 400, { error: 'message and signature are required' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const address = await verifySiweLogin({ message, signature }, { takeNonce });
|
|
39
|
+
const userId = await deps.ensureUser(address);
|
|
40
|
+
log.info({ userId, address }, 'siwe: session issued');
|
|
41
|
+
const session = signSession({ subject: address, agentIds: [] }, secret, {
|
|
42
|
+
ttlSec: sessionTtlFromEnv(),
|
|
43
|
+
});
|
|
44
|
+
sendJson(req, res, 200, { session, address });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ROUTES: Record<string, string> = {
|
|
48
|
+
[NONCE_PATH]: 'GET',
|
|
49
|
+
[VERIFY_PATH]: 'POST',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function refused(
|
|
53
|
+
req: IncomingMessage,
|
|
54
|
+
res: ServerResponse,
|
|
55
|
+
path: string,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (req.method === 'OPTIONS') {
|
|
58
|
+
res.writeHead(204, cors(req)).end();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (req.method !== ROUTES[path]) {
|
|
62
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function handleSiweAuthRequest(
|
|
69
|
+
req: IncomingMessage,
|
|
70
|
+
res: ServerResponse,
|
|
71
|
+
deps: SiweRouteDeps = defaultDeps,
|
|
72
|
+
): boolean {
|
|
73
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
74
|
+
if (ROUTES[path] === undefined) return false;
|
|
75
|
+
if (refused(req, res, path)) return true;
|
|
76
|
+
const secret = process.env.METRO_SESSION_SECRET?.trim() ?? '';
|
|
77
|
+
if (secret === '') {
|
|
78
|
+
sendJson(req, res, 503, { error: 'sign-in is not configured' });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (path === NONCE_PATH) {
|
|
82
|
+
sendJson(req, res, 200, { nonce: mintNonce() });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
handleVerify(req, res, secret, deps).catch((err: unknown) => {
|
|
86
|
+
apiFailure(req, res, err, 'siwe');
|
|
87
|
+
});
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { mkdirSync, statSync, type FSWatcher } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { errMsg, log } from './log.js';
|
|
5
4
|
import { daemonSelf } from './events.js';
|
|
@@ -22,9 +21,7 @@ import {
|
|
|
22
21
|
startWatcher,
|
|
23
22
|
type TrainState,
|
|
24
23
|
} from './supervisor-io.js';
|
|
25
|
-
|
|
26
|
-
export const TRAINS_DIR =
|
|
27
|
-
process.env.METRO_TRAINS_DIR ?? join(homedir(), '.metro', 'trains');
|
|
24
|
+
import { trainsDir } from './paths.js';
|
|
28
25
|
|
|
29
26
|
const DYLD_FALLBACK_ENV: Record<string, string> =
|
|
30
27
|
process.platform === 'darwin'
|
|
@@ -45,7 +42,7 @@ export class TrainSupervisor {
|
|
|
45
42
|
private watcher: FSWatcher | null = null;
|
|
46
43
|
private reloadTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
47
44
|
|
|
48
|
-
constructor(private dir: string =
|
|
45
|
+
constructor(private dir: string = trainsDir()) {}
|
|
49
46
|
|
|
50
47
|
running(): string[] {
|
|
51
48
|
return [...this.trains.keys()];
|
|
@@ -3,12 +3,11 @@ import { and, asc, eq, inArray } from 'drizzle-orm';
|
|
|
3
3
|
import { ApiError } from '../daemon/api-error.js';
|
|
4
4
|
import { getDb } from './client.js';
|
|
5
5
|
import { registerKey, rotateAgentKey, unregisterAgentKey } from './key-map.js';
|
|
6
|
-
import { newId, parseId } from './ids.js';
|
|
6
|
+
import { AGENT_NAME_RE, newId, parseId } from './ids.js';
|
|
7
7
|
import { agents, stations } from './schema.js';
|
|
8
8
|
import { isUniqueViolation } from './users.js';
|
|
9
9
|
import { projectIdOrThrow } from './projects.js';
|
|
10
10
|
|
|
11
|
-
export const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{1,31}$/;
|
|
12
11
|
const KEY_ATTEMPTS = 5;
|
|
13
12
|
|
|
14
13
|
export class AgentAdminError extends ApiError {}
|
|
@@ -93,11 +92,11 @@ async function selectKeyRows(ids: string[]): Promise<KeyRow[]> {
|
|
|
93
92
|
.where(inArray(agents.id, ids));
|
|
94
93
|
}
|
|
95
94
|
|
|
96
|
-
export async function
|
|
97
|
-
|
|
95
|
+
export async function listAgentsForUser(
|
|
96
|
+
subject: string,
|
|
98
97
|
project: string,
|
|
99
98
|
): Promise<AgentSummary[]> {
|
|
100
|
-
const projectId = await projectIdOrThrow(
|
|
99
|
+
const projectId = await projectIdOrThrow(subject, project);
|
|
101
100
|
const rows = await getDb()
|
|
102
101
|
.select({ id: agents.id, name: agents.name, projectId: agents.projectId })
|
|
103
102
|
.from(agents)
|
|
@@ -123,14 +122,14 @@ async function insertAgent(
|
|
|
123
122
|
return id;
|
|
124
123
|
}
|
|
125
124
|
|
|
126
|
-
export async function
|
|
127
|
-
|
|
125
|
+
export async function createAgentForUser(
|
|
126
|
+
subject: string,
|
|
128
127
|
project: string,
|
|
129
128
|
rawName: string,
|
|
130
129
|
): Promise<CreatedAgent> {
|
|
131
130
|
const name = normalizeAgentName(rawName);
|
|
132
131
|
const key = newApiKey();
|
|
133
|
-
const id = await insertAgent(await projectIdOrThrow(
|
|
132
|
+
const id = await insertAgent(await projectIdOrThrow(subject, project), name, key);
|
|
134
133
|
if (servesEveryAgent()) registerKey(key, id);
|
|
135
134
|
return { id, name, key };
|
|
136
135
|
}
|
|
@@ -145,7 +144,7 @@ interface Deletable {
|
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
export async function ownedAgentOrThrow(
|
|
148
|
-
|
|
147
|
+
subject: string,
|
|
149
148
|
id: string,
|
|
150
149
|
): Promise<Deletable> {
|
|
151
150
|
const rows = await getDb().select().from(agents).where(eq(agents.id, id));
|
|
@@ -153,7 +152,7 @@ export async function ownedAgentOrThrow(
|
|
|
153
152
|
const missing = new AgentAdminError('no such agent', 404);
|
|
154
153
|
if (!row) throw missing;
|
|
155
154
|
try {
|
|
156
|
-
await projectIdOrThrow(
|
|
155
|
+
await projectIdOrThrow(subject, row.projectId);
|
|
157
156
|
} catch {
|
|
158
157
|
throw missing;
|
|
159
158
|
}
|
|
@@ -179,21 +178,21 @@ async function writeNewKey(id: string, projectId: string): Promise<string> {
|
|
|
179
178
|
throw new AgentAdminError('could not allocate a free api key', 500);
|
|
180
179
|
}
|
|
181
180
|
|
|
182
|
-
export async function
|
|
183
|
-
|
|
181
|
+
export async function resetAgentKeyForUser(
|
|
182
|
+
subject: string,
|
|
184
183
|
id: string,
|
|
185
184
|
): Promise<ResetAgentKey> {
|
|
186
|
-
const { agent, projectId } = await ownedAgentOrThrow(
|
|
185
|
+
const { agent, projectId } = await ownedAgentOrThrow(subject, id);
|
|
187
186
|
const key = await writeNewKey(agent.id, projectId);
|
|
188
187
|
rotateAgentKey(agent.id, daemonServesAgent(agent.id) ? key : null);
|
|
189
188
|
return { id: agent.id, name: agent.name, key };
|
|
190
189
|
}
|
|
191
190
|
|
|
192
|
-
export async function
|
|
193
|
-
|
|
191
|
+
export async function deleteAgentForUser(
|
|
192
|
+
subject: string,
|
|
194
193
|
id: string,
|
|
195
194
|
): Promise<DeletedAgent> {
|
|
196
|
-
const { agent, projectId } = await ownedAgentOrThrow(
|
|
195
|
+
const { agent, projectId } = await ownedAgentOrThrow(subject, id);
|
|
197
196
|
await getDb().transaction(async (tx) => {
|
|
198
197
|
const attached = await tx
|
|
199
198
|
.select({ id: stations.id })
|