@openparachute/hub 0.5.13 → 0.5.14-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 (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -26,11 +26,19 @@ import {
26
26
  findTunnelByName,
27
27
  routeDns,
28
28
  } from "../cloudflare/tunnel.ts";
29
- import { SERVICES_MANIFEST_PATH } from "../config.ts";
29
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
30
+ import {
31
+ type EnsureHubOpts,
32
+ HUB_DEFAULT_PORT,
33
+ ensureHubRunning,
34
+ readHubPort,
35
+ } from "../hub-control.ts";
36
+ import { deriveHubOrigin } from "../hub-origin.ts";
30
37
  import { type AliveFn, defaultAlive } from "../process-state.ts";
31
38
  import { readManifest } from "../services-manifest.ts";
32
39
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
33
40
  import type { VaultAuthStatus } from "../vault/auth-status.ts";
41
+ import { WELL_KNOWN_DIR } from "../well-known.ts";
34
42
  import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
35
43
 
36
44
  const AUTH_DOC_URL =
@@ -112,6 +120,37 @@ export interface ExposeCloudflareOpts {
112
120
  logPath?: string;
113
121
  /** Override `~/.cloudflared` for tests and `$HOME`-free environments. */
114
122
  cloudflaredHome?: string;
123
+ /**
124
+ * Config root for hub PID / port / log files. Defaults to `~/.parachute`.
125
+ * Threaded into `ensureHubRunning` so cloudflared's ingress target stays
126
+ * in sync with where the hub actually bound.
127
+ */
128
+ configDir?: string;
129
+ /**
130
+ * Override the public hub origin (the `iss` claim baked into the OAuth
131
+ * issuer). Mirrors the Tailscale path — when set, this URL is what the
132
+ * hub advertises rather than the cloudflared hostname.
133
+ */
134
+ hubOrigin?: string;
135
+ /**
136
+ * Overrides for hub lifecycle — primarily for tests. Tests pass
137
+ * `skipHubLifecycle: true` (above) plus a seeded `hub.port` file so the
138
+ * cloudflare path can resolve a port without actually spawning a hub.
139
+ */
140
+ hubEnsureOpts?: Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log">;
141
+ /**
142
+ * Directory holding hub.html (passed through to the hub server on first
143
+ * spawn). Defaults to the same `well-known/` resolution the Tailscale
144
+ * path uses.
145
+ */
146
+ wellKnownDir?: string;
147
+ /**
148
+ * Skip spawning the hub server. Tests flip this on and pre-seed
149
+ * `<configDir>/hub/run/hub.port` so `readHubPort` can resolve the
150
+ * cloudflared target without a live process. Production always leaves
151
+ * this off so the bringup self-heals a missing hub.
152
+ */
153
+ skipHub?: boolean;
115
154
  now?: () => Date;
116
155
  /**
117
156
  * Override `~/.parachute/vault` for the 2FA-enrollment probe. Tests
@@ -139,6 +178,11 @@ interface Resolved {
139
178
  configPath: string;
140
179
  logPath: string;
141
180
  cloudflaredHome: string;
181
+ configDir: string;
182
+ hubOrigin: string | undefined;
183
+ hubEnsureOpts: Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log">;
184
+ wellKnownDir: string;
185
+ skipHub: boolean;
142
186
  now: () => Date;
143
187
  vaultHome: string | undefined;
144
188
  vaultAuthStatus: VaultAuthStatus | undefined;
@@ -146,7 +190,12 @@ interface Resolved {
146
190
 
147
191
  function resolve(opts: ExposeCloudflareOpts): Resolved {
148
192
  const tunnelName = opts.tunnelName ?? DEFAULT_TUNNEL_NAME;
149
- const paths = cloudflaredPathsFor(tunnelName);
193
+ const configDir = opts.configDir ?? CONFIG_DIR;
194
+ // Derive per-tunnel config/log paths from the *resolved* configDir, not the
195
+ // real `CONFIG_DIR`. When a test threads a tmp `configDir` but omits explicit
196
+ // `configPath`/`logPath`, this keeps the derived files inside the tmp dir
197
+ // instead of writing fixtures into the operator's real ~/.parachute.
198
+ const paths = cloudflaredPathsFor(tunnelName, configDir);
150
199
  return {
151
200
  runner: opts.runner ?? defaultRunner,
152
201
  spawner: opts.spawner ?? defaultCloudflaredSpawner,
@@ -159,6 +208,11 @@ function resolve(opts: ExposeCloudflareOpts): Resolved {
159
208
  configPath: opts.configPath ?? paths.configPath,
160
209
  logPath: opts.logPath ?? paths.logPath,
161
210
  cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
211
+ configDir,
212
+ hubOrigin: opts.hubOrigin,
213
+ hubEnsureOpts: opts.hubEnsureOpts ?? {},
214
+ wellKnownDir: opts.wellKnownDir ?? WELL_KNOWN_DIR,
215
+ skipHub: opts.skipHub ?? false,
162
216
  now: opts.now ?? (() => new Date()),
163
217
  vaultHome: opts.vaultHome,
164
218
  vaultAuthStatus: opts.vaultAuthStatus,
@@ -179,11 +233,13 @@ function printAuthGuidance(log: (line: string) => void, vaultUrl: string): void
179
233
  log(" then point your connector at:");
180
234
  log(` ${vaultUrl}`);
181
235
  log("");
182
- log(" Scripts / machines:");
183
- log(" parachute vault tokens create # creates a pvt_… bearer token");
184
- log(" Authorization: Bearer pvt_… # attach to every request");
236
+ log(" Scripts / machines (hub-issued JWT — set the owner password first):");
237
+ log(" parachute auth mint-token --scope vault:<name>:read # or :write");
238
+ log(" Authorization: Bearer <hub-jwt> # attach the printed token to every request");
239
+ log(" (or: Admin → Vaults → Connect mints one and shows the header for you)");
185
240
  log("");
186
- log("Neither is a prerequisite for the other. Full auth reference:");
241
+ log("The owner password gates both paths browser sign-in and minting tokens.");
242
+ log("Full auth reference:");
187
243
  log(` ${AUTH_DOC_URL}`);
188
244
  }
189
245
 
@@ -239,6 +295,46 @@ export async function exposeCloudflareUp(
239
295
  return 1;
240
296
  }
241
297
 
298
+ // Resolve the public hub origin before spawning the hub server — it gets
299
+ // baked into the OAuth `iss` claim via the `--issuer` flag. For Cloudflare
300
+ // ingress the canonical origin is the user-supplied hostname (mirrors the
301
+ // Tailscale Funnel path which uses the tailnet FQDN). Falling back to the
302
+ // request origin would put `http://127.0.0.1:<port>` in tokens, which any
303
+ // client following RFC 8414 would reject.
304
+ const canonicalOrigin = `https://${hostname}`;
305
+ const hubOrigin =
306
+ deriveHubOrigin({ override: r.hubOrigin, exposeFqdn: hostname }) ?? canonicalOrigin;
307
+
308
+ // Ensure the hub is running and figure out the loopback port cloudflared
309
+ // should target. The hub does all internal routing (discovery, admin,
310
+ // OAuth, well-known, per-vault proxy, generic /<svc>/* dispatch) — same
311
+ // shape the Tailscale Funnel path uses (see `planEntries` in expose.ts).
312
+ // Pre-2026-05-27 the cloudflared config routed straight at vault's port,
313
+ // so a public URL like https://gitcoin.parachute.computer/ returned 404
314
+ // from vault itself instead of the hub's discovery page; admin / OAuth
315
+ // were unreachable. Aaron hit this on a fresh EC2 install.
316
+ let hubPort: number;
317
+ if (r.skipHub) {
318
+ const existing = readHubPort(r.configDir);
319
+ if (existing === undefined) {
320
+ throw new Error("skipHub set but no hub.port on disk — tests must seed one");
321
+ }
322
+ hubPort = existing;
323
+ } else {
324
+ const hub = await ensureHubRunning({
325
+ reservedPorts: manifest.services.map((s) => s.port),
326
+ ...r.hubEnsureOpts,
327
+ configDir: r.configDir,
328
+ wellKnownDir: r.wellKnownDir,
329
+ issuer: hubOrigin,
330
+ log: r.log,
331
+ });
332
+ hubPort = hub.port;
333
+ if (hub.started) r.log(`✓ hub started (pid ${hub.pid}, port ${hub.port}).`);
334
+ else r.log(`✓ hub already running (pid ${hub.pid}, port ${hub.port}).`);
335
+ }
336
+ if (hubPort === 0) hubPort = HUB_DEFAULT_PORT;
337
+
242
338
  let tunnel: Tunnel | undefined;
243
339
  try {
244
340
  tunnel = await findTunnelByName(r.runner, r.tunnelName);
@@ -282,7 +378,13 @@ export async function exposeCloudflareUp(
282
378
  tunnelUuid: tunnel.id,
283
379
  credentialsFile: credsFile,
284
380
  hostname,
285
- servicePort: vaultEntry.port,
381
+ // Route into the hub, not vault directly. The hub dispatches
382
+ // discovery / admin / OAuth / per-vault proxy / generic /<svc>/*
383
+ // — same shape Tailscale Funnel uses (single mount → hub catchall).
384
+ // Pre-fix this was `vaultEntry.port`, which served vault's own 404
385
+ // page on every request that wasn't /vault/<name>/… — admin SPA and
386
+ // OAuth surfaces were unreachable from the public URL.
387
+ servicePort: hubPort,
286
388
  },
287
389
  r.configPath,
288
390
  );
@@ -329,9 +431,11 @@ export async function exposeCloudflareUp(
329
431
 
330
432
  r.log("");
331
433
  r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
332
- r.log(` URL: ${baseUrl}`);
333
- r.log(` Vault: ${vaultUrl}`);
334
- r.log(` Logs: ${r.logPath}`);
434
+ r.log(` Open: ${baseUrl}/`);
435
+ r.log(` Admin: ${baseUrl}/admin/`);
436
+ r.log(` Vault: ${vaultUrl}`);
437
+ r.log(` OAuth: ${hubOrigin}`);
438
+ r.log(` Logs: ${r.logPath}`);
335
439
  r.log("");
336
440
  r.log("Point a claude.ai / ChatGPT connector at:");
337
441
  r.log(` ${vaultUrl}`);
@@ -13,6 +13,7 @@
13
13
  import { createInterface } from "node:readline/promises";
14
14
  import {
15
15
  DEFAULT_CLOUDFLARED_HOME,
16
+ cloudflaredInstallHint,
16
17
  isCloudflaredInstalled,
17
18
  isCloudflaredLoggedIn,
18
19
  } from "../cloudflare/detect.ts";
@@ -273,19 +274,17 @@ async function guideCloudflareSetup(
273
274
  return false;
274
275
  }
275
276
  } else {
277
+ // 2026-05-27 refresh: distro-package paths (`apt-get`, `dnf`) are
278
+ // unreliable across versions — Aaron hit `No match for argument:
279
+ // cloudflared` on Amazon Linux 2023 — and the
280
+ // pkg.cloudflare.com / developers.cloudflare.com paths the old hint
281
+ // pointed at now serve HTML/404. Defer to `cloudflaredInstallHint`,
282
+ // which writes the canonical GitHub-release static-binary path
283
+ // matching the host's architecture.
276
284
  r.log("");
277
285
  r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
278
- r.log("Install one way:");
279
- r.log(" Debian / Ubuntu:");
280
- r.log(
281
- " curl -L https://pkg.cloudflare.com/install.sh | sudo bash && sudo apt-get install -y cloudflared",
282
- );
283
- r.log(" RHEL / Fedora:");
284
- r.log(" sudo dnf install cloudflared");
285
- r.log(" Tarball / other:");
286
- r.log(
287
- " https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
288
- );
286
+ r.log("");
287
+ for (const line of cloudflaredInstallHint(r.platform).split("\n")) r.log(line);
289
288
  r.log("");
290
289
  r.log("After install, re-run: parachute expose public");
291
290
  return false;
@@ -28,7 +28,7 @@
28
28
  * tailscale/cloudflared.
29
29
  */
30
30
 
31
- import { DEFAULT_CLOUDFLARED_HOME } from "../cloudflare/detect.ts";
31
+ import { DEFAULT_CLOUDFLARED_HOME, cloudflaredInstallHint } from "../cloudflare/detect.ts";
32
32
  import {
33
33
  type DetectProvidersOpts,
34
34
  type ProviderAvailability,
@@ -109,9 +109,11 @@ function reportNeitherReady(r: Resolved, p: ProviderAvailability): number {
109
109
  r.log("");
110
110
  r.log(" Option B — Cloudflare Tunnel (your own domain, Cloudflare DNS):");
111
111
  if (!p.cloudflare.available) {
112
- r.log(
113
- " 1. Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
114
- );
112
+ // 2026-05-27 refresh: defer to the shared install-hint helper so the
113
+ // canonical install path stays in one place. Pre-refresh this surface
114
+ // hard-coded a developers.cloudflare.com URL that now serves HTML/404.
115
+ r.log(" 1. Install cloudflared:");
116
+ for (const line of cloudflaredInstallHint().split("\n")) r.log(` ${line}`);
115
117
  r.log(" 2. Log in: cloudflared tunnel login");
116
118
  r.log(" 3. Re-run with --domain: parachute expose public --cloudflare --domain <hostname>");
117
119
  } else {
@@ -24,6 +24,7 @@ import { type ServiceEntry, readManifest } from "../services-manifest.ts";
24
24
  import { type ServeEntry, bringupCommand, teardownCommand } from "../tailscale/commands.ts";
25
25
  import { getFqdn, isTailscaleInstalled } from "../tailscale/detect.ts";
26
26
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
27
+ import { clearVaultHubOrigin } from "../vault-hub-origin-env.ts";
27
28
  import type { VaultAuthStatus } from "../vault/auth-status.ts";
28
29
  import {
29
30
  WELL_KNOWN_DIR,
@@ -438,6 +439,13 @@ export async function exposeOff(layer: ExposeLayer, opts: ExposeOpts = {}): Prom
438
439
  }
439
440
 
440
441
  clearExposeState(statePath);
442
+ // Drop the persisted PARACHUTE_HUB_ORIGIN from vault's `.env`. `expose up`
443
+ // (via the vault restart) persisted the public origin so the launchd /
444
+ // systemd daemon validates `iss` against it. With exposure gone, a
445
+ // local-only hub mints loopback-`iss` tokens, so a stale public origin left
446
+ // in `.env` would itself cause the mismatch on the next daemon restart.
447
+ // Reverting to vault's loopback default (`getHubOrigin`) keeps them aligned.
448
+ clearVaultHubOrigin(configDir, log);
441
449
  // Pair to the debug-only write at expose-up — clean up the inspection artifact
442
450
  // on teardown so it doesn't outlive the layer it described.
443
451
  if (existsSync(wellKnownFilePath)) {