@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -136,6 +136,24 @@ export interface LifecycleOpts {
136
136
  killWaitMs?: number;
137
137
  /** Poll interval while waiting for SIGTERM to land. */
138
138
  pollIntervalMs?: number;
139
+ /**
140
+ * How long `start` sleeps before re-checking `alive(pid)` to catch the
141
+ * spawn-then-immediately-die failure shape (hub#194: notes-serve crashed
142
+ * 50ms in on Bun.resolveSync, but `start` reported success because the
143
+ * spawn returned a pid). 250ms is the default in production — long
144
+ * enough to catch real silent-crashes (resolve failures, port
145
+ * collisions, missing args) without making `parachute start` feel
146
+ * laggy.
147
+ *
148
+ * Defaulting policy: if `alive` is not overridden, the settle defaults
149
+ * to 0 (skipped). Stub spawners hand back fake pids that the real
150
+ * `defaultAlive` would mark as dead, which would make every existing
151
+ * stub-spawner test fail spuriously. Tests that want to exercise the
152
+ * settle path inject both `alive` and `startSettleMs` explicitly.
153
+ * Production paths use the real `defaultAlive` and get the real 250ms
154
+ * settle.
155
+ */
156
+ startSettleMs?: number;
139
157
  /**
140
158
  * Override the hub origin passed to services as PARACHUTE_HUB_ORIGIN. If
141
159
  * unset, `start` derives it from `expose-state.json` (when exposed) or
@@ -168,6 +186,7 @@ interface Resolved {
168
186
  log: (line: string) => void;
169
187
  killWaitMs: number;
170
188
  pollIntervalMs: number;
189
+ startSettleMs: number;
171
190
  hubOrigin: string | undefined;
172
191
  ensureHub: (opts: EnsureHubOpts) => Promise<EnsureHubResult>;
173
192
  stopHubFn: (opts: StopHubOpts) => Promise<boolean>;
@@ -186,6 +205,14 @@ function resolve(opts: LifecycleOpts): Resolved {
186
205
  log: opts.log ?? ((line) => console.log(line)),
187
206
  killWaitMs: opts.killWaitMs ?? 10_000,
188
207
  pollIntervalMs: opts.pollIntervalMs ?? 200,
208
+ // See `LifecycleOpts.startSettleMs` doc. Production (no spawner
209
+ // override, no alive override) gets the 250ms settle. Tests that
210
+ // inject a stub spawner without a stub alive get 0 — `defaultAlive`
211
+ // against a fake pid would always report dead and break unrelated
212
+ // tests. Tests that want to exercise the settle path explicitly
213
+ // override `alive`, which re-enables the default 250ms.
214
+ startSettleMs:
215
+ opts.startSettleMs ?? (opts.spawner === undefined || opts.alive !== undefined ? 250 : 0),
189
216
  hubOrigin: resolveHubOrigin(opts.hubOrigin, configDir),
190
217
  ensureHub: opts.hub?.ensureRunning ?? ensureHubRunning,
191
218
  stopHubFn: opts.hub?.stop ?? stopHub,
@@ -371,16 +398,38 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
371
398
  if (entry.installDir) spawnerOpts.cwd = entry.installDir;
372
399
  const passOpts =
373
400
  spawnerOpts.env !== undefined || spawnerOpts.cwd !== undefined ? spawnerOpts : undefined;
401
+ let pid: number;
374
402
  try {
375
- const pid = r.spawner.spawn(cmd, logFile, passOpts);
376
- writePid(short, pid, r.configDir);
377
- r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
378
- if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
403
+ pid = r.spawner.spawn(cmd, logFile, passOpts);
379
404
  } catch (err) {
380
405
  failures++;
381
406
  const msg = err instanceof Error ? err.message : String(err);
382
407
  r.log(`✗ ${short} failed to start: ${msg}`);
408
+ continue;
409
+ }
410
+ writePid(short, pid, r.configDir);
411
+
412
+ // Settle-poll for spawn-then-immediately-die (hub#194). A spawn returning
413
+ // a pid only proves the kernel forked the process; the child may exit
414
+ // microseconds later if its main code path throws before listening
415
+ // (e.g. notes-serve's Bun.resolveSync failing for bun-linked installs).
416
+ // Without this poll, we'd report success and the operator would chase
417
+ // a phantom 502.
418
+ if (r.startSettleMs > 0) {
419
+ await r.sleep(r.startSettleMs);
420
+ if (!r.alive(pid)) {
421
+ clearPid(short, r.configDir);
422
+ failures++;
423
+ r.log(
424
+ `✗ ${short} failed to start: spawned pid ${pid} but the process exited within ${r.startSettleMs}ms.`,
425
+ );
426
+ r.log(` Tail the log for details: tail -50 ${logFile}`);
427
+ continue;
428
+ }
383
429
  }
430
+
431
+ r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
432
+ if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
384
433
  }
385
434
  return failures === 0 ? 0 : 1;
386
435
  }
@@ -1,7 +1,14 @@
1
1
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
2
2
  import { HUB_SVC, readHubPort } from "../hub-control.ts";
3
+ import {
4
+ type DetectInstallSourceDeps,
5
+ detectHubInstallSource,
6
+ detectInstallSource,
7
+ formatInstallSourceLabel,
8
+ isStale,
9
+ } from "../install-source.ts";
3
10
  import { type AliveFn, defaultAlive, formatUptime, processState } from "../process-state.ts";
4
- import { getSpec, shortNameForManifest } from "../service-spec.ts";
11
+ import { canonicalPortForManifest, getSpec, shortNameForManifest } from "../service-spec.ts";
5
12
  import { type ServiceEntry, readManifest } from "../services-manifest.ts";
6
13
 
7
14
  export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
@@ -14,6 +21,19 @@ export interface StatusOpts {
14
21
  configDir?: string;
15
22
  alive?: AliveFn;
16
23
  now?: () => Date;
24
+ /**
25
+ * Test seam for install-source detection. Production reads the filesystem
26
+ * + shells out to git; tests inject stubs so each case (npm / bun-linked /
27
+ * unknown / stale) is exercised deterministically without depending on
28
+ * the operator's actual bun globals.
29
+ */
30
+ installSourceDeps?: DetectInstallSourceDeps;
31
+ /**
32
+ * Directory containing the running hub source. Defaults to `import.meta.dir`
33
+ * (the directory of this file). Tests override so the hub row's install
34
+ * source classification doesn't depend on the test runner's location.
35
+ */
36
+ hubSrcDir?: string;
17
37
  }
18
38
 
19
39
  export interface ProbeResult {
@@ -71,9 +91,25 @@ interface StatusRow {
71
91
  uptimeLabel: string;
72
92
  healthLabel: string;
73
93
  latencyLabel: string;
94
+ sourceLabel: string;
74
95
  url: string | undefined;
75
96
  healthy: boolean;
76
97
  skipped: boolean;
98
+ /**
99
+ * Canonical-port drift warning. Set when the entry has a known canonical
100
+ * port (first-party / known short) AND the actual port differs. Surfaced
101
+ * as a continuation line under the row so operators see a silent miswire
102
+ * (e.g. parachute-hub#195: scribe + agent both at 1944) without us
103
+ * hard-erroring on a deliberate operator port change.
104
+ */
105
+ driftWarning?: string;
106
+ /**
107
+ * Version-drift indicator (hub#243). Set when a bun-linked service's
108
+ * `services.json.version` lags the live `package.json` version at its
109
+ * checkout. Surfaced as a continuation line so operators can spot a
110
+ * stale-after-rebuild row without comparing columns by eye.
111
+ */
112
+ staleNote?: string;
77
113
  }
78
114
 
79
115
  /**
@@ -90,7 +126,13 @@ function urlForEntry(entry: ServiceEntry, short: string | undefined): string | u
90
126
  return `http://127.0.0.1:${entry.port}${first}`;
91
127
  }
92
128
 
93
- function hubRow(configDir: string, alive: AliveFn, nowDate: Date): StatusRow | undefined {
129
+ function hubRow(
130
+ configDir: string,
131
+ alive: AliveFn,
132
+ nowDate: Date,
133
+ hubSrcDir: string,
134
+ installSourceDeps: DetectInstallSourceDeps,
135
+ ): StatusRow | undefined {
94
136
  const proc = processState(HUB_SVC, configDir, alive);
95
137
  if (proc.status === "unknown") return undefined;
96
138
  const port = readHubPort(configDir);
@@ -99,15 +141,17 @@ function hubRow(configDir: string, alive: AliveFn, nowDate: Date): StatusRow | u
99
141
  const pidLabel = proc.status === "running" && proc.pid !== undefined ? String(proc.pid) : "-";
100
142
  const uptimeLabel =
101
143
  proc.status === "running" && proc.startedAt ? formatUptime(proc.startedAt, nowDate) : "-";
144
+ const source = detectHubInstallSource(hubSrcDir, installSourceDeps);
102
145
  return {
103
146
  service: "parachute-hub (internal)",
104
147
  port: portLabel,
105
- version: "-",
148
+ version: source.livePackageVersion ?? "-",
106
149
  processLabel,
107
150
  pidLabel,
108
151
  uptimeLabel,
109
152
  healthLabel: "-",
110
153
  latencyLabel: "-",
154
+ sourceLabel: formatInstallSourceLabel(source),
111
155
  url: port !== undefined ? `http://127.0.0.1:${port}` : undefined,
112
156
  healthy: true,
113
157
  skipped: true,
@@ -122,6 +166,8 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
122
166
  const configDir = opts.configDir ?? CONFIG_DIR;
123
167
  const alive = opts.alive ?? defaultAlive;
124
168
  const now = opts.now ?? (() => new Date());
169
+ const installSourceDeps = opts.installSourceDeps ?? {};
170
+ const hubSrcDir = opts.hubSrcDir ?? import.meta.dir;
125
171
 
126
172
  const manifest = readManifest(manifestPath);
127
173
  if (manifest.services.length === 0) {
@@ -157,6 +203,30 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
157
203
 
158
204
  const url = urlForEntry(entry, short);
159
205
 
206
+ // Canonical-port drift detection (hub#195). Only fires for known
207
+ // first-party services where we have a canonical assignment. Third-party
208
+ // rows have no canonical to compare against. Warning is informational —
209
+ // operators may have moved a service off canonical deliberately.
210
+ // Note: multi-vault instance rows (`parachute-vault-<instance>`) don't
211
+ // match a canonical manifest name, so drift warnings don't fire for
212
+ // them. Intentional — see `canonicalPortForManifest` for the rationale.
213
+ const canonical = canonicalPortForManifest(entry.name);
214
+ const driftWarning =
215
+ canonical !== undefined && canonical !== entry.port
216
+ ? `canonical port is ${canonical}`
217
+ : undefined;
218
+
219
+ // Install-source detection (hub#243). One filesystem walk + maybe one
220
+ // `git rev-parse` per row. Failures degrade silently to `unknown` —
221
+ // status output should never error out on a missing checkout dir.
222
+ const detectArgs: { entryName: string; installDir?: string } = { entryName: entry.name };
223
+ if (entry.installDir !== undefined) detectArgs.installDir = entry.installDir;
224
+ const source = detectInstallSource(detectArgs, installSourceDeps);
225
+ const sourceLabel = formatInstallSourceLabel(source);
226
+ const staleNote = isStale(entry.version, source)
227
+ ? `STALE: services.json cached ${entry.version}; live package.json ${source.livePackageVersion}`
228
+ : undefined;
229
+
160
230
  // Only skip probe when we know the process is dead (PID file was
161
231
  // present but kill(pid, 0) failed). "unknown" status (no PID file)
162
232
  // still probes — externally-managed services should report health.
@@ -170,9 +240,12 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
170
240
  uptimeLabel,
171
241
  healthLabel: "-",
172
242
  latencyLabel: "-",
243
+ sourceLabel,
173
244
  url,
174
245
  healthy: false,
175
246
  skipped: true,
247
+ driftWarning,
248
+ staleNote,
176
249
  };
177
250
  }
178
251
 
@@ -191,19 +264,32 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
191
264
  uptimeLabel,
192
265
  healthLabel,
193
266
  latencyLabel: `${p.latencyMs}ms`,
267
+ sourceLabel,
194
268
  url,
195
269
  healthy: p.healthy,
196
270
  skipped: false,
271
+ driftWarning,
272
+ staleNote,
197
273
  };
198
274
  }),
199
275
  );
200
276
 
201
277
  // Hub is an internal service — not in services.json, but users notice
202
278
  // when it's dead. Only show it if we've seen it run.
203
- const hub = hubRow(configDir, alive, nowDate);
279
+ const hub = hubRow(configDir, alive, nowDate, hubSrcDir, installSourceDeps);
204
280
  if (hub) rows.push(hub);
205
281
 
206
- const header = ["SERVICE", "PORT", "VERSION", "PROCESS", "PID", "UPTIME", "HEALTH", "LATENCY"];
282
+ const header = [
283
+ "SERVICE",
284
+ "PORT",
285
+ "VERSION",
286
+ "PROCESS",
287
+ "PID",
288
+ "UPTIME",
289
+ "HEALTH",
290
+ "LATENCY",
291
+ "SOURCE",
292
+ ];
207
293
  const textRows = rows.map((r) => [
208
294
  r.service,
209
295
  r.port,
@@ -213,14 +299,17 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
213
299
  r.uptimeLabel,
214
300
  r.healthLabel,
215
301
  r.latencyLabel,
302
+ r.sourceLabel,
216
303
  ]);
217
304
  const widths = header.map((_, i) =>
218
305
  Math.max(header[i]?.length ?? 0, ...textRows.map((r) => r[i]?.length ?? 0)),
219
306
  );
220
307
  print(formatRow(header, widths));
221
- // URL stays on a continuation line rather than a column. URLs are long
222
- // (vault's MCP path runs ~40 chars), and a ninth column would push the
223
- // table past 80 cols on every install. The " → " prefix groups visually
308
+ // URL, drift, and stale notes stay on continuation lines rather than
309
+ // columns. URLs are long (vault's MCP path runs ~40 chars); SOURCE labels
310
+ // can be long for bun-linked rows. Spreading them across columns would
311
+ // push the table well past 80 cols on every install — continuation lines
312
+ // keep the table scannable. The " → " / " ! " prefixes group visually
224
313
  // with the row above without misleading the table widths.
225
314
  for (let i = 0; i < textRows.length; i++) {
226
315
  const cells = textRows[i];
@@ -228,6 +317,8 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
228
317
  if (!cells || !row) continue;
229
318
  print(formatRow(cells, widths));
230
319
  if (row.url) print(` → ${row.url}`);
320
+ if (row.driftWarning) print(` ! ${row.driftWarning}`);
321
+ if (row.staleNote) print(` ! ${row.staleNote}`);
231
322
  }
232
323
 
233
324
  /**
package/src/csrf.ts CHANGED
@@ -19,9 +19,12 @@
19
19
  * pre-login and post-login forms, and it works no matter how many tabs the
20
20
  * operator has open.
21
21
  *
22
- * The cookie is HttpOnly (the form doesn't need JS to read it; the server
23
- * embeds the value at render time), SameSite=Lax (matches the session
24
- * cookie), Secure, and Path=/ (covers every admin form, OAuth or otherwise).
22
+ * The cookie is HttpOnly: consumers receive the token value via either the
23
+ * server-rendered HTML form (cookie + embedded value, classic double-submit)
24
+ * or via the JSON body of `/api/me` (cookie alongside body same pattern,
25
+ * just JSON instead of HTML). Neither path needs JS to read the cookie
26
+ * directly. SameSite=Lax (matches the session cookie), Secure, and Path=/
27
+ * (covers every admin form, OAuth flow, and `/api/me` consumer).
25
28
  *
26
29
  * Token entropy: 32 random bytes, base64url-encoded — same shape as session
27
30
  * IDs. No HMAC needed: the value is opaque to the client and only ever
package/src/help.ts CHANGED
@@ -44,9 +44,9 @@ Services:
44
44
  What it does:
45
45
  1. bun add -g @openparachute/<service>[@<tag>]
46
46
  2. run any service-specific init (e.g. \`parachute-vault init\`)
47
- 3. assign a canonical port (1939–1949) and write \`PORT=<port>\` into
48
- \`~/.parachute/<service>/.env\`. Idempotent an existing PORT wins, so
49
- re-installs and operator-edited ports survive across upgrades.
47
+ 3. assign a canonical port (1939–1949) and reflect it in
48
+ \`~/.parachute/services.json\`the single source of truth at boot
49
+ (services follow a 4-tier resolvePort ladder; services.json wins).
50
50
  4. verify the service registered itself in ~/.parachute/services.json
51
51
  5. for scribe in a TTY: prompt for transcription provider + API key
52
52
  (or take \`--scribe-provider\` / \`--scribe-key\`)
@@ -124,7 +124,7 @@ Examples:
124
124
  }
125
125
 
126
126
  export function statusHelp(): string {
127
- return `parachute status — show installed services, process state, and health
127
+ return `parachute status — show installed services, process state, health, install source
128
128
 
129
129
  Usage:
130
130
  parachute status
@@ -133,22 +133,28 @@ What it does:
133
133
  Reads ~/.parachute/services.json. For each registered service:
134
134
  - checks PID file at ~/.parachute/<svc>/run/<svc>.pid → running/stopped
135
135
  - probes http://localhost:<port><health> (skipped for known-stopped processes)
136
+ - classifies the install source as bun-linked (local checkout) or npm
136
137
 
137
138
  Stopped services show "-" for health and don't count toward the exit
138
139
  code — they're an expected state after fresh install before \`parachute
139
140
  start\`. Running or externally-managed services that fail health checks
140
141
  do exit 1.
141
142
 
143
+ A "STALE: services.json cached … live package.json …" continuation line
144
+ appears under a row when a bun-linked service has been rebuilt but the
145
+ manifest's cached version hasn't caught up — re-install (\`parachute
146
+ install <pkg>\`) refreshes the row.
147
+
142
148
  Exit codes:
143
149
  0 all probed services healthy (or none running)
144
150
  1 one or more probed services unhealthy
145
151
 
146
152
  Example:
147
153
  $ parachute status
148
- SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY
149
- parachute-vault 1940 0.2.4 running 12345 2h 13m ok 2ms
154
+ SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY SOURCE
155
+ parachute-vault 1940 0.2.4 running 12345 2h 13m ok 2ms bun-linked → parachute-vault @ 8aa167b
150
156
  → http://127.0.0.1:1940/vault/default/mcp
151
- parachute-notes 1942 0.0.1 stopped - - - -
157
+ parachute-notes 1942 0.0.1 stopped - - - - npm (0.3.15-rc.1)
152
158
  → http://127.0.0.1:1942/notes
153
159
  `;
154
160
  }
package/src/hub-db.ts CHANGED
@@ -130,6 +130,69 @@ const MIGRATIONS: readonly Migration[] = [
130
130
  CREATE INDEX tokens_family ON tokens (family_id) WHERE family_id IS NOT NULL;
131
131
  `,
132
132
  },
133
+ {
134
+ version: 6,
135
+ sql: `
136
+ -- Token registry generalization (closes hub#212 Phase 1). Until v6 the
137
+ -- tokens table only held OAuth refresh tokens; v6 generalizes it to a
138
+ -- single registry across every issued JWT class (refresh, access,
139
+ -- operator, mint-token). Three structural changes:
140
+ --
141
+ -- 1. user_id becomes NULLABLE. OAuth-issued rows still set it to the
142
+ -- caller's user (canonical identity field). CLI-minted /
143
+ -- operator-minted rows leave user_id NULL and put the operator/
144
+ -- service name in the new \`subject\` column.
145
+ -- 2. Three new columns: \`permissions\` (JSON, custom claim per
146
+ -- auth-architecture-shape.md §11.3), \`created_via\` (provenance
147
+ -- tag: oauth_refresh / cli_mint / operator_mint), \`subject\`
148
+ -- (non-user identity for service / operator mints).
149
+ -- 3. Existing rows backfill \`created_via='oauth_refresh'\` because
150
+ -- the table was OAuth-refresh-only before v6.
151
+ --
152
+ -- SQLite has no ALTER COLUMN to drop NOT NULL, so we use the
153
+ -- recreate-and-rename pattern. Inside the migration transaction the
154
+ -- whole swap is atomic; concurrent reads (there are none — hub is
155
+ -- single-writer) wouldn't see a half-state. FKs from tokens → users
156
+ -- stay enforced for non-NULL user_id values; nothing references
157
+ -- tokens, so the drop is safe.
158
+ CREATE TABLE tokens_new (
159
+ jti TEXT PRIMARY KEY,
160
+ user_id TEXT REFERENCES users(id),
161
+ client_id TEXT NOT NULL,
162
+ scopes TEXT NOT NULL,
163
+ refresh_token_hash TEXT,
164
+ family_id TEXT,
165
+ expires_at TEXT NOT NULL,
166
+ revoked_at TEXT,
167
+ created_at TEXT NOT NULL,
168
+ permissions TEXT,
169
+ created_via TEXT NOT NULL DEFAULT 'oauth_refresh',
170
+ subject TEXT
171
+ );
172
+ INSERT INTO tokens_new (
173
+ jti, user_id, client_id, scopes, refresh_token_hash, family_id,
174
+ expires_at, revoked_at, created_at,
175
+ permissions, created_via, subject
176
+ )
177
+ SELECT
178
+ jti, user_id, client_id, scopes, refresh_token_hash, family_id,
179
+ expires_at, revoked_at, created_at,
180
+ NULL, 'oauth_refresh', NULL
181
+ FROM tokens;
182
+ DROP TABLE tokens;
183
+ ALTER TABLE tokens_new RENAME TO tokens;
184
+ -- Recreate indexes (DROP TABLE took them with it).
185
+ CREATE INDEX tokens_user ON tokens (user_id) WHERE user_id IS NOT NULL;
186
+ CREATE INDEX tokens_active_refresh ON tokens (refresh_token_hash)
187
+ WHERE refresh_token_hash IS NOT NULL AND revoked_at IS NULL;
188
+ CREATE INDEX tokens_family ON tokens (family_id) WHERE family_id IS NOT NULL;
189
+ -- New: revocation list endpoint queries on (revoked_at, expires_at).
190
+ CREATE INDEX tokens_revoked ON tokens (revoked_at)
191
+ WHERE revoked_at IS NOT NULL;
192
+ -- Subject lookup for non-user mints (operator name, service name).
193
+ CREATE INDEX tokens_subject ON tokens (subject) WHERE subject IS NOT NULL;
194
+ `,
195
+ },
133
196
  ];
134
197
 
135
198
  export function openHubDb(path: string = hubDbPath()): Database {