@openparachute/hub 0.3.0-rc.1 → 0.5.1

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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
package/src/help.ts CHANGED
@@ -6,15 +6,17 @@ export function topLevelHelp(): string {
6
6
  return `parachute ${pkg.version} — top-level CLI for the Parachute ecosystem
7
7
 
8
8
  Usage:
9
+ parachute setup interactive walk-through: install services + configure
9
10
  parachute install <service> install and register a service
10
11
  services: ${services}
11
12
  parachute status show installed services, process state, health
12
13
  parachute start [service] start all services (or one) in the background
13
14
  parachute stop [service] stop all services (or one) — SIGTERM then SIGKILL
14
15
  parachute restart [service] stop + start
16
+ parachute upgrade [service] pull / re-install + restart (skips if no changes)
15
17
  parachute logs <service> [-f] print service logs; -f to tail
16
- parachute expose tailnet [off] HTTPS across your tailnet
17
- parachute expose public [off] HTTPS on the public internet (Funnel)
18
+ parachute expose tailnet [off] HTTPS across your tailnet (supported)
19
+ parachute expose public [off] HTTPS on the public internet (exploratory)
18
20
  parachute migrate [--dry-run] archive legacy files at ecosystem root
19
21
  parachute auth <cmd> identity (set password, manage 2FA)
20
22
  parachute vault <args...> vault-specific ops (tokens, 2fa, config, init,
@@ -80,6 +82,47 @@ Aliases:
80
82
  `;
81
83
  }
82
84
 
85
+ export function setupHelp(): string {
86
+ return `parachute setup — interactive walk-through to install + configure services
87
+
88
+ Usage:
89
+ parachute setup [--tag <name>] [--no-start]
90
+
91
+ What it does:
92
+ 1. surveys ~/.parachute/services.json — already-installed services are
93
+ reported, then skipped from the picker
94
+ 2. shows a numbered multi-select for the remaining first-party services
95
+ (vault, notes, scribe; channel is exploratory and only offered by name)
96
+ 3. pre-collects all interactive answers up front so the installs can run
97
+ without further prompting:
98
+ - vault: vault name (default \`default\`)
99
+ - scribe: transcription provider + API key for cloud providers
100
+ 4. iterates \`parachute install <svc>\` per pick, threading the collected
101
+ answers and the shared --tag / --no-start flags
102
+ 5. prints a summary banner with the running URLs (hub, vault, notes, scribe)
103
+ and a hint for connecting Claude Code
104
+
105
+ Behavior:
106
+ - Partial success is preserved: if one install fails, prior successful
107
+ installs are NOT rolled back. The exit code reflects the FIRST failure
108
+ (root cause), so subsequent fallout doesn't mask the original problem.
109
+ - Non-TTY / piped invocations should use \`parachute install <svc>\` per
110
+ service instead — \`setup\` assumes a terminal for the prompts.
111
+ - Selection accepts numbers (\`1,3\`), names (\`vault, notes\`), or \`all\`.
112
+
113
+ Flags:
114
+ --tag <name> npm dist-tag or exact version, applied to every install
115
+ in this walk-through (e.g. \`--tag rc\`)
116
+ --no-start skip the post-install daemon start for every service.
117
+ For CI / scripted bring-up.
118
+
119
+ Examples:
120
+ parachute setup # interactive walk-through
121
+ parachute setup --tag rc # bootstrap to the rc dist-tag
122
+ parachute setup --no-start # install without auto-starting (CI)
123
+ `;
124
+ }
125
+
83
126
  export function statusHelp(): string {
84
127
  return `parachute status — show installed services, process state, and health
85
128
 
@@ -116,19 +159,34 @@ export function exposeHelp(): string {
116
159
  Usage:
117
160
  parachute expose tailnet [off]
118
161
  parachute expose public [off]
162
+ parachute expose public --tailnet
119
163
  parachute expose public --cloudflare --domain <hostname>
120
164
  parachute expose public off --cloudflare
121
165
 
122
- Interactive:
123
- Run in a terminal with no flags, \`parachute expose public\` walks you
124
- through provider selection (Tailscale Funnel vs. Cloudflare Tunnel),
125
- offers to install missing dependencies on macOS, and prompts for the
126
- Cloudflare hostname when needed. Piped / non-TTY invocations keep the
127
- scripted behavior: default to Tailscale, flags override.
166
+ Status:
167
+ tailnet is the supported exposure shape. The hub's OAuth + per-module
168
+ scope work is being designed against tailnet first (already auth'd at
169
+ the network layer, every user's tailnet is their own).
170
+ public is exploratory the flag still works for early testers, but
171
+ the public-internet posture (DNS, cross-internet OAuth, Funnel quirks)
172
+ hasn't been hardened. Prefer tailnet until public re-enters the
173
+ documented narrative post-OAuth.
174
+
175
+ Provider auto-detect (\`expose public\`):
176
+ - In a terminal with no provider flag, walks an interactive picker
177
+ (Tailscale Funnel vs. Cloudflare Tunnel), offers to install missing
178
+ dependencies on macOS, and prompts for the Cloudflare hostname when
179
+ needed.
180
+ - In a non-TTY (CI / piped), detects which provider is set up:
181
+ * exactly one configured → uses it.
182
+ * both configured → fails with a hint at --tailnet/--cloudflare.
183
+ * neither configured → fails with install pointers for both.
184
+ \`--skip-provider-check\` bypasses detection and falls through to
185
+ today's Tailscale-Funnel default (CI escape hatch).
128
186
 
129
187
  Layers:
130
- tailnet HTTPS across your tailnet (tailscale serve)
131
- public HTTPS on the public internet
188
+ tailnet HTTPS across your tailnet (tailscale serve) — supported
189
+ public HTTPS on the public internet — exploratory
132
190
  - default: Tailscale Funnel (no domain needed, *.ts.net URL)
133
191
  - --cloudflare + --domain: named Cloudflare tunnel on your own
134
192
  domain (stable URL, free, no bandwidth caps)
@@ -137,17 +195,25 @@ Tailscale and Cloudflare modes share no state. Either can be up without the
137
195
  other. Inside each mode, switching on/off is idempotent.
138
196
 
139
197
  Flags:
140
- --hub-origin <url> override the OAuth issuer URL advertised to clients
141
- (default: https://<fqdn> when exposed, else http://127.0.0.1:<hub-port>)
142
- --cloudflare use a named Cloudflare tunnel for the public layer
143
- (requires --domain)
144
- --domain <hostname> fully-qualified hostname to route through the tunnel
145
- (e.g. vault.example.com). The apex must be a zone on
146
- your Cloudflare account.
198
+ --hub-origin <url> override the OAuth issuer URL advertised to clients
199
+ (default: https://<fqdn> when exposed, else http://127.0.0.1:<hub-port>)
200
+ --tailnet pin \`expose public\` to Tailscale Funnel,
201
+ bypassing the picker / auto-detect
202
+ --cloudflare pin \`expose public\` to a named Cloudflare tunnel
203
+ (requires --domain)
204
+ --domain <hostname> fully-qualified hostname to route through the tunnel
205
+ (e.g. vault.example.com). The apex must be a zone on
206
+ your Cloudflare account.
207
+ --tunnel-name <name> Cloudflare tunnel name (default: \`parachute\`).
208
+ Use to coexist multiple named tunnels on one box.
209
+ --skip-provider-check bypass non-TTY auto-detect, default to Tailscale
210
+ Funnel as before. Intended for CI / scripts whose
211
+ environment is already pre-flighted.
147
212
 
148
213
  Examples:
149
214
  parachute expose tailnet # tailnet HTTPS
150
- parachute expose public # Funnel: *.ts.net URL
215
+ parachute expose public # auto-pick / picker
216
+ parachute expose public --tailnet # force Tailscale Funnel
151
217
  parachute expose public off # stop the Funnel
152
218
  parachute expose public --cloudflare --domain vault.example.com
153
219
  # stable URL via cloudflared
@@ -177,6 +243,7 @@ export function startHelp(): string {
177
243
  Usage:
178
244
  parachute start start every installed service
179
245
  parachute start <service> start just that one
246
+ parachute start hub start the internal hub (port 1939)
180
247
 
181
248
  What it does:
182
249
  For each target service, spawns its start command detached, redirects
@@ -187,16 +254,23 @@ What it does:
187
254
  If a stale PID file exists (process died without cleanup), it's cleared
188
255
  and the service starts fresh.
189
256
 
257
+ \`parachute start hub\` brings up the internal hub directly (normally
258
+ spawned implicitly by \`parachute expose\`). Useful when restarting a
259
+ hub that crashed without an active expose layer.
260
+
190
261
  Flags:
191
262
  --hub-origin <url> override PARACHUTE_HUB_ORIGIN passed to services
192
- (default: current expose-state hub origin, else loopback)
263
+ (default: current expose-state hub origin, else loopback).
264
+ For \`start hub\`, also doubles as the hub's --issuer.
193
265
 
194
266
  Examples:
195
267
  parachute start bring everything up
196
268
  parachute start vault just vault
269
+ parachute start hub just the internal hub
197
270
  parachute logs vault watch what just started
198
271
 
199
272
  Start commands by service:
273
+ hub bun <cli>/hub-server.ts --port <picked> ...
200
274
  vault parachute-vault serve
201
275
  scribe parachute-scribe serve
202
276
  channel parachute-channel daemon
@@ -210,6 +284,7 @@ export function stopHelp(): string {
210
284
  Usage:
211
285
  parachute stop stop every installed service
212
286
  parachute stop <service> stop just that one
287
+ parachute stop hub stop the internal hub
213
288
 
214
289
  What it does:
215
290
  Sends SIGTERM, waits up to 10s for a clean exit, then escalates to
@@ -217,9 +292,13 @@ What it does:
217
292
 
218
293
  No-op if the service wasn't running.
219
294
 
295
+ Bare \`parachute stop\` (no service) does NOT stop the hub — that's
296
+ managed by the active expose layer (or \`parachute stop hub\` directly).
297
+
220
298
  Examples:
221
299
  parachute stop stop everything before sleep
222
300
  parachute stop vault just vault
301
+ parachute stop hub just the internal hub
223
302
  `;
224
303
  }
225
304
 
@@ -229,18 +308,53 @@ export function restartHelp(): string {
229
308
  Usage:
230
309
  parachute restart restart every installed service
231
310
  parachute restart <service> restart just that one
311
+ parachute restart hub restart the internal hub
232
312
 
233
313
  What it does:
234
314
  Equivalent to \`parachute stop <svc> && parachute start <svc>\`.
235
315
  `;
236
316
  }
237
317
 
318
+ export function upgradeHelp(): string {
319
+ return `parachute upgrade — pull / re-install + restart in one step
320
+
321
+ Usage:
322
+ parachute upgrade upgrade every installed service
323
+ parachute upgrade <service> upgrade just that one
324
+ parachute upgrade [svc] --tag <name>
325
+ npm-installed services only — pin a dist-tag
326
+ (default: latest). Ignored when bun-linked.
327
+
328
+ What it does:
329
+ Detects whether the target service is bun-linked from a local checkout
330
+ (the dev-mode shape) or npm-installed from a published artifact:
331
+
332
+ bun-linked git pull --ff-only in the checkout, bun install if
333
+ package.json/bun.lock changed, bun run build for frontend
334
+ modules with a build script, then \`parachute restart\`.
335
+ Refuses on a dirty working tree — commit or stash first.
336
+
337
+ npm bun add -g <pkg>@<tag>, then \`parachute restart\` if the
338
+ installed version actually moved.
339
+
340
+ Idempotent: if the source didn't change (HEAD unchanged after pull, or
341
+ package.json version unchanged after bun add -g), the restart is skipped.
342
+ Re-running on an up-to-date install is a fast no-op.
343
+
344
+ Examples:
345
+ parachute upgrade sweep every installed service
346
+ parachute upgrade vault just vault
347
+ parachute upgrade vault --tag rc pin the rc dist-tag (npm path only)
348
+ `;
349
+ }
350
+
238
351
  export function logsHelp(): string {
239
352
  return `parachute logs — print service logs
240
353
 
241
354
  Usage:
242
355
  parachute logs <service> print the last 200 lines
243
356
  parachute logs <service> -f tail the log (like \`tail -f\`)
357
+ parachute logs hub logs for the internal hub
244
358
 
245
359
  Log file:
246
360
  ~/.parachute/<service>/logs/<service>.log
@@ -3,6 +3,7 @@ import { createServer } from "node:net";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { CONFIG_DIR } from "./config.ts";
6
+ import { hubDbPath } from "./hub-db.ts";
6
7
  import {
7
8
  type AliveFn,
8
9
  clearPid,
@@ -123,6 +124,14 @@ export interface EnsureHubOpts {
123
124
  reservedPorts?: Iterable<number>;
124
125
  /** How long to wait after spawn before claiming readiness. Short — tests set to 0. */
125
126
  readyWaitMs?: number;
127
+ /**
128
+ * Public origin to use as the OAuth `iss` claim and as the base for the
129
+ * authorization-server metadata document. Forwarded to the hub server as
130
+ * `--issuer <url>`. When omitted, the hub falls back to the request's own
131
+ * origin — fine for loopback testing, wrong under tailscale where the
132
+ * request origin is `http://127.0.0.1:<port>`.
133
+ */
134
+ issuer?: string;
126
135
  log?: (line: string) => void;
127
136
  }
128
137
 
@@ -180,6 +189,9 @@ export async function ensureHubRunning(opts: EnsureHubOpts = {}): Promise<Ensure
180
189
  String(chosenPort),
181
190
  "--well-known-dir",
182
191
  wellKnownDir,
192
+ "--db",
193
+ hubDbPath(configDir),
194
+ ...(opts.issuer ? ["--issuer", opts.issuer] : []),
183
195
  ];
184
196
  const pid = spawner.spawn(cmd, logFile);
185
197
  writePid(HUB_SVC, pid, configDir);
package/src/hub-db.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Hub-local SQLite database. Opens `~/.parachute/hub.db` (overridable via
3
+ * `$PARACHUTE_HOME`). Holds everything the hub owns as the ecosystem's OAuth
4
+ * issuer — signing keys (v1), users + opaque refresh tokens (v2), OAuth
5
+ * clients + auth-codes + grants + browser sessions (v3).
6
+ *
7
+ * Each open() runs `migrate()` to bring the schema up to date. A
8
+ * `schema_version` table records every applied migration so re-opens are
9
+ * cheap and idempotent. Migrations are append-only — never edit a prior
10
+ * entry; add a new one with a higher number.
11
+ */
12
+ import { Database } from "bun:sqlite";
13
+ import { mkdirSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { CONFIG_DIR } from "./config.ts";
16
+
17
+ export function hubDbPath(configDir: string = CONFIG_DIR): string {
18
+ return join(configDir, "hub.db");
19
+ }
20
+
21
+ interface Migration {
22
+ version: number;
23
+ sql: string;
24
+ }
25
+
26
+ const MIGRATIONS: readonly Migration[] = [
27
+ {
28
+ version: 1,
29
+ sql: `
30
+ CREATE TABLE signing_keys (
31
+ kid TEXT PRIMARY KEY,
32
+ public_key_pem TEXT NOT NULL,
33
+ private_key_pem TEXT NOT NULL,
34
+ algorithm TEXT NOT NULL,
35
+ created_at TEXT NOT NULL,
36
+ retired_at TEXT
37
+ );
38
+ CREATE INDEX signing_keys_active ON signing_keys (retired_at)
39
+ WHERE retired_at IS NULL;
40
+ `,
41
+ },
42
+ {
43
+ version: 2,
44
+ sql: `
45
+ CREATE TABLE users (
46
+ id TEXT PRIMARY KEY,
47
+ username TEXT UNIQUE NOT NULL,
48
+ password_hash TEXT NOT NULL,
49
+ created_at TEXT NOT NULL,
50
+ updated_at TEXT NOT NULL
51
+ );
52
+ CREATE TABLE tokens (
53
+ jti TEXT PRIMARY KEY,
54
+ user_id TEXT NOT NULL REFERENCES users(id),
55
+ client_id TEXT NOT NULL,
56
+ scopes TEXT NOT NULL,
57
+ refresh_token_hash TEXT,
58
+ expires_at TEXT NOT NULL,
59
+ revoked_at TEXT,
60
+ created_at TEXT NOT NULL
61
+ );
62
+ CREATE INDEX tokens_user ON tokens (user_id);
63
+ CREATE INDEX tokens_active_refresh ON tokens (refresh_token_hash)
64
+ WHERE refresh_token_hash IS NOT NULL AND revoked_at IS NULL;
65
+ `,
66
+ },
67
+ {
68
+ version: 3,
69
+ sql: `
70
+ CREATE TABLE clients (
71
+ client_id TEXT PRIMARY KEY,
72
+ client_secret_hash TEXT,
73
+ redirect_uris TEXT NOT NULL,
74
+ scopes TEXT NOT NULL,
75
+ client_name TEXT,
76
+ registered_at TEXT NOT NULL
77
+ );
78
+ CREATE TABLE auth_codes (
79
+ code TEXT PRIMARY KEY,
80
+ client_id TEXT NOT NULL REFERENCES clients(client_id),
81
+ user_id TEXT NOT NULL REFERENCES users(id),
82
+ redirect_uri TEXT NOT NULL,
83
+ scopes TEXT NOT NULL,
84
+ code_challenge TEXT NOT NULL,
85
+ code_challenge_method TEXT NOT NULL,
86
+ expires_at TEXT NOT NULL,
87
+ used_at TEXT,
88
+ created_at TEXT NOT NULL
89
+ );
90
+ CREATE TABLE grants (
91
+ user_id TEXT NOT NULL REFERENCES users(id),
92
+ client_id TEXT NOT NULL REFERENCES clients(client_id),
93
+ scopes TEXT NOT NULL,
94
+ granted_at TEXT NOT NULL,
95
+ PRIMARY KEY (user_id, client_id)
96
+ );
97
+ CREATE TABLE sessions (
98
+ id TEXT PRIMARY KEY,
99
+ user_id TEXT NOT NULL REFERENCES users(id),
100
+ expires_at TEXT NOT NULL,
101
+ created_at TEXT NOT NULL
102
+ );
103
+ CREATE INDEX sessions_user ON sessions (user_id);
104
+ `,
105
+ },
106
+ {
107
+ version: 4,
108
+ sql: `
109
+ -- DCR approval gate (closes #74). Public DCR was unauthenticated before
110
+ -- this migration; pre-existing rows are grandfathered as 'approved' so
111
+ -- already-trusted clients keep working. New rows default to 'pending'
112
+ -- unless the registrant authenticates with an operator token carrying
113
+ -- hub:admin scope.
114
+ ALTER TABLE clients ADD COLUMN status TEXT NOT NULL DEFAULT 'pending';
115
+ UPDATE clients SET status = 'approved';
116
+ CREATE INDEX clients_status ON clients (status);
117
+ `,
118
+ },
119
+ {
120
+ version: 5,
121
+ sql: `
122
+ -- Refresh-token rotation + replay detection (closes #73). Each chain
123
+ -- of rotated refresh tokens shares a family_id; replaying a revoked
124
+ -- refresh token in a family signals theft and revokes every row in
125
+ -- that family (RFC 6819 §5.2.2.3). Pre-existing rows are backfilled
126
+ -- with their own jti as the family — grandfathered as singletons so
127
+ -- in-flight tokens keep working without spurious family revocation.
128
+ ALTER TABLE tokens ADD COLUMN family_id TEXT;
129
+ UPDATE tokens SET family_id = jti WHERE family_id IS NULL;
130
+ CREATE INDEX tokens_family ON tokens (family_id) WHERE family_id IS NOT NULL;
131
+ `,
132
+ },
133
+ ];
134
+
135
+ export function openHubDb(path: string = hubDbPath()): Database {
136
+ mkdirSync(dirname(path), { recursive: true });
137
+ const db = new Database(path);
138
+ db.exec("PRAGMA journal_mode = WAL");
139
+ db.exec("PRAGMA foreign_keys = ON");
140
+ migrate(db);
141
+ return db;
142
+ }
143
+
144
+ export function migrate(db: Database): void {
145
+ db.exec(`
146
+ CREATE TABLE IF NOT EXISTS schema_version (
147
+ version INTEGER PRIMARY KEY,
148
+ applied_at TEXT NOT NULL
149
+ );
150
+ `);
151
+ const applied = new Set<number>(
152
+ (db.query("SELECT version FROM schema_version").all() as { version: number }[]).map(
153
+ (r) => r.version,
154
+ ),
155
+ );
156
+ const insert = db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)");
157
+ for (const m of MIGRATIONS) {
158
+ if (applied.has(m.version)) continue;
159
+ db.transaction(() => {
160
+ db.exec(m.sql);
161
+ insert.run(m.version, new Date().toISOString());
162
+ })();
163
+ }
164
+ }