@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
@@ -35,12 +35,18 @@
35
35
  import { spawnSync } from "node:child_process";
36
36
  import { join } from "node:path";
37
37
  import { fileURLToPath } from "node:url";
38
+ import pkg from "../../package.json" with { type: "json" };
38
39
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
39
40
  import { type ExposeState, readExposeState } from "../expose-state.ts";
40
41
  import { type EnsureHubOpts, HUB_DEFAULT_PORT, HUB_SVC, readHubPort } from "../hub-control.ts";
41
42
  import { hubDbPath, openHubDb } from "../hub-db.ts";
42
43
  import { deriveHubOrigin } from "../hub-origin.ts";
43
- import { ensureHubUnit, installAndStartHubUnit } from "../hub-unit.ts";
44
+ import {
45
+ type EnsureHubVersionMatchesResult,
46
+ ensureHubUnit,
47
+ ensureHubVersionMatches,
48
+ installAndStartHubUnit,
49
+ } from "../hub-unit.ts";
44
50
  import { issueOperatorToken, readOperatorTokenFile } from "../operator-token.ts";
45
51
  import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
46
52
  import { findService, readManifestLenient } from "../services-manifest.ts";
@@ -81,6 +87,18 @@ export interface InitOpts {
81
87
  * Design §3.3 (init row), §4.1/§4.2, appendix (c).
82
88
  */
83
89
  ensureHub?: (opts: EnsureHubOpts) => Promise<{ pid: number; port: number; started: boolean }>;
90
+ /**
91
+ * Test seam: version-check-and-restart at the hub adoption point (#590).
92
+ * After init confirms a hub is answering on the canonical port, it compares
93
+ * the RUNNING hub's `/health` version against this installed package version;
94
+ * on mismatch it restarts the managed unit (once) so a freshly-installed hub
95
+ * never adopts a stale zombie. Production wires `ensureHubVersionMatches`;
96
+ * tests stub it to assert the call without touching launchctl / the live hub.
97
+ */
98
+ ensureHubVersion?: (ctx: {
99
+ port: number;
100
+ log: (line: string) => void;
101
+ }) => Promise<EnsureHubVersionMatchesResult>;
84
102
  /**
85
103
  * Test seam: guarantee an operator token exists once the hub is up (design
86
104
  * §3.1 / §3.3). Production reads `operator.token`; if absent AND a hub user
@@ -162,6 +180,17 @@ export interface InitOpts {
162
180
  * already known so there's no question to ask).
163
181
  */
164
182
  noWizardPrompt?: boolean;
183
+ /**
184
+ * Test seam: probe the running hub for its first-claim bootstrap token
185
+ * (hub#576). Production hits `GET http://127.0.0.1:<port>/admin/setup` with
186
+ * `accept: application/json` and reads `bootstrapToken` (the hub returns it
187
+ * only to loopback callers). Returns the token string when the hub is in
188
+ * wizard mode (no admin yet), or `undefined` when there's no token to surface
189
+ * (admin already exists, or the probe failed). Init uses it to print the
190
+ * token next to the admin URL when the hub is publicly exposed, so a browser
191
+ * operator can claim the box without digging through the hub logs.
192
+ */
193
+ fetchBootstrapTokenImpl?: (loopbackUrl: string) => Promise<string | undefined>;
165
194
  }
166
195
 
167
196
  /**
@@ -461,6 +490,41 @@ async function defaultRunCliWizard(opts: {
461
490
  return await runCliWizard(opts);
462
491
  }
463
492
 
493
+ /**
494
+ * Default impl for the bootstrap-token probe (hub#576). GETs the loopback hub's
495
+ * `/admin/setup` with `accept: application/json` and returns the `bootstrapToken`
496
+ * the hub hands to loopback callers. Returns `undefined` on any failure (hub
497
+ * not answering, no token because an admin already exists, malformed body) —
498
+ * surfacing the token is a convenience, never a hard dependency of init.
499
+ */
500
+ async function defaultFetchBootstrapToken(loopbackUrl: string): Promise<string | undefined> {
501
+ // Debug breadcrumb (gated on PARACHUTE_DEBUG so it never clutters the normal
502
+ // operator output). When the token doesn't print in the field, this tells a
503
+ // troubleshooter WHY — hub didn't answer, returned non-200, or the body
504
+ // carried no token (already-claimed / no-gate) — instead of a silent nothing.
505
+ const debug = (msg: string): void => {
506
+ if (process.env.PARACHUTE_DEBUG) console.error(`[init][bootstrap-token] ${msg}`);
507
+ };
508
+ try {
509
+ const res = await fetch(`${loopbackUrl.replace(/\/+$/, "")}/admin/setup`, {
510
+ headers: { accept: "application/json" },
511
+ });
512
+ if (!res.ok) {
513
+ debug(`probe returned ${res.status}; not printing a token`);
514
+ return undefined;
515
+ }
516
+ const body = (await res.json()) as { bootstrapToken?: unknown };
517
+ if (typeof body.bootstrapToken === "string" && body.bootstrapToken.length > 0) {
518
+ return body.bootstrapToken;
519
+ }
520
+ debug("probe ok but no bootstrapToken in body (already-claimed or no gate active)");
521
+ return undefined;
522
+ } catch (err) {
523
+ debug(`probe failed: ${err instanceof Error ? err.message : String(err)}`);
524
+ return undefined;
525
+ }
526
+ }
527
+
464
528
  /**
465
529
  * Prompt for the wizard-choice question (hub#168 Cut 4). Returns the
466
530
  * picked option, or `undefined` if the operator quit. Default is
@@ -536,6 +600,18 @@ export async function init(opts: InitOpts = {}): Promise<number> {
536
600
  // spawn). The `ensureHub` seam is preserved for tests (and the return shape is
537
601
  // unchanged); only the production default flipped.
538
602
  const ensureHub = opts.ensureHub ?? defaultEnsureHubViaUnit;
603
+ // #590: after the hub is confirmed up, compare its RUNNING version to the
604
+ // installed package version and restart the managed unit on mismatch, so a
605
+ // freshly-installed hub never adopts a stale zombie that merely answers
606
+ // /health. Injectable for tests.
607
+ const ensureHubVersion =
608
+ opts.ensureHubVersion ??
609
+ ((ctx) =>
610
+ ensureHubVersionMatches({
611
+ installedVersion: pkg.version,
612
+ port: ctx.port,
613
+ log: ctx.log,
614
+ }));
539
615
  const guaranteeOperatorToken = opts.guaranteeOperatorToken ?? defaultGuaranteeOperatorToken;
540
616
  const readExposeStateFn = opts.readExposeStateFn ?? (() => readExposeState());
541
617
  const isTty = opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -547,6 +623,7 @@ export async function init(opts: InitOpts = {}): Promise<number> {
547
623
  const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
548
624
  const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
549
625
  const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
626
+ const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
550
627
 
551
628
  log("Parachute init — getting your hub set up.");
552
629
  log("");
@@ -593,6 +670,38 @@ export async function init(opts: InitOpts = {}): Promise<number> {
593
670
  // overridden, so the fallback is almost always correct.
594
671
  if (hubPort === undefined) hubPort = HUB_DEFAULT_PORT;
595
672
 
673
+ // Step 1.25 (#590): the hub answered /health, but is it the version we just
674
+ // installed? A zombie LaunchAgent survives `rm -rf ~/.parachute`, so a brand-
675
+ // new install can adopt month-old code that merely keeps the port. Compare the
676
+ // RUNNING version to the installed package version; on mismatch, restart the
677
+ // managed unit (once) so the tunnel/wizard/vault-install downstream bind to the
678
+ // NEW code. A non-unit-managed hub (legacy detached pid / dev `bun run serve`)
679
+ // is NOT killed — we surface the mismatch + an actionable message and bail so
680
+ // the operator decides. A still-mismatched-after-restart (bun-linked branch)
681
+ // warns + continues rather than looping.
682
+ try {
683
+ const versionResult = await ensureHubVersion({ port: hubPort, log });
684
+ for (const m of versionResult.messages) log(m);
685
+ if (versionResult.outcome === "not-unit-managed") {
686
+ // We can't safely take over a hub we don't own. Stop here so init doesn't
687
+ // wire a fresh tunnel + credentials to a stale runtime (the #590 field bug).
688
+ log("");
689
+ log("Resolve the version mismatch above, then re-run `parachute init`.");
690
+ return 1;
691
+ }
692
+ if (versionResult.outcome === "restart-failed") {
693
+ log("");
694
+ log("The hub service manager rejected the restart command.");
695
+ log("Try checking the logs:");
696
+ log(" parachute logs hub");
697
+ return 1;
698
+ }
699
+ // `match` / `not-running` / `restarted` / `still-mismatched` → continue.
700
+ } catch (err) {
701
+ // A version-check failure must never block init — degrade to a note.
702
+ log(`note: hub version check skipped (${err instanceof Error ? err.message : String(err)})`);
703
+ }
704
+
596
705
  // Step 1.5: guarantee an operator token exists (design §3.1 / §3.3). Under
597
706
  // the unified model every per-module verb is an authenticated module-ops
598
707
  // call, so the steady-state operator needs an `operator.token` on disk — the
@@ -619,7 +728,12 @@ export async function init(opts: InitOpts = {}): Promise<number> {
619
728
  exposeTailnetImpl,
620
729
  exposeCloudflareImpl,
621
730
  });
622
- if (code !== 0) return code;
731
+ // hub#565: exposure is an ENHANCEMENT, not a prerequisite. A failed
732
+ // expose chain must NOT abort init — warn + print the exact retry
733
+ // command, then fall through to vault install + the admin-URL/wizard
734
+ // handoff on the loopback URL. Init's contract is hub up → vault module
735
+ // installed → admin URL → wizard, ALWAYS.
736
+ if (code !== 0) warnExposeFailedContinue(opts.exposeChoice, log);
623
737
  // Refresh state — the chain may have brought up an FQDN.
624
738
  exposeState = readExposeStateFn();
625
739
  } else if (opts.noExposePrompt) {
@@ -642,7 +756,9 @@ export async function init(opts: InitOpts = {}): Promise<number> {
642
756
  exposeTailnetImpl,
643
757
  exposeCloudflareImpl,
644
758
  });
645
- if (code !== 0) return code;
759
+ // hub#565: warn + continue on a failed expose chain rather than
760
+ // aborting init (same contract as the non-interactive branch above).
761
+ if (code !== 0) warnExposeFailedContinue(picked, log);
646
762
  exposeState = readExposeStateFn();
647
763
  }
648
764
  }
@@ -704,6 +820,19 @@ export async function init(opts: InitOpts = {}): Promise<number> {
704
820
  return 1;
705
821
  }
706
822
 
823
+ // hub#576: when the hub is publicly exposed AND still in wizard mode (no
824
+ // admin yet), the admin URL above is a public FQDN — whoever opens it first
825
+ // claims the box. Surface the first-claim bootstrap token in the operator's
826
+ // OWN terminal so the wizard's account step demands proof of box access. We
827
+ // only probe + print on the public-FQDN path: a loopback-only install needs
828
+ // no token (reaching 127.0.0.1 already proves access), and the CLI-wizard
829
+ // path picks the token up transparently over loopback (above). The probe is
830
+ // best-effort — a failure (or an already-claimed hub) just prints nothing.
831
+ let bootstrapToken: string | undefined;
832
+ if (exposeState?.canonicalFqdn) {
833
+ bootstrapToken = await fetchBootstrapTokenImpl(`http://127.0.0.1:${hubPort}`);
834
+ }
835
+
707
836
  log("");
708
837
  if (hasVault) {
709
838
  log("Looks good — your hub is up and a vault is configured.");
@@ -713,6 +842,24 @@ export async function init(opts: InitOpts = {}): Promise<number> {
713
842
  log("");
714
843
  log(` ${adminUrl}`);
715
844
  log("");
845
+ if (bootstrapToken) {
846
+ log("Because this hub is reachable on the public internet, the wizard asks for a");
847
+ log("one-time bootstrap token before it lets anyone create the admin account —");
848
+ log("so whoever opens the URL first can't claim your hub. Paste this when asked:");
849
+ log("");
850
+ log(` ${bootstrapToken}`);
851
+ log("");
852
+ log("(Valid until the admin is created or the hub restarts. Re-run `parachute init`");
853
+ log(" to mint a fresh one.)");
854
+ log("");
855
+ }
856
+ // hub#565: when we're on the loopback URL (no public exposure active),
857
+ // remind the operator they can expose later. Skipped once an FQDN is up.
858
+ if (!exposeState?.canonicalFqdn) {
859
+ log("(Reachable on this machine. To expose it publicly later, run");
860
+ log(" `parachute expose public --cloudflare` or `parachute expose public --tailnet`.)");
861
+ log("");
862
+ }
716
863
 
717
864
  // Step 4.5: offer the operator the CLI wizard vs. the browser wizard
718
865
  // (hub#168 Cut 4). Aaron's 2026-05-28 directive: "we should be able to
@@ -742,7 +889,13 @@ export async function init(opts: InitOpts = {}): Promise<number> {
742
889
  if (choice === "cli") {
743
890
  log("");
744
891
  log("Launching the CLI wizard. (You can also visit the URL above in a browser any time.)");
745
- return await runCliWizardImpl({ hubUrl: adminUrl.replace(/\/admin\/?$/, ""), log });
892
+ // hub#576: drive the CLI wizard against the LOOPBACK hub, not the public
893
+ // FQDN in `adminUrl`. The wizard runs on this box, so loopback is both
894
+ // correct and what lets the hub hand it the bootstrap token transparently
895
+ // (the loopback-gated GET /admin/setup probe) — the operator never has to
896
+ // copy the token out of the startup logs.
897
+ const cliWizardUrl = `http://127.0.0.1:${hubPort}`;
898
+ return await runCliWizardImpl({ hubUrl: cliWizardUrl, log });
746
899
  }
747
900
 
748
901
  // Step 5: offer to open the browser. Skip in non-TTY shells (CI),
@@ -784,6 +937,32 @@ export async function init(opts: InitOpts = {}): Promise<number> {
784
937
  return 0;
785
938
  }
786
939
 
940
+ /** The exact retry command for a given exposure choice (hub#565 / #566). */
941
+ export function exposeRetryCommand(choice: ExposeChoice): string {
942
+ if (choice === "tailnet") return "parachute expose public --tailnet";
943
+ // `none` never reaches here in practice — `runExposureChoice("none")` always
944
+ // returns 0, so `warnExposeFailedContinue` (the only caller) is never invoked
945
+ // for it. It falls through to the `--cloudflare` branch below; harmless, and
946
+ // spelled out so the fallthrough isn't read as a bug.
947
+ // Cloudflare (and the unreachable `none`): default the bare command to
948
+ // `--cloudflare` so the operator who picked Cloudflare lands in the right
949
+ // provider on retry (bare `parachute expose public` defaults to Tailscale
950
+ // Funnel — hub#566).
951
+ return "parachute expose public --cloudflare";
952
+ }
953
+
954
+ /**
955
+ * hub#565: warn that the exposure chain failed but init is continuing anyway,
956
+ * and print the exact retry command. Exposure is an enhancement, not a
957
+ * prerequisite — init still installs the vault module and hands off to the
958
+ * wizard on the loopback URL.
959
+ */
960
+ function warnExposeFailedContinue(choice: ExposeChoice, log: (line: string) => void): void {
961
+ log("");
962
+ log("⚠ Couldn't finish setting up public access — continuing without it.");
963
+ log(` To expose publicly later, run: ${exposeRetryCommand(choice)}`);
964
+ }
965
+
787
966
  /**
788
967
  * Dispatch the chosen exposure path. Returns the exit code of the
789
968
  * downstream chain. `none` is a no-op (success).