@stage-labs/metro 0.1.0-beta.31 → 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/cli.js +5 -0
- package/dist/runtime.js +4 -1
- package/dist/serve.js +57 -0
- 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 +16 -15
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +9 -9
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +8 -7
- 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 +109 -71
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +16 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +14 -14
- 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/run-api.ts +2 -2
- 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 +4 -0
- 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 +9 -9
- 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 +9 -9
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +31 -31
- 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 +48 -39
- package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +7 -7
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +2 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +22 -12
- 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/google-auth.ts +0 -164
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { ApiError } from './api-error.js';
|
|
3
|
+
import { errMsg, log } from './log.js';
|
|
4
|
+
import { AttachSessions } from './attach-session.js';
|
|
5
|
+
import type { AgentApiDeps } from './agent-api.js';
|
|
6
|
+
import { ATTACHABLE, type AccountApiDeps } from './account-api.js';
|
|
7
|
+
import type { AgentConnectorApiDeps } from './agent-connector-api.js';
|
|
8
|
+
import type { ProjectApiDeps } from './project-api.js';
|
|
9
|
+
import type { SessionApis } from './session-apis.js';
|
|
10
|
+
import type { ModeInfo } from './mode-api.js';
|
|
11
|
+
import {
|
|
12
|
+
claimLocalOwner,
|
|
13
|
+
LOCAL_PROJECT_ID,
|
|
14
|
+
localAttachAccount,
|
|
15
|
+
localCreateAgent,
|
|
16
|
+
localDeleteAgent,
|
|
17
|
+
localDetachAccount,
|
|
18
|
+
localListAgents,
|
|
19
|
+
localOwnedAgentOrThrow,
|
|
20
|
+
localOwner,
|
|
21
|
+
localResetAgentKey,
|
|
22
|
+
} from '../db/file-admin.js';
|
|
23
|
+
import { normalizeAddress } from '../db/users.js';
|
|
24
|
+
import type { Project } from '../db/projects.js';
|
|
25
|
+
import type { StationName } from '../db/schema.js';
|
|
26
|
+
|
|
27
|
+
export interface LocalModeDeps {
|
|
28
|
+
syncStations: (station: StationName) => Promise<void>;
|
|
29
|
+
closeAgentSession: (id: string) => Promise<boolean>;
|
|
30
|
+
gatherAccounts: AgentApiDeps['gatherAccounts'];
|
|
31
|
+
capabilities: AgentApiDeps['capabilities'];
|
|
32
|
+
liveness: AgentApiDeps['liveness'];
|
|
33
|
+
prepareAccount: AccountApiDeps['prepareAccount'];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const notHere = (what: string): Promise<never> =>
|
|
37
|
+
Promise.reject(
|
|
38
|
+
new ApiError(`${what} are managed on metro.box, not on a local daemon`, 400),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
function localProject(): Project {
|
|
42
|
+
return {
|
|
43
|
+
id: LOCAL_PROJECT_ID,
|
|
44
|
+
name: hostname(),
|
|
45
|
+
isDefault: true,
|
|
46
|
+
owner: true,
|
|
47
|
+
role: 'admin',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function attachSessions(deps: LocalModeDeps): AttachSessions {
|
|
52
|
+
return new AttachSessions({
|
|
53
|
+
authorize: async (owner) => {
|
|
54
|
+
await localOwnedAgentOrThrow(owner.subject, owner.agentId);
|
|
55
|
+
},
|
|
56
|
+
complete: async (owner, station, config) => {
|
|
57
|
+
const ref = await localAttachAccount(owner.subject, owner.agentId, station, config);
|
|
58
|
+
const activated = await deps.syncStations(station).then(
|
|
59
|
+
() => true,
|
|
60
|
+
(err: unknown) => {
|
|
61
|
+
log.warn(
|
|
62
|
+
{ station, err: errMsg(err) },
|
|
63
|
+
'attach-session: station reload failed, the change lands at the next boot',
|
|
64
|
+
);
|
|
65
|
+
return false;
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
return { accountId: ref.accountId, activated };
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
74
|
+
return {
|
|
75
|
+
attachSessions: attachSessions(deps),
|
|
76
|
+
listAgents: localListAgents,
|
|
77
|
+
createAgent: localCreateAgent,
|
|
78
|
+
deleteAgent: localDeleteAgent,
|
|
79
|
+
resetKey: async (subject, id) => {
|
|
80
|
+
const reset = await localResetAgentKey(subject, id);
|
|
81
|
+
const closed = await deps.closeAgentSession(id);
|
|
82
|
+
log.info({ agent: reset.name, id, sessionClosed: closed }, 'local: key rotated');
|
|
83
|
+
return reset;
|
|
84
|
+
},
|
|
85
|
+
gatherAccounts: deps.gatherAccounts,
|
|
86
|
+
capabilities: deps.capabilities,
|
|
87
|
+
attachable: ATTACHABLE.filter((s) => s !== 'webhook'),
|
|
88
|
+
liveness: deps.liveness,
|
|
89
|
+
releaseRuntime: () => notHere('runtime leases'),
|
|
90
|
+
runtimes: () => Promise.resolve(new Map()),
|
|
91
|
+
connectorIds: () => Promise.resolve(new Map()),
|
|
92
|
+
prepareAccount: deps.prepareAccount,
|
|
93
|
+
attachAccount: localAttachAccount,
|
|
94
|
+
detachAccount: localDetachAccount,
|
|
95
|
+
syncStations: deps.syncStations,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const projectApi: ProjectApiDeps = {
|
|
100
|
+
listProjects: (subject) => {
|
|
101
|
+
const owner = localOwner();
|
|
102
|
+
const mine = owner !== null && owner === normalizeAddress(subject);
|
|
103
|
+
return Promise.resolve(mine ? [localProject()] : []);
|
|
104
|
+
},
|
|
105
|
+
createProject: () => notHere('projects'),
|
|
106
|
+
renameProject: () => notHere('projects'),
|
|
107
|
+
deleteProject: () => notHere('projects'),
|
|
108
|
+
listMembers: (subject, id) => {
|
|
109
|
+
const owner = localOwner();
|
|
110
|
+
if (id !== LOCAL_PROJECT_ID || owner === null || owner !== normalizeAddress(subject))
|
|
111
|
+
return Promise.reject(new ApiError('no such project', 404));
|
|
112
|
+
return Promise.resolve([
|
|
113
|
+
{ id: 'owner', address: owner, email: null, role: 'admin', owner: true },
|
|
114
|
+
]);
|
|
115
|
+
},
|
|
116
|
+
addMember: () => notHere('members'),
|
|
117
|
+
setMemberRole: () => notHere('members'),
|
|
118
|
+
removeMember: () => notHere('members'),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const agentConnectorApi: AgentConnectorApiDeps = {
|
|
122
|
+
agentConnectors: async (subject, id) => {
|
|
123
|
+
const { agent } = await localOwnedAgentOrThrow(subject, id);
|
|
124
|
+
return { ...agent, connectorIds: [] };
|
|
125
|
+
},
|
|
126
|
+
addConnector: () => notHere('connectors'),
|
|
127
|
+
removeConnector: () => notHere('connectors'),
|
|
128
|
+
mintCode: () =>
|
|
129
|
+
Promise.reject(new ApiError('a local daemon has no pairing codes', 400)),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export function localConnectHint(port: number): string {
|
|
133
|
+
const ui = process.env.METRO_UI_URL ?? 'https://metro.box';
|
|
134
|
+
const here = `http://127.0.0.1:${String(port)}`;
|
|
135
|
+
const forward = `ssh -L ${String(port)}:127.0.0.1:${String(port)} ${hostname()}`;
|
|
136
|
+
return [
|
|
137
|
+
'Manage this machine from the web UI:',
|
|
138
|
+
'',
|
|
139
|
+
` ${ui}/#/connect/${encodeURIComponent(here)}`,
|
|
140
|
+
'',
|
|
141
|
+
`From another computer, forward the port first: ${forward}`,
|
|
142
|
+
'',
|
|
143
|
+
].join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function localModeInfo(): ModeInfo {
|
|
147
|
+
return { mode: 'local', owner: localOwner(), project: LOCAL_PROJECT_ID };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
151
|
+
return {
|
|
152
|
+
agentApi: agentApi(deps),
|
|
153
|
+
agentConnectorApi,
|
|
154
|
+
projectApi,
|
|
155
|
+
siwe: { ensureUser: claimLocalOwner },
|
|
156
|
+
mode: localModeInfo,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { cors, sendJson } from './api-http.js';
|
|
3
|
+
|
|
4
|
+
const PATH = '/api/mode';
|
|
5
|
+
|
|
6
|
+
export type DaemonMode = 'hosted' | 'linked' | 'local';
|
|
7
|
+
|
|
8
|
+
export interface ModeInfo {
|
|
9
|
+
mode: DaemonMode;
|
|
10
|
+
owner: string | null;
|
|
11
|
+
project: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function handleModeRequest(
|
|
15
|
+
req: IncomingMessage,
|
|
16
|
+
res: ServerResponse,
|
|
17
|
+
info: () => ModeInfo,
|
|
18
|
+
): boolean {
|
|
19
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
20
|
+
if (path !== PATH) return false;
|
|
21
|
+
if (req.method === 'OPTIONS') {
|
|
22
|
+
res.writeHead(204, cors(req)).end();
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (req.method !== 'GET') {
|
|
26
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
sendJson(req, res, 200, info());
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
13
14
|
import { log } from './log.js';
|
|
14
15
|
|
|
15
16
|
export const STATE_DIR =
|
|
@@ -23,6 +24,15 @@ export const CONFIG_ENV_FILE = join(CONFIG_DIR, '.env');
|
|
|
23
24
|
|
|
24
25
|
export const TRAINS_ENV_FILE = join(homedir(), '.metro', '.env');
|
|
25
26
|
|
|
27
|
+
export function trainsDir(): string {
|
|
28
|
+
const env = process.env.METRO_TRAINS_DIR?.trim();
|
|
29
|
+
if (env !== undefined && env !== '') return env;
|
|
30
|
+
return fileURLToPath(new URL('../../trains', import.meta.url));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const isLocalMode = (): boolean =>
|
|
34
|
+
process.env.METRO_MODE?.trim() === 'local';
|
|
35
|
+
|
|
26
36
|
export const envSources = (): readonly string[] => [
|
|
27
37
|
join(process.cwd(), '.env'),
|
|
28
38
|
TRAINS_ENV_FILE,
|
|
@@ -15,32 +15,32 @@ import type { Member, Project } from '../db/projects.js';
|
|
|
15
15
|
const PREFIX = '/api/projects';
|
|
16
16
|
|
|
17
17
|
export interface ProjectApiDeps {
|
|
18
|
-
listProjects: (
|
|
19
|
-
createProject: (
|
|
18
|
+
listProjects: (subject: string) => Promise<Project[]>;
|
|
19
|
+
createProject: (subject: string, name: unknown) => Promise<Project>;
|
|
20
20
|
renameProject: (
|
|
21
|
-
|
|
21
|
+
subject: string,
|
|
22
22
|
id: string,
|
|
23
23
|
name: unknown,
|
|
24
24
|
) => Promise<Project>;
|
|
25
25
|
deleteProject: (
|
|
26
|
-
|
|
26
|
+
subject: string,
|
|
27
27
|
id: string,
|
|
28
28
|
) => Promise<{ id: string; name: string }>;
|
|
29
|
-
listMembers: (
|
|
29
|
+
listMembers: (subject: string, id: string) => Promise<Member[]>;
|
|
30
30
|
addMember: (
|
|
31
|
-
|
|
31
|
+
subject: string,
|
|
32
32
|
id: string,
|
|
33
33
|
invited: unknown,
|
|
34
34
|
role: unknown,
|
|
35
35
|
) => Promise<Member[]>;
|
|
36
36
|
setMemberRole: (
|
|
37
|
-
|
|
37
|
+
subject: string,
|
|
38
38
|
id: string,
|
|
39
39
|
memberId: string,
|
|
40
40
|
role: unknown,
|
|
41
41
|
) => Promise<Member[]>;
|
|
42
42
|
removeMember: (
|
|
43
|
-
|
|
43
|
+
subject: string,
|
|
44
44
|
id: string,
|
|
45
45
|
memberId: string,
|
|
46
46
|
) => Promise<Member[]>;
|
|
@@ -98,11 +98,11 @@ async function handleIndex(
|
|
|
98
98
|
session: ApiSession,
|
|
99
99
|
): Promise<void> {
|
|
100
100
|
if (req.method === 'GET') {
|
|
101
|
-
sendJson(req, res, 200, { projects: await deps.listProjects(session.
|
|
101
|
+
sendJson(req, res, 200, { projects: await deps.listProjects(session.subject) });
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
104
|
const made = await deps.createProject(
|
|
105
|
-
session.
|
|
105
|
+
session.subject,
|
|
106
106
|
bodyField(await readJsonBody(req), 'name'),
|
|
107
107
|
);
|
|
108
108
|
log.info({ id: made.id, name: made.name }, 'project-api: created project');
|
|
@@ -117,14 +117,14 @@ async function handleMembers(
|
|
|
117
117
|
id: string,
|
|
118
118
|
): Promise<void> {
|
|
119
119
|
if (req.method === 'GET') {
|
|
120
|
-
sendJson(req, res, 200, { members: await deps.listMembers(session.
|
|
120
|
+
sendJson(req, res, 200, { members: await deps.listMembers(session.subject, id) });
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
const body = await readJsonBody(req);
|
|
124
124
|
const members = await deps.addMember(
|
|
125
|
-
session.
|
|
125
|
+
session.subject,
|
|
126
126
|
id,
|
|
127
|
-
bodyField(body, '
|
|
127
|
+
bodyField(body, 'address'),
|
|
128
128
|
bodyField(body, 'role'),
|
|
129
129
|
);
|
|
130
130
|
sendJson(req, res, 200, { members });
|
|
@@ -138,12 +138,12 @@ async function handleMember(
|
|
|
138
138
|
tgt: { id: string; memberId: string },
|
|
139
139
|
): Promise<void> {
|
|
140
140
|
if (req.method === 'DELETE') {
|
|
141
|
-
const members = await deps.removeMember(session.
|
|
141
|
+
const members = await deps.removeMember(session.subject, tgt.id, tgt.memberId);
|
|
142
142
|
sendJson(req, res, 200, { members });
|
|
143
143
|
return;
|
|
144
144
|
}
|
|
145
145
|
const members = await deps.setMemberRole(
|
|
146
|
-
session.
|
|
146
|
+
session.subject,
|
|
147
147
|
tgt.id,
|
|
148
148
|
tgt.memberId,
|
|
149
149
|
bodyField(await readJsonBody(req), 'role'),
|
|
@@ -170,12 +170,12 @@ async function route(
|
|
|
170
170
|
res,
|
|
171
171
|
200,
|
|
172
172
|
await deps.renameProject(
|
|
173
|
-
session.
|
|
173
|
+
session.subject,
|
|
174
174
|
tgt.id,
|
|
175
175
|
bodyField(await readJsonBody(req), 'name'),
|
|
176
176
|
),
|
|
177
177
|
);
|
|
178
|
-
else sendJson(req, res, 200, await deps.deleteProject(session.
|
|
178
|
+
else sendJson(req, res, 200, await deps.deleteProject(session.subject, tgt.id));
|
|
179
179
|
} catch (err) {
|
|
180
180
|
apiFailure(req, res, err, 'project-api');
|
|
181
181
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
sendJson,
|
|
10
10
|
} from './api-http.js';
|
|
11
11
|
import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
|
|
12
|
-
import { sessionTtlFromEnv } from './
|
|
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';
|
|
@@ -78,7 +78,7 @@ async function handleClaim(
|
|
|
78
78
|
}
|
|
79
79
|
const lease = await deps.claimRuntime(taken.agentId, labelOf(body));
|
|
80
80
|
const token = signRunToken(
|
|
81
|
-
{
|
|
81
|
+
{ subject: taken.subject, agentId: lease.agentId, runtimeId: lease.runtimeId },
|
|
82
82
|
secret,
|
|
83
83
|
{ ttlSec: sessionTtlFromEnv() },
|
|
84
84
|
);
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
handleAgentConnectorRequest,
|
|
17
17
|
type AgentConnectorApiDeps,
|
|
18
18
|
} from './agent-connector-api.js';
|
|
19
|
+
import type { SiweRouteDeps } from './siwe-routes.js';
|
|
20
|
+
import type { ModeInfo } from './mode-api.js';
|
|
19
21
|
|
|
20
22
|
export interface SessionApis {
|
|
21
23
|
agentApi?: AgentApiDeps;
|
|
@@ -24,6 +26,8 @@ export interface SessionApis {
|
|
|
24
26
|
projectApi?: ProjectApiDeps;
|
|
25
27
|
runApi?: RunApiDeps;
|
|
26
28
|
relayApi?: RelayApiDeps;
|
|
29
|
+
siwe?: SiweRouteDeps;
|
|
30
|
+
mode?: () => ModeInfo;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function handleSessionApis(
|
|
@@ -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
102
|
export interface AgentClaims {
|
|
103
|
-
|
|
103
|
+
subject: string;
|
|
104
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,7 +144,7 @@ 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
150
|
export function signAgentToken(
|
|
@@ -156,7 +156,7 @@ export function signAgentToken(
|
|
|
156
156
|
return sign(
|
|
157
157
|
{
|
|
158
158
|
typ: 'agent',
|
|
159
|
-
sub: claims.
|
|
159
|
+
sub: claims.subject,
|
|
160
160
|
agent: claims.agentId,
|
|
161
161
|
iat,
|
|
162
162
|
exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
|
|
@@ -176,7 +176,7 @@ export function verifyAgentToken(
|
|
|
176
176
|
throw new SessionError('agent token expired');
|
|
177
177
|
if (typeof p.sub !== 'string' || typeof p.agent !== 'string')
|
|
178
178
|
throw new SessionError('malformed agent token');
|
|
179
|
-
return {
|
|
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
|
+
}
|