@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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { AGENT_NAME_RE, ID_RE } from './ids.js';
|
|
5
|
+
import { STATIONS, type StationName } from './schema.js';
|
|
6
|
+
import type { LoadedAccount, LoadedAgent, StationSource } from './materialize.js';
|
|
7
|
+
|
|
8
|
+
export const AGENT_FILE = 'agent.json';
|
|
9
|
+
|
|
10
|
+
const KEY_RE = /^mk_[A-Za-z0-9_-]{43}$/;
|
|
11
|
+
const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
|
|
12
|
+
const STATION_NAMES = new Set<string>(STATIONS);
|
|
13
|
+
|
|
14
|
+
export class AgentFileError extends Error {}
|
|
15
|
+
|
|
16
|
+
export interface AgentFile {
|
|
17
|
+
version: 1;
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
key: string | null;
|
|
21
|
+
owner: string | null;
|
|
22
|
+
stations: LoadedAccount[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function agentsDir(): string {
|
|
26
|
+
const explicit = process.env.METRO_AGENTS_DIR?.trim();
|
|
27
|
+
return explicit !== undefined && explicit !== ''
|
|
28
|
+
? explicit
|
|
29
|
+
: join(homedir(), '.metro', 'agents');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
33
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
34
|
+
|
|
35
|
+
function fail(path: string, reason: string): never {
|
|
36
|
+
throw new AgentFileError(`${path}: ${reason}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isStationName(value: unknown): value is StationName {
|
|
40
|
+
return typeof value === 'string' && STATION_NAMES.has(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function allowlistOf(raw: unknown, path: string, where: string): string[] | null {
|
|
44
|
+
if (raw === null || raw === undefined) return null;
|
|
45
|
+
if (!Array.isArray(raw)) fail(path, `${where}.allowlist is not a list of strings`);
|
|
46
|
+
const list = raw.filter((entry): entry is string => typeof entry === 'string');
|
|
47
|
+
if (list.length !== raw.length)
|
|
48
|
+
fail(path, `${where}.allowlist is not a list of strings`);
|
|
49
|
+
return list;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stationOf(raw: unknown, path: string, index: number): LoadedAccount {
|
|
53
|
+
const where = `stations[${String(index)}]`;
|
|
54
|
+
if (!isRecord(raw)) fail(path, `${where} is not an object`);
|
|
55
|
+
const { station, id, config } = raw;
|
|
56
|
+
if (!isStationName(station)) fail(path, `${where}.station is not a known station`);
|
|
57
|
+
if (typeof id !== 'string' || !ID_RE.test(id))
|
|
58
|
+
fail(path, `${where}.id is not an 11-character id`);
|
|
59
|
+
if (!isRecord(config)) fail(path, `${where}.config is not an object`);
|
|
60
|
+
return { station, id, allowlist: allowlistOf(raw.allowlist, path, where), config };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function optionalMatch(
|
|
64
|
+
value: unknown,
|
|
65
|
+
re: RegExp,
|
|
66
|
+
path: string,
|
|
67
|
+
what: string,
|
|
68
|
+
): string | null {
|
|
69
|
+
if (value === null || value === undefined) return null;
|
|
70
|
+
if (typeof value !== 'string' || !re.test(value)) fail(path, what);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
75
|
+
let parsed: unknown;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(raw);
|
|
78
|
+
} catch {
|
|
79
|
+
fail(path, 'is not valid JSON');
|
|
80
|
+
}
|
|
81
|
+
if (!isRecord(parsed)) fail(path, 'is not a JSON object');
|
|
82
|
+
if (parsed.version !== 1) fail(path, 'version must be 1');
|
|
83
|
+
const { id, name, stations } = parsed;
|
|
84
|
+
if (typeof id !== 'string' || !ID_RE.test(id))
|
|
85
|
+
fail(path, 'id is not an 11-character id');
|
|
86
|
+
if (typeof name !== 'string' || !AGENT_NAME_RE.test(name))
|
|
87
|
+
fail(path, 'name is not a valid agent name');
|
|
88
|
+
const key = optionalMatch(parsed.key, KEY_RE, path, 'key is not an mk_ key');
|
|
89
|
+
const owner = optionalMatch(
|
|
90
|
+
parsed.owner,
|
|
91
|
+
ADDRESS_RE,
|
|
92
|
+
path,
|
|
93
|
+
'owner is not a lowercase Ethereum address',
|
|
94
|
+
);
|
|
95
|
+
if (!Array.isArray(stations)) fail(path, 'stations is not a list');
|
|
96
|
+
return {
|
|
97
|
+
version: 1,
|
|
98
|
+
id,
|
|
99
|
+
name,
|
|
100
|
+
key,
|
|
101
|
+
owner,
|
|
102
|
+
stations: stations.map((s, i) => stationOf(s, path, i)),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readAgentFile(path: string): AgentFile {
|
|
107
|
+
let raw: string;
|
|
108
|
+
try {
|
|
109
|
+
raw = readFileSync(path, 'utf8');
|
|
110
|
+
} catch {
|
|
111
|
+
fail(path, 'cannot be read');
|
|
112
|
+
}
|
|
113
|
+
return parseAgentFile(raw, path);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function listAgentFiles(dir = agentsDir()): string[] {
|
|
117
|
+
if (!existsSync(dir)) return [];
|
|
118
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
119
|
+
.filter((entry) => entry.isDirectory())
|
|
120
|
+
.map((entry) => join(dir, entry.name, AGENT_FILE))
|
|
121
|
+
.filter((path) => existsSync(path))
|
|
122
|
+
.sort();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function assertUnique(agents: AgentFile[], paths: string[]): void {
|
|
126
|
+
const ids = new Map<string, string>();
|
|
127
|
+
const keys = new Map<string, string>();
|
|
128
|
+
agents.forEach((agent, i) => {
|
|
129
|
+
const path = paths[i] ?? '';
|
|
130
|
+
const sameId = ids.get(agent.id);
|
|
131
|
+
if (sameId !== undefined) fail(path, `id ${agent.id} is already used by ${sameId}`);
|
|
132
|
+
ids.set(agent.id, path);
|
|
133
|
+
if (agent.key === null) return;
|
|
134
|
+
const sameKey = keys.get(agent.key);
|
|
135
|
+
if (sameKey !== undefined) fail(path, `its key is already used by ${sameKey}`);
|
|
136
|
+
keys.set(agent.key, path);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function loadFileAgents(dir = agentsDir()): LoadedAgent[] {
|
|
141
|
+
const paths = listAgentFiles(dir);
|
|
142
|
+
const agents = paths.map(readAgentFile);
|
|
143
|
+
assertUnique(agents, paths);
|
|
144
|
+
return agents.map((agent) => ({
|
|
145
|
+
id: agent.id,
|
|
146
|
+
name: agent.name,
|
|
147
|
+
key: agent.key,
|
|
148
|
+
accounts: agent.stations,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const fileSource: StationSource = () => Promise.resolve(loadFileAgents());
|
|
@@ -11,6 +11,7 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { eq } from 'drizzle-orm';
|
|
12
12
|
import { log } from '../daemon/log.js';
|
|
13
13
|
import { writeSecure } from '../daemon/secure-fs.js';
|
|
14
|
+
import { isLocalMode, trainsDir } from '../daemon/paths.js';
|
|
14
15
|
import { closeDb, databaseUrl, getDb } from './client.js';
|
|
15
16
|
import {
|
|
16
17
|
setAgentMap,
|
|
@@ -44,7 +45,6 @@ export interface LoadedAgent {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
const METRO_DIR = join(homedir(), '.metro');
|
|
47
|
-
const TRAINS_DIR = process.env.METRO_TRAINS_DIR ?? join(METRO_DIR, 'trains');
|
|
48
48
|
|
|
49
49
|
const STATION_TARGETS: Record<StationName, StationTarget> = {
|
|
50
50
|
xmtp: {
|
|
@@ -209,7 +209,7 @@ function trainConfig(
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
function trainStubPath(station: StationName): string {
|
|
212
|
-
return join(
|
|
212
|
+
return join(trainsDir(), `${station}.ts`);
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
function currentText(path: string): string | null {
|
|
@@ -230,7 +230,7 @@ export const isLocalRuntime = (): boolean =>
|
|
|
230
230
|
(process.env.METRO_RUN_TOKEN?.trim() ?? '') !== '';
|
|
231
231
|
|
|
232
232
|
export const stationRunsHere = (station: StationName): boolean =>
|
|
233
|
-
isLocalRuntime() || !MOVABLE_STATIONS.has(station);
|
|
233
|
+
isLocalRuntime() || isLocalMode() || !MOVABLE_STATIONS.has(station);
|
|
234
234
|
|
|
235
235
|
interface WrittenStations {
|
|
236
236
|
active: Map<StationName, number>;
|
|
@@ -239,7 +239,7 @@ interface WrittenStations {
|
|
|
239
239
|
|
|
240
240
|
function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
241
241
|
mkdirSync(METRO_DIR, { recursive: true });
|
|
242
|
-
mkdirSync(
|
|
242
|
+
mkdirSync(trainsDir(), { recursive: true });
|
|
243
243
|
|
|
244
244
|
const byStation = new Map<StationName, LoadedAccount[]>();
|
|
245
245
|
const map: AgentMap = {};
|
|
@@ -331,12 +331,21 @@ async function loadAndWrite(
|
|
|
331
331
|
return { ...writeStations(list), agents: list.length };
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
-
export
|
|
334
|
+
export interface MaterializeOptions {
|
|
335
|
+
allowEmpty?: boolean;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function materializeFrom(
|
|
339
|
+
source: StationSource,
|
|
340
|
+
opts: MaterializeOptions = {},
|
|
341
|
+
): Promise<void> {
|
|
335
342
|
const { active, agents: found } = await loadAndWrite(source);
|
|
336
343
|
|
|
337
|
-
if (found === 0
|
|
344
|
+
if (found === 0 && opts.allowEmpty !== true)
|
|
345
|
+
throw new Error('no agents found — nothing to materialize');
|
|
346
|
+
const removed = pruneStations(active);
|
|
338
347
|
log.info(
|
|
339
|
-
{ stations: stationLabels(active), agents: found },
|
|
348
|
+
{ stations: stationLabels(active), agents: found, removed },
|
|
340
349
|
'materialized station accounts',
|
|
341
350
|
);
|
|
342
351
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ensureUserByAddress,
|
|
3
|
+
normalizeAddress,
|
|
4
|
+
userIdForSubject,
|
|
5
|
+
} from './users.js';
|
|
2
6
|
import { and, asc, eq } from 'drizzle-orm';
|
|
3
7
|
import { ApiError } from '../daemon/api-error.js';
|
|
4
8
|
import { getDb } from './client.js';
|
|
@@ -17,7 +21,8 @@ export interface Project {
|
|
|
17
21
|
|
|
18
22
|
export interface Member {
|
|
19
23
|
id: string;
|
|
20
|
-
|
|
24
|
+
address: string | null;
|
|
25
|
+
email: string | null;
|
|
21
26
|
role: ProjectRole;
|
|
22
27
|
owner: boolean;
|
|
23
28
|
}
|
|
@@ -50,10 +55,10 @@ export interface Access {
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
export async function memberAccessOrThrow(
|
|
53
|
-
|
|
58
|
+
subject: string,
|
|
54
59
|
projectId: string,
|
|
55
60
|
): Promise<Access> {
|
|
56
|
-
const userId = await
|
|
61
|
+
const userId = await userIdForSubject(subject);
|
|
57
62
|
if (userId === null) throw missing();
|
|
58
63
|
const rows = await getDb()
|
|
59
64
|
.select({
|
|
@@ -79,10 +84,10 @@ export async function memberAccessOrThrow(
|
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
export async function projectIdOrThrow(
|
|
82
|
-
|
|
87
|
+
subject: string,
|
|
83
88
|
projectId: string,
|
|
84
89
|
): Promise<string> {
|
|
85
|
-
return (await memberAccessOrThrow(
|
|
90
|
+
return (await memberAccessOrThrow(subject, projectId)).projectId;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
async function addMemberRow(
|
|
@@ -111,14 +116,14 @@ export async function ensureDefaultProject(userId: string): Promise<string> {
|
|
|
111
116
|
return id;
|
|
112
117
|
}
|
|
113
118
|
|
|
114
|
-
export async function ensureUserWithProject(
|
|
115
|
-
const userId = await
|
|
119
|
+
export async function ensureUserWithProject(address: string): Promise<string> {
|
|
120
|
+
const userId = await ensureUserByAddress(address);
|
|
116
121
|
await ensureDefaultProject(userId);
|
|
117
122
|
return userId;
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
export async function
|
|
121
|
-
const userId = await
|
|
125
|
+
export async function listProjectsForUser(subject: string): Promise<Project[]> {
|
|
126
|
+
const userId = await userIdForSubject(subject);
|
|
122
127
|
if (userId === null) return [];
|
|
123
128
|
const rows = await getDb()
|
|
124
129
|
.select({
|
|
@@ -141,25 +146,26 @@ export async function listProjectsForEmail(email: string): Promise<Project[]> {
|
|
|
141
146
|
}));
|
|
142
147
|
}
|
|
143
148
|
|
|
144
|
-
export async function
|
|
145
|
-
|
|
149
|
+
export async function createProjectForUser(
|
|
150
|
+
subject: string,
|
|
146
151
|
raw: unknown,
|
|
147
152
|
): Promise<Project> {
|
|
148
153
|
const name = projectName(raw);
|
|
149
|
-
const userId = await
|
|
154
|
+
const userId = await userIdForSubject(subject);
|
|
155
|
+
if (userId === null) throw new ProjectError('unknown account', 401);
|
|
150
156
|
const id = newId();
|
|
151
157
|
await getDb().insert(projects).values({ id, name, ownerId: userId });
|
|
152
158
|
await addMemberRow(id, userId, 'admin');
|
|
153
159
|
return { id, name, isDefault: false, owner: true, role: 'admin' };
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
export async function
|
|
157
|
-
|
|
162
|
+
export async function renameProjectForUser(
|
|
163
|
+
subject: string,
|
|
158
164
|
projectId: string,
|
|
159
165
|
raw: unknown,
|
|
160
166
|
): Promise<Project> {
|
|
161
167
|
const name = projectName(raw);
|
|
162
|
-
const access = await memberAccessOrThrow(
|
|
168
|
+
const access = await memberAccessOrThrow(subject, projectId);
|
|
163
169
|
if (access.role !== 'admin')
|
|
164
170
|
throw forbidden('only an admin can rename a project');
|
|
165
171
|
await getDb()
|
|
@@ -175,11 +181,11 @@ export async function renameProjectForEmail(
|
|
|
175
181
|
};
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
export async function
|
|
179
|
-
|
|
184
|
+
export async function deleteProjectForUser(
|
|
185
|
+
subject: string,
|
|
180
186
|
projectId: string,
|
|
181
187
|
): Promise<{ id: string; name: string }> {
|
|
182
|
-
const access = await memberAccessOrThrow(
|
|
188
|
+
const access = await memberAccessOrThrow(subject, projectId);
|
|
183
189
|
if (access.ownerId !== access.userId)
|
|
184
190
|
throw forbidden('only the owner can delete a project');
|
|
185
191
|
if (access.isDefault)
|
|
@@ -188,21 +194,22 @@ export async function deleteProjectForEmail(
|
|
|
188
194
|
await getDb().delete(projects).where(eq(projects.id, access.projectId));
|
|
189
195
|
} catch {
|
|
190
196
|
throw new ProjectError(
|
|
191
|
-
'this project still holds agents
|
|
197
|
+
'this project still holds agents or connectors',
|
|
192
198
|
409,
|
|
193
199
|
);
|
|
194
200
|
}
|
|
195
201
|
return { id: access.projectId, name: access.name };
|
|
196
202
|
}
|
|
197
203
|
|
|
198
|
-
export async function
|
|
199
|
-
|
|
204
|
+
export async function listMembersForUser(
|
|
205
|
+
subject: string,
|
|
200
206
|
projectId: string,
|
|
201
207
|
): Promise<Member[]> {
|
|
202
|
-
const access = await memberAccessOrThrow(
|
|
208
|
+
const access = await memberAccessOrThrow(subject, projectId);
|
|
203
209
|
const rows = await getDb()
|
|
204
210
|
.select({
|
|
205
211
|
id: projectMembers.id,
|
|
212
|
+
address: users.address,
|
|
206
213
|
email: users.email,
|
|
207
214
|
role: projectMembers.role,
|
|
208
215
|
userId: projectMembers.userId,
|
|
@@ -213,6 +220,7 @@ export async function listMembersForEmail(
|
|
|
213
220
|
.orderBy(asc(projectMembers.id));
|
|
214
221
|
return rows.map((r) => ({
|
|
215
222
|
id: r.id,
|
|
223
|
+
address: r.address,
|
|
216
224
|
email: r.email,
|
|
217
225
|
role: r.role,
|
|
218
226
|
owner: r.userId === access.ownerId,
|
|
@@ -225,29 +233,30 @@ function readRole(raw: unknown): ProjectRole {
|
|
|
225
233
|
}
|
|
226
234
|
|
|
227
235
|
async function adminAccessOrThrow(
|
|
228
|
-
|
|
236
|
+
subject: string,
|
|
229
237
|
projectId: string,
|
|
230
238
|
): Promise<Access> {
|
|
231
|
-
const access = await memberAccessOrThrow(
|
|
239
|
+
const access = await memberAccessOrThrow(subject, projectId);
|
|
232
240
|
if (access.role !== 'admin')
|
|
233
241
|
throw forbidden('only an admin can manage members');
|
|
234
242
|
return access;
|
|
235
243
|
}
|
|
236
244
|
|
|
237
|
-
export async function
|
|
238
|
-
|
|
245
|
+
export async function addMemberForUser(
|
|
246
|
+
subject: string,
|
|
239
247
|
projectId: string,
|
|
240
248
|
raw: unknown,
|
|
241
249
|
rawRole: unknown,
|
|
242
250
|
): Promise<Member[]> {
|
|
243
|
-
const access = await adminAccessOrThrow(
|
|
251
|
+
const access = await adminAccessOrThrow(subject, projectId);
|
|
244
252
|
const role = readRole(rawRole);
|
|
245
|
-
const invited =
|
|
246
|
-
if (invited ===
|
|
247
|
-
|
|
253
|
+
const invited = normalizeAddress(typeof raw === 'string' ? raw : '');
|
|
254
|
+
if (invited === null)
|
|
255
|
+
throw new ProjectError('an Ethereum address is required', 400);
|
|
256
|
+
const invitedId = await ensureUserByAddress(invited);
|
|
248
257
|
await ensureDefaultProject(invitedId);
|
|
249
258
|
await addMemberRow(access.projectId, invitedId, role);
|
|
250
|
-
return
|
|
259
|
+
return listMembersForUser(subject, projectId);
|
|
251
260
|
}
|
|
252
261
|
|
|
253
262
|
async function memberRowOrThrow(
|
|
@@ -268,13 +277,13 @@ async function memberRowOrThrow(
|
|
|
268
277
|
return row;
|
|
269
278
|
}
|
|
270
279
|
|
|
271
|
-
export async function
|
|
272
|
-
|
|
280
|
+
export async function setMemberRoleForUser(
|
|
281
|
+
subject: string,
|
|
273
282
|
projectId: string,
|
|
274
283
|
memberId: string,
|
|
275
284
|
rawRole: unknown,
|
|
276
285
|
): Promise<Member[]> {
|
|
277
|
-
const access = await adminAccessOrThrow(
|
|
286
|
+
const access = await adminAccessOrThrow(subject, projectId);
|
|
278
287
|
const role = readRole(rawRole);
|
|
279
288
|
const member = await memberRowOrThrow(access.projectId, memberId);
|
|
280
289
|
if (member.userId === access.ownerId)
|
|
@@ -283,18 +292,18 @@ export async function setMemberRoleForEmail(
|
|
|
283
292
|
.update(projectMembers)
|
|
284
293
|
.set({ role })
|
|
285
294
|
.where(eq(projectMembers.id, memberId));
|
|
286
|
-
return
|
|
295
|
+
return listMembersForUser(subject, projectId);
|
|
287
296
|
}
|
|
288
297
|
|
|
289
|
-
export async function
|
|
290
|
-
|
|
298
|
+
export async function removeMemberForUser(
|
|
299
|
+
subject: string,
|
|
291
300
|
projectId: string,
|
|
292
301
|
memberId: string,
|
|
293
302
|
): Promise<Member[]> {
|
|
294
|
-
const access = await adminAccessOrThrow(
|
|
303
|
+
const access = await adminAccessOrThrow(subject, projectId);
|
|
295
304
|
const member = await memberRowOrThrow(access.projectId, memberId);
|
|
296
305
|
if (member.userId === access.ownerId)
|
|
297
306
|
throw forbidden('the owner cannot be removed from their own project');
|
|
298
307
|
await getDb().delete(projectMembers).where(eq(projectMembers.id, memberId));
|
|
299
|
-
return
|
|
308
|
+
return listMembersForUser(subject, projectId);
|
|
300
309
|
}
|
|
@@ -1,31 +1,24 @@
|
|
|
1
1
|
import { ownedAgentOrThrow } from './agent-admin.js';
|
|
2
|
-
import {
|
|
3
|
-
import { mintRunCode } from '../daemon/run-pair.js';
|
|
2
|
+
import { mintAgentCode } from '../daemon/agent-pair.js';
|
|
4
3
|
import { loadAllStationsFor, unmovableStations } from './materialize.js';
|
|
5
4
|
import { releaseRuntime } from './runtimes.js';
|
|
6
5
|
|
|
7
|
-
export async function
|
|
8
|
-
|
|
6
|
+
export async function mintAgentCodeForUser(
|
|
7
|
+
subject: string,
|
|
9
8
|
agentId: string,
|
|
10
|
-
): Promise<{ code: string; expiresAt: number }> {
|
|
11
|
-
const { agent } = await ownedAgentOrThrow(
|
|
12
|
-
|
|
13
|
-
if (blocked.length > 0)
|
|
14
|
-
throw new ApiError(
|
|
15
|
-
`'${agent.name}' holds ${blocked.join(', ')}, which only runs on metro ` +
|
|
16
|
-
'because a webhook url has to be publicly reachable. Its deliveries ' +
|
|
17
|
-
'would arrive at metro while this agent listens on your machine, so ' +
|
|
18
|
-
'they would be dropped. Detach it, or attach it to a second agent that ' +
|
|
19
|
-
'stays on metro, then authorize this one.',
|
|
20
|
-
409,
|
|
21
|
-
);
|
|
22
|
-
return mintRunCode({ email, agentId: agent.id });
|
|
9
|
+
): Promise<{ code: string; expiresAt: number; agent: string }> {
|
|
10
|
+
const { agent } = await ownedAgentOrThrow(subject, agentId);
|
|
11
|
+
return { ...mintAgentCode({ subject, agentId: agent.id }), agent: agent.name };
|
|
23
12
|
}
|
|
24
13
|
|
|
25
|
-
export async function
|
|
26
|
-
|
|
14
|
+
export async function blockedStationsFor(agentId: string): Promise<string[]> {
|
|
15
|
+
return unmovableStations(await loadAllStationsFor(agentId));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function releaseRuntimeForUser(
|
|
19
|
+
subject: string,
|
|
27
20
|
agentId: string,
|
|
28
21
|
): Promise<void> {
|
|
29
|
-
const { agent } = await ownedAgentOrThrow(
|
|
22
|
+
const { agent } = await ownedAgentOrThrow(subject, agentId);
|
|
30
23
|
await releaseRuntime(agent.id);
|
|
31
24
|
}
|
|
@@ -19,7 +19,8 @@ export type StationName = (typeof STATIONS)[number];
|
|
|
19
19
|
|
|
20
20
|
export const users = pgTable('users', {
|
|
21
21
|
id: text('id').primaryKey(),
|
|
22
|
-
email: text('email').
|
|
22
|
+
email: text('email').unique(),
|
|
23
|
+
address: text('address').unique(),
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
export const PROJECT_ROLES = ['admin', 'member'] as const;
|
|
@@ -97,34 +98,20 @@ export const connectors = pgTable(
|
|
|
97
98
|
},
|
|
98
99
|
);
|
|
99
100
|
|
|
100
|
-
export const
|
|
101
|
-
'
|
|
101
|
+
export const agentConnectors = pgTable(
|
|
102
|
+
'agent_connectors',
|
|
102
103
|
{
|
|
103
104
|
id: text('id').primaryKey(),
|
|
104
|
-
|
|
105
|
-
.notNull()
|
|
106
|
-
.references(() => projects.id, { onDelete: 'restrict' }),
|
|
107
|
-
name: text('name').notNull(),
|
|
108
|
-
},
|
|
109
|
-
(t) => [
|
|
110
|
-
unique('collections_project_id_name_unique').on(t.projectId, t.name),
|
|
111
|
-
],
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
export const collectionItems = pgTable(
|
|
115
|
-
'collection_items',
|
|
116
|
-
{
|
|
117
|
-
id: text('id').primaryKey(),
|
|
118
|
-
collectionId: text('collection_id')
|
|
105
|
+
agentId: text('agent_id')
|
|
119
106
|
.notNull()
|
|
120
|
-
.references(() =>
|
|
107
|
+
.references(() => agents.id, { onDelete: 'cascade' }),
|
|
121
108
|
connectorId: text('connector_id')
|
|
122
109
|
.notNull()
|
|
123
110
|
.references(() => connectors.id, { onDelete: 'cascade' }),
|
|
124
111
|
},
|
|
125
112
|
(t) => [
|
|
126
|
-
unique('
|
|
127
|
-
t.
|
|
113
|
+
unique('agent_connectors_agent_id_connector_id_unique').on(
|
|
114
|
+
t.agentId,
|
|
128
115
|
t.connectorId,
|
|
129
116
|
),
|
|
130
117
|
],
|
|
@@ -1,34 +1,47 @@
|
|
|
1
|
-
import { eq } from 'drizzle-orm';
|
|
1
|
+
import { eq, type SQL } from 'drizzle-orm';
|
|
2
2
|
import { ApiError } from '../daemon/api-error.js';
|
|
3
3
|
import { getDb } from './client.js';
|
|
4
4
|
import { newId } from './ids.js';
|
|
5
5
|
import { users } from './schema.js';
|
|
6
6
|
|
|
7
7
|
const UNIQUE_VIOLATION = '23505';
|
|
8
|
+
const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
|
|
8
9
|
|
|
9
10
|
export class UserError extends ApiError {}
|
|
10
11
|
|
|
12
|
+
const CAUSE_DEPTH = 5;
|
|
13
|
+
|
|
11
14
|
export function isUniqueViolation(err: unknown): boolean {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
let current: unknown = err;
|
|
16
|
+
for (let depth = 0; depth < CAUSE_DEPTH; depth += 1) {
|
|
17
|
+
if (typeof current !== 'object' || current === null) return false;
|
|
18
|
+
const { code, cause } = current as { code?: unknown; cause?: unknown };
|
|
19
|
+
if (code === UNIQUE_VIOLATION) return true;
|
|
20
|
+
current = cause;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export function normalizeEmail(raw: string): string {
|
|
20
26
|
return raw.trim().toLowerCase();
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
export
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
export function normalizeAddress(raw: string): string | null {
|
|
30
|
+
const address = raw.trim().toLowerCase();
|
|
31
|
+
return ADDRESS_RE.test(address) ? address : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function userIdWhere(where: SQL): Promise<string | null> {
|
|
35
|
+
const rows = await getDb().select({ id: users.id }).from(users).where(where);
|
|
29
36
|
return rows[0]?.id ?? null;
|
|
30
37
|
}
|
|
31
38
|
|
|
39
|
+
export async function userIdForSubject(subject: string): Promise<string | null> {
|
|
40
|
+
const address = normalizeAddress(subject);
|
|
41
|
+
if (address !== null) return userIdWhere(eq(users.address, address));
|
|
42
|
+
return userIdWhere(eq(users.email, normalizeEmail(subject)));
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
export async function resolveUserId(
|
|
33
46
|
insert: () => Promise<string | undefined>,
|
|
34
47
|
lookup: () => Promise<string | null>,
|
|
@@ -41,17 +54,19 @@ export async function resolveUserId(
|
|
|
41
54
|
return existing;
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
export async function
|
|
45
|
-
const
|
|
57
|
+
export async function ensureUserByAddress(raw: string): Promise<string> {
|
|
58
|
+
const address = normalizeAddress(raw);
|
|
59
|
+
if (address === null)
|
|
60
|
+
throw new UserError('an Ethereum address is required', 400);
|
|
46
61
|
return resolveUserId(
|
|
47
62
|
async () => {
|
|
48
63
|
const rows = await getDb()
|
|
49
64
|
.insert(users)
|
|
50
|
-
.values({ id: newId(),
|
|
51
|
-
.onConflictDoNothing({ target: users.
|
|
65
|
+
.values({ id: newId(), address })
|
|
66
|
+
.onConflictDoNothing({ target: users.address })
|
|
52
67
|
.returning({ id: users.id });
|
|
53
68
|
return rows[0]?.id;
|
|
54
69
|
},
|
|
55
|
-
() =>
|
|
70
|
+
() => userIdWhere(eq(users.address, address)),
|
|
56
71
|
);
|
|
57
72
|
}
|
|
@@ -5,7 +5,7 @@ import { agentIdForKey } from '../db/key-map.js';
|
|
|
5
5
|
|
|
6
6
|
export type RequestIdentity =
|
|
7
7
|
| { kind: 'agent'; agentId: string }
|
|
8
|
-
| { kind: '
|
|
8
|
+
| { kind: 'session'; subject: string; agentIds: string[] };
|
|
9
9
|
|
|
10
10
|
const storage = new AsyncLocalStorage<RequestIdentity>();
|
|
11
11
|
|
|
@@ -55,8 +55,8 @@ export function authenticate(
|
|
|
55
55
|
|
|
56
56
|
if (cfg.sessionSecret !== '' && looksLikeJwt(token)) {
|
|
57
57
|
try {
|
|
58
|
-
const {
|
|
59
|
-
return { kind: '
|
|
58
|
+
const { subject, agentIds } = verifySession(token, cfg.sessionSecret);
|
|
59
|
+
return { kind: 'session', subject, agentIds };
|
|
60
60
|
} catch {
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
@@ -67,7 +67,7 @@ export function authenticate(
|
|
|
67
67
|
export function allowedAgents(
|
|
68
68
|
identity: RequestIdentity | undefined,
|
|
69
69
|
): Set<string> {
|
|
70
|
-
if (identity?.kind === '
|
|
70
|
+
if (identity?.kind === 'session') return new Set(identity.agentIds);
|
|
71
71
|
if (identity?.kind === 'agent') return new Set([identity.agentId]);
|
|
72
72
|
return new Set();
|
|
73
73
|
}
|
|
@@ -3,7 +3,7 @@ import { allowedAgents, type RequestIdentity } from './request-identity.js';
|
|
|
3
3
|
export function sessionScopeKey(identity: RequestIdentity): string {
|
|
4
4
|
const ids = [...allowedAgents(identity)].sort();
|
|
5
5
|
if (ids.length > 0) return `${AGENTS_PREFIX}${ids.join(',')}`;
|
|
6
|
-
return `
|
|
6
|
+
return `user:${identity.kind === 'session' ? identity.subject : identity.agentId}`;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const AGENTS_PREFIX = 'agents:';
|
package/runtime/runtime.json
CHANGED