@stage-labs/metro 0.1.0-beta.58 → 0.1.0-beta.59

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.58",
3
+ "version": "0.1.0-beta.59",
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": {
@@ -118,6 +118,7 @@ async function sessionApis(): Promise<SessionApis> {
118
118
  prepareAccount,
119
119
  });
120
120
  const vault = await import('../db/vault.js');
121
+ const servers = await import('../db/servers.js');
121
122
  return {
122
123
  vaultApi: {
123
124
  list: vault.listVaultForOwner,
@@ -125,6 +126,12 @@ async function sessionApis(): Promise<SessionApis> {
125
126
  get: vault.getVaultForOwner,
126
127
  remove: vault.deleteVaultForOwner,
127
128
  },
129
+ serversApi: {
130
+ list: servers.listServersForOwner,
131
+ add: servers.addServerForOwner,
132
+ rename: servers.renameServerForOwner,
133
+ remove: servers.deleteServerForOwner,
134
+ },
128
135
  mode: hostedMode,
129
136
  };
130
137
  }
@@ -0,0 +1,21 @@
1
+ export interface ServerEntry {
2
+ id: string;
3
+ host: string;
4
+ name: string | null;
5
+ addedAt: string;
6
+ }
7
+
8
+ const HOST_RE = /^[a-z0-9][a-z0-9.-]*(?::\d{1,5})?$/;
9
+ const HOST_MAX = 253;
10
+ const NAME_MAX = 40;
11
+
12
+ export function parseServerHost(raw: string): string | null {
13
+ const host = raw.trim().toLowerCase();
14
+ return host.length <= HOST_MAX && HOST_RE.test(host) ? host : null;
15
+ }
16
+
17
+ export function parseServerName(raw: unknown): string | null {
18
+ if (typeof raw !== 'string') return null;
19
+ const name = raw.replace(/[\p{Cc}]/gu, '').trim().slice(0, NAME_MAX);
20
+ return name === '' ? null : name;
21
+ }
@@ -0,0 +1,80 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { log } from './log.js';
3
+ import { apiFailure, cors, readJsonBody, sendJson } from './api-http.js';
4
+ import { signedIdentity } from './signed-identity.js';
5
+ import type { ServerEntry } from './server-types.js';
6
+ import { parseId } from '../db/ids.js';
7
+
8
+ const PREFIX = '/api/servers';
9
+
10
+ export interface ServersApiDeps {
11
+ list: (subject: string) => Promise<ServerEntry[]>;
12
+ add: (subject: string, body: unknown) => Promise<ServerEntry>;
13
+ rename: (subject: string, id: string, body: unknown) => Promise<ServerEntry>;
14
+ remove: (subject: string, id: string) => Promise<{ id: string; host: string }>;
15
+ }
16
+
17
+ type Target = { kind: 'index' } | { kind: 'server'; id: string } | { kind: 'unknown' } | null;
18
+
19
+ function target(path: string): Target {
20
+ if (path === PREFIX || path === `${PREFIX}/`) return { kind: 'index' };
21
+ if (!path.startsWith(`${PREFIX}/`)) return null;
22
+ const segments = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
23
+ const id = parseId(segments[0] ?? '');
24
+ return id === null || segments.length !== 1 ? { kind: 'unknown' } : { kind: 'server', id };
25
+ }
26
+
27
+ const ALLOWED: Record<'index' | 'server', string[]> = { index: ['GET', 'POST'], server: ['PUT', 'DELETE'] };
28
+
29
+ async function answer(
30
+ req: IncomingMessage,
31
+ deps: ServersApiDeps,
32
+ owner: string,
33
+ tgt: { kind: 'index' } | { kind: 'server'; id: string },
34
+ ): Promise<unknown> {
35
+ if (tgt.kind === 'index')
36
+ return req.method === 'GET' ? { servers: await deps.list(owner) } : deps.add(owner, await readJsonBody(req));
37
+ return req.method === 'DELETE' ? deps.remove(owner, tgt.id) : deps.rename(owner, tgt.id, await readJsonBody(req));
38
+ }
39
+
40
+ async function route(
41
+ req: IncomingMessage,
42
+ res: ServerResponse,
43
+ deps: ServersApiDeps,
44
+ tgt: { kind: 'index' } | { kind: 'server'; id: string },
45
+ ): Promise<void> {
46
+ try {
47
+ const owner = await signedIdentity(req);
48
+ if (owner === null) {
49
+ sendJson(req, res, 401, { error: 'unauthorized' });
50
+ return;
51
+ }
52
+ const body = await answer(req, deps, owner, tgt);
53
+ if (req.method !== 'GET') log.info({ method: req.method, target: tgt }, 'servers-api: list changed');
54
+ sendJson(req, res, 200, body);
55
+ } catch (err) {
56
+ apiFailure(req, res, err, 'servers-api');
57
+ }
58
+ }
59
+
60
+ export function handleServersApiRequest(req: IncomingMessage, res: ServerResponse, deps: ServersApiDeps): boolean {
61
+ const path = (req.url ?? '').split('?')[0] ?? '';
62
+ const tgt = target(path);
63
+ if (tgt === null) return false;
64
+ if (req.method === 'OPTIONS') {
65
+ res.writeHead(204, cors(req)).end();
66
+ return true;
67
+ }
68
+ if (tgt.kind === 'unknown') {
69
+ sendJson(req, res, 404, { error: 'no such server' });
70
+ return true;
71
+ }
72
+ if (!ALLOWED[tgt.kind].includes(req.method ?? '')) {
73
+ sendJson(req, res, 405, { error: 'method not allowed' });
74
+ return true;
75
+ }
76
+ route(req, res, deps, tgt).catch((err: unknown) => {
77
+ apiFailure(req, res, err, 'servers-api');
78
+ });
79
+ return true;
80
+ }
@@ -15,6 +15,7 @@ import { handleLocalCliRequest, type LocalCliDeps } from './local-cli-api.js';
15
15
  import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
16
16
  import type { ModeInfo } from './mode-api.js';
17
17
  import { handleVaultApiRequest, type VaultApiDeps } from './vault-api.js';
18
+ import { handleServersApiRequest, type ServersApiDeps } from './servers-api.js';
18
19
  import { handleBundleRequest, type BundleApiDeps } from './bundle-api.js';
19
20
  import { handleUpdateRequest, type UpdateApiDeps } from './update-api.js';
20
21
 
@@ -23,6 +24,7 @@ export interface SessionApis {
23
24
  agentConnectorApi?: AgentConnectorApiDeps;
24
25
  bundleApi?: BundleApiDeps;
25
26
  vaultApi?: VaultApiDeps;
27
+ serversApi?: ServersApiDeps;
26
28
  updateApi?: UpdateApiDeps;
27
29
  localCli?: LocalCliDeps;
28
30
  claudeApi?: ClaudeApiDeps;
@@ -46,6 +48,7 @@ export function handleSessionApis(
46
48
  ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
47
49
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
48
50
  ...when(apis.vaultApi, (d) => handleVaultApiRequest(req, res, d)),
51
+ ...when(apis.serversApi, (d) => handleServersApiRequest(req, res, d)),
49
52
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
50
53
  ...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
51
54
  ...when(apis.agentConnectorApi, (d) => handleAgentConnectorRequest(req, res, d)),
@@ -40,6 +40,47 @@ const runText = (command: string, args: string[]): Promise<string> =>
40
40
  });
41
41
  });
42
42
 
43
+ export type Probe = (url: string) => Promise<boolean>;
44
+
45
+ const PROBE_MS = 5_000;
46
+
47
+ export async function daemonAnswersAt(url: string): Promise<boolean> {
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => {
50
+ controller.abort();
51
+ }, PROBE_MS);
52
+ try {
53
+ const res = await fetch(`${url}/api/mode`, { signal: controller.signal });
54
+ if (!res.ok) return false;
55
+ const body: unknown = await res.json();
56
+ return typeof body === 'object' && body !== null && (body as { mode?: unknown }).mode === 'local';
57
+ } catch {
58
+ return false;
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+
64
+ export function nodeNameIn(statusJson: string): string | null {
65
+ try {
66
+ const parsed: unknown = JSON.parse(statusJson);
67
+ const self = typeof parsed === 'object' && parsed !== null ? (parsed as { Self?: { DNSName?: unknown } }).Self : undefined;
68
+ const name = typeof self?.DNSName === 'string' ? self.DNSName.replace(/\.$/, '').toLowerCase() : '';
69
+ return name === '' ? null : name;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ async function adoptFunnel(bin: string, port: number, probe: Probe): Promise<Adopted> {
76
+ const name = nodeNameIn(await runText(bin, ['status', '--json']));
77
+ if (name !== null) {
78
+ const url = `https://${name}`;
79
+ if (await probe(url)) return { url, hint: `a Funnel already publishes this daemon at ${url}; using it` };
80
+ }
81
+ return funnelAlreadyServing(await runText(bin, ['funnel', 'status']), port);
82
+ }
83
+
43
84
  export function funnelAlreadyServing(status: string, port: number): Adopted {
44
85
  const lines = status.split('\n');
45
86
  const target = `http://127.0.0.1:${String(port)}`;
@@ -63,13 +104,13 @@ function tailscaleBin(): string {
63
104
  return configured === '' ? 'tailscale' : configured;
64
105
  }
65
106
 
66
- export const funnelDriver = (port: number, bin = tailscaleBin()): TunnelDriver => ({
107
+ export const funnelDriver = (port: number, bin = tailscaleBin(), probe: Probe = daemonAnswersAt): TunnelDriver => ({
67
108
  name: 'tailscale funnel',
68
109
  command: bin,
69
110
  args: ['funnel', String(port)],
70
111
  urlIn: funnelUrlIn,
71
112
  waitsForDns: true,
72
- adopt: async () => funnelAlreadyServing(await runText(bin, ['funnel', 'status']), port),
113
+ adopt: () => adoptFunnel(bin, port, probe),
73
114
  });
74
115
 
75
116
  let liveUrl: string | null = null;
@@ -1,4 +1,4 @@
1
- import { index, jsonb, pgTable, text } from 'drizzle-orm/pg-core';
1
+ import { index, jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
2
2
 
3
3
  import { STATIONS, type ConnectorTransport, type StationName } from './stations.js';
4
4
 
@@ -7,8 +7,8 @@ export { STATIONS, type ConnectorTransport, type StationName };
7
7
  export type ProjectRole = 'admin' | 'member';
8
8
 
9
9
 
10
- export const vault = pgTable(
11
- 'vault',
10
+ export const vaults = pgTable(
11
+ 'vaults',
12
12
  {
13
13
  id: text('id').primaryKey(),
14
14
  owner: text('owner').notNull(),
@@ -17,5 +17,17 @@ export const vault = pgTable(
17
17
  envelope: jsonb('envelope').notNull(),
18
18
  syncedAt: text('synced_at').notNull(),
19
19
  },
20
- (t) => [index('vault_owner_idx').on(t.owner)],
20
+ (t) => [index('vaults_owner_idx').on(t.owner)],
21
+ );
22
+
23
+ export const servers = pgTable(
24
+ 'servers',
25
+ {
26
+ id: text('id').primaryKey(),
27
+ owner: text('owner').notNull(),
28
+ host: text('host').notNull(),
29
+ name: text('name'),
30
+ addedAt: text('added_at').notNull(),
31
+ },
32
+ (t) => [uniqueIndex('servers_owner_host_idx').on(t.owner, t.host), index('servers_owner_idx').on(t.owner)],
21
33
  );
@@ -0,0 +1,83 @@
1
+ import { and, asc, eq } from 'drizzle-orm';
2
+ import { ApiError } from '../daemon/api-error.js';
3
+ import { isRecord } from '../daemon/is-record.js';
4
+ import { getDb } from './client.js';
5
+ import { newId, parseId } from './ids.js';
6
+ import { servers } from './schema.js';
7
+ import { normalizeAddress } from './address.js';
8
+ import { parseServerHost, parseServerName, type ServerEntry } from '../daemon/server-types.js';
9
+
10
+ export class ServerListError extends ApiError {}
11
+
12
+ const missing = (): ServerListError => new ServerListError('no such server', 404);
13
+
14
+ function ownerOf(subject: string): string {
15
+ const address = normalizeAddress(subject);
16
+ if (address === null) throw new ServerListError('the server list needs a signed identity', 403);
17
+ return address;
18
+ }
19
+
20
+ function idOf(raw: string): string {
21
+ const id = parseId(raw);
22
+ if (id === null) throw missing();
23
+ return id;
24
+ }
25
+
26
+ const entryOf = (row: { id: string; host: string; name: string | null; addedAt: string }): ServerEntry => ({
27
+ id: row.id,
28
+ host: row.host,
29
+ name: row.name,
30
+ addedAt: row.addedAt,
31
+ });
32
+
33
+ const columns = { id: servers.id, host: servers.host, name: servers.name, addedAt: servers.addedAt };
34
+
35
+ export async function listServersForOwner(subject: string): Promise<ServerEntry[]> {
36
+ const owner = ownerOf(subject);
37
+ const rows = await getDb().select(columns).from(servers).where(eq(servers.owner, owner)).orderBy(asc(servers.addedAt));
38
+ return rows.map(entryOf);
39
+ }
40
+
41
+ export async function addServerForOwner(subject: string, body: unknown): Promise<ServerEntry> {
42
+ const owner = ownerOf(subject);
43
+ const host = parseServerHost(isRecord(body) && typeof body.host === 'string' ? body.host : '');
44
+ if (host === null) throw new ServerListError('host is not a server address', 400);
45
+ const name = parseServerName(isRecord(body) ? body.name : undefined);
46
+ const db = getDb();
47
+ const held = await db.select(columns).from(servers).where(and(eq(servers.owner, owner), eq(servers.host, host)));
48
+ const row = held[0];
49
+ if (row !== undefined) {
50
+ if (name === null) return entryOf(row);
51
+ await db.update(servers).set({ name }).where(eq(servers.id, row.id));
52
+ return entryOf({ ...row, name });
53
+ }
54
+ const next = { id: newId(), owner, host, name, addedAt: new Date().toISOString() };
55
+ await db.insert(servers).values(next);
56
+ return entryOf(next);
57
+ }
58
+
59
+ export async function renameServerForOwner(subject: string, rawId: string, body: unknown): Promise<ServerEntry> {
60
+ const owner = ownerOf(subject);
61
+ const id = idOf(rawId);
62
+ const name = parseServerName(isRecord(body) ? body.name : undefined);
63
+ const rows = await getDb()
64
+ .update(servers)
65
+ .set({ name })
66
+ .where(and(eq(servers.id, id), eq(servers.owner, owner)))
67
+ .returning(columns);
68
+ const row = rows[0];
69
+ if (row === undefined) throw missing();
70
+ return entryOf(row);
71
+ }
72
+
73
+ export async function deleteServerForOwner(subject: string, rawId: string): Promise<{ id: string; host: string }> {
74
+ const owner = ownerOf(subject);
75
+ const id = idOf(rawId);
76
+ const gone = await getDb()
77
+ .delete(servers)
78
+ .where(and(eq(servers.id, id), eq(servers.owner, owner)))
79
+ .returning({ id: servers.id, host: servers.host });
80
+ const row = gone[0];
81
+ if (row === undefined) throw missing();
82
+ return row;
83
+ }
@@ -3,7 +3,7 @@ import { ApiError } from '../daemon/api-error.js';
3
3
  import { isRecord } from '../daemon/is-record.js';
4
4
  import { getDb } from './client.js';
5
5
  import { AGENT_NAME_RE, parseId } from './ids.js';
6
- import { vault } from './schema.js';
6
+ import { vaults } from './schema.js';
7
7
  import { STATIONS } from './stations.js';
8
8
  import { normalizeAddress } from './address.js';
9
9
 
@@ -66,10 +66,10 @@ const entryOf = (row: { id: string; name: string; stations: unknown; syncedAt: s
66
66
  export async function listVaultForOwner(subject: string): Promise<VaultEntry[]> {
67
67
  const owner = ownerOf(subject);
68
68
  const rows = await getDb()
69
- .select({ id: vault.id, name: vault.name, stations: vault.stations, syncedAt: vault.syncedAt })
70
- .from(vault)
71
- .where(eq(vault.owner, owner))
72
- .orderBy(asc(vault.name));
69
+ .select({ id: vaults.id, name: vaults.name, stations: vaults.stations, syncedAt: vaults.syncedAt })
70
+ .from(vaults)
71
+ .where(eq(vaults.owner, owner))
72
+ .orderBy(asc(vaults.name));
73
73
  return rows.map(entryOf);
74
74
  }
75
75
 
@@ -78,19 +78,19 @@ export async function putVaultForOwner(subject: string, id: string, body: unknow
78
78
  const syncedAt = new Date().toISOString();
79
79
  const values = { id, owner: input.owner, name: input.name, stations: input.stations, envelope: input.envelope, syncedAt };
80
80
  const db = getDb();
81
- const held = await db.select({ owner: vault.owner }).from(vault).where(eq(vault.id, id));
81
+ const held = await db.select({ owner: vaults.owner }).from(vaults).where(eq(vaults.id, id));
82
82
  if (held[0] !== undefined && held[0].owner !== input.owner) throw missing();
83
83
  await db
84
- .insert(vault)
84
+ .insert(vaults)
85
85
  .values(values)
86
- .onConflictDoUpdate({ target: vault.id, set: { name: input.name, stations: input.stations, envelope: input.envelope, syncedAt } });
86
+ .onConflictDoUpdate({ target: vaults.id, set: { name: input.name, stations: input.stations, envelope: input.envelope, syncedAt } });
87
87
  return entryOf(values);
88
88
  }
89
89
 
90
90
  export async function getVaultForOwner(subject: string, id: string): Promise<VaultBundle> {
91
91
  const owner = ownerOf(subject);
92
92
  if (parseId(id) === null) throw missing();
93
- const rows = await getDb().select().from(vault).where(and(eq(vault.id, id), eq(vault.owner, owner)));
93
+ const rows = await getDb().select().from(vaults).where(and(eq(vaults.id, id), eq(vaults.owner, owner)));
94
94
  const row = rows[0];
95
95
  if (row === undefined) throw missing();
96
96
  return { ...entryOf(row), envelope: isRecord(row.envelope) ? row.envelope : {} };
@@ -100,9 +100,9 @@ export async function deleteVaultForOwner(subject: string, id: string): Promise<
100
100
  const owner = ownerOf(subject);
101
101
  if (parseId(id) === null) throw missing();
102
102
  const gone = await getDb()
103
- .delete(vault)
104
- .where(and(eq(vault.id, id), eq(vault.owner, owner)))
105
- .returning({ id: vault.id, name: vault.name });
103
+ .delete(vaults)
104
+ .where(and(eq(vaults.id, id), eq(vaults.owner, owner)))
105
+ .returning({ id: vaults.id, name: vaults.name });
106
106
  const row = gone[0];
107
107
  if (row === undefined) throw missing();
108
108
  return row;
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.58"
2
+ "version": "0.1.0-beta.59"
3
3
  }