@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
@@ -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,
@@ -34,50 +35,23 @@ const asText = (value: unknown): string =>
34
35
  typeof value === 'string' ? value : '';
35
36
 
36
37
  export interface ConnectorApiDeps extends OAuthRouteDeps {
37
- listConnectors: (email: string, project: string) => Promise<Connector[]>;
38
+ listConnectors: (subject: 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: (subject: string, agentId: string) => Promise<AgentConnectors>;
41
+ fenceRuntime: Fence;
68
42
  createConnector: (
69
- email: string,
43
+ subject: string,
70
44
  project: string,
71
45
  input: ConnectorInput,
72
46
  ) => Promise<Connector>;
73
- verifyConnector: (email: string, id: string) => Promise<ConnectorCheck>;
74
- disconnectConnector: (email: string, id: string) => Promise<Connector>;
47
+ verifyConnector: (subject: string, id: string) => Promise<ConnectorCheck>;
48
+ disconnectConnector: (subject: string, id: string) => Promise<Connector>;
75
49
  renameConnector: (
76
- email: string,
50
+ subject: string,
77
51
  id: string,
78
52
  name: string,
79
53
  ) => Promise<Connector>;
80
- deleteConnector: (email: string, id: string) => Promise<DeletedConnector>;
54
+ deleteConnector: (subject: string, id: string) => Promise<DeletedConnector>;
81
55
  }
82
56
 
83
57
  type Routable =
@@ -141,7 +115,7 @@ async function handleList(
141
115
  sendJson(req, res, 400, { error: 'a project is required' });
142
116
  return;
143
117
  }
144
- const rows = await deps.listConnectors(session.email, project);
118
+ const rows = await deps.listConnectors(session.subject, project);
145
119
  sendJson(req, res, 200, {
146
120
  connectors: rows.map((row) => connectorPayload(row)),
147
121
  });
@@ -161,7 +135,7 @@ async function handleCreate(
161
135
  }
162
136
  const offered = asText(bodyField(body, 'value')).trim() !== '';
163
137
  try {
164
- const created = await deps.createConnector(session.email, project, {
138
+ const created = await deps.createConnector(session.subject, project, {
165
139
  name: bodyField(body, 'name'),
166
140
  url: bodyField(body, 'url'),
167
141
  header: bodyField(body, 'header'),
@@ -187,7 +161,7 @@ async function handleVerify(
187
161
  session: ApiSession,
188
162
  id: string,
189
163
  ): Promise<void> {
190
- const check = await deps.verifyConnector(session.email, id);
164
+ const check = await deps.verifyConnector(session.subject, id);
191
165
  log.info(
192
166
  { id: check.id, name: check.name, ok: check.ok },
193
167
  'connector-api: re-verified connector',
@@ -202,7 +176,7 @@ async function handleDisconnect(
202
176
  session: ApiSession,
203
177
  id: string,
204
178
  ): Promise<void> {
205
- const row = await deps.disconnectConnector(session.email, id);
179
+ const row = await deps.disconnectConnector(session.subject, id);
206
180
  log.info(
207
181
  { id: row.id, name: row.name },
208
182
  'connector-api: signed the connector out',
@@ -217,7 +191,7 @@ async function handleDelete(
217
191
  session: ApiSession,
218
192
  id: string,
219
193
  ): Promise<void> {
220
- const gone = await deps.deleteConnector(session.email, id);
194
+ const gone = await deps.deleteConnector(session.subject, id);
221
195
  log.info(
222
196
  { id: gone.id, name: gone.name },
223
197
  'connector-api: deleted connector',
@@ -236,7 +210,7 @@ async function handleConnector(
236
210
  await handleDelete(req, res, deps, session, id);
237
211
  return;
238
212
  }
239
- const row = await deps.getConnector(session.email, id);
213
+ const row = await deps.getConnector(session.subject, id);
240
214
  sendJson(req, res, 200, connectorPayload(row, true));
241
215
  }
242
216
 
@@ -252,7 +226,7 @@ async function handleRename(
252
226
  sendJson(req, res, 400, { error: 'name is required' });
253
227
  return;
254
228
  }
255
- const row = await deps.renameConnector(session.email, id, name);
229
+ const row = await deps.renameConnector(session.subject, id, name);
256
230
  log.info({ id: row.id, name: row.name }, 'connector-api: renamed connector');
257
231
  sendJson(req, res, 200, connectorPayload(row));
258
232
  }
@@ -24,16 +24,16 @@ export function hostOf(url: string): string {
24
24
 
25
25
  export interface OAuthRouteDeps {
26
26
  createPendingConnector: (
27
- email: string,
27
+ subject: string,
28
28
  project: string,
29
29
  input: { name: unknown; url: unknown },
30
30
  ) => Promise<Connector>;
31
31
  reconnectConnector: (
32
- email: string,
32
+ subject: string,
33
33
  id: string,
34
34
  auth: OAuthAuth,
35
35
  ) => Promise<Connector>;
36
- getConnector: (email: string, id: string) => Promise<Connector>;
36
+ getConnector: (subject: string, id: string) => Promise<Connector>;
37
37
  }
38
38
 
39
39
  export async function startOAuth(
@@ -46,12 +46,12 @@ export async function startOAuth(
46
46
  payload: (row: Connector) => Record<string, unknown>,
47
47
  ): Promise<void> {
48
48
  const url = parseConnectorUrl(bodyField(body, 'url'));
49
- const row = await deps.createPendingConnector(session.email, project, {
49
+ const row = await deps.createPendingConnector(session.subject, project, {
50
50
  name: bodyField(body, 'name'),
51
51
  url: bodyField(body, 'url'),
52
52
  });
53
53
  const authorize = await beginOAuth({
54
- email: session.email,
54
+ subject: session.subject,
55
55
  name: row.name,
56
56
  url,
57
57
  returnTo: asText(bodyField(body, 'returnTo')),
@@ -71,11 +71,11 @@ export async function handleConnect(
71
71
  session: ApiSession,
72
72
  id: string,
73
73
  ): Promise<void> {
74
- const row = await deps.getConnector(session.email, id);
74
+ const row = await deps.getConnector(session.subject, id);
75
75
  const url = parseConnectorUrl(row.url);
76
76
  const body = await readJsonBody(req);
77
77
  const authorize = await beginOAuth({
78
- email: session.email,
78
+ subject: session.subject,
79
79
  name: row.name,
80
80
  url,
81
81
  returnTo: asText(bodyField(body, 'returnTo')),
@@ -110,7 +110,7 @@ async function saveOAuth(
110
110
  entry: PendingAuth,
111
111
  auth: OAuthAuth,
112
112
  ): Promise<Connector> {
113
- return deps.reconnectConnector(entry.email, entry.connectorId, auth);
113
+ return deps.reconnectConnector(entry.subject, entry.connectorId, auth);
114
114
  }
115
115
 
116
116
  async function settleCallback(
@@ -4,7 +4,7 @@ import {
4
4
  type OAuthAuth,
5
5
  type OAuthTokens,
6
6
  } from './connector-verify.js';
7
- import { validateReturnTo } from './google-oauth.js';
7
+ import { validateReturnTo } from './session-config.js';
8
8
  import {
9
9
  authorizeUrl,
10
10
  exchangeCode,
@@ -32,7 +32,7 @@ const resourceOf = (url: URL): string => url.toString();
32
32
 
33
33
  export interface BeginInput {
34
34
  connectorId: string;
35
- email: string;
35
+ subject: string;
36
36
  name: string;
37
37
  url: URL;
38
38
  returnTo: string;
@@ -47,7 +47,7 @@ export async function beginOAuth(input: BeginInput): Promise<string> {
47
47
  const verifier = newVerifier();
48
48
  const resource = resourceOf(input.url);
49
49
  const state = startPending({
50
- email: input.email,
50
+ subject: input.subject,
51
51
  name: input.name,
52
52
  url: input.url.toString(),
53
53
  resource,
@@ -27,7 +27,8 @@ import {
27
27
  } from './tunnel.js';
28
28
  import { attachmentEventUrl, handleAttachRequest } from './attach-serve.js';
29
29
  import { webhookEntry } from '@metro-labs/webhook';
30
- import { handleGoogleAuthRequest } from './google-oauth.js';
30
+ import { handleSiweAuthRequest } from './siwe-routes.js';
31
+ import { handleModeRequest } from './mode-api.js';
31
32
  import { handleUploadRequest } from './upload-api.js';
32
33
  import {
33
34
  handleMonitorRequest,
@@ -323,6 +324,15 @@ async function handleWebhookRoute(
323
324
  return true;
324
325
  }
325
326
 
327
+ function handleSignInRoutes(
328
+ req: IncomingMessage,
329
+ res: ServerResponse,
330
+ apis: SessionApis,
331
+ ): boolean {
332
+ if (handleSiweAuthRequest(req, res, apis.siwe)) return true;
333
+ return apis.mode !== undefined && handleModeRequest(req, res, apis.mode);
334
+ }
335
+
326
336
  async function handlePreMcpRoutes(
327
337
  req: IncomingMessage,
328
338
  res: ServerResponse,
@@ -331,7 +341,7 @@ async function handlePreMcpRoutes(
331
341
  monitorCall?: MonitorCall,
332
342
  ): Promise<boolean> {
333
343
  if (handleHealth(req, res)) return true;
334
- if (await handleGoogleAuthRequest(req, res)) return true;
344
+ if (handleSignInRoutes(req, res, apis)) return true;
335
345
  if (handleSessionApis(req, res, apis)) return true;
336
346
  if (handleUploadRequest(req, res)) return true;
337
347
  if (handleAttachRequest(req, res)) return true;
@@ -71,9 +71,16 @@ export function daemonSelf(): Line {
71
71
  }
72
72
 
73
73
  export function selfLine(): Line | null {
74
- if (process.env.CLAUDECODE) {
75
- const s = claudeSessionId();
76
- return s ? Line.claude(claudeUserId(), s) : null;
74
+ if (!process.env.CLAUDECODE) return null;
75
+ const s = claudeSessionId();
76
+ if (!s) return null;
77
+ try {
78
+ return Line.claude(claudeUserId(), s);
79
+ } catch (e) {
80
+ log.warn(
81
+ { reason: errMsg(e) },
82
+ 'could not resolve a Claude account id; relaying with no self line',
83
+ );
84
+ return null;
77
85
  }
78
- return null;
79
86
  }
@@ -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
+ }
@@ -7,7 +7,7 @@ const SWEEP_MS = 60_000;
7
7
  const MAX_PENDING = 100;
8
8
 
9
9
  export interface PendingAuth {
10
- email: string;
10
+ subject: string;
11
11
  name: string;
12
12
  url: string;
13
13
  resource: string;
@@ -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: (email: string) => Promise<Project[]>;
19
- createProject: (email: string, name: unknown) => Promise<Project>;
18
+ listProjects: (subject: string) => Promise<Project[]>;
19
+ createProject: (subject: string, name: unknown) => Promise<Project>;
20
20
  renameProject: (
21
- email: string,
21
+ subject: string,
22
22
  id: string,
23
23
  name: unknown,
24
24
  ) => Promise<Project>;
25
25
  deleteProject: (
26
- email: string,
26
+ subject: string,
27
27
  id: string,
28
28
  ) => Promise<{ id: string; name: string }>;
29
- listMembers: (email: string, id: string) => Promise<Member[]>;
29
+ listMembers: (subject: string, id: string) => Promise<Member[]>;
30
30
  addMember: (
31
- email: string,
31
+ subject: string,
32
32
  id: string,
33
33
  invited: unknown,
34
34
  role: unknown,
35
35
  ) => Promise<Member[]>;
36
36
  setMemberRole: (
37
- email: string,
37
+ subject: string,
38
38
  id: string,
39
39
  memberId: string,
40
40
  role: unknown,
41
41
  ) => Promise<Member[]>;
42
42
  removeMember: (
43
- email: string,
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.email) });
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.email,
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.email, id) });
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.email,
125
+ session.subject,
126
126
  id,
127
- bodyField(body, 'email'),
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.email, tgt.id, tgt.memberId);
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.email,
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.email,
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.email, tgt.id));
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
  }