@stage-labs/metro 0.1.0-beta.57 → 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/dist/cli.js CHANGED
@@ -14,9 +14,9 @@ const USAGE = `metro — run your agent on this machine
14
14
  run the daemon: the agent, its channels and its connectors live in
15
15
  ~/.metro/agents here, and the page at metro.box manages it through the
16
16
  link it prints; --owner names the one wallet that may sign in (remembered
17
- after the first start); --tunnel adds a public https address through a
18
- Cloudflare quick tunnel (needs cloudflared, no account) so the link works
19
- from anywhere
17
+ after the first start); --tunnel publishes the daemon through Tailscale
18
+ Funnel at a permanent https://<machine>.<tailnet>.ts.net (Tailscale
19
+ installed and signed in on this machine), so the link works from anywhere
20
20
  metro stop stop the metro daemon on this machine
21
21
  metro tail <agent-id>
22
22
  follow this machine's inbound events, one JSON line each
package/dist/serve.js CHANGED
@@ -10,7 +10,7 @@ const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
10
10
  const PORT_FLAG = /^--port=(.*)$/;
11
11
  const OWNER_FLAG = /^--owner=(.*)$/;
12
12
  const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
13
- const USAGE = 'usage: metro serve [--port <n>] [--tunnel [quick|tailscale]] [--owner <address>]';
13
+ const USAGE = 'usage: metro serve [--port <n>] [--tunnel] [--owner <address>]';
14
14
  const TUNNEL_FLAG = /^--tunnel=(.*)$/;
15
15
  const TAILSCALE_APP = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
16
16
  function portOf(raw) {
@@ -20,22 +20,21 @@ function portOf(raw) {
20
20
  return n;
21
21
  }
22
22
  function tunnelOf(raw) {
23
- if (raw === undefined || raw === '' || raw === 'quick')
24
- return 'quick';
25
- if (raw === 'tailscale')
26
- return 'tailscale';
27
- throw new Error(`'${raw}' is not a tunnel kind (quick or tailscale) — ${USAGE}`);
23
+ if (raw === undefined || raw === '' || raw === 'tailscale')
24
+ return true;
25
+ throw new Error(`'${raw}' is not a tunnel metro knows; the public address is Tailscale Funnel — ${USAGE}`);
28
26
  }
29
27
  function tunnelFlag(argv, i) {
30
28
  const arg = argv[i] ?? '';
31
29
  const inline = TUNNEL_FLAG.exec(arg);
32
30
  if (inline)
33
- return { kind: tunnelOf(inline[1]), consumed: 0 };
31
+ return { consumed: Number(tunnelOf(inline[1])) - 1 };
34
32
  if (arg !== '--tunnel')
35
33
  return null;
36
34
  const next = argv[i + 1];
37
35
  const takesValue = next !== undefined && !next.startsWith('-');
38
- return { kind: tunnelOf(takesValue ? next : undefined), consumed: takesValue ? 1 : 0 };
36
+ tunnelOf(takesValue ? next : undefined);
37
+ return { consumed: takesValue ? 1 : 0 };
39
38
  }
40
39
  function ownerOf(raw) {
41
40
  if (raw === undefined || !ADDRESS.test(raw))
@@ -44,13 +43,13 @@ function ownerOf(raw) {
44
43
  }
45
44
  export function parseServeArgs(argv) {
46
45
  let port = localPort();
47
- let tunnel = null;
46
+ let tunnel = false;
48
47
  let owner = null;
49
48
  for (let i = 0; i < argv.length; i += 1) {
50
49
  const arg = argv[i] ?? '';
51
50
  const tunnelArg = tunnelFlag(argv, i);
52
51
  if (tunnelArg) {
53
- tunnel = tunnelArg.kind;
52
+ tunnel = true;
54
53
  i += tunnelArg.consumed;
55
54
  continue;
56
55
  }
@@ -111,18 +110,6 @@ export function findTailscale(candidates = ['tailscale', TAILSCALE_APP]) {
111
110
  'Funnel also has to be enabled once on your tailnet: https://tailscale.com/kb/1223/funnel');
112
111
  return bin;
113
112
  }
114
- function findCloudflared() {
115
- const found = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' });
116
- if (found.error !== undefined || found.status !== 0)
117
- throw new Error('metro serve --tunnel needs cloudflared, which is not on PATH.\n' +
118
- 'Debian/Ubuntu (it is not in the distro repos; this adds Cloudflare\'s signed one):\n' +
119
- ' sudo mkdir -p --mode=0755 /usr/share/keyrings\n' +
120
- ' curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null\n' +
121
- " echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list\n" +
122
- ' sudo apt-get update && sudo apt-get install cloudflared\n' +
123
- 'macOS: brew install cloudflared\n' +
124
- 'Others: https://pkg.cloudflare.com/');
125
- }
126
113
  export function servePlan(opts) {
127
114
  const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !SCRUBBED.has(key)));
128
115
  return {
@@ -141,7 +128,7 @@ export function servePlan(opts) {
141
128
  ? {}
142
129
  : { METRO_RUNTIME_STORE: opts.runtime.dir, METRO_RUNTIME_MANIFEST: opts.runtime.manifest }),
143
130
  METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
144
- ...(opts.tunnel === null ? {} : { METRO_TUNNEL: opts.tunnel }),
131
+ ...(opts.tunnel ? { METRO_TUNNEL: 'tailscale' } : {}),
145
132
  ...(opts.tailscaleBin === undefined ? {} : { METRO_TAILSCALE_BIN: opts.tailscaleBin }),
146
133
  ...(opts.owner === null ? {} : { METRO_OWNER: opts.owner }),
147
134
  },
@@ -154,9 +141,7 @@ export function serve(argv) {
154
141
  throw new Error(`a metro serve daemon is already running on this machine (pid ${String(running)}). ` +
155
142
  'Stop it first: metro stop');
156
143
  requireOwner(owner);
157
- if (tunnel === 'quick')
158
- findCloudflared();
159
- const tailscaleBin = tunnel === 'tailscale' ? findTailscale() : undefined;
144
+ const tailscaleBin = tunnel ? findTailscale() : undefined;
160
145
  process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
161
146
  return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner, ...(tailscaleBin === undefined ? {} : { tailscaleBin }) }));
162
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.57",
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": {
@@ -6,7 +6,7 @@ import { errMsg, log, logFatalSync } from './log.js';
6
6
  import { acquireLock, isLocalMode, STATE_DIR, trainsDir } from './paths.js';
7
7
  import { installCrashGuard, markDaemonReady } from './crash-guard.js';
8
8
  import { METRO_VERSION } from './version.js';
9
- import { driverFor, Tunnel, tunnelKind, webhookPort } from './tunnel.js';
9
+ import { funnelDriver, Tunnel, tunnelWanted, webhookPort } from './tunnel.js';
10
10
  import {
11
11
  localConnectHint,
12
12
  publicConnectHint,
@@ -61,8 +61,7 @@ supervisor.onTrainEvent((env, train) => {
61
61
  });
62
62
 
63
63
  let webhookServer: Server | null = null;
64
- const tunnelWanted = tunnelKind();
65
- const tunnel = tunnelWanted === null ? null : new Tunnel(driverFor(tunnelWanted, webhookPort()), announcePublic);
64
+ const tunnel = tunnelWanted() ? new Tunnel(funnelDriver(webhookPort()), announcePublic) : null;
66
65
 
67
66
  function announcePublic(url: string): void {
68
67
  process.stderr.write(`\n${publicConnectHint(url, localOwner())}`);
@@ -74,7 +73,7 @@ setTrainCallBackend((train, action, args) =>
74
73
 
75
74
  function announceLocalEndpoint(): void {
76
75
  process.stderr.write(
77
- `\n${tunnelWanted === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint(tunnelWanted)}`,
76
+ `\n${tunnel === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint()}`,
78
77
  );
79
78
  const key = localAgentKey();
80
79
  if (key === null) {
@@ -119,6 +118,7 @@ async function sessionApis(): Promise<SessionApis> {
119
118
  prepareAccount,
120
119
  });
121
120
  const vault = await import('../db/vault.js');
121
+ const servers = await import('../db/servers.js');
122
122
  return {
123
123
  vaultApi: {
124
124
  list: vault.listVaultForOwner,
@@ -126,6 +126,12 @@ async function sessionApis(): Promise<SessionApis> {
126
126
  get: vault.getVaultForOwner,
127
127
  remove: vault.deleteVaultForOwner,
128
128
  },
129
+ serversApi: {
130
+ list: servers.listServersForOwner,
131
+ add: servers.addServerForOwner,
132
+ rename: servers.renameServerForOwner,
133
+ remove: servers.deleteServerForOwner,
134
+ },
129
135
  mode: hostedMode,
130
136
  };
131
137
  }
@@ -35,7 +35,5 @@ export function publicConnectHint(url: string, owner: string | null): string {
35
35
  ].join('\n');
36
36
  }
37
37
 
38
- export const tunnelPendingHint = (kind: 'quick' | 'tailscale'): string =>
39
- kind === 'quick'
40
- ? 'Bringing up a public address through a Cloudflare quick tunnel…\n'
41
- : 'Publishing this daemon through Tailscale Funnel… (if Tailscale asks to enable Funnel, its link is in the log)\n';
38
+ export const tunnelPendingHint = (): string =>
39
+ 'Publishing this daemon through Tailscale Funnel… (if Tailscale asks to enable Funnel, its link is in the log)\n';
@@ -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)),
@@ -11,22 +11,11 @@ const RESTART_DELAY_MAX_MS = 30_000;
11
11
  const TAKEN_RE = /listener already exists/i;
12
12
  const FUNNEL_ON_RE = /\(Funnel on\)/i;
13
13
 
14
- export type TunnelKind = 'quick' | 'tailscale';
14
+ export const tunnelWanted = (): boolean => process.env.METRO_TUNNEL?.trim() === 'tailscale';
15
15
 
16
- export function tunnelKind(): TunnelKind | null {
17
- const raw = process.env.METRO_TUNNEL?.trim();
18
- return raw === 'quick' || raw === 'tailscale' ? raw : null;
19
- }
20
-
21
- export const quickTunnelWanted = (): boolean => tunnelKind() === 'quick';
22
-
23
- const QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
24
16
  const FUNNEL_URL_RE = /https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\.ts\.net(?::\d{2,5})?(?=[\s/]|$)/i;
25
17
  const SILENT_MS = 20_000;
26
18
 
27
- export const quickTunnelUrlIn = (text: string): string | null =>
28
- QUICK_URL_RE.exec(text)?.[0] ?? null;
29
-
30
19
  export const funnelUrlIn = (text: string): string | null =>
31
20
  FUNNEL_URL_RE.exec(text)?.[0]?.toLowerCase() ?? null;
32
21
 
@@ -51,6 +40,47 @@ const runText = (command: string, args: string[]): Promise<string> =>
51
40
  });
52
41
  });
53
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
+
54
84
  export function funnelAlreadyServing(status: string, port: number): Adopted {
55
85
  const lines = status.split('\n');
56
86
  const target = `http://127.0.0.1:${String(port)}`;
@@ -69,31 +99,20 @@ export function funnelAlreadyServing(status: string, port: number): Adopted {
69
99
  return { url: null, hint: 'port 443 is held by another serve config on this node; run: tailscale serve reset' };
70
100
  }
71
101
 
72
- export const quickDriver = (port: number): TunnelDriver => ({
73
- name: 'cloudflared quick tunnel',
74
- command: 'cloudflared',
75
- args: ['--no-autoupdate', 'tunnel', '--url', `http://127.0.0.1:${String(port)}`],
76
- urlIn: quickTunnelUrlIn,
77
- waitsForDns: true,
78
- });
79
-
80
102
  function tailscaleBin(): string {
81
103
  const configured = process.env.METRO_TAILSCALE_BIN?.trim() ?? '';
82
104
  return configured === '' ? 'tailscale' : configured;
83
105
  }
84
106
 
85
- export const funnelDriver = (port: number, bin = tailscaleBin()): TunnelDriver => ({
107
+ export const funnelDriver = (port: number, bin = tailscaleBin(), probe: Probe = daemonAnswersAt): TunnelDriver => ({
86
108
  name: 'tailscale funnel',
87
109
  command: bin,
88
110
  args: ['funnel', String(port)],
89
111
  urlIn: funnelUrlIn,
90
112
  waitsForDns: true,
91
- adopt: async () => funnelAlreadyServing(await runText(bin, ['funnel', 'status']), port),
113
+ adopt: () => adoptFunnel(bin, port, probe),
92
114
  });
93
115
 
94
- export const driverFor = (kind: TunnelKind, port: number): TunnelDriver =>
95
- kind === 'quick' ? quickDriver(port) : funnelDriver(port);
96
-
97
116
  let liveUrl: string | null = null;
98
117
 
99
118
  export const currentTunnelUrl = (): string | null => liveUrl;
@@ -114,7 +133,7 @@ async function resolvesAt(server: string, host: string): Promise<boolean> {
114
133
  return answers.some((a) => a.length > 0);
115
134
  }
116
135
 
117
- export async function resolvesAtCloudflare(host: string): Promise<boolean> {
136
+ export async function resolvesPublicly(host: string): Promise<boolean> {
118
137
  const found = await Promise.all(PUBLIC_RESOLVERS.map((server) => resolvesAt(server, host)));
119
138
  return found.some(Boolean);
120
139
  }
@@ -193,7 +212,7 @@ export class Tunnel {
193
212
  constructor(
194
213
  private driver: TunnelDriver,
195
214
  private onUrl: (url: string) => void = () => undefined,
196
- private resolves: Resolves = resolvesAtCloudflare,
215
+ private resolves: Resolves = resolvesPublicly,
197
216
  ) {}
198
217
 
199
218
  private noticeUrl(text: string): void {
@@ -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.57"
2
+ "version": "0.1.0-beta.59"
3
3
  }