@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.
Files changed (59) hide show
  1. package/dist/api.js +12 -12
  2. package/dist/bedrock.js +5 -3
  3. package/dist/cli.js +23 -11
  4. package/dist/runtime.js +4 -1
  5. package/dist/serve.js +57 -0
  6. package/dist/store.js +26 -3
  7. package/package.json +1 -1
  8. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +5 -5
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +24 -28
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +34 -10
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +5 -3
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +2 -2
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +119 -72
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +31 -20
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +17 -43
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +8 -8
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +3 -3
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -2
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +11 -4
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +158 -0
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +1 -1
  27. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +10 -0
  28. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +17 -17
  29. package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
  30. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +15 -6
  31. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +1 -1
  32. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +12 -3
  33. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
  34. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +21 -21
  35. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
  36. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
  37. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
  38. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +2 -5
  39. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +15 -16
  40. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
  41. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
  42. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +32 -32
  43. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +250 -0
  44. package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
  45. package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +2 -0
  46. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +16 -7
  47. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +49 -40
  48. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +13 -20
  49. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +8 -21
  50. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +32 -17
  51. package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +4 -4
  52. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +1 -1
  53. package/runtime/runtime.json +1 -1
  54. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
  55. package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
  56. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +0 -164
  57. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
  58. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
  59. 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());
@@ -17,3 +17,5 @@ export function parseId(raw: unknown): string | null {
17
17
  if (typeof raw !== 'string') return null;
18
18
  return ID_RE.test(raw) ? raw : null;
19
19
  }
20
+
21
+ export const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{1,31}$/;
@@ -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(TRAINS_DIR, `${station}.ts`);
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(TRAINS_DIR, { recursive: true });
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 async function materializeFrom(source: StationSource): Promise<void> {
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) throw new Error('no agents found — nothing to materialize');
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 { ensureUser, normalizeEmail, userIdForEmail } from './users.js';
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
- email: string;
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
- email: string,
58
+ subject: string,
54
59
  projectId: string,
55
60
  ): Promise<Access> {
56
- const userId = await userIdForEmail(email);
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
- email: string,
87
+ subject: string,
83
88
  projectId: string,
84
89
  ): Promise<string> {
85
- return (await memberAccessOrThrow(email, projectId)).projectId;
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(rawEmail: string): Promise<string> {
115
- const userId = await ensureUser(rawEmail);
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 listProjectsForEmail(email: string): Promise<Project[]> {
121
- const userId = await userIdForEmail(email);
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 createProjectForEmail(
145
- email: string,
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 ensureUser(email);
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 renameProjectForEmail(
157
- email: string,
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(email, projectId);
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 deleteProjectForEmail(
179
- email: string,
184
+ export async function deleteProjectForUser(
185
+ subject: string,
180
186
  projectId: string,
181
187
  ): Promise<{ id: string; name: string }> {
182
- const access = await memberAccessOrThrow(email, projectId);
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, connectors or collections',
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 listMembersForEmail(
199
- email: string,
204
+ export async function listMembersForUser(
205
+ subject: string,
200
206
  projectId: string,
201
207
  ): Promise<Member[]> {
202
- const access = await memberAccessOrThrow(email, projectId);
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
- email: string,
236
+ subject: string,
229
237
  projectId: string,
230
238
  ): Promise<Access> {
231
- const access = await memberAccessOrThrow(email, projectId);
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 addMemberForEmail(
238
- email: string,
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(email, projectId);
251
+ const access = await adminAccessOrThrow(subject, projectId);
244
252
  const role = readRole(rawRole);
245
- const invited = normalizeEmail(typeof raw === 'string' ? raw : '');
246
- if (invited === '') throw new ProjectError('an email is required', 400);
247
- const invitedId = await ensureUser(invited);
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 listMembersForEmail(email, projectId);
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 setMemberRoleForEmail(
272
- email: string,
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(email, projectId);
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 listMembersForEmail(email, projectId);
295
+ return listMembersForUser(subject, projectId);
287
296
  }
288
297
 
289
- export async function removeMemberForEmail(
290
- email: string,
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(email, projectId);
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 listMembersForEmail(email, projectId);
308
+ return listMembersForUser(subject, projectId);
300
309
  }
@@ -1,31 +1,24 @@
1
1
  import { ownedAgentOrThrow } from './agent-admin.js';
2
- import { ApiError } from '../daemon/api-error.js';
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 mintRuntimeCodeForEmail(
8
- email: string,
6
+ export async function mintAgentCodeForUser(
7
+ subject: string,
9
8
  agentId: string,
10
- ): Promise<{ code: string; expiresAt: number }> {
11
- const { agent } = await ownedAgentOrThrow(email, agentId);
12
- const blocked = unmovableStations(await loadAllStationsFor(agent.id));
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 releaseRuntimeForEmail(
26
- email: string,
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(email, agentId);
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').notNull().unique(),
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 collections = pgTable(
101
- 'collections',
101
+ export const agentConnectors = pgTable(
102
+ 'agent_connectors',
102
103
  {
103
104
  id: text('id').primaryKey(),
104
- projectId: text('project_id')
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(() => collections.id, { onDelete: 'cascade' }),
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('collection_items_collection_id_connector_id_unique').on(
127
- t.collectionId,
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
- return (
13
- typeof err === 'object' &&
14
- err !== null &&
15
- (err as { code?: unknown }).code === UNIQUE_VIOLATION
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 async function userIdForEmail(rawEmail: string): Promise<string | null> {
24
- const email = normalizeEmail(rawEmail);
25
- const rows = await getDb()
26
- .select({ id: users.id })
27
- .from(users)
28
- .where(eq(users.email, email));
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 ensureUser(rawEmail: string): Promise<string> {
45
- const email = normalizeEmail(rawEmail);
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(), email })
51
- .onConflictDoNothing({ target: users.email })
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
- () => userIdForEmail(email),
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: 'google'; email: string; agentIds: string[] };
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 { email, agentIds } = verifySession(token, cfg.sessionSecret);
59
- return { kind: 'google', email, agentIds };
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 === 'google') return new Set(identity.agentIds);
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 `email:${identity.kind === 'google' ? identity.email : identity.agentId}`;
6
+ return `user:${identity.kind === 'session' ? identity.subject : identity.agentId}`;
7
7
  }
8
8
 
9
9
  const AGENTS_PREFIX = 'agents:';
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.30"
2
+ "version": "0.1.0-beta.32"
3
3
  }