@openparachute/hub 0.6.3 → 0.6.4-rc.10

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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -13,9 +13,9 @@
13
13
  * client to hit `/oauth/register` *within* that window is auto-approved
14
14
  * (single-use, the value is cleared on consume). Past-due or absent
15
15
  * means the standard pending-approval flow applies. Motivator: a
16
- * canonical onboarding (install hub → wizardinstall Notes →
17
- * authorize) shouldn't bounce the operator through a manual approve
18
- * step they just set up the hub for.
16
+ * canonical onboarding (install hub → exposewizard installs
17
+ * vault/surface → authorize) shouldn't bounce the operator through a
18
+ * manual approve step they just set up the hub for.
19
19
  *
20
20
  * Schema lives in `hub-db.ts` migration v7. This module is just the typed
21
21
  * accessor — single-row reads/writes per key, no joins, no caching. The
package/src/hub-unit.ts CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  systemdUnitPathForName,
54
54
  } from "./managed-unit.ts";
55
55
  import { type PortListeningFn, defaultPortListening } from "./port-probe.ts";
56
+ import { enrichedUnitPath } from "./spawn-path.ts";
56
57
 
57
58
  /** Default canonical hub port (the 1939 pin). */
58
59
  export const HUB_UNIT_DEFAULT_PORT = 1939;
@@ -75,6 +76,16 @@ export interface HubUnitDeps extends ManagedUnitDeps {
75
76
  * uses a bounded `fetch`; tests inject a deterministic stub.
76
77
  */
77
78
  probeHealth: (port: number) => Promise<boolean>;
79
+ /**
80
+ * HTTP `/health` probe that ALSO reads the JSON `version` field of the
81
+ * running hub (#590). Resolves to `{ ok, version }` — `ok` mirrors
82
+ * {@link probeHealth} (2xx), `version` is the running hub's reported version
83
+ * (or `undefined` when the body has no `version` field — a very old hub that
84
+ * predates the field; the caller treats that as a mismatch). Resolves to
85
+ * `null` when the hub doesn't answer at all (connection-refused / timeout).
86
+ * Production uses a bounded `fetch`; tests inject a deterministic stub.
87
+ */
88
+ probeHealthVersion: (port: number) => Promise<{ ok: boolean; version?: string } | null>;
78
89
  /** TCP connect-probe for readiness polling (reuses `defaultPortListening`). */
79
90
  portListening: PortListeningFn;
80
91
  /** Sleep between readiness polls (tests pin to 0). */
@@ -97,9 +108,41 @@ async function defaultProbeHealth(port: number): Promise<boolean> {
97
108
  }
98
109
  }
99
110
 
111
+ /**
112
+ * Default version-aware `/health` probe (#590). Reads the JSON body and pulls
113
+ * out the `version` field. Returns `null` on any network error / timeout (the
114
+ * hub isn't answering); `{ ok, version }` otherwise — `version` is `undefined`
115
+ * when the body has no string `version` field (a very old hub, or a non-JSON
116
+ * body), which the caller treats as a mismatch. 1.5s timeout, mirroring
117
+ * {@link defaultProbeHealth}.
118
+ */
119
+ async function defaultProbeHealthVersion(
120
+ port: number,
121
+ ): Promise<{ ok: boolean; version?: string } | null> {
122
+ try {
123
+ const res = await fetch(`http://127.0.0.1:${port}/health`, {
124
+ signal: AbortSignal.timeout(1500),
125
+ });
126
+ let version: string | undefined;
127
+ try {
128
+ const body = (await res.json()) as unknown;
129
+ if (body && typeof body === "object" && "version" in body) {
130
+ const v = (body as { version?: unknown }).version;
131
+ if (typeof v === "string" && v.length > 0) version = v;
132
+ }
133
+ } catch {
134
+ // Non-JSON body → no version. Leave `version` undefined (→ mismatch).
135
+ }
136
+ return version !== undefined ? { ok: res.ok, version } : { ok: res.ok };
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
100
142
  export const defaultHubUnitDeps: HubUnitDeps = {
101
143
  ...defaultManagedUnitDeps,
102
144
  probeHealth: defaultProbeHealth,
145
+ probeHealthVersion: defaultProbeHealthVersion,
103
146
  portListening: defaultPortListening,
104
147
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
105
148
  };
@@ -157,6 +200,16 @@ export function isHubUnitInstalled(deps: HubUnitDeps): boolean {
157
200
  * Is a service manager (systemd / launchd) available on this platform at all?
158
201
  * macOS → launchctl; Linux → systemctl. A box with neither (a bare container,
159
202
  * an init-less host) has no manager — the foreground-`serve`-only path (R19/D1).
203
+ *
204
+ * NOTE: production `deps.which` is `Bun.which`, which resolves against the
205
+ * process PATH. This ASSUMES `launchctl` (`/bin/launchctl`) / `systemctl` are on
206
+ * the PATH — true on any normal macOS / systemd box. A deliberately stripped
207
+ * PATH (a `nix develop` shell that omits `/bin`, a minimal CI image) would make
208
+ * `which` return null and misclassify a genuinely launchd-managed hub as
209
+ * not-unit-managed. The #590 version-check then degrades to the "stop it
210
+ * yourself" path rather than restarting the unit — a safe (never-kill)
211
+ * degradation, but worth knowing if a dev sees not-unit-managed on a box that
212
+ * clearly runs the hub under launchd.
160
213
  */
161
214
  export function hasServiceManager(deps: HubUnitDeps): boolean {
162
215
  if (deps.platform === "darwin") return deps.which("launchctl") !== null;
@@ -341,6 +394,209 @@ export function restartHubUnit(deps: HubUnitDeps): HubUnitManagerOpResult {
341
394
  return { outcome: "ok", messages: [] };
342
395
  }
343
396
 
397
+ /**
398
+ * Outcome of {@link ensureHubVersionMatches} (#590).
399
+ */
400
+ export type HubVersionOutcome =
401
+ /** The running hub's version matched the installed version — no action. */
402
+ | "match"
403
+ /** Hub wasn't answering `/health` at all — nothing to compare (no-op). */
404
+ | "not-running"
405
+ /**
406
+ * Versions mismatched, the hub is unit-managed, the unit was restarted, and
407
+ * the running version now matches the installed version. The zombie was
408
+ * cleared.
409
+ */
410
+ | "restarted"
411
+ /**
412
+ * Versions mismatched and the hub is unit-managed, but after the (single)
413
+ * restart the running version STILL doesn't match — e.g. a bun-linked
414
+ * checkout on a feature branch whose package.json version trails the running
415
+ * code, or a restart that adopted yet-another stale build. We restart at most
416
+ * once and then continue rather than loop (the restart-loop guard).
417
+ */
418
+ | "still-mismatched"
419
+ /**
420
+ * Versions mismatched but the running hub is NOT unit-managed (a legacy
421
+ * detached pid, or a dev `bun run serve` in a terminal, or no service
422
+ * manager at all). We do NOT kill it blindly — we surface the mismatch +
423
+ * an actionable message and stop.
424
+ */
425
+ | "not-unit-managed"
426
+ /**
427
+ * Versions mismatched, the hub is unit-managed, but the restart command
428
+ * itself failed (the manager rejected it). Surface the manager's error.
429
+ */
430
+ | "restart-failed";
431
+
432
+ export interface EnsureHubVersionMatchesResult {
433
+ outcome: HubVersionOutcome;
434
+ /** The running hub's reported version (undefined when it had no version field / wasn't running). */
435
+ runningVersion?: string;
436
+ /** The installed package version we compared against. */
437
+ installedVersion: string;
438
+ /** Human-readable lines the caller should surface (mismatch notice, actionable hints). */
439
+ messages: string[];
440
+ }
441
+
442
+ export interface EnsureHubVersionMatchesOpts {
443
+ /** The installed package version (the caller reads its own `package.json`). */
444
+ installedVersion: string;
445
+ /** Hub port to probe (default 1939). */
446
+ port?: number;
447
+ /** Injectable deps (defaults to production). */
448
+ deps?: HubUnitDeps;
449
+ /** Readiness budget after a restart, in ms (default 15s). */
450
+ readyTimeoutMs?: number;
451
+ /** Poll interval for the post-restart re-probe, in ms (default 250). */
452
+ readyPollMs?: number;
453
+ log?: (line: string) => void;
454
+ }
455
+
456
+ /**
457
+ * Version-check-and-restart at a hub adoption point (#590).
458
+ *
459
+ * The field bug: a freshly-installed hub (e.g. 0.6.4-rc.9) adopts an
460
+ * arbitrarily-stale RUNNING hub (0.5.14-rc.4) merely because it answers
461
+ * `/health` on 1939 — a zombie LaunchAgent survives `rm -rf ~/.parachute`, and
462
+ * everything downstream (tunnel, wizard, vault install) then binds to month-old
463
+ * code running against a directory deleted out from under it.
464
+ *
465
+ * This helper closes that edge. Given the INSTALLED package version (the caller
466
+ * reads its own `package.json` at runtime), it:
467
+ * 1. Probes `/health` for the RUNNING version. Not answering → `not-running`
468
+ * (nothing to adopt; the caller's bringup path handles starting it).
469
+ * 2. Version matches → `match` (today's behavior, no extra restart).
470
+ * 3. Version mismatches (INCLUDING a hub with no `version` field — a very old
471
+ * hub — which reads as "undefined ≠ installed"):
472
+ * a. If the running hub is NOT unit-managed (no manager / no unit
473
+ * installed) → `not-unit-managed`. We do NOT kill it blindly: a
474
+ * detached legacy pid or a dev `bun run serve` may be the operator's,
475
+ * and KeepAlive-less processes aren't ours to reap. Surface an
476
+ * actionable message and stop.
477
+ * b. If it IS unit-managed → restart the unit ONCE
478
+ * ({@link restartHubUnit}), then re-probe `/health` until the version
479
+ * matches or the timeout elapses:
480
+ * - now matches → `restarted` (zombie cleared).
481
+ * - still mismatched → `still-mismatched` (restart-loop guard: we
482
+ * restart at most once; a bun-linked branch checkout whose
483
+ * package.json trails the code stays here — warn + continue, do
484
+ * not loop).
485
+ * - restart command failed → `restart-failed`.
486
+ *
487
+ * The CALLER decides whether a given outcome is fatal. `init` and the expose
488
+ * chains both want: `match`/`not-running`/`restarted` → continue silently-ish;
489
+ * `not-unit-managed`/`still-mismatched`/`restart-failed` → warn loudly (and, for
490
+ * init, optionally bail) so a brand-new tunnel never wires to a zombie.
491
+ *
492
+ * Everything is behind the {@link HubUnitDeps} seam — no real launchctl /
493
+ * systemctl / HTTP call in tests.
494
+ */
495
+ export async function ensureHubVersionMatches(
496
+ opts: EnsureHubVersionMatchesOpts,
497
+ ): Promise<EnsureHubVersionMatchesResult> {
498
+ const deps = opts.deps ?? defaultHubUnitDeps;
499
+ const port = opts.port ?? HUB_UNIT_DEFAULT_PORT;
500
+ const installedVersion = opts.installedVersion;
501
+ const readyTimeoutMs = opts.readyTimeoutMs ?? 15_000;
502
+ const readyPollMs = opts.readyPollMs ?? 250;
503
+ const log = opts.log ?? (() => {});
504
+
505
+ const probe = await deps.probeHealthVersion(port);
506
+ if (probe === null) {
507
+ // Hub isn't answering — nothing to compare. The caller's bringup path owns
508
+ // starting it; this helper is a no-op here.
509
+ return { outcome: "not-running", installedVersion, messages: [] };
510
+ }
511
+
512
+ const runningVersion = probe.version;
513
+ if (runningVersion === installedVersion) {
514
+ // Exactly today's behavior — versions agree, no extra restart.
515
+ return { outcome: "match", runningVersion, installedVersion, messages: [] };
516
+ }
517
+
518
+ // Mismatch (includes the no-`version`-field very-old-hub case → undefined).
519
+ const runningLabel = runningVersion ?? "an older version (no version field)";
520
+
521
+ // Is this hub one we can restart through the manager? If there's no manager,
522
+ // or no unit installed, the running hub is a legacy detached pid / a dev
523
+ // foreground `serve` — NOT ours to reap. Surface + stop (do not kill blindly).
524
+ if (!hasServiceManager(deps) || !isHubUnitInstalled(deps)) {
525
+ return {
526
+ outcome: "not-unit-managed",
527
+ runningVersion,
528
+ installedVersion,
529
+ messages: [
530
+ `⚠ the running hub is ${runningLabel} but ${installedVersion} is installed.`,
531
+ " The running hub is NOT managed by a Parachute service unit (a detached process or a foreground `parachute serve` / `bun src/cli.ts serve`), so it won't be restarted automatically.",
532
+ ` Stop it yourself (find it with \`lsof -ti :${port}\` then \`kill <pid>\`, or quit the foreground \`parachute serve\` / \`bun src/cli.ts serve\` on a dev checkout), then re-run so the new code is adopted.`,
533
+ ],
534
+ };
535
+ }
536
+
537
+ // Unit-managed mismatch: restart the unit ONCE to pick up the new code.
538
+ log(
539
+ `⚠ the running hub is ${runningLabel} but ${installedVersion} is installed — restarting the hub unit to pick up the new code.`,
540
+ );
541
+ const restart = restartHubUnit(deps);
542
+ if (restart.outcome !== "ok") {
543
+ return {
544
+ outcome: "restart-failed",
545
+ runningVersion,
546
+ installedVersion,
547
+ messages: [
548
+ `⚠ the running hub is ${runningLabel} but ${installedVersion} is installed, and the hub unit restart failed.`,
549
+ ...restart.messages,
550
+ ],
551
+ };
552
+ }
553
+
554
+ // Builders for the two terminal outcomes of the post-restart re-probe loop.
555
+ const restartedResult = (v: string): EnsureHubVersionMatchesResult => ({
556
+ outcome: "restarted",
557
+ runningVersion: v,
558
+ installedVersion,
559
+ messages: [`✓ hub unit restarted; now running ${installedVersion}.`],
560
+ });
561
+ const stillMismatchedResult = (last: string | undefined): EnsureHubVersionMatchesResult => {
562
+ const reports = last ? ` (reports ${last})` : "";
563
+ return {
564
+ outcome: "still-mismatched",
565
+ ...(last !== undefined ? { runningVersion: last } : {}),
566
+ installedVersion,
567
+ messages: [
568
+ `⚠ restarted the hub unit, but it is still not reporting ${installedVersion}${reports}.`,
569
+ " This can happen with a bun-linked checkout on a feature branch whose package.json version trails the running code.",
570
+ ` Continuing — verify with \`parachute status\` / \`curl http://127.0.0.1:${port}/health\` if the hub should be on a specific version.`,
571
+ ],
572
+ };
573
+ };
574
+
575
+ // Re-probe `/health` until the running version matches the installed version
576
+ // or the readiness budget elapses. Restart-loop guard: we restart AT MOST
577
+ // once — if it still mismatches after this single restart (e.g. a bun-linked
578
+ // checkout on a branch), we warn + continue rather than looping.
579
+ const deadline = Date.now() + readyTimeoutMs;
580
+ for (;;) {
581
+ const after = await deps.probeHealthVersion(port);
582
+ if (after !== null && after.version === installedVersion) {
583
+ return restartedResult(installedVersion);
584
+ }
585
+ if (Date.now() >= deadline) {
586
+ // Report the last-observed (still-stale) version if the hub came back.
587
+ return stillMismatchedResult(after?.version ?? runningVersion);
588
+ }
589
+ if (readyPollMs > 0) await deps.sleep(readyPollMs);
590
+ else break;
591
+ }
592
+ // readyPollMs === 0 fast-path: one more probe, then settle.
593
+ const finalProbe = await deps.probeHealthVersion(port);
594
+ if (finalProbe !== null && finalProbe.version === installedVersion) {
595
+ return restartedResult(installedVersion);
596
+ }
597
+ return stillMismatchedResult(finalProbe?.version ?? runningVersion);
598
+ }
599
+
344
600
  /**
345
601
  * Run-state of the hub UNIT as reported by the platform manager (design §6.4).
346
602
  * This is the manager's view — NOT a liveness verdict. The hub answering
@@ -623,14 +879,8 @@ export function hubUnitMessages(): ManagedUnitMessages {
623
879
  };
624
880
  }
625
881
 
626
- /**
627
- * Sane default PATH for the hub unit when the caller doesn't supply one: bun's
628
- * global bin first (so supervised children resolve a bun-linked binary on cold
629
- * boot, R20), then the usual system dirs.
630
- */
631
- function defaultUnitPath(bunInstall: string): string {
632
- return `${bunInstall}/bin:/usr/local/bin:/usr/bin:/bin`;
633
- }
882
+ // The hub-unit PATH is built by `enrichedUnitPath` (src/spawn-path.ts) so this
883
+ // init-bringup path and the `migrate --to-supervised` cutover path can't drift.
634
884
 
635
885
  /**
636
886
  * Build + install + start the hub unit, then wait for hub readiness (design
@@ -652,7 +902,7 @@ export async function installAndStartHubUnit(
652
902
  const deps = opts.deps ?? defaultHubUnitDeps;
653
903
  const port = opts.port ?? HUB_UNIT_DEFAULT_PORT;
654
904
  const bunInstall = opts.bunInstall ?? `${deps.homeDir()}/.bun`;
655
- const path = opts.path ?? defaultUnitPath(bunInstall);
905
+ const path = opts.path ?? enrichedUnitPath(bunInstall, deps.homeDir(), deps.platform);
656
906
  const logPath = opts.logPath ?? `${opts.parachuteHome}/hub/logs/hub.log`;
657
907
  const log = opts.log ?? (() => {});
658
908
 
package/src/invites.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * One-time, expiring invite links (design
3
+ * 2026-06-04-individual-users-and-vault-operations.md §7). An admin issues
4
+ * a link; the recipient opens `/account/setup/<token>`, picks a username +
5
+ * password, and gets their OWN freshly-provisioned vault as owner.
6
+ *
7
+ * Token model — mirrors `auth-codes.ts` (single-use + expiring + sha256-at-
8
+ * rest), with the key difference that invites are LONGER-LIVED (default 7
9
+ * days vs the 60s auth-code TTL), so the row stores **sha256(token)**, never
10
+ * the raw value. The raw token is returned exactly ONCE from `issueInvite`
11
+ * and never persisted — a DB read alone can't replay the link (the same
12
+ * posture as the bootstrap token). Lookup hashes the URL token and selects
13
+ * by the hash.
14
+ *
15
+ * What an invite pre-authorizes: creating exactly ONE account + the one
16
+ * named/created vault at the baked-in role — NEVER host:admin, NEVER another
17
+ * vault. The redeemed user inherits only the `user_vaults` row's authority.
18
+ * The redemption flow (`/account/setup/<token>` in hub-server.ts) enforces
19
+ * the createUser-then-stamp ordering so a createUser failure leaves the
20
+ * invite re-usable.
21
+ *
22
+ * Single-use is enforced by stamping `used_at` on redemption — a replay
23
+ * attempt sees the row with `used_at` set and `redeemInvite` throws
24
+ * `InviteUsedError`. Revocation is a separate `revoked_at` stamp the admin
25
+ * sets before redemption. Expiry is enforced at redeem-time.
26
+ */
27
+ import type { Database } from "bun:sqlite";
28
+ import { createHash, randomBytes } from "node:crypto";
29
+
30
+ /** Default invite lifetime — long enough to deliver out-of-band (no email), short enough to bound a leaked link. */
31
+ export const DEFAULT_INVITE_TTL_SECONDS = 7 * 24 * 60 * 60;
32
+
33
+ /** Token entropy in bytes — 256 bits, matching the bootstrap / auth-code token. */
34
+ const INVITE_TOKEN_BYTES = 32;
35
+
36
+ export type InviteStatus = "pending" | "redeemed" | "expired" | "revoked";
37
+
38
+ export interface Invite {
39
+ /** sha256(raw token), hex. The raw token is never stored. */
40
+ tokenHash: string;
41
+ createdBy: string | null;
42
+ /** Pinned vault name, or null when the redeemer names their own vault. */
43
+ vaultName: string | null;
44
+ /** `user_vaults.role` granted on redemption (`'write'` = owner). */
45
+ role: string;
46
+ /** Whether redemption provisions a NEW vault for the redeemer. */
47
+ provisionVault: boolean;
48
+ /** `'internal' | 'off'` mirror knob for the provisioned vault, or null. */
49
+ defaultMirror: string | null;
50
+ expiresAt: string;
51
+ usedAt: string | null;
52
+ redeemedUserId: string | null;
53
+ revokedAt: string | null;
54
+ createdAt: string;
55
+ }
56
+
57
+ export class InviteNotFoundError extends Error {
58
+ constructor() {
59
+ super("invite not found");
60
+ this.name = "InviteNotFoundError";
61
+ }
62
+ }
63
+
64
+ export class InviteExpiredError extends Error {
65
+ constructor() {
66
+ super("invite has expired");
67
+ this.name = "InviteExpiredError";
68
+ }
69
+ }
70
+
71
+ export class InviteUsedError extends Error {
72
+ constructor() {
73
+ super("invite has already been redeemed");
74
+ this.name = "InviteUsedError";
75
+ }
76
+ }
77
+
78
+ export class InviteRevokedError extends Error {
79
+ constructor() {
80
+ super("invite has been revoked");
81
+ this.name = "InviteRevokedError";
82
+ }
83
+ }
84
+
85
+ interface Row {
86
+ token: string;
87
+ created_by: string | null;
88
+ vault_name: string | null;
89
+ role: string;
90
+ provision_vault: number;
91
+ default_mirror: string | null;
92
+ expires_at: string;
93
+ used_at: string | null;
94
+ redeemed_user_id: string | null;
95
+ revoked_at: string | null;
96
+ created_at: string;
97
+ }
98
+
99
+ function rowToInvite(r: Row): Invite {
100
+ return {
101
+ tokenHash: r.token,
102
+ createdBy: r.created_by,
103
+ vaultName: r.vault_name,
104
+ role: r.role,
105
+ provisionVault: r.provision_vault === 1,
106
+ defaultMirror: r.default_mirror,
107
+ expiresAt: r.expires_at,
108
+ usedAt: r.used_at,
109
+ redeemedUserId: r.redeemed_user_id,
110
+ revokedAt: r.revoked_at,
111
+ createdAt: r.created_at,
112
+ };
113
+ }
114
+
115
+ /** sha256 of the raw token, hex — the at-rest representation + PK. */
116
+ export function hashInviteToken(rawToken: string): string {
117
+ return createHash("sha256").update(rawToken).digest("hex");
118
+ }
119
+
120
+ /** Derive an invite's status from its stamps + the current time. */
121
+ export function inviteStatus(invite: Invite, now: Date = new Date()): InviteStatus {
122
+ if (invite.revokedAt) return "revoked";
123
+ if (invite.usedAt) return "redeemed";
124
+ if (now.getTime() > new Date(invite.expiresAt).getTime()) return "expired";
125
+ return "pending";
126
+ }
127
+
128
+ export interface IssueInviteOpts {
129
+ /** Admin user id issuing the invite (audit). */
130
+ createdBy: string;
131
+ /** Pinned vault name; omit/null to let the redeemer name their own. */
132
+ vaultName?: string | null;
133
+ /** `user_vaults` role granted on redemption. Default `'write'` (owner). */
134
+ role?: string;
135
+ /** Provision a new vault on redemption. Default `true` (the primary flow). */
136
+ provisionVault?: boolean;
137
+ /** `'internal' | 'off'` mirror knob for the provisioned vault. */
138
+ defaultMirror?: string | null;
139
+ /** Lifetime in seconds. Default {@link DEFAULT_INVITE_TTL_SECONDS} (7 days). */
140
+ expiresInSeconds?: number;
141
+ now?: () => Date;
142
+ }
143
+
144
+ export interface IssuedInvite {
145
+ /**
146
+ * The raw token — returned EXACTLY ONCE here and never persisted. The
147
+ * caller builds the redemption URL from it (`/account/setup/<rawToken>`)
148
+ * and shows it once; the hub keeps only `sha256(rawToken)`.
149
+ */
150
+ rawToken: string;
151
+ invite: Invite;
152
+ }
153
+
154
+ /**
155
+ * Mint an invite: generate a 256-bit raw token, store its sha256, return the
156
+ * raw token once. The row's PK is the hash, so a DB compromise can't replay
157
+ * the link.
158
+ */
159
+ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
160
+ const rawToken = randomBytes(INVITE_TOKEN_BYTES).toString("base64url");
161
+ const tokenHash = hashInviteToken(rawToken);
162
+ const now = opts.now?.() ?? new Date();
163
+ const createdAt = now.toISOString();
164
+ const ttl = opts.expiresInSeconds ?? DEFAULT_INVITE_TTL_SECONDS;
165
+ const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
166
+ const role = opts.role ?? "write";
167
+ const vaultName = opts.vaultName ?? null;
168
+ const provisionVault = opts.provisionVault ?? true;
169
+ const defaultMirror = opts.defaultMirror ?? null;
170
+
171
+ db.prepare(
172
+ `INSERT INTO invites
173
+ (token, created_by, vault_name, role, provision_vault, default_mirror,
174
+ expires_at, used_at, redeemed_user_id, revoked_at, created_at)
175
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
176
+ ).run(
177
+ tokenHash,
178
+ opts.createdBy,
179
+ vaultName,
180
+ role,
181
+ provisionVault ? 1 : 0,
182
+ defaultMirror,
183
+ expiresAt,
184
+ createdAt,
185
+ );
186
+
187
+ return {
188
+ rawToken,
189
+ invite: {
190
+ tokenHash,
191
+ createdBy: opts.createdBy,
192
+ vaultName,
193
+ role,
194
+ provisionVault,
195
+ defaultMirror,
196
+ expiresAt,
197
+ usedAt: null,
198
+ redeemedUserId: null,
199
+ revokedAt: null,
200
+ createdAt,
201
+ },
202
+ };
203
+ }
204
+
205
+ /** Look up an invite by its raw (URL) token. Hashes then selects. */
206
+ export function findInviteByRawToken(db: Database, rawToken: string): Invite | null {
207
+ const hash = hashInviteToken(rawToken);
208
+ const row = db.query<Row, [string]>("SELECT * FROM invites WHERE token = ?").get(hash);
209
+ return row ? rowToInvite(row) : null;
210
+ }
211
+
212
+ /** Look up an invite by its sha256 hash (admin DELETE/revoke by id). */
213
+ export function findInviteByHash(db: Database, tokenHash: string): Invite | null {
214
+ const row = db.query<Row, [string]>("SELECT * FROM invites WHERE token = ?").get(tokenHash);
215
+ return row ? rowToInvite(row) : null;
216
+ }
217
+
218
+ /** List every invite, newest first, with derived status. */
219
+ export function listInvites(
220
+ db: Database,
221
+ now: Date = new Date(),
222
+ ): (Invite & { status: InviteStatus })[] {
223
+ const rows = db.query<Row, []>("SELECT * FROM invites ORDER BY created_at DESC").all();
224
+ return rows.map((r) => {
225
+ const invite = rowToInvite(r);
226
+ return { ...invite, status: inviteStatus(invite, now) };
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Validate an invite for redemption WITHOUT consuming it. Throws on every
232
+ * not-redeemable branch (not-found / expired / used / revoked). Returns the
233
+ * invite when it's redeemable. The redemption handler calls this FIRST (so a
234
+ * bad token is rejected before any account/vault work), then does
235
+ * createUser, then `consumeInvite` AFTER the user row commits.
236
+ */
237
+ export function assertInviteRedeemable(
238
+ db: Database,
239
+ rawToken: string,
240
+ now: Date = new Date(),
241
+ ): Invite {
242
+ const invite = findInviteByRawToken(db, rawToken);
243
+ if (!invite) throw new InviteNotFoundError();
244
+ // Revoked + used are terminal regardless of clock; check them before expiry
245
+ // so a revoked-then-expired invite reports the more specific reason.
246
+ if (invite.revokedAt) throw new InviteRevokedError();
247
+ if (invite.usedAt) throw new InviteUsedError();
248
+ if (now.getTime() > new Date(invite.expiresAt).getTime()) {
249
+ throw new InviteExpiredError();
250
+ }
251
+ return invite;
252
+ }
253
+
254
+ /**
255
+ * Mark an invite consumed — stamp `used_at` + `redeemed_user_id`. Called
256
+ * Called within the account-creation transaction (or after a committed user
257
+ * row). Single-use + not-revoked is enforced by the
258
+ * `used_at IS NULL AND revoked_at IS NULL` guard in the UPDATE: a racing
259
+ * second redeem — or a concurrent revoke — updates zero rows and the caller
260
+ * treats that as already-consumed/revoked. Race-safe because sqlite
261
+ * serializes writes.
262
+ *
263
+ * Returns `true` if THIS call consumed the invite, `false` otherwise.
264
+ */
265
+ export function consumeInvite(
266
+ db: Database,
267
+ tokenHash: string,
268
+ redeemedUserId: string,
269
+ now: Date = new Date(),
270
+ ): boolean {
271
+ const res = db
272
+ .prepare(
273
+ "UPDATE invites SET used_at = ?, redeemed_user_id = ? WHERE token = ? AND used_at IS NULL AND revoked_at IS NULL",
274
+ )
275
+ .run(now.toISOString(), redeemedUserId, tokenHash);
276
+ return res.changes > 0;
277
+ }
278
+
279
+ /**
280
+ * Revoke a pending invite (admin DELETE). Stamps `revoked_at` only when the
281
+ * invite isn't already used or revoked. Returns `true` if this call revoked
282
+ * it, `false` if it was already consumed/revoked or not found.
283
+ */
284
+ export function revokeInvite(db: Database, tokenHash: string, now: Date = new Date()): boolean {
285
+ const res = db
286
+ .prepare(
287
+ "UPDATE invites SET revoked_at = ? WHERE token = ? AND used_at IS NULL AND revoked_at IS NULL",
288
+ )
289
+ .run(now.toISOString(), tokenHash);
290
+ return res.changes > 0;
291
+ }