@stage-labs/metro 0.1.0-beta.53 → 0.1.0-beta.55
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/serve.js +56 -7
- 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/agent-api.ts +5 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +9 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +6 -13
- 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 +4 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/bundle-api.ts +5 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +3 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connect-hint.ts +4 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +5 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-key.ts +33 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-registry.ts +13 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-routes.ts +54 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/{session-config.ts → return-to.ts} +0 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +9 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/signed-identity.ts +36 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/tunnel.ts +87 -24
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/update-api.ts +2 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-api.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-api.ts +2 -32
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +0 -10
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +0 -6
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/index.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +2 -28
- package/runtime/node_modules/@metro-labs/mcp/src/monitor/api.ts +2 -5
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +1 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +0 -21
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +0 -96
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +0 -73
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +0 -43
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +0 -86
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +0 -41
|
@@ -8,14 +8,57 @@ import { readJson } from './secure-fs.js';
|
|
|
8
8
|
|
|
9
9
|
const RESTART_DELAY_MS = 2_000;
|
|
10
10
|
|
|
11
|
-
export
|
|
12
|
-
|
|
11
|
+
export type TunnelKind = 'quick' | 'tailscale';
|
|
12
|
+
|
|
13
|
+
export function tunnelKind(): TunnelKind | null {
|
|
14
|
+
const raw = process.env.METRO_TUNNEL?.trim();
|
|
15
|
+
return raw === 'quick' || raw === 'tailscale' ? raw : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const quickTunnelWanted = (): boolean => tunnelKind() === 'quick';
|
|
13
19
|
|
|
14
20
|
const QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
21
|
+
const FUNNEL_URL_RE = /https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\.ts\.net(?::\d{2,5})?(?=[\s/]|$)/i;
|
|
22
|
+
const SILENT_MS = 20_000;
|
|
15
23
|
|
|
16
24
|
export const quickTunnelUrlIn = (text: string): string | null =>
|
|
17
25
|
QUICK_URL_RE.exec(text)?.[0] ?? null;
|
|
18
26
|
|
|
27
|
+
export const funnelUrlIn = (text: string): string | null =>
|
|
28
|
+
FUNNEL_URL_RE.exec(text)?.[0]?.toLowerCase() ?? null;
|
|
29
|
+
|
|
30
|
+
export interface TunnelDriver {
|
|
31
|
+
name: string;
|
|
32
|
+
command: string;
|
|
33
|
+
args: string[];
|
|
34
|
+
urlIn: (text: string) => string | null;
|
|
35
|
+
waitsForDns: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const quickDriver = (port: number): TunnelDriver => ({
|
|
39
|
+
name: 'cloudflared quick tunnel',
|
|
40
|
+
command: 'cloudflared',
|
|
41
|
+
args: ['--no-autoupdate', 'tunnel', '--url', `http://127.0.0.1:${String(port)}`],
|
|
42
|
+
urlIn: quickTunnelUrlIn,
|
|
43
|
+
waitsForDns: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function tailscaleBin(): string {
|
|
47
|
+
const configured = process.env.METRO_TAILSCALE_BIN?.trim() ?? '';
|
|
48
|
+
return configured === '' ? 'tailscale' : configured;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const funnelDriver = (port: number, bin = tailscaleBin()): TunnelDriver => ({
|
|
52
|
+
name: 'tailscale funnel',
|
|
53
|
+
command: bin,
|
|
54
|
+
args: ['funnel', String(port)],
|
|
55
|
+
urlIn: funnelUrlIn,
|
|
56
|
+
waitsForDns: false,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const driverFor = (kind: TunnelKind, port: number): TunnelDriver =>
|
|
60
|
+
kind === 'quick' ? quickDriver(port) : funnelDriver(port);
|
|
61
|
+
|
|
19
62
|
let liveUrl: string | null = null;
|
|
20
63
|
|
|
21
64
|
export const currentTunnelUrl = (): string | null => liveUrl;
|
|
@@ -104,53 +147,72 @@ export function tokenMatches(secret: string, given: string): boolean {
|
|
|
104
147
|
export class Tunnel {
|
|
105
148
|
private child: ChildProcess | null = null;
|
|
106
149
|
private closed = false;
|
|
150
|
+
private output: string[] = [];
|
|
107
151
|
|
|
108
152
|
constructor(
|
|
109
|
-
private
|
|
153
|
+
private driver: TunnelDriver,
|
|
110
154
|
private onUrl: (url: string) => void = () => undefined,
|
|
111
155
|
private resolves: Resolves = resolvesAtCloudflare,
|
|
112
156
|
) {}
|
|
113
157
|
|
|
114
|
-
private origin(): string {
|
|
115
|
-
return `http://127.0.0.1:${String(this.port)}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
158
|
private noticeUrl(text: string): void {
|
|
119
|
-
const url =
|
|
159
|
+
const url = this.driver.urlIn(text);
|
|
120
160
|
if (url === null || url === liveUrl) return;
|
|
121
161
|
liveUrl = url;
|
|
122
|
-
|
|
162
|
+
if (!this.driver.waitsForDns) {
|
|
163
|
+
log.info({ url }, `${this.driver.name} up`);
|
|
164
|
+
this.onUrl(url);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
log.info({ url }, `${this.driver.name} up; waiting for its name to resolve`);
|
|
123
168
|
this.announceWhenResolvable(url).catch((err: unknown) => {
|
|
124
|
-
log.warn({ err: errMsg(err) },
|
|
169
|
+
log.warn({ err: errMsg(err) }, `${this.driver.name}: announce failed`);
|
|
125
170
|
});
|
|
126
171
|
}
|
|
127
172
|
|
|
128
173
|
private async announceWhenResolvable(url: string): Promise<void> {
|
|
129
174
|
const resolved = await untilResolvable(new URL(url).host, this.resolves);
|
|
130
175
|
if (liveUrl !== url) return;
|
|
131
|
-
if (!resolved) log.warn({ url },
|
|
176
|
+
if (!resolved) log.warn({ url }, `${this.driver.name} name still not resolving; announcing anyway`);
|
|
132
177
|
this.onUrl(url);
|
|
133
178
|
}
|
|
134
179
|
|
|
180
|
+
private read(d: Buffer | string): void {
|
|
181
|
+
const text = (typeof d === 'string' ? d : d.toString('utf8')).trim();
|
|
182
|
+
if (text === '') return;
|
|
183
|
+
this.output.push(text);
|
|
184
|
+
log.debug({ output: text }, this.driver.name);
|
|
185
|
+
this.noticeUrl(text);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private silentCheck(): void {
|
|
189
|
+
if (this.closed || liveUrl !== null || this.child === null) return;
|
|
190
|
+
log.warn(
|
|
191
|
+
{ output: this.output.join('\n') },
|
|
192
|
+
`${this.driver.name}: no public address after ${String(SILENT_MS / 1000)}s; its own output follows`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
135
196
|
start(): void {
|
|
136
197
|
if (this.closed) return;
|
|
137
|
-
log.info({
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
['--no-autoupdate', 'tunnel', '--url', this.origin()],
|
|
141
|
-
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
142
|
-
);
|
|
198
|
+
log.info({ command: this.driver.command, args: this.driver.args }, `${this.driver.name} starting`);
|
|
199
|
+
this.output = [];
|
|
200
|
+
const child = spawn(this.driver.command, this.driver.args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
143
201
|
this.child = child;
|
|
202
|
+
child.stdout?.on('data', (d: Buffer | string) => {
|
|
203
|
+
this.read(d);
|
|
204
|
+
});
|
|
144
205
|
child.stderr?.on('data', (d: Buffer | string) => {
|
|
145
|
-
|
|
146
|
-
log.debug({ cloudflared: text }, 'cloudflared');
|
|
147
|
-
this.noticeUrl(text);
|
|
206
|
+
this.read(d);
|
|
148
207
|
});
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
this.silentCheck();
|
|
210
|
+
}, SILENT_MS).unref();
|
|
149
211
|
child.on('exit', (code) => {
|
|
150
212
|
this.child = null;
|
|
151
213
|
liveUrl = null;
|
|
152
214
|
if (this.closed) return;
|
|
153
|
-
log.warn({ code },
|
|
215
|
+
log.warn({ code, output: this.output.slice(-5).join('\n') }, `${this.driver.name} exited; restarting`);
|
|
154
216
|
setTimeout(() => {
|
|
155
217
|
this.start();
|
|
156
218
|
}, RESTART_DELAY_MS);
|
|
@@ -159,17 +221,18 @@ export class Tunnel {
|
|
|
159
221
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
160
222
|
this.closed = true;
|
|
161
223
|
log.error(
|
|
162
|
-
|
|
224
|
+
{ command: this.driver.command },
|
|
225
|
+
`${this.driver.name}: the binary is not installed, so there is no public address; install it and restart`,
|
|
163
226
|
);
|
|
164
227
|
return;
|
|
165
228
|
}
|
|
166
|
-
log.warn({ err: errMsg(err) },
|
|
229
|
+
log.warn({ err: errMsg(err) }, `${this.driver.name} spawn error`);
|
|
167
230
|
});
|
|
168
231
|
}
|
|
169
232
|
|
|
170
233
|
stop(): void {
|
|
171
234
|
this.closed = true;
|
|
172
|
-
this.child?.kill();
|
|
235
|
+
this.child?.kill('SIGINT');
|
|
173
236
|
this.child = null;
|
|
174
237
|
}
|
|
175
238
|
}
|
|
@@ -93,13 +93,10 @@ export function handleUpdateRequest(req: IncomingMessage, res: ServerResponse, d
|
|
|
93
93
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
94
94
|
return true;
|
|
95
95
|
}
|
|
96
|
-
const session = apiSession(req);
|
|
97
|
-
if (!session) {
|
|
98
|
-
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
96
|
Promise.resolve()
|
|
102
97
|
.then(async (): Promise<unknown> => {
|
|
98
|
+
const session = await apiSession(req);
|
|
99
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
103
100
|
deps.authorize(session.subject);
|
|
104
101
|
const bin = binOrThrow(deps);
|
|
105
102
|
if (req.method === 'GET') return { ...(await check(bin)), running: METRO_VERSION };
|
|
@@ -4,7 +4,6 @@ import { guessMime } from '../stations/attachments.js';
|
|
|
4
4
|
import { safeFileName } from '../stations/attach-inline.js';
|
|
5
5
|
import {
|
|
6
6
|
allowedAgents,
|
|
7
|
-
authConfigFromEnv,
|
|
8
7
|
authenticate,
|
|
9
8
|
extractToken,
|
|
10
9
|
} from '../mcp/request-identity.js';
|
|
@@ -69,7 +68,7 @@ const query = (req: IncomingMessage, key: string): string | undefined =>
|
|
|
69
68
|
new URL(req.url ?? '/', 'http://localhost').searchParams.get(key) ?? undefined;
|
|
70
69
|
|
|
71
70
|
const identityScope = (req: IncomingMessage): Set<string> =>
|
|
72
|
-
allowedAgents(authenticate(req
|
|
71
|
+
allowedAgents(authenticate(req) ?? undefined);
|
|
73
72
|
|
|
74
73
|
function ownerFromScope(req: IncomingMessage, allowed: Set<string>): string {
|
|
75
74
|
const requested = query(req, 'agent');
|
|
@@ -1,42 +1,12 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from './log.js';
|
|
3
|
-
import { verifyMessage } from 'viem';
|
|
4
3
|
import { apiFailure, cors, readJsonBody, sendJson } from './api-http.js';
|
|
5
|
-
import { normalizeAddress } from '../db/address.js';
|
|
6
4
|
import { parseId } from '../db/ids.js';
|
|
7
5
|
import { ENVELOPE_MAX, type VaultBundle, type VaultEntry } from './vault-types.js';
|
|
6
|
+
import { signedIdentity } from './signed-identity.js';
|
|
8
7
|
|
|
9
8
|
const PREFIX = '/api/vault';
|
|
10
9
|
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
|
-
}
|
|
40
10
|
|
|
41
11
|
export interface VaultApiDeps {
|
|
42
12
|
list: (subject: string) => Promise<VaultEntry[]>;
|
|
@@ -64,7 +34,7 @@ async function route(
|
|
|
64
34
|
tgt: { kind: 'index' } | { kind: 'bundle'; id: string },
|
|
65
35
|
): Promise<void> {
|
|
66
36
|
try {
|
|
67
|
-
const owner = await
|
|
37
|
+
const owner = await signedIdentity(req);
|
|
68
38
|
if (owner === null) {
|
|
69
39
|
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
70
40
|
return;
|
|
@@ -54,16 +54,6 @@ export function setLocalOwner(raw: string, dir = agentsDir()): string {
|
|
|
54
54
|
return address;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function ownerSignIn(raw: string, dir = agentsDir()): Promise<string> {
|
|
58
|
-
const owner = localOwner(dir);
|
|
59
|
-
if (owner === null)
|
|
60
|
-
return Promise.reject(
|
|
61
|
-
new ApiError('this machine has no owner yet: start it with metro serve --owner <address>', 403),
|
|
62
|
-
);
|
|
63
|
-
if (owner !== normalizeAddress(raw))
|
|
64
|
-
return Promise.reject(new ApiError('this machine belongs to another wallet', 403));
|
|
65
|
-
return Promise.resolve(owner);
|
|
66
|
-
}
|
|
67
57
|
|
|
68
58
|
function isOwner(subject: string, dir: string): boolean {
|
|
69
59
|
const owner = localOwner(dir);
|
|
@@ -4,12 +4,6 @@ import { STATIONS, type ConnectorTransport, type StationName } from './stations.
|
|
|
4
4
|
|
|
5
5
|
export { STATIONS, type ConnectorTransport, type StationName };
|
|
6
6
|
|
|
7
|
-
export const users = pgTable('users', {
|
|
8
|
-
id: text('id').primaryKey(),
|
|
9
|
-
email: text('email').unique(),
|
|
10
|
-
address: text('address').unique(),
|
|
11
|
-
});
|
|
12
|
-
|
|
13
7
|
export type ProjectRole = 'admin' | 'member';
|
|
14
8
|
|
|
15
9
|
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
} from '../daemon/http.js';
|
|
6
6
|
import {
|
|
7
7
|
allowedAgents,
|
|
8
|
-
authConfigFromEnv,
|
|
9
8
|
authenticate,
|
|
10
9
|
runWithIdentity,
|
|
11
10
|
type RequestIdentity,
|
|
@@ -156,7 +155,7 @@ export async function createMetroMcp(): Promise<{
|
|
|
156
155
|
req: IncomingMessage,
|
|
157
156
|
res: ServerResponse,
|
|
158
157
|
): Promise<void> => {
|
|
159
|
-
const identity = authenticate(req
|
|
158
|
+
const identity = authenticate(req);
|
|
160
159
|
if (!identity) {
|
|
161
160
|
res.writeHead(401).end('unauthorized');
|
|
162
161
|
return;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import type { IncomingMessage } from 'node:http';
|
|
3
|
-
import { verifySession } from '../daemon/session.js';
|
|
4
3
|
import { agentIdForKey } from '../db/key-map.js';
|
|
5
4
|
|
|
6
5
|
export type RequestIdentity =
|
|
@@ -20,14 +19,6 @@ export function currentIdentity(): RequestIdentity | undefined {
|
|
|
20
19
|
return storage.getStore();
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
export interface AuthConfig {
|
|
24
|
-
sessionSecret: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function authConfigFromEnv(): AuthConfig {
|
|
28
|
-
return { sessionSecret: process.env.METRO_SESSION_SECRET?.trim() ?? '' };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
22
|
export function extractToken(req: IncomingMessage): string | undefined {
|
|
32
23
|
const header = req.headers.authorization;
|
|
33
24
|
if (header?.startsWith('Bearer ')) {
|
|
@@ -40,28 +31,11 @@ export function extractToken(req: IncomingMessage): string | undefined {
|
|
|
40
31
|
return qt ?? undefined;
|
|
41
32
|
}
|
|
42
33
|
|
|
43
|
-
|
|
44
|
-
/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token);
|
|
45
|
-
|
|
46
|
-
export function authenticate(
|
|
47
|
-
req: IncomingMessage,
|
|
48
|
-
cfg: AuthConfig,
|
|
49
|
-
): RequestIdentity | null {
|
|
34
|
+
export function authenticate(req: IncomingMessage): RequestIdentity | null {
|
|
50
35
|
const token = extractToken(req);
|
|
51
36
|
if (!token) return null;
|
|
52
|
-
|
|
53
37
|
const agentId = agentIdForKey(token);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (cfg.sessionSecret !== '' && looksLikeJwt(token)) {
|
|
57
|
-
try {
|
|
58
|
-
const { subject, agentIds } = verifySession(token, cfg.sessionSecret);
|
|
59
|
-
return { kind: 'session', subject, agentIds };
|
|
60
|
-
} catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
38
|
+
return agentId === undefined ? null : { kind: 'agent', agentId };
|
|
65
39
|
}
|
|
66
40
|
|
|
67
41
|
export function allowedAgents(
|
|
@@ -6,7 +6,6 @@ import { callTargetDenied, eventInScope } from '../db/agent-scope.js';
|
|
|
6
6
|
import { hasAnyKey } from '../db/key-map.js';
|
|
7
7
|
import {
|
|
8
8
|
allowedAgents,
|
|
9
|
-
authConfigFromEnv,
|
|
10
9
|
authenticate,
|
|
11
10
|
} from '../mcp/request-identity.js';
|
|
12
11
|
import { METRO_VERSION } from '../daemon/version.js';
|
|
@@ -20,9 +19,7 @@ export type MonitorCall = (
|
|
|
20
19
|
const KEEPALIVE_MS = 25_000;
|
|
21
20
|
const CALL_BODY_MAX = 256 * 1024;
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
return hasAnyKey() || authConfigFromEnv().sessionSecret !== '';
|
|
25
|
-
}
|
|
22
|
+
const monitorEnabled = (): boolean => hasAnyKey();
|
|
26
23
|
|
|
27
24
|
function cors(req: IncomingMessage): Record<string, string> {
|
|
28
25
|
return {
|
|
@@ -230,7 +227,7 @@ export function handleMonitorRequest(
|
|
|
230
227
|
const path = (req.url ?? '').split('?', 2)[0] ?? '';
|
|
231
228
|
if (!path.startsWith('/api/')) return false;
|
|
232
229
|
if (preflight(req, res, path)) return true;
|
|
233
|
-
const identity = authenticate(req
|
|
230
|
+
const identity = authenticate(req);
|
|
234
231
|
if (!identity) {
|
|
235
232
|
sendJson(res, req, 401, { error: 'unauthorized' });
|
|
236
233
|
return true;
|
package/runtime/runtime.json
CHANGED
package/runtime/stations.json
CHANGED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { agentsDir } from '../db/file-source.js';
|
|
5
|
-
import { ensureSecureDir, writeSecure } from './secure-fs.js';
|
|
6
|
-
|
|
7
|
-
const SECRET_FILE = '.session-secret';
|
|
8
|
-
|
|
9
|
-
export function ensureLocalSessionSecret(dir = agentsDir()): string {
|
|
10
|
-
const configured = process.env.METRO_SESSION_SECRET?.trim() ?? '';
|
|
11
|
-
if (configured !== '') return configured;
|
|
12
|
-
const path = join(dir, SECRET_FILE);
|
|
13
|
-
let secret = existsSync(path) ? readFileSync(path, 'utf8').trim() : '';
|
|
14
|
-
if (secret === '') {
|
|
15
|
-
secret = randomBytes(32).toString('base64url');
|
|
16
|
-
ensureSecureDir(dir);
|
|
17
|
-
writeSecure(path, `${secret}\n`);
|
|
18
|
-
}
|
|
19
|
-
process.env.METRO_SESSION_SECRET = secret;
|
|
20
|
-
return secret;
|
|
21
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
import { ID_RE } from '../db/ids.js';
|
|
3
|
-
|
|
4
|
-
export class SessionError extends Error {}
|
|
5
|
-
|
|
6
|
-
const b64url = (buf: Buffer): string =>
|
|
7
|
-
buf
|
|
8
|
-
.toString('base64')
|
|
9
|
-
.replace(/\+/g, '-')
|
|
10
|
-
.replace(/\//g, '_')
|
|
11
|
-
.replace(/=+$/, '');
|
|
12
|
-
|
|
13
|
-
const b64urlJson = (v: unknown): string =>
|
|
14
|
-
b64url(Buffer.from(JSON.stringify(v)));
|
|
15
|
-
|
|
16
|
-
const decodeJson = (seg: string): unknown =>
|
|
17
|
-
JSON.parse(
|
|
18
|
-
Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString(
|
|
19
|
-
'utf8',
|
|
20
|
-
),
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
const HEADER = b64urlJson({ alg: 'HS256', typ: 'JWT' });
|
|
24
|
-
|
|
25
|
-
function sign(payload: Record<string, unknown>, secret: string): string {
|
|
26
|
-
if (secret === '') throw new SessionError('missing signing secret');
|
|
27
|
-
const body = b64urlJson(payload);
|
|
28
|
-
const data = `${HEADER}.${body}`;
|
|
29
|
-
const sig = b64url(createHmac('sha256', secret).update(data).digest());
|
|
30
|
-
return `${data}.${sig}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function verify(token: string, secret: string): Record<string, unknown> {
|
|
34
|
-
if (secret === '') throw new SessionError('missing signing secret');
|
|
35
|
-
const parts = token.split('.');
|
|
36
|
-
if (parts.length !== 3) throw new SessionError('malformed token');
|
|
37
|
-
const data = `${parts[0]}.${parts[1]}`;
|
|
38
|
-
const expected = b64url(createHmac('sha256', secret).update(data).digest());
|
|
39
|
-
const a = Buffer.from(parts[2] ?? '');
|
|
40
|
-
const b = Buffer.from(expected);
|
|
41
|
-
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
42
|
-
throw new SessionError('bad signature');
|
|
43
|
-
let payload: unknown;
|
|
44
|
-
try {
|
|
45
|
-
payload = decodeJson(parts[1] ?? '');
|
|
46
|
-
} catch {
|
|
47
|
-
throw new SessionError('malformed payload');
|
|
48
|
-
}
|
|
49
|
-
if (typeof payload !== 'object' || payload === null)
|
|
50
|
-
throw new SessionError('malformed payload');
|
|
51
|
-
return payload as Record<string, unknown>;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const nowSec = (now?: number): number => Math.floor((now ?? Date.now()) / 1000);
|
|
55
|
-
|
|
56
|
-
interface SessionClaims {
|
|
57
|
-
subject: string;
|
|
58
|
-
agentIds: string[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function signSession(
|
|
62
|
-
claims: SessionClaims,
|
|
63
|
-
secret: string,
|
|
64
|
-
opts: { ttlSec?: number; now?: number } = {},
|
|
65
|
-
): string {
|
|
66
|
-
const iat = nowSec(opts.now);
|
|
67
|
-
return sign(
|
|
68
|
-
{
|
|
69
|
-
typ: 'session',
|
|
70
|
-
sub: claims.subject,
|
|
71
|
-
agent_ids: claims.agentIds,
|
|
72
|
-
iat,
|
|
73
|
-
exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
|
|
74
|
-
},
|
|
75
|
-
secret,
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function verifySession(
|
|
80
|
-
token: string,
|
|
81
|
-
secret: string,
|
|
82
|
-
now?: number,
|
|
83
|
-
): SessionClaims {
|
|
84
|
-
const p = verify(token, secret);
|
|
85
|
-
if (p.typ !== 'session') throw new SessionError('wrong token type');
|
|
86
|
-
if (typeof p.exp !== 'number' || p.exp < nowSec(now))
|
|
87
|
-
throw new SessionError('session expired');
|
|
88
|
-
const ids = p.agent_ids;
|
|
89
|
-
if (
|
|
90
|
-
typeof p.sub !== 'string' ||
|
|
91
|
-
!Array.isArray(ids) ||
|
|
92
|
-
ids.some((a) => typeof a !== 'string' || !ID_RE.test(a))
|
|
93
|
-
)
|
|
94
|
-
throw new SessionError('malformed session');
|
|
95
|
-
return { subject: p.sub, agentIds: ids as string[] };
|
|
96
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
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 SiweDeps {
|
|
14
|
-
takeNonce: (nonce: string) => boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const HEX_SIGNATURE = /^0x[0-9a-fA-F]{130}$/;
|
|
18
|
-
|
|
19
|
-
export function allowedSiweDomain(domain: string): boolean {
|
|
20
|
-
return allowedWebHost(domain.replace(/:\d+$/, ''));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function hostOf(uri: string): string | null {
|
|
24
|
-
try {
|
|
25
|
-
return new URL(uri).host;
|
|
26
|
-
} catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function fieldsOrThrow(message: string): {
|
|
32
|
-
address: `0x${string}`;
|
|
33
|
-
domain: string;
|
|
34
|
-
nonce: string;
|
|
35
|
-
parsed: ReturnType<typeof parseSiweMessage>;
|
|
36
|
-
} {
|
|
37
|
-
const parsed = parseSiweMessage(message);
|
|
38
|
-
const { address, domain, nonce, uri, version } = parsed;
|
|
39
|
-
if (
|
|
40
|
-
address === undefined ||
|
|
41
|
-
domain === undefined ||
|
|
42
|
-
nonce === undefined ||
|
|
43
|
-
uri === undefined ||
|
|
44
|
-
version !== '1'
|
|
45
|
-
)
|
|
46
|
-
throw new SiweError('that is not a sign-in message', 400);
|
|
47
|
-
if (!allowedSiweDomain(domain))
|
|
48
|
-
throw new SiweError(`sign-in messages for ${domain} are not accepted here`, 400);
|
|
49
|
-
if (hostOf(uri) !== domain)
|
|
50
|
-
throw new SiweError('the sign-in message names a different site', 400);
|
|
51
|
-
return { address, domain, nonce, parsed };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function verifySiweLogin(
|
|
55
|
-
input: SiweLogin,
|
|
56
|
-
deps: SiweDeps,
|
|
57
|
-
): Promise<string> {
|
|
58
|
-
if (!HEX_SIGNATURE.test(input.signature))
|
|
59
|
-
throw new SiweError('that is not an Ethereum signature', 400);
|
|
60
|
-
const { address, domain, nonce, parsed } = fieldsOrThrow(input.message);
|
|
61
|
-
const time = new Date();
|
|
62
|
-
if (!validateSiweMessage({ message: parsed, domain, nonce, time }))
|
|
63
|
-
throw new SiweError('this sign-in message is expired or does not match', 400);
|
|
64
|
-
if (!deps.takeNonce(nonce))
|
|
65
|
-
throw new SiweError('that sign-in has expired or was already used', 400);
|
|
66
|
-
const signature = input.signature as Hex;
|
|
67
|
-
if (!(await verifyMessage({ address, message: input.message, signature })))
|
|
68
|
-
throw new SiweError(
|
|
69
|
-
'the signature does not match the address; smart-contract wallets are not supported yet',
|
|
70
|
-
401,
|
|
71
|
-
);
|
|
72
|
-
return address.toLowerCase();
|
|
73
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
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
|
-
}
|