@stage-labs/metro 0.1.0-beta.173 → 0.1.0-beta.174

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 CHANGED
@@ -1,9 +1,11 @@
1
- import { localAgents, localDaemonUp, pickLocalAgent } from './local.js';
1
+ import { localAgent, localDaemonUp } from './local.js';
2
2
  import { localUrl } from './runtime.js';
3
3
  const NO_DAEMON = `no metro daemon on ${localUrl()} — start one with: metro serve`;
4
- export async function whoisAuthorized(wanted) {
4
+ export async function whoisAuthorized() {
5
5
  if (!(await localDaemonUp()))
6
6
  throw new Error(NO_DAEMON);
7
- const agent = pickLocalAgent(localAgents(), wanted);
8
- return { agent: agent.name, where: localUrl() };
7
+ const agent = localAgent();
8
+ if (agent === null)
9
+ throw new Error('no agent on this machine yet');
10
+ return { agent: agent.id, where: localUrl() };
9
11
  }
package/dist/claude.js CHANGED
@@ -3,7 +3,7 @@ import { markOnboardingDone, seedChannels } from './onboarding.js';
3
3
  import { writeMcpConfig } from './mcp-config.js';
4
4
  import { currentRoute, permissionMode, routeModelEnv, systemPrompt } from './route.js';
5
5
  import { settingsConflicts, settingsFiles } from './claude-settings.js';
6
- import { localAgents, pickLocalAgent } from './local.js';
6
+ import { localAgent } from './local.js';
7
7
  import { PROVIDER_FLAGS } from './provider-flags.js';
8
8
  import { localPort, localUrl } from './runtime.js';
9
9
  const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
@@ -76,27 +76,8 @@ async function daemonServing(base = localUrl()) {
76
76
  return false;
77
77
  }
78
78
  }
79
- export function agentKey(agents, wanted) {
80
- if (agents.length === 0)
81
- return { skip: 'no agent lives on this machine yet, so Claude Code talks to Anthropic directly' };
82
- try {
83
- return { key: pickLocalAgent(agents, wanted).key };
84
- }
85
- catch {
86
- return {
87
- skip: wanted === undefined || wanted === ''
88
- ? 'this box has no agent yet (or several old ones), so Claude Code talks to Anthropic directly for now'
89
- : `no agent named '${wanted}' lives here, so Claude Code talks to Anthropic directly`,
90
- };
91
- }
92
- }
93
- function localAgentList() {
94
- try {
95
- return localAgents();
96
- }
97
- catch {
98
- return [];
99
- }
79
+ export function agentKey(agent) {
80
+ return agent === null ? { skip: 'no agent lives on this machine yet, so Claude Code talks to Anthropic directly' } : { key: agent.key };
100
81
  }
101
82
  async function verdict() {
102
83
  const pinned = pinnedBy(process.env);
@@ -105,7 +86,7 @@ async function verdict() {
105
86
  const conflicts = settingsConflicts(settingsFiles());
106
87
  if (conflicts.length > 0)
107
88
  return { skip: `a settings file pins the provider (${conflicts.join(', ')}), so Claude Code keeps it` };
108
- const picked = agentKey(localAgentList(), undefined);
89
+ const picked = agentKey(localAgent());
109
90
  if ('skip' in picked)
110
91
  return picked;
111
92
  if (await daemonServing())
@@ -131,7 +112,7 @@ export function runClaude(args, env) {
131
112
  async function servedKey(decision) {
132
113
  if ('key' in decision)
133
114
  return decision.key;
134
- const picked = agentKey(localAgentList(), undefined);
115
+ const picked = agentKey(localAgent());
135
116
  if ('skip' in picked)
136
117
  return null;
137
118
  return (await daemonServing()) ? picked.key : null;
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ const USAGE = `metro — run your agent on this machine
22
22
  starts at boot and after a crash, so the page can stop, start and
23
23
  restart it with no shell; metro service uninstall and status as well
24
24
  metro stop stop metro on this machine, metro serve included
25
- metro tail <agent-id>
25
+ metro tail [agent-id]
26
26
  follow this machine's inbound events, one JSON line each
27
27
  metro whoami print the agent this machine runs
28
28
  metro claude [args...]
@@ -53,8 +53,8 @@ async function stopDaemon() {
53
53
  const HELP = new Set([undefined, 'help', '--help', '-h']);
54
54
  const COMMANDS = {
55
55
  whoami: async () => {
56
- const { agent, where } = await whoisAuthorized(process.argv[3]);
57
- process.stdout.write(`agent '${agent}' on ${where}\n`);
56
+ const { agent, where } = await whoisAuthorized();
57
+ process.stdout.write(`agent ${agent} on ${where}\n`);
58
58
  return 0;
59
59
  },
60
60
  serve: () => serve(process.argv.slice(3)),
package/dist/local.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { localUrl } from './runtime.js';
@@ -7,64 +7,30 @@ export function agentsDir() {
7
7
  const explicit = process.env.METRO_AGENTS_DIR?.trim();
8
8
  return explicit !== undefined && explicit !== '' ? explicit : join(homedir(), '.metro', 'agents');
9
9
  }
10
- function agentFiles(dir) {
11
- const fixed = join(dir, 'agent.json');
12
- if (existsSync(fixed))
13
- return [fixed];
14
- if (!existsSync(dir))
15
- return [];
16
- return readdirSync(dir, { withFileTypes: true })
17
- .filter((entry) => entry.isDirectory())
18
- .map((entry) => join(dir, entry.name, 'agent.json'))
19
- .filter((path) => existsSync(path));
20
- }
21
- export function localAgents(dir = agentsDir()) {
22
- const out = [];
23
- for (const path of agentFiles(dir)) {
24
- try {
25
- const file = JSON.parse(readFileSync(path, 'utf8'));
26
- if (typeof file.id === 'string' && typeof file.key === 'string')
27
- out.push({ id: file.id, name: typeof file.name === 'string' ? file.name : 'agent', key: file.key });
28
- }
29
- catch {
30
- continue;
31
- }
10
+ function agentFile(dir) {
11
+ try {
12
+ return JSON.parse(readFileSync(join(dir, 'agent.json'), 'utf8'));
13
+ }
14
+ catch {
15
+ return null;
32
16
  }
33
- return out.sort((a, b) => a.name.localeCompare(b.name));
17
+ }
18
+ export function localAgent(dir = agentsDir()) {
19
+ const file = agentFile(dir);
20
+ return typeof file?.id === 'string' && typeof file.key === 'string' ? { id: file.id, key: file.key } : null;
34
21
  }
35
22
  export function localStations(dir = agentsDir()) {
23
+ const stations = agentFile(dir)?.stations;
24
+ if (!Array.isArray(stations))
25
+ return [];
36
26
  const out = new Set();
37
- for (const path of agentFiles(dir)) {
38
- try {
39
- const file = JSON.parse(readFileSync(path, 'utf8'));
40
- if (!Array.isArray(file.stations))
41
- continue;
42
- for (const s of file.stations) {
43
- const station = s.station;
44
- if (typeof station === 'string')
45
- out.add(station);
46
- }
47
- }
48
- catch {
49
- continue;
50
- }
27
+ for (const s of stations) {
28
+ const station = s.station;
29
+ if (typeof station === 'string')
30
+ out.add(station);
51
31
  }
52
32
  return [...out].sort();
53
33
  }
54
- export function pickLocalAgent(agents, wanted) {
55
- if (wanted !== undefined && wanted !== '') {
56
- const found = agents.find((a) => a.name === wanted || a.id === wanted);
57
- if (found === undefined)
58
- throw new Error(`no local agent named '${wanted}'`);
59
- return found;
60
- }
61
- const only = agents[0];
62
- if (only !== undefined && agents.length === 1)
63
- return only;
64
- throw new Error(agents.length === 0
65
- ? 'no agent on this machine yet'
66
- : `several agents on this machine — name one: ${agents.map((a) => a.name).join(', ')}`);
67
- }
68
34
  export async function localDaemonUp(base = localUrl()) {
69
35
  try {
70
36
  const res = await fetch(`${base}/api/mode`, { signal: AbortSignal.timeout(PROBE_MS) });
package/dist/serve.js CHANGED
@@ -8,7 +8,7 @@ import { findBun, localPort, spawnPlan } from './runtime.js';
8
8
  import { prepareRuntime } from './runtime-install.js';
9
9
  import { ensureNodeName } from './node-name.js';
10
10
  import { holdUntilStart } from './hold.js';
11
- const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
11
+ const SCRUBBED = new Set(['DATABASE_URL']);
12
12
  const PORT_FLAG = /^--port=(.*)$/;
13
13
  const OWNER_FLAG = /^--owner=(.*)$/;
14
14
  const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
@@ -24,14 +24,14 @@ function portOf(raw) {
24
24
  const ORGANIZATION = /^org_[A-Za-z0-9]{10,64}$/;
25
25
  function ownerOf(raw) {
26
26
  if (raw !== undefined && ORGANIZATION.test(raw))
27
- return raw;
28
- if (raw === undefined || !ADDRESS.test(raw))
29
- throw new Error(`'${raw ?? ''}' is neither an organization id (org_…) nor an Ethereum address — ${USAGE}`);
30
- return raw.toLowerCase();
27
+ return { owner: raw };
28
+ if (raw !== undefined && ADDRESS.test(raw))
29
+ return { owner: null, ignoredOwner: raw };
30
+ throw new Error(`'${raw ?? ''}' is not an organization id (org_…) — ${USAGE}`);
31
31
  }
32
32
  export function parseServeArgs(argv) {
33
33
  let port = localPort();
34
- let owner = null;
34
+ let owner = { owner: null };
35
35
  for (let i = 0; i < argv.length; i += 1) {
36
36
  const arg = argv[i] ?? '';
37
37
  if (TUNNEL_FLAG.test(arg))
@@ -58,10 +58,14 @@ export function parseServeArgs(argv) {
58
58
  }
59
59
  throw new Error(`unknown argument '${arg}' — ${USAGE}`);
60
60
  }
61
- return { port, owner };
61
+ return { port, ...owner };
62
62
  }
63
- export function requireOwner(owner, dir = agentsDir()) {
64
- if (owner !== null || existsSync(join(dir, '.owner')))
63
+ export function requireOwner(args, dir = agentsDir()) {
64
+ if (args.ignoredOwner !== undefined) {
65
+ process.stderr.write(`--owner ${args.ignoredOwner} is a wallet address, which metro ignores: the organization id in ${join(dir, '.owner')} decides who signs in\n`);
66
+ return;
67
+ }
68
+ if (args.owner !== null || existsSync(join(dir, '.owner')))
65
69
  return;
66
70
  throw new Error('no owner is set for this machine, so nobody could sign in.\n' +
67
71
  'Pass the organization that owns it once; it is remembered in ' +
@@ -71,9 +75,7 @@ export function requireOwner(owner, dir = agentsDir()) {
71
75
  export function readOwner(dir = agentsDir()) {
72
76
  try {
73
77
  const raw = readFileSync(join(dir, '.owner'), 'utf8').trim();
74
- if (ORGANIZATION.test(raw))
75
- return raw;
76
- return ADDRESS.test(raw.toLowerCase()) ? raw.toLowerCase() : null;
78
+ return ORGANIZATION.test(raw) ? raw : null;
77
79
  }
78
80
  catch {
79
81
  return null;
@@ -142,12 +144,13 @@ export function servePlan(opts) {
142
144
  };
143
145
  }
144
146
  export function serve(argv) {
145
- const { port, owner } = parseServeArgs(argv);
147
+ const args = parseServeArgs(argv);
148
+ const { port, owner } = args;
146
149
  const running = serveLockedBy();
147
150
  if (running !== null)
148
151
  throw new Error(`a metro serve daemon is already running on this machine (pid ${String(running)}). ` +
149
152
  'Stop it first: metro stop');
150
- requireOwner(owner);
153
+ requireOwner(args);
151
154
  const tailscaleBin = findTailscale();
152
155
  const node = ensureNodeName(tailscaleBin);
153
156
  process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}, published as ${node} on your tailnet\n`);
package/dist/service.js CHANGED
@@ -126,13 +126,13 @@ export function servicePlan(host, serveArgs) {
126
126
  throw new Error(`metro service supports Linux (systemd) and macOS (launchd), not ${host.platform}`);
127
127
  }
128
128
  function install(serveArgs, deps) {
129
- const { owner } = parseServeArgs(serveArgs);
129
+ const args = parseServeArgs(serveArgs);
130
130
  const plan = servicePlan(deps.host, serveArgs);
131
131
  if (deps.exists(plan.file)) {
132
132
  deps.out(`metro is already installed as a ${plan.kind} service (${plan.file}) and restarts on its own; metro service status says whether it is running. To change its arguments: metro service uninstall, then install again`);
133
133
  return 0;
134
134
  }
135
- deps.preflight(owner);
135
+ deps.preflight(args);
136
136
  const pid = deps.running();
137
137
  if (pid !== null)
138
138
  throw new Error(`a metro serve is running on this machine (pid ${String(pid)}). Stop it first (metro stop), then install: the service takes over from there`);
@@ -209,8 +209,8 @@ function realDeps() {
209
209
  return { status: code, output: `${result.stdout}${result.stderr}` };
210
210
  },
211
211
  running: serveLockedBy,
212
- preflight: (owner) => {
213
- requireOwner(owner);
212
+ preflight: (args) => {
213
+ requireOwner(args);
214
214
  findBun();
215
215
  findTailscale();
216
216
  },
package/dist/tail.js CHANGED
@@ -1,4 +1,4 @@
1
- import { localAgents } from './local.js';
1
+ import { localAgent } from './local.js';
2
2
  import { assertAgentId, localUrl } from './runtime.js';
3
3
  const BACKOFF_MS = [1_000, 2_000, 5_000];
4
4
  const STALL_MS = 90_000;
@@ -22,9 +22,9 @@ function keySource(agentId) {
22
22
  return { resolve: () => Promise.resolve(fromEnv), fixed: true };
23
23
  return {
24
24
  resolve: () => {
25
- const agent = localAgents().find((a) => a.id === agentId);
26
- if (agent === undefined)
27
- throw new Error(`no agent ${agentId} on this machine — create or restore it in the web UI of metro serve, or set METRO_AGENT_KEY`);
25
+ const agent = localAgent();
26
+ if (agent === null || (agentId !== undefined && agent.id !== agentId))
27
+ throw new Error(`no agent ${agentId === undefined ? '' : `${agentId} `}on this machine — start metro serve, or set METRO_AGENT_KEY`);
28
28
  return Promise.resolve(agent.key);
29
29
  },
30
30
  fixed: false,
@@ -89,7 +89,7 @@ function note(state, message) {
89
89
  process.stderr.write(`metro: ${message}\n`);
90
90
  }
91
91
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
92
- async function settle(end, source, key, agentId, state) {
92
+ async function settle(end, source, key, state) {
93
93
  if (end === 'unauthorized') {
94
94
  if (source.fixed)
95
95
  throw new Error('the key in METRO_AGENT_KEY was refused by the local daemon');
@@ -97,13 +97,13 @@ async function settle(end, source, key, agentId, state) {
97
97
  return source.resolve();
98
98
  }
99
99
  if (end === 'unreachable')
100
- note(state, `no metro daemon on ${localUrl()} — run 'metro serve' (agent ${agentId})`);
100
+ note(state, `no metro daemon on ${localUrl()} — run 'metro serve'`);
101
101
  else
102
102
  note(state, 'stream ended — reconnecting');
103
103
  return key;
104
104
  }
105
105
  export async function tailEvents(argv) {
106
- const agentId = assertAgentId(argv[0]);
106
+ const agentId = argv[0] === undefined ? undefined : assertAgentId(argv[0]);
107
107
  const source = keySource(agentId);
108
108
  let key = await source.resolve();
109
109
  const state = { last: '' };
@@ -112,7 +112,7 @@ export async function tailEvents(argv) {
112
112
  const end = await streamOnce(key);
113
113
  if (end === 'ended')
114
114
  attempt = 0;
115
- key = await settle(end, source, key, agentId, state);
115
+ key = await settle(end, source, key, state);
116
116
  await delay(backoffAt(attempt));
117
117
  attempt += 1;
118
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.173",
3
+ "version": "0.1.0-beta.174",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.173",
3
+ "version": "0.1.0-beta.174",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -1,30 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { readFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
6
6
  const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
7
7
 
8
- function readAgent(path) {
8
+ function readAgent() {
9
+ const path = join(agentsDir(), 'agent.json');
10
+ let parsed;
9
11
  try {
10
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
11
- return typeof parsed?.key === 'string' ? parsed : null;
12
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
12
13
  } catch {
13
- return null;
14
+ throw new Error(`no agent on this machine yet (${path})`);
14
15
  }
15
- }
16
-
17
- function pickAgent() {
18
- const dir = agentsDir();
19
- const fixed = readAgent(join(dir, 'agent.json'));
20
- if (fixed !== null) return fixed;
21
- if (!existsSync(dir)) throw new Error(`no agent on this machine yet (${dir})`);
22
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
23
- if (!entry.isDirectory()) continue;
24
- const found = readAgent(join(dir, entry.name, 'agent.json'));
25
- if (found !== null) return found;
26
- }
27
- throw new Error(`no agent on this machine yet (${dir})`);
16
+ if (typeof parsed?.key !== 'string') throw new Error(`no agent key in ${path}`);
17
+ return parsed;
28
18
  }
29
19
 
30
20
  if (process.argv[2] !== 'headers') {
@@ -33,7 +23,7 @@ if (process.argv[2] !== 'headers') {
33
23
  }
34
24
 
35
25
  try {
36
- process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${pickAgent().key}` })}\n`);
26
+ process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${readAgent().key}` })}\n`);
37
27
  } catch (err) {
38
28
  process.stderr.write(`metro: ${err.message}\n`);
39
29
  process.stdout.write('{}\n');
@@ -7,7 +7,6 @@
7
7
  "exports": {
8
8
  "./log": "./src/log.ts",
9
9
  "./events": "./src/events.ts",
10
- "./identity": "./src/identity.ts",
11
10
  "./is-record": "./src/is-record.ts",
12
11
  "./secure-fs": "./src/secure-fs.ts",
13
12
  "./version": "./src/version.ts",
@@ -15,7 +14,6 @@
15
14
  "./trains/protocol": "./src/protocol.ts",
16
15
  "./ids": "./src/ids.ts",
17
16
  "./tickets": "./src/tickets.ts",
18
- "./address": "./src/address.ts",
19
17
  "./station-names": "./src/station-names.ts",
20
18
  "./str": "./src/str.ts",
21
19
  "./lines": "./src/lines.ts",
@@ -24,7 +24,6 @@ import { newId } from '@metro-labs/core/ids';
24
24
  import { registerKey } from './keys.js';
25
25
  import { MOVABLE_STATIONS, type LoadedAgent } from '../stations/materialize.js';
26
26
  import type { StationName } from '@metro-labs/core/station-names';
27
- import { normalizeAddress } from '@metro-labs/core/address';
28
27
 
29
28
  const OWNER_FILE = '.owner';
30
29
 
@@ -37,7 +36,7 @@ const missing = (): AgentAdminError => new AgentAdminError('no such agent', 404)
37
36
 
38
37
  export function parseOwner(raw: string): string | null {
39
38
  const text = raw.trim();
40
- return isOrganizationId(text) ? text : normalizeAddress(text);
39
+ return isOrganizationId(text) ? text : null;
41
40
  }
42
41
 
43
42
  export function localOwner(dir = agentsDir()): string | null {
@@ -50,7 +49,7 @@ export function localOwner(dir = agentsDir()): string | null {
50
49
 
51
50
  export function setLocalOwner(raw: string, dir = agentsDir()): string {
52
51
  const owner = parseOwner(raw);
53
- if (owner === null) throw new ApiError(`'${raw}' is neither an organization id nor an Ethereum address`, 400);
52
+ if (owner === null) throw new ApiError(`'${raw}' is not an organization id`, 400);
54
53
  ensureSecureDir(dir);
55
54
  writeSecure(join(dir, OWNER_FILE), `${owner}\n`);
56
55
  return owner;
@@ -42,6 +42,7 @@ import { prepareAccount } from '../stations/attach.js';
42
42
  import { materializeFrom, reloadFrom } from '../stations/materialize.js';
43
43
  import type { StationName } from '@metro-labs/core/station-names';
44
44
  import { startUploadReaper } from '../files/upload-store.js';
45
+ import { startAttachReaper } from '../files/attach-reaper.js';
45
46
 
46
47
  installCrashGuard();
47
48
  acquireLock(join(STATE_DIR, '.tail-lock'));
@@ -126,6 +127,7 @@ installBearerSessions(agentsDir(), localOwner);
126
127
  metroMcp.startInbound();
127
128
  syncPluginServers(readLocalConnectors());
128
129
  startUploadReaper();
130
+ startAttachReaper();
129
131
  announceLocalEndpoint();
130
132
  tunnel?.start();
131
133
  log.info(
@@ -2,29 +2,20 @@ import { localOwner, setLocalOwner } from '../agents/file-admin.js';
2
2
  import { isOrganizationId } from '@metro-labs/http/workos-token';
3
3
  import { log } from '@metro-labs/core/log';
4
4
 
5
- const NO_ONE = 'nobody can sign in; restart with metro serve --owner <organization id>';
6
-
7
- const WALLET = `the owner is a wallet address, and wallets cannot sign in any more; ${NO_ONE}`;
8
-
9
- function keepsOrganization(wanted: string, stored: string | null): stored is string {
10
- return wanted !== '' && stored !== null && isOrganizationId(stored) && !isOrganizationId(wanted);
11
- }
12
-
13
- function announce(owner: string | null): string | null {
14
- if (owner === null) log.warn(`local daemon: no owner set, so ${NO_ONE}`);
15
- else if (!isOrganizationId(owner)) log.warn(`local daemon: ${WALLET}`);
16
- return owner;
17
- }
18
-
19
5
  export function applyLocalOwner(): string | null {
20
6
  const wanted = process.env.METRO_OWNER?.trim() ?? '';
21
7
  const stored = localOwner();
22
- if (keepsOrganization(wanted, stored)) {
23
- log.info({ owner: stored, ignored: wanted }, 'local daemon: this machine belongs to an organization, so the wallet in --owner is ignored');
8
+ if (stored !== null) {
9
+ if (wanted !== '' && wanted !== stored)
10
+ log.info({ owner: stored, ignored: wanted }, 'local daemon: this machine already belongs to an organization, so --owner is ignored');
24
11
  return stored;
25
12
  }
26
- if (wanted === '') return announce(stored);
27
- const owner = setLocalOwner(wanted);
28
- log.info({ owner }, 'local daemon: owner set from METRO_OWNER');
29
- return announce(owner);
13
+ if (isOrganizationId(wanted)) {
14
+ const owner = setLocalOwner(wanted);
15
+ log.info({ owner }, 'local daemon: owner set from METRO_OWNER');
16
+ return owner;
17
+ }
18
+ if (wanted !== '') log.info({ ignored: wanted }, 'local daemon: --owner is not an organization id, so it is ignored');
19
+ log.warn('local daemon: no organization owns this machine, so nobody can sign in; restart with metro serve --owner <organization id>');
20
+ return null;
30
21
  }
@@ -0,0 +1,174 @@
1
+ import { readdirSync, rmSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {
4
+ attachDir,
5
+ resolveCachedAttachment,
6
+ } from '@metro-labs/core/stations/attachments';
7
+ import { errMsg, log } from '@metro-labs/core/log';
8
+
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+ const MB = 1024 * 1024;
11
+ const DEFAULT_TTL_DAYS = 7;
12
+ const DEFAULT_MAX_MB = 2048;
13
+ const PART_TTL_MS = 60 * 60 * 1000;
14
+ const SWEEP_MS = 10 * 60 * 1000;
15
+ const SIDECARS = ['.owner', '.grant'] as const;
16
+
17
+ export interface AttachSweepOptions {
18
+ dir?: string;
19
+ now?: number;
20
+ ttlMs?: number;
21
+ maxBytes?: number;
22
+ }
23
+
24
+ export interface AttachSweepResult {
25
+ expired: number;
26
+ capped: number;
27
+ orphans: number;
28
+ parts: number;
29
+ freedBytes: number;
30
+ }
31
+
32
+ interface Entry {
33
+ name: string;
34
+ mtime: number;
35
+ size: number;
36
+ }
37
+
38
+ const positive = (raw: string | undefined, fallback: number): number => {
39
+ const value = Number(raw);
40
+ return Number.isFinite(value) && value > 0 ? value : fallback;
41
+ };
42
+
43
+ export const attachTtlMs = (): number =>
44
+ positive(process.env.METRO_ATTACH_TTL_DAYS, DEFAULT_TTL_DAYS) * DAY_MS;
45
+
46
+ export const attachMaxBytes = (): number =>
47
+ positive(process.env.METRO_ATTACH_MAX_MB, DEFAULT_MAX_MB) * MB;
48
+
49
+ const cacheShaped = (name: string): boolean =>
50
+ resolveCachedAttachment(name) !== null;
51
+
52
+ function stripSuffix(name: string, suffixes: readonly string[]): string | undefined {
53
+ const suffix = suffixes.find((s) => name.endsWith(s));
54
+ if (suffix === undefined) return undefined;
55
+ const base = name.slice(0, -suffix.length);
56
+ return cacheShaped(base) ? base : undefined;
57
+ }
58
+
59
+ function listEntries(dir: string): Entry[] {
60
+ let names: string[];
61
+ try {
62
+ names = readdirSync(dir);
63
+ } catch {
64
+ return [];
65
+ }
66
+ const entries: Entry[] = [];
67
+ for (const name of names) {
68
+ try {
69
+ const stat = statSync(join(dir, name));
70
+ if (stat.isFile()) entries.push({ name, mtime: stat.mtimeMs, size: stat.size });
71
+ } catch {
72
+ continue;
73
+ }
74
+ }
75
+ return entries;
76
+ }
77
+
78
+ function removeEntry(dir: string, name: string): void {
79
+ rmSync(join(dir, name), { force: true });
80
+ }
81
+
82
+ function removeWithSidecars(dir: string, name: string): void {
83
+ removeEntry(dir, name);
84
+ for (const suffix of SIDECARS) removeEntry(dir, `${name}${suffix}`);
85
+ }
86
+
87
+ interface Sweep {
88
+ dir: string;
89
+ now: number;
90
+ entries: Entry[];
91
+ result: AttachSweepResult;
92
+ }
93
+
94
+ function sweepParts(sweep: Sweep): Set<string> {
95
+ const fresh = new Set<string>();
96
+ for (const entry of sweep.entries) {
97
+ const base = stripSuffix(entry.name, ['.part']);
98
+ if (base === undefined) continue;
99
+ if (sweep.now - entry.mtime <= PART_TTL_MS) {
100
+ fresh.add(base);
101
+ continue;
102
+ }
103
+ removeEntry(sweep.dir, entry.name);
104
+ sweep.result.parts += 1;
105
+ sweep.result.freedBytes += entry.size;
106
+ }
107
+ return fresh;
108
+ }
109
+
110
+ function sweepExpired(sweep: Sweep, ttlMs: number): Entry[] {
111
+ const kept: Entry[] = [];
112
+ for (const entry of sweep.entries) {
113
+ if (!cacheShaped(entry.name)) continue;
114
+ if (sweep.now - entry.mtime <= ttlMs) {
115
+ kept.push(entry);
116
+ continue;
117
+ }
118
+ removeWithSidecars(sweep.dir, entry.name);
119
+ sweep.result.expired += 1;
120
+ sweep.result.freedBytes += entry.size;
121
+ }
122
+ return kept;
123
+ }
124
+
125
+ function sweepOrphans(sweep: Sweep, busy: Set<string>): void {
126
+ const present = new Set(sweep.entries.map((e) => e.name));
127
+ for (const entry of sweep.entries) {
128
+ const base = stripSuffix(entry.name, SIDECARS);
129
+ if (base === undefined || present.has(base) || busy.has(base)) continue;
130
+ removeEntry(sweep.dir, entry.name);
131
+ sweep.result.orphans += 1;
132
+ }
133
+ }
134
+
135
+ function capSize(sweep: Sweep, kept: Entry[], maxBytes: number): void {
136
+ let total = kept.reduce((sum, e) => sum + e.size, 0);
137
+ for (const entry of [...kept].sort((a, b) => a.mtime - b.mtime)) {
138
+ if (total <= maxBytes) return;
139
+ removeWithSidecars(sweep.dir, entry.name);
140
+ total -= entry.size;
141
+ sweep.result.capped += 1;
142
+ sweep.result.freedBytes += entry.size;
143
+ }
144
+ }
145
+
146
+ export function sweepAttachments(options: AttachSweepOptions = {}): AttachSweepResult {
147
+ const dir = options.dir ?? attachDir();
148
+ const sweep: Sweep = {
149
+ dir,
150
+ now: options.now ?? Date.now(),
151
+ entries: listEntries(dir),
152
+ result: { expired: 0, capped: 0, orphans: 0, parts: 0, freedBytes: 0 },
153
+ };
154
+ const busy = sweepParts(sweep);
155
+ const kept = sweepExpired(sweep, options.ttlMs ?? attachTtlMs());
156
+ sweepOrphans(sweep, busy);
157
+ capSize(sweep, kept, options.maxBytes ?? attachMaxBytes());
158
+ return sweep.result;
159
+ }
160
+
161
+ export function startAttachReaper(): void {
162
+ const run = (): void => {
163
+ try {
164
+ const swept = sweepAttachments();
165
+ if (swept.expired + swept.capped + swept.orphans + swept.parts > 0) {
166
+ log.info(swept, 'attachments: reaped the cache');
167
+ }
168
+ } catch (err) {
169
+ log.warn({ err: errMsg(err) }, 'attachments: sweep failed');
170
+ }
171
+ };
172
+ run();
173
+ setInterval(run, SWEEP_MS).unref?.();
174
+ }
@@ -3,7 +3,7 @@ import { cors, sendJson } from './api-http.js';
3
3
 
4
4
  const PATH = '/api/mode';
5
5
 
6
- export type DaemonMode = 'hosted' | 'linked' | 'local';
6
+ export type DaemonMode = 'hosted' | 'local';
7
7
 
8
8
  export interface ModeInfo {
9
9
  mode: DaemonMode;
@@ -1,10 +1,9 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import { createPublicKey, verify, type JsonWebKey, type KeyObject } from 'node:crypto';
3
3
 
4
- export const WORKOS_API = 'https://api.workos.com';
4
+ const WORKOS_API = 'https://api.workos.com';
5
5
  const ORG_RE = /^org_[A-Za-z0-9]{10,64}$/;
6
6
  export const isOrganizationId = (value: string): boolean => ORG_RE.test(value);
7
- export const WORKOS_ISSUER = 'https://api.workos.com';
8
7
  export const DEFAULT_CLIENT_ID = 'client_01M2TJJJ6RM9CT081QB4XZK3G2';
9
8
  const SKEW_S = 60;
10
9
  const REFETCH_COOLDOWN_MS = 60_000;
@@ -129,7 +128,7 @@ function parseToken(token: string): Parts | null {
129
128
  return { signed: `${head}.${body}`, body, signature: b64url(sig), kid };
130
129
  }
131
130
 
132
- export async function verifyToken(token: string, keys: SigningKeys, issuer = WORKOS_ISSUER, now = Date.now()): Promise<Session | null> {
131
+ export async function verifyToken(token: string, keys: SigningKeys, issuer = WORKOS_API, now = Date.now()): Promise<Session | null> {
133
132
  const parts = parseToken(token);
134
133
  if (parts === null) return null;
135
134
  const key = await keys.keyFor(parts.kid, now).catch(() => null);
@@ -144,7 +143,7 @@ export function bearerToken(req: IncomingMessage): string | null {
144
143
  return scheme?.toLowerCase() === 'bearer' && token?.includes('.') === true ? token : null;
145
144
  }
146
145
 
147
- export async function bearerSession(req: IncomingMessage, keys: SigningKeys, issuer = WORKOS_ISSUER): Promise<Session | null> {
146
+ export async function bearerSession(req: IncomingMessage, keys: SigningKeys, issuer = WORKOS_API): Promise<Session | null> {
148
147
  const token = bearerToken(req);
149
148
  return token === null ? null : verifyToken(token, keys, issuer);
150
149
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.173"
2
+ "version": "0.1.0-beta.174"
3
3
  }
@@ -1,6 +0,0 @@
1
- const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
2
-
3
- export function normalizeAddress(raw: string): string | null {
4
- const address = raw.trim().toLowerCase();
5
- return ADDRESS_RE.test(address) ? address : null;
6
- }