@stage-labs/metro 0.1.0-beta.30 → 0.1.0-beta.31

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 (28) hide show
  1. package/dist/api.js +12 -12
  2. package/dist/bedrock.js +5 -3
  3. package/dist/cli.js +18 -11
  4. package/dist/store.js +26 -3
  5. package/package.json +1 -1
  6. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +9 -14
  7. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +29 -6
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +27 -18
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +21 -19
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +4 -30
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +13 -4
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +8 -3
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +13 -13
  17. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
  18. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
  19. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +1 -1
  20. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +1 -1
  21. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +8 -15
  22. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +6 -20
  23. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +10 -5
  24. package/runtime/runtime.json +1 -1
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
  27. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
  28. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-collections.ts +0 -218
@@ -9,9 +9,10 @@ import {
9
9
  readJsonBody,
10
10
  sendJson,
11
11
  type ApiSession,
12
+ type Fence,
12
13
  } from './api-http.js';
13
14
  import { parseId } from '../db/ids.js';
14
- import type { ConnectorCollectionRow } from '../db/connector-collections.js';
15
+ import type { AgentConnectors } from '../db/agent-connectors.js';
15
16
  import type { RelayServerEntry } from './connector-json.js';
16
17
  import {
17
18
  handleCallback,
@@ -36,35 +37,8 @@ const asText = (value: unknown): string =>
36
37
  export interface ConnectorApiDeps extends OAuthRouteDeps {
37
38
  listConnectors: (email: string, project: string) => Promise<Connector[]>;
38
39
  connectorNamesByIds: (ids: string[]) => Promise<RelayServerEntry[]>;
39
- listCollections: (
40
- email: string,
41
- project: string,
42
- ) => Promise<ConnectorCollectionRow[]>;
43
- getCollection: (email: string, id: string) => Promise<ConnectorCollectionRow>;
44
- createCollection: (
45
- email: string,
46
- project: string,
47
- name: string,
48
- ) => Promise<ConnectorCollectionRow>;
49
- renameCollection: (
50
- email: string,
51
- id: string,
52
- name: string,
53
- ) => Promise<ConnectorCollectionRow>;
54
- deleteCollection: (
55
- email: string,
56
- id: string,
57
- ) => Promise<{ id: string; name: string }>;
58
- addToCollection: (
59
- email: string,
60
- id: string,
61
- connectorId: string,
62
- ) => Promise<ConnectorCollectionRow>;
63
- removeFromCollection: (
64
- email: string,
65
- id: string,
66
- connectorId: string,
67
- ) => Promise<ConnectorCollectionRow>;
40
+ agentConnectors: (email: string, agentId: string) => Promise<AgentConnectors>;
41
+ fenceRuntime: Fence;
68
42
  createConnector: (
69
43
  email: string,
70
44
  project: string,
@@ -1,15 +1,16 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { RelayTarget } from '../db/connector-relay.js';
3
- import { cliIdentity } from './api-http.js';
3
+ import { agentIdentity, assertLease, type Fence } from './api-http.js';
4
4
  import { ApiError } from './api-error.js';
5
5
  import { errMsg, log } from './log.js';
6
6
 
7
7
  export interface RelayApiDeps {
8
8
  target: (
9
- collectionId: string,
9
+ agentId: string,
10
10
  connectorId: string,
11
11
  force: boolean,
12
12
  ) => Promise<RelayTarget>;
13
+ fence: Fence;
13
14
  }
14
15
 
15
16
  const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
@@ -196,18 +197,18 @@ type Exchanged =
196
197
 
197
198
  async function exchange(
198
199
  req: IncomingMessage,
199
- collectionId: string,
200
+ agentId: string,
200
201
  connectorId: string,
201
202
  deps: RelayApiDeps,
202
203
  body: Uint8Array<ArrayBuffer> | null,
203
204
  signal: AbortSignal,
204
205
  ): Promise<Exchanged> {
205
- const target = await deps.target(collectionId, connectorId, false);
206
+ const target = await deps.target(agentId, connectorId, false);
206
207
  if (target.kind !== 'ok') return { kind: target.kind };
207
208
  let upstream = await forward(req, target, body, signal);
208
209
  if (!authFailed(upstream.status)) return { kind: 'response', upstream };
209
210
  await upstream.body?.cancel();
210
- const fresh = await deps.target(collectionId, connectorId, true);
211
+ const fresh = await deps.target(agentId, connectorId, true);
211
212
  if (fresh.kind !== 'ok') return { kind: 'signin' };
212
213
  upstream = await forward(req, fresh, body, signal);
213
214
  if (!authFailed(upstream.status)) return { kind: 'response', upstream };
@@ -218,7 +219,7 @@ async function exchange(
218
219
  async function relayExchange(
219
220
  req: IncomingMessage,
220
221
  res: ServerResponse,
221
- collectionId: string,
222
+ agentId: string,
222
223
  connectorId: string,
223
224
  deps: RelayApiDeps,
224
225
  ): Promise<void> {
@@ -234,7 +235,7 @@ async function relayExchange(
234
235
  const body = req.method === 'POST' ? await readCapped(req) : null;
235
236
  const out = await exchange(
236
237
  req,
237
- collectionId,
238
+ agentId,
238
239
  connectorId,
239
240
  deps,
240
241
  body,
@@ -262,13 +263,14 @@ function dispatch(
262
263
  connectorId: string,
263
264
  deps: RelayApiDeps,
264
265
  ): void {
265
- const who = cliIdentity(req);
266
+ const who = agentIdentity(req);
266
267
  if (who === null) {
267
268
  answer(res, 401, { error: 'unauthorized' });
268
269
  return;
269
270
  }
270
- relayExchange(req, res, who.collectionId, connectorId, deps).catch(
271
- (err: unknown) => {
271
+ assertLease(who, deps.fence)
272
+ .then(() => relayExchange(req, res, who.agentId, connectorId, deps))
273
+ .catch((err: unknown) => {
272
274
  if (err instanceof ApiError) {
273
275
  answer(res, err.status, { error: err.message });
274
276
  } else {
@@ -277,8 +279,7 @@ function dispatch(
277
279
  if (!res.writableEnded) res.end();
278
280
  }
279
281
  closeIfBodyUnread(req, res);
280
- },
281
- );
282
+ });
282
283
  }
283
284
 
284
285
  export function handleRelayRequest(
@@ -8,7 +8,7 @@ import {
8
8
  runIdentity,
9
9
  sendJson,
10
10
  } from './api-http.js';
11
- import { RUN_CODE_RE, takeRunCode } from './run-pair.js';
11
+ import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
12
12
  import { sessionTtlFromEnv } from './google-oauth.js';
13
13
  import { signRunToken } from './session.js';
14
14
  import type { LoadedAgent } from '../db/materialize.js';
@@ -27,6 +27,7 @@ export interface RunApiDeps {
27
27
  fenceRuntime: (runtimeId: string, agentId: string) => Promise<void>;
28
28
  touchRuntime: (runtimeId: string) => Promise<void>;
29
29
  loadAgent: (agentId: string) => Promise<LoadedAgent>;
30
+ blockedStations: (agentId: string) => Promise<string[]>;
30
31
  }
31
32
 
32
33
  function secretOrNull(): string | null {
@@ -53,20 +54,28 @@ async function handleClaim(
53
54
  const body = await readJsonBody(req);
54
55
  const raw = bodyField(body, 'code');
55
56
  const code = typeof raw === 'string' ? raw.trim() : '';
56
- if (!RUN_CODE_RE.test(code)) {
57
+ if (!AGENT_CODE_RE.test(code)) {
57
58
  sendJson(req, res, 400, {
58
59
  error:
59
- 'that does not look like a runtime code — metro start wants the mr_… code from the agent page',
60
+ 'that does not look like an agent code — metro start wants the ma_… code from the agent page',
60
61
  });
61
62
  return;
62
63
  }
63
- const taken = takeRunCode(code);
64
+ const taken = takeAgentCode(code);
64
65
  if (taken === undefined) {
65
66
  sendJson(req, res, 400, {
66
67
  error: 'that code has expired or was already used',
67
68
  });
68
69
  return;
69
70
  }
71
+ const blocked = await deps.blockedStations(taken.agentId);
72
+ if (blocked.length > 0) {
73
+ sendJson(req, res, 409, {
74
+ error:
75
+ `this agent holds ${blocked.join(', ')}, which only runs on metro because a webhook url has to be publicly reachable. Detach it, or move it to a second agent that stays on metro, then start this one.`,
76
+ });
77
+ return;
78
+ }
70
79
  const lease = await deps.claimRuntime(taken.agentId, labelOf(body));
71
80
  const token = signRunToken(
72
81
  { email: taken.email, agentId: lease.agentId, runtimeId: lease.runtimeId },
@@ -5,7 +5,6 @@ import {
5
5
  handleConnectorApiRequest,
6
6
  type ConnectorApiDeps,
7
7
  } from './connector-api.js';
8
- import { handleCollectionApiRequest } from './collection-api.js';
9
8
  import { handleCliPairRequest } from './cli-pair-api.js';
10
9
  import { handleRunApiRequest, type RunApiDeps } from './run-api.js';
11
10
  import type { RelayApiDeps } from './relay.js';
@@ -13,9 +12,14 @@ import {
13
12
  handleProjectApiRequest,
14
13
  type ProjectApiDeps,
15
14
  } from './project-api.js';
15
+ import {
16
+ handleAgentConnectorRequest,
17
+ type AgentConnectorApiDeps,
18
+ } from './agent-connector-api.js';
16
19
 
17
20
  export interface SessionApis {
18
21
  agentApi?: AgentApiDeps;
22
+ agentConnectorApi?: AgentConnectorApiDeps;
19
23
  connectorApi?: ConnectorApiDeps;
20
24
  projectApi?: ProjectApiDeps;
21
25
  runApi?: RunApiDeps;
@@ -27,7 +31,7 @@ export function handleSessionApis(
27
31
  res: ServerResponse,
28
32
  apis: SessionApis,
29
33
  ): boolean {
30
- const { agentApi, connectorApi, projectApi, runApi } = apis;
34
+ const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi } = apis;
31
35
  const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
32
36
  if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
33
37
  if (projectApi)
@@ -35,9 +39,10 @@ export function handleSessionApis(
35
39
  if (connectorApi)
36
40
  routes.push(
37
41
  () => handleCliPairRequest(req, res, connectorApi),
38
- () => handleCollectionApiRequest(req, res, connectorApi),
39
42
  () => handleConnectorApiRequest(req, res, connectorApi),
40
43
  );
44
+ if (agentConnectorApi)
45
+ routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
41
46
  if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
42
47
  return routes.some((run) => run());
43
48
  }
@@ -99,9 +99,9 @@ interface SessionClaims {
99
99
  agentIds: string[];
100
100
  }
101
101
 
102
- export interface CliClaims {
102
+ export interface AgentClaims {
103
103
  email: string;
104
- collectionId: string;
104
+ agentId: string;
105
105
  }
106
106
 
107
107
  export interface RunClaims {
@@ -147,17 +147,17 @@ export function verifySession(
147
147
  return { email: p.sub, agentIds: ids as string[] };
148
148
  }
149
149
 
150
- export function signCliToken(
151
- claims: CliClaims,
150
+ export function signAgentToken(
151
+ claims: AgentClaims,
152
152
  secret: string,
153
153
  opts: { ttlSec?: number; now?: number } = {},
154
154
  ): string {
155
155
  const iat = nowSec(opts.now);
156
156
  return sign(
157
157
  {
158
- typ: 'cli',
158
+ typ: 'agent',
159
159
  sub: claims.email,
160
- collection: claims.collectionId,
160
+ agent: claims.agentId,
161
161
  iat,
162
162
  exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
163
163
  },
@@ -165,18 +165,18 @@ export function signCliToken(
165
165
  );
166
166
  }
167
167
 
168
- export function verifyCliToken(
168
+ export function verifyAgentToken(
169
169
  token: string,
170
170
  secret: string,
171
171
  now?: number,
172
- ): CliClaims {
172
+ ): AgentClaims {
173
173
  const p = verify(token, secret);
174
- if (p.typ !== 'cli') throw new SessionError('wrong token type');
174
+ if (p.typ !== 'agent') throw new SessionError('wrong token type');
175
175
  if (typeof p.exp !== 'number' || p.exp < nowSec(now))
176
- throw new SessionError('cli token expired');
177
- if (typeof p.sub !== 'string' || typeof p.collection !== 'string')
178
- throw new SessionError('malformed cli token');
179
- return { email: p.sub, collectionId: p.collection };
176
+ throw new SessionError('agent token expired');
177
+ if (typeof p.sub !== 'string' || typeof p.agent !== 'string')
178
+ throw new SessionError('malformed agent token');
179
+ return { email: p.sub, agentId: p.agent };
180
180
  }
181
181
 
182
182
  export function signRunToken(
@@ -0,0 +1,143 @@
1
+ import { and, asc, eq, inArray, ne } from 'drizzle-orm';
2
+ import { getDb } from './client.js';
3
+ import { newId } from './ids.js';
4
+ import { isUniqueViolation } from './users.js';
5
+ import { ownedAgentOrThrow } from './agent-admin.js';
6
+ import { ConnectorError } from './connector-config.js';
7
+ import { agentConnectors, agents, connectors } from './schema.js';
8
+
9
+ export interface AgentConnectors {
10
+ id: string;
11
+ name: string;
12
+ connectorIds: string[];
13
+ }
14
+
15
+ const nameClash = (name: string, agent: string): ConnectorError =>
16
+ new ConnectorError(
17
+ `the agent '${agent}' already has a connector named '${name}'`,
18
+ 409,
19
+ );
20
+
21
+ async function collidingAgent(
22
+ agentIds: string[],
23
+ name: string,
24
+ exceptConnectorId: string,
25
+ ): Promise<string | null> {
26
+ if (agentIds.length === 0) return null;
27
+ const rows = await getDb()
28
+ .select({ agent: agents.name })
29
+ .from(agentConnectors)
30
+ .innerJoin(connectors, eq(connectors.id, agentConnectors.connectorId))
31
+ .innerJoin(agents, eq(agents.id, agentConnectors.agentId))
32
+ .where(
33
+ and(
34
+ inArray(agentConnectors.agentId, agentIds),
35
+ eq(connectors.name, name),
36
+ ne(connectors.id, exceptConnectorId),
37
+ ),
38
+ )
39
+ .limit(1);
40
+ return rows[0]?.agent ?? null;
41
+ }
42
+
43
+ export async function assertRenameFreeOfClash(
44
+ connectorId: string,
45
+ name: string,
46
+ ): Promise<void> {
47
+ const rows = await getDb()
48
+ .select({ agentId: agentConnectors.agentId })
49
+ .from(agentConnectors)
50
+ .where(eq(agentConnectors.connectorId, connectorId));
51
+ const clash = await collidingAgent(
52
+ rows.map((r) => r.agentId),
53
+ name,
54
+ connectorId,
55
+ );
56
+ if (clash !== null) throw nameClash(name, clash);
57
+ }
58
+
59
+ export async function connectorIdsOfAgent(agentId: string): Promise<string[]> {
60
+ const rows = await getDb()
61
+ .select({ connectorId: agentConnectors.connectorId })
62
+ .from(agentConnectors)
63
+ .where(eq(agentConnectors.agentId, agentId))
64
+ .orderBy(asc(agentConnectors.id));
65
+ return rows.map((r) => r.connectorId);
66
+ }
67
+
68
+ export async function connectorIdsByAgent(
69
+ agentIds: string[],
70
+ ): Promise<Map<string, string[]>> {
71
+ const out = new Map<string, string[]>();
72
+ if (agentIds.length === 0) return out;
73
+ const rows = await getDb()
74
+ .select({
75
+ agentId: agentConnectors.agentId,
76
+ connectorId: agentConnectors.connectorId,
77
+ })
78
+ .from(agentConnectors)
79
+ .where(inArray(agentConnectors.agentId, agentIds))
80
+ .orderBy(asc(agentConnectors.id));
81
+ for (const row of rows) {
82
+ const list = out.get(row.agentId) ?? [];
83
+ list.push(row.connectorId);
84
+ out.set(row.agentId, list);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ export async function agentConnectorsForEmail(
90
+ email: string,
91
+ agentId: string,
92
+ ): Promise<AgentConnectors> {
93
+ const { agent } = await ownedAgentOrThrow(email, agentId);
94
+ return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
95
+ }
96
+
97
+ async function connectorNameIn(
98
+ projectId: string,
99
+ connectorId: string,
100
+ ): Promise<string | null> {
101
+ const rows = await getDb()
102
+ .select({ name: connectors.name })
103
+ .from(connectors)
104
+ .where(and(eq(connectors.id, connectorId), eq(connectors.projectId, projectId)));
105
+ return rows[0]?.name ?? null;
106
+ }
107
+
108
+ export async function addConnectorToAgentForEmail(
109
+ email: string,
110
+ agentId: string,
111
+ connectorId: string,
112
+ ): Promise<AgentConnectors> {
113
+ const { agent, projectId } = await ownedAgentOrThrow(email, agentId);
114
+ const name = await connectorNameIn(projectId, connectorId);
115
+ if (name === null) throw new ConnectorError('no such connector', 404);
116
+ const clash = await collidingAgent([agent.id], name, connectorId);
117
+ if (clash !== null) throw nameClash(name, clash);
118
+ try {
119
+ await getDb()
120
+ .insert(agentConnectors)
121
+ .values({ id: newId(), agentId: agent.id, connectorId });
122
+ } catch (err) {
123
+ if (!isUniqueViolation(err)) throw err;
124
+ }
125
+ return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
126
+ }
127
+
128
+ export async function removeConnectorFromAgentForEmail(
129
+ email: string,
130
+ agentId: string,
131
+ connectorId: string,
132
+ ): Promise<AgentConnectors> {
133
+ const { agent } = await ownedAgentOrThrow(email, agentId);
134
+ await getDb()
135
+ .delete(agentConnectors)
136
+ .where(
137
+ and(
138
+ eq(agentConnectors.agentId, agent.id),
139
+ eq(agentConnectors.connectorId, connectorId),
140
+ ),
141
+ );
142
+ return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
143
+ }
@@ -8,7 +8,7 @@ import {
8
8
  import { errMsg, log } from '../daemon/log.js';
9
9
  import { readConfig, type ConnectorConfig } from './connector-config.js';
10
10
  import { getDb } from './client.js';
11
- import { collectionItems, connectors } from './schema.js';
11
+ import { agentConnectors, connectors } from './schema.js';
12
12
 
13
13
  export type RelayTarget =
14
14
  | { kind: 'ok'; url: string; headers: Record<string, string> }
@@ -32,17 +32,17 @@ export function headerAuthHeaders(
32
32
  }
33
33
 
34
34
  async function memberRow(
35
- collectionId: string,
35
+ agentId: string,
36
36
  connectorId: string,
37
37
  ): Promise<ConnectorRow | undefined> {
38
38
  const rows = await getDb()
39
39
  .select({ connector: connectors })
40
- .from(collectionItems)
41
- .innerJoin(connectors, eq(collectionItems.connectorId, connectors.id))
40
+ .from(agentConnectors)
41
+ .innerJoin(connectors, eq(agentConnectors.connectorId, connectors.id))
42
42
  .where(
43
43
  and(
44
- eq(collectionItems.collectionId, collectionId),
45
- eq(collectionItems.connectorId, connectorId),
44
+ eq(agentConnectors.agentId, agentId),
45
+ eq(agentConnectors.connectorId, connectorId),
46
46
  ),
47
47
  );
48
48
  return rows[0]?.connector;
@@ -112,11 +112,11 @@ export function fixedTarget(
112
112
  }
113
113
 
114
114
  export async function relayTarget(
115
- collectionId: string,
115
+ agentId: string,
116
116
  connectorId: string,
117
117
  force: boolean,
118
118
  ): Promise<RelayTarget> {
119
- const row = await memberRow(collectionId, connectorId);
119
+ const row = await memberRow(agentId, connectorId);
120
120
  if (row === undefined) return { kind: 'missing' };
121
121
  parseConnectorUrl(row.url);
122
122
  const config = readConfig(row.config);
@@ -1,4 +1,4 @@
1
- import { assertRenameFreeOfClash } from './connector-collections.js';
1
+ import { assertRenameFreeOfClash } from './agent-connectors.js';
2
2
  import { projectIdOrThrow } from './projects.js';
3
3
  import { and, asc, eq, inArray } from 'drizzle-orm';
4
4
  import {
@@ -188,7 +188,7 @@ export async function deleteProjectForEmail(
188
188
  await getDb().delete(projects).where(eq(projects.id, access.projectId));
189
189
  } catch {
190
190
  throw new ProjectError(
191
- 'this project still holds agents, connectors or collections',
191
+ 'this project still holds agents or connectors',
192
192
  409,
193
193
  );
194
194
  }
@@ -1,25 +1,18 @@
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(
6
+ export async function mintAgentCodeForEmail(
8
7
  email: string,
9
8
  agentId: string,
10
- ): Promise<{ code: string; expiresAt: number }> {
9
+ ): Promise<{ code: string; expiresAt: number; agent: string }> {
11
10
  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 });
11
+ return { ...mintAgentCode({ email, agentId: agent.id }), agent: agent.name };
12
+ }
13
+
14
+ export async function blockedStationsFor(agentId: string): Promise<string[]> {
15
+ return unmovableStations(await loadAllStationsFor(agentId));
23
16
  }
24
17
 
25
18
  export async function releaseRuntimeForEmail(
@@ -97,34 +97,20 @@ export const connectors = pgTable(
97
97
  },
98
98
  );
99
99
 
100
- export const collections = pgTable(
101
- 'collections',
100
+ export const agentConnectors = pgTable(
101
+ 'agent_connectors',
102
102
  {
103
103
  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')
104
+ agentId: text('agent_id')
119
105
  .notNull()
120
- .references(() => collections.id, { onDelete: 'cascade' }),
106
+ .references(() => agents.id, { onDelete: 'cascade' }),
121
107
  connectorId: text('connector_id')
122
108
  .notNull()
123
109
  .references(() => connectors.id, { onDelete: 'cascade' }),
124
110
  },
125
111
  (t) => [
126
- unique('collection_items_collection_id_connector_id_unique').on(
127
- t.collectionId,
112
+ unique('agent_connectors_agent_id_connector_id_unique').on(
113
+ t.agentId,
128
114
  t.connectorId,
129
115
  ),
130
116
  ],
@@ -8,12 +8,17 @@ const UNIQUE_VIOLATION = '23505';
8
8
 
9
9
  export class UserError extends ApiError {}
10
10
 
11
+ const CAUSE_DEPTH = 5;
12
+
11
13
  export function isUniqueViolation(err: unknown): boolean {
12
- return (
13
- typeof err === 'object' &&
14
- err !== null &&
15
- (err as { code?: unknown }).code === UNIQUE_VIOLATION
16
- );
14
+ let current: unknown = err;
15
+ for (let depth = 0; depth < CAUSE_DEPTH; depth += 1) {
16
+ if (typeof current !== 'object' || current === null) return false;
17
+ const { code, cause } = current as { code?: unknown; cause?: unknown };
18
+ if (code === UNIQUE_VIOLATION) return true;
19
+ current = cause;
20
+ }
21
+ return false;
17
22
  }
18
23
 
19
24
  export function normalizeEmail(raw: string): string {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.30"
2
+ "version": "0.1.0-beta.31"
3
3
  }
@@ -1,20 +0,0 @@
1
- import { makeCodeStore } from './pair-codes.js';
2
-
3
- export interface CliCode {
4
- email: string;
5
- collectionId: string;
6
- }
7
-
8
- const store = makeCodeStore<CliCode>('mc');
9
-
10
- export const CLI_CODE_RE = store.pattern;
11
-
12
- export const mintCliCode = (
13
- entry: CliCode,
14
- now?: number,
15
- ): { code: string; expiresAt: number } => store.mint(entry, now);
16
-
17
- export const takeCliCode = (code: string, now?: number): CliCode | undefined =>
18
- store.take(code, now);
19
-
20
- export const cliCodeCount = (): number => store.count();