@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -14,12 +14,20 @@ import {
14
14
  exposeCloudflareOff,
15
15
  exposeCloudflareUp,
16
16
  } from "../commands/expose-cloudflare.ts";
17
+ import { readEnvFileValues } from "../env-file.ts";
18
+ import { readExposeState } from "../expose-state.ts";
19
+ import { writeHubPort } from "../hub-control.ts";
17
20
  import type { CommandResult, Runner } from "../tailscale/run.ts";
18
21
 
22
+ // Default seeded hub port used by tests with `skipHub: true`. The cloudflared
23
+ // path reads `<configDir>/hub/run/hub.port` instead of spawning a real hub.
24
+ const TEST_HUB_PORT = 1939;
25
+
19
26
  interface TestEnv {
20
27
  configDir: string;
21
28
  manifestPath: string;
22
29
  statePath: string;
30
+ exposeStatePath: string;
23
31
  configPath: string;
24
32
  logPath: string;
25
33
  cloudflaredHome: string;
@@ -35,12 +43,18 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
35
43
  const cloudflaredHome = join(dir, "cloudflared");
36
44
  const manifestPath = join(configDir, "services.json");
37
45
  const statePath = join(configDir, "cloudflared-state.json");
46
+ const exposeStatePath = join(configDir, "expose-state.json");
38
47
  const configPath = join(configDir, "cloudflared", "parachute", "config.yml");
39
48
  const logPath = join(configDir, "cloudflared", "parachute", "cloudflared.log");
40
49
 
41
50
  require("node:fs").mkdirSync(configDir, { recursive: true });
42
51
  require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
43
52
 
53
+ // Seed the hub port so `skipHub: true` invocations can resolve a port
54
+ // without spawning the actual hub process. Matches the seam pattern used
55
+ // by expose.test.ts (which threads `hubEnsureOpts` for the same purpose).
56
+ writeHubPort(TEST_HUB_PORT, configDir);
57
+
44
58
  if (loggedIn) {
45
59
  writeFileSync(join(cloudflaredHome, "cert.pem"), "---");
46
60
  }
@@ -62,6 +76,7 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
62
76
  configDir,
63
77
  manifestPath,
64
78
  statePath,
79
+ exposeStatePath,
65
80
  configPath,
66
81
  logPath,
67
82
  cloudflaredHome,
@@ -125,9 +140,12 @@ describe("exposeCloudflareUp", () => {
125
140
  log: (l) => logs.push(l),
126
141
  manifestPath: env.manifestPath,
127
142
  statePath: env.statePath,
143
+ exposeStatePath: env.exposeStatePath,
128
144
  configPath: env.configPath,
129
145
  logPath: env.logPath,
130
146
  cloudflaredHome: env.cloudflaredHome,
147
+ configDir: env.configDir,
148
+ skipHub: true,
131
149
  now: () => new Date("2026-04-22T12:00:00Z"),
132
150
  });
133
151
 
@@ -170,18 +188,207 @@ describe("exposeCloudflareUp", () => {
170
188
  const yaml = readFileSync(env.configPath, "utf8");
171
189
  expect(yaml).toContain(`tunnel: ${uuid}`);
172
190
  expect(yaml).toContain("- hostname: vault.example.com");
173
- expect(yaml).toContain("service: http://localhost:1940");
191
+ // Routes through the hub (not directly at vault). The hub dispatches
192
+ // discovery / admin / OAuth / per-vault proxy / generic /<svc>/* —
193
+ // same shape Tailscale Funnel uses. Pre-2026-05-27 this was
194
+ // http://localhost:1940 (vault's port), which served vault's own 404
195
+ // page on every request that wasn't /vault/<name>/...
196
+ expect(yaml).toContain(`service: http://localhost:${TEST_HUB_PORT}`);
174
197
 
175
198
  // Security copy surfaces both paths plus a pointer to the auth doc.
176
199
  const joined = logs.join("\n");
177
200
  expect(joined).toContain("parachute auth set-password");
178
- expect(joined).toContain("parachute vault tokens create");
201
+ // Scripts/machines path points at the hub-JWT mint (vault#412 / hub#466
202
+ // DROPped `vault tokens create`), not the removed pvt_* command.
203
+ expect(joined).toContain("parachute auth mint-token --scope vault:");
204
+ expect(joined).toContain("Bearer <hub-jwt>");
205
+ expect(joined).not.toContain("vault tokens create");
206
+ expect(joined).not.toContain("pvt_");
179
207
  expect(joined).toContain("auth-model.md");
180
208
  } finally {
181
209
  env.cleanup();
182
210
  }
183
211
  });
184
212
 
213
+ test("persists expose-state.json with the canonicalFqdn + public hubOrigin (Fix 1)", async () => {
214
+ // The OAuth-iss bug: pre-fix the cloudflare path never wrote
215
+ // expose-state.json, so `readExposeState()` returned undefined and
216
+ // downstream consumers (init's resolveAdminUrl, lifecycle's
217
+ // resolveHubOrigin, the vault .env PARACHUTE_HUB_ORIGIN persistence)
218
+ // fell back to loopback — wrong OAuth `iss` on Cloudflare deploys.
219
+ const env = makeEnv();
220
+ try {
221
+ const uuid = "eeeeeeee-0000-0000-0000-000000000005";
222
+ const { runner } = queueRunner([
223
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
224
+ { code: 0, stdout: "[]", stderr: "" },
225
+ {
226
+ code: 0,
227
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
228
+ stderr: "",
229
+ },
230
+ { code: 0, stdout: "", stderr: "" },
231
+ ]);
232
+ const { spawner } = fakeSpawner(42200);
233
+
234
+ // Pre-condition: no expose-state.json yet.
235
+ expect(readExposeState(env.exposeStatePath)).toBeUndefined();
236
+
237
+ const code = await exposeCloudflareUp("gitcoin.parachute.computer", {
238
+ runner,
239
+ spawner,
240
+ alive: () => false,
241
+ kill: () => {},
242
+ log: () => {},
243
+ manifestPath: env.manifestPath,
244
+ statePath: env.statePath,
245
+ exposeStatePath: env.exposeStatePath,
246
+ configPath: env.configPath,
247
+ logPath: env.logPath,
248
+ cloudflaredHome: env.cloudflaredHome,
249
+ configDir: env.configDir,
250
+ skipHub: true,
251
+ });
252
+
253
+ expect(code).toBe(0);
254
+ const exposeState = readExposeState(env.exposeStatePath);
255
+ expect(exposeState).toBeDefined();
256
+ expect(exposeState?.layer).toBe("public");
257
+ expect(exposeState?.mode).toBe("subdomain");
258
+ expect(exposeState?.canonicalFqdn).toBe("gitcoin.parachute.computer");
259
+ expect(exposeState?.funnel).toBe(false);
260
+ expect(exposeState?.port).toBe(TEST_HUB_PORT);
261
+ // The public origin OAuth clients will see — the load-bearing field.
262
+ expect(exposeState?.hubOrigin).toBe("https://gitcoin.parachute.computer");
263
+ // Single hub-catchall proxy entry (matches the Tailscale path's shape).
264
+ expect(exposeState?.entries).toEqual([
265
+ {
266
+ kind: "proxy",
267
+ mount: "/",
268
+ target: `http://localhost:${TEST_HUB_PORT}`,
269
+ service: "hub",
270
+ },
271
+ ]);
272
+ } finally {
273
+ env.cleanup();
274
+ }
275
+ });
276
+
277
+ test("persists the public hub origin to vault/.env + restarts vault (Cloudflare 401 fix)", async () => {
278
+ // The Cloudflare 401 P0: the cloudflare path wrote expose-state.json but —
279
+ // unlike the Tailscale path, which auto-restarts vault and so flows the
280
+ // public origin into vault/.env via lifecycle's persistVaultHubOrigin —
281
+ // never touched vault's .env or restarted it. The launchd/systemd daemon
282
+ // kept booting vault with NO PARACHUTE_HUB_ORIGIN → vault fell back to
283
+ // loopback as its expected issuer → every hub-minted token (iss=public)
284
+ // failed the iss check → 401. This asserts the durable .env write + the
285
+ // running-vault restart that mirrors the Tailscale path.
286
+ const env = makeEnv();
287
+ try {
288
+ // Seed vault as "running" so the restart branch fires. PID lives at
289
+ // <configDir>/vault/run/vault.pid (see process-state.ts:pidPath).
290
+ const vaultRun = join(env.configDir, "vault", "run");
291
+ require("node:fs").mkdirSync(vaultRun, { recursive: true });
292
+ writeFileSync(join(vaultRun, "vault.pid"), "99001");
293
+
294
+ const uuid = "ffffffff-0000-0000-0000-000000000006";
295
+ const { runner } = queueRunner([
296
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
297
+ { code: 0, stdout: "[]", stderr: "" },
298
+ {
299
+ code: 0,
300
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
301
+ stderr: "",
302
+ },
303
+ { code: 0, stdout: "", stderr: "" },
304
+ ]);
305
+ const { spawner } = fakeSpawner(42300);
306
+ const restarted: string[] = [];
307
+
308
+ const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
309
+ runner,
310
+ spawner,
311
+ // `alive` reports the seeded vault pid as running so processState() ===
312
+ // "running" and the restart branch executes.
313
+ alive: (pid) => pid === 99001,
314
+ kill: () => {},
315
+ log: () => {},
316
+ manifestPath: env.manifestPath,
317
+ statePath: env.statePath,
318
+ exposeStatePath: env.exposeStatePath,
319
+ configPath: env.configPath,
320
+ logPath: env.logPath,
321
+ cloudflaredHome: env.cloudflaredHome,
322
+ configDir: env.configDir,
323
+ skipHub: true,
324
+ restartService: async (short) => {
325
+ restarted.push(short);
326
+ return 0;
327
+ },
328
+ });
329
+
330
+ expect(code).toBe(0);
331
+ // Durable half: the public origin is written to vault/.env (NOT loopback,
332
+ // NOT unset) so the daemon boot path validates iss against it.
333
+ expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
334
+ "https://gitcoin-parachute.unforced.dev",
335
+ );
336
+ // Live half: the running vault is restarted to re-read the new origin.
337
+ expect(restarted).toEqual(["vault"]);
338
+ } finally {
339
+ env.cleanup();
340
+ }
341
+ });
342
+
343
+ test("persists vault/.env but does NOT restart when vault isn't running", async () => {
344
+ // No vault pidfile → processState() !== "running" → no restart, but the
345
+ // durable .env write still happens so the next daemon boot is correct.
346
+ const env = makeEnv();
347
+ try {
348
+ const uuid = "ffffffff-0000-0000-0000-000000000007";
349
+ const { runner } = queueRunner([
350
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
351
+ { code: 0, stdout: "[]", stderr: "" },
352
+ {
353
+ code: 0,
354
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
355
+ stderr: "",
356
+ },
357
+ { code: 0, stdout: "", stderr: "" },
358
+ ]);
359
+ const { spawner } = fakeSpawner(42301);
360
+ const restarted: string[] = [];
361
+
362
+ const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
363
+ runner,
364
+ spawner,
365
+ alive: () => false,
366
+ kill: () => {},
367
+ log: () => {},
368
+ manifestPath: env.manifestPath,
369
+ statePath: env.statePath,
370
+ exposeStatePath: env.exposeStatePath,
371
+ configPath: env.configPath,
372
+ logPath: env.logPath,
373
+ cloudflaredHome: env.cloudflaredHome,
374
+ configDir: env.configDir,
375
+ skipHub: true,
376
+ restartService: async (short) => {
377
+ restarted.push(short);
378
+ return 0;
379
+ },
380
+ });
381
+
382
+ expect(code).toBe(0);
383
+ expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
384
+ "https://gitcoin-parachute.unforced.dev",
385
+ );
386
+ expect(restarted).toEqual([]);
387
+ } finally {
388
+ env.cleanup();
389
+ }
390
+ });
391
+
185
392
  test("reuses existing tunnel when name already present", async () => {
186
393
  const env = makeEnv();
187
394
  try {
@@ -206,9 +413,12 @@ describe("exposeCloudflareUp", () => {
206
413
  log: (l) => logs.push(l),
207
414
  manifestPath: env.manifestPath,
208
415
  statePath: env.statePath,
416
+ exposeStatePath: env.exposeStatePath,
209
417
  configPath: env.configPath,
210
418
  logPath: env.logPath,
211
419
  cloudflaredHome: env.cloudflaredHome,
420
+ configDir: env.configDir,
421
+ skipHub: true,
212
422
  });
213
423
  expect(code).toBe(0);
214
424
  // No `tunnel create` — only list + route.
@@ -233,9 +443,12 @@ describe("exposeCloudflareUp", () => {
233
443
  log: (l) => logs.push(l),
234
444
  manifestPath: env.manifestPath,
235
445
  statePath: env.statePath,
446
+ exposeStatePath: env.exposeStatePath,
236
447
  configPath: env.configPath,
237
448
  logPath: env.logPath,
238
449
  cloudflaredHome: env.cloudflaredHome,
450
+ configDir: env.configDir,
451
+ skipHub: true,
239
452
  });
240
453
 
241
454
  expect(code).toBe(1);
@@ -259,9 +472,12 @@ describe("exposeCloudflareUp", () => {
259
472
  log: (l) => logs.push(l),
260
473
  manifestPath: env.manifestPath,
261
474
  statePath: env.statePath,
475
+ exposeStatePath: env.exposeStatePath,
262
476
  configPath: env.configPath,
263
477
  logPath: env.logPath,
264
478
  cloudflaredHome: env.cloudflaredHome,
479
+ configDir: env.configDir,
480
+ skipHub: true,
265
481
  tunnelName: "bad name with spaces",
266
482
  });
267
483
 
@@ -288,9 +504,12 @@ describe("exposeCloudflareUp", () => {
288
504
  log: (l) => logs.push(l),
289
505
  manifestPath: env.manifestPath,
290
506
  statePath: env.statePath,
507
+ exposeStatePath: env.exposeStatePath,
291
508
  configPath: env.configPath,
292
509
  logPath: env.logPath,
293
510
  cloudflaredHome: env.cloudflaredHome,
511
+ configDir: env.configDir,
512
+ skipHub: true,
294
513
  });
295
514
 
296
515
  expect(code).toBe(1);
@@ -314,9 +533,12 @@ describe("exposeCloudflareUp", () => {
314
533
  log: (l) => logs.push(l),
315
534
  manifestPath: env.manifestPath,
316
535
  statePath: env.statePath,
536
+ exposeStatePath: env.exposeStatePath,
317
537
  configPath: env.configPath,
318
538
  logPath: env.logPath,
319
539
  cloudflaredHome: env.cloudflaredHome,
540
+ configDir: env.configDir,
541
+ skipHub: true,
320
542
  });
321
543
 
322
544
  expect(code).toBe(1);
@@ -339,9 +561,12 @@ describe("exposeCloudflareUp", () => {
339
561
  log: (l) => logs.push(l),
340
562
  manifestPath: env.manifestPath,
341
563
  statePath: env.statePath,
564
+ exposeStatePath: env.exposeStatePath,
342
565
  configPath: env.configPath,
343
566
  logPath: env.logPath,
344
567
  cloudflaredHome: env.cloudflaredHome,
568
+ configDir: env.configDir,
569
+ skipHub: true,
345
570
  });
346
571
 
347
572
  expect(code).toBe(1);
@@ -373,9 +598,12 @@ describe("exposeCloudflareUp", () => {
373
598
  log: (l) => logs.push(l),
374
599
  manifestPath: env.manifestPath,
375
600
  statePath: env.statePath,
601
+ exposeStatePath: env.exposeStatePath,
376
602
  configPath: env.configPath,
377
603
  logPath: env.logPath,
378
604
  cloudflaredHome: env.cloudflaredHome,
605
+ configDir: env.configDir,
606
+ skipHub: true,
379
607
  });
380
608
 
381
609
  expect(code).toBe(1);
@@ -422,9 +650,12 @@ describe("exposeCloudflareUp", () => {
422
650
  log: () => {},
423
651
  manifestPath: env.manifestPath,
424
652
  statePath: env.statePath,
653
+ exposeStatePath: env.exposeStatePath,
425
654
  configPath: env.configPath,
426
655
  logPath: env.logPath,
427
656
  cloudflaredHome: env.cloudflaredHome,
657
+ configDir: env.configDir,
658
+ skipHub: true,
428
659
  });
429
660
 
430
661
  expect(code).toBe(0);
@@ -436,6 +667,191 @@ describe("exposeCloudflareUp", () => {
436
667
  }
437
668
  });
438
669
 
670
+ test("hub#487: kills orphan connectors found by pgrep before spawning, not just the state pid", async () => {
671
+ // The orphan-accumulation bug: each re-expose spawned a fresh connector
672
+ // without killing prior ones, and state only tracked the most-recent pid.
673
+ // Orphans the state file lost track of (crashed mid-rewrite, started by
674
+ // hand) must still be swept — `connectorPids` finds them by UUID/config
675
+ // path. Here state knows pid 99999, but pgrep also surfaces 88888 + 77777
676
+ // serving the same tunnel; all three get SIGTERM before the new spawn.
677
+ const env = makeEnv();
678
+ try {
679
+ const uuid = "cccccccc-0000-0000-0000-000000000003";
680
+ const priorRecord: CloudflaredTunnelRecord = {
681
+ pid: 99999,
682
+ tunnelUuid: uuid,
683
+ tunnelName: "parachute",
684
+ hostname: "vault.example.com",
685
+ startedAt: "2026-04-21T00:00:00.000Z",
686
+ configPath: env.configPath,
687
+ };
688
+ writeCloudflaredState({ version: 2, tunnels: { parachute: priorRecord } }, env.statePath);
689
+
690
+ const { runner } = queueRunner([
691
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
692
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
693
+ { code: 0, stdout: "", stderr: "" }, // route dns
694
+ ]);
695
+ const { spawner, seen } = fakeSpawner(42010);
696
+ const killed: number[] = [];
697
+
698
+ const code = await exposeCloudflareUp("vault.example.com", {
699
+ runner,
700
+ spawner,
701
+ alive: () => true, // all candidate pids report alive
702
+ kill: (pid) => killed.push(pid),
703
+ // pgrep surfaces two orphans the state record didn't track.
704
+ connectorPids: () => [88888, 77777],
705
+ resolveHost: async () => ["104.16.0.1"], // Cloudflare — no DNS warning
706
+ log: () => {},
707
+ manifestPath: env.manifestPath,
708
+ statePath: env.statePath,
709
+ exposeStatePath: env.exposeStatePath,
710
+ configPath: env.configPath,
711
+ logPath: env.logPath,
712
+ cloudflaredHome: env.cloudflaredHome,
713
+ configDir: env.configDir,
714
+ skipHub: true,
715
+ });
716
+
717
+ expect(code).toBe(0);
718
+ // Every prior connector (state pid + both pgrep orphans) is stopped
719
+ // before the new one spawns.
720
+ expect(killed.sort()).toEqual([77777, 88888, 99999]);
721
+ // Exactly one fresh connector spawned, and it's the one recorded.
722
+ expect(seen).toHaveLength(1);
723
+ expect(findTunnelRecord(readCloudflaredState(env.statePath), "parachute")?.pid).toBe(42010);
724
+ } finally {
725
+ env.cleanup();
726
+ }
727
+ });
728
+
729
+ test("hub#487: warns when DNS doesn't resolve yet (pending zone)", async () => {
730
+ // route dns succeeded but the hostname doesn't resolve — the "pending"
731
+ // zone shape (NS not switched at the registrar). Non-fatal: still exit 0,
732
+ // still print the URLs, but add the nameserver-switch nudge.
733
+ const env = makeEnv();
734
+ try {
735
+ const uuid = "dddddddd-0000-0000-0000-000000000004";
736
+ const { runner } = queueRunner([
737
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
738
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
739
+ { code: 0, stdout: "", stderr: "" },
740
+ ]);
741
+ const { spawner } = fakeSpawner(42020);
742
+ const logs: string[] = [];
743
+
744
+ const code = await exposeCloudflareUp("vault.newzone.com", {
745
+ runner,
746
+ spawner,
747
+ alive: () => false,
748
+ kill: () => {},
749
+ connectorPids: () => [],
750
+ resolveHost: async () => [], // NXDOMAIN / not live yet
751
+ log: (l) => logs.push(l),
752
+ manifestPath: env.manifestPath,
753
+ statePath: env.statePath,
754
+ exposeStatePath: env.exposeStatePath,
755
+ configPath: env.configPath,
756
+ logPath: env.logPath,
757
+ cloudflaredHome: env.cloudflaredHome,
758
+ configDir: env.configDir,
759
+ skipHub: true,
760
+ });
761
+
762
+ expect(code).toBe(0); // non-fatal — the expose still completes
763
+ const joined = logs.join("\n");
764
+ expect(joined).toContain("DNS isn't live yet for vault.newzone.com");
765
+ expect(joined).toContain("dig +short newzone.com NS");
766
+ expect(joined).toContain("ns.cloudflare.com");
767
+ // The success URLs still print.
768
+ expect(joined).toContain("https://vault.newzone.com/admin/");
769
+ } finally {
770
+ env.cleanup();
771
+ }
772
+ });
773
+
774
+ test("hub#487: warns when hostname resolves but not to Cloudflare (shadowed)", async () => {
775
+ // route dns succeeded but the hostname resolves to a non-Cloudflare IP —
776
+ // a Pages project / grey-cloud A record shadowing the tunnel → edge 404.
777
+ const env = makeEnv();
778
+ try {
779
+ const uuid = "eeeeeeee-0000-0000-0000-000000000006";
780
+ const { runner } = queueRunner([
781
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
782
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
783
+ { code: 0, stdout: "", stderr: "" },
784
+ ]);
785
+ const { spawner } = fakeSpawner(42021);
786
+ const logs: string[] = [];
787
+
788
+ const code = await exposeCloudflareUp("docs.parachute.computer", {
789
+ runner,
790
+ spawner,
791
+ alive: () => false,
792
+ kill: () => {},
793
+ connectorPids: () => [],
794
+ resolveHost: async () => ["203.0.113.10"], // not a Cloudflare range
795
+ log: (l) => logs.push(l),
796
+ manifestPath: env.manifestPath,
797
+ statePath: env.statePath,
798
+ exposeStatePath: env.exposeStatePath,
799
+ configPath: env.configPath,
800
+ logPath: env.logPath,
801
+ cloudflaredHome: env.cloudflaredHome,
802
+ configDir: env.configDir,
803
+ skipHub: true,
804
+ });
805
+
806
+ expect(code).toBe(0);
807
+ const joined = logs.join("\n");
808
+ expect(joined).toContain("not to Cloudflare's edge");
809
+ expect(joined).toContain("shadowed");
810
+ expect(joined).toContain("Pages project");
811
+ } finally {
812
+ env.cleanup();
813
+ }
814
+ });
815
+
816
+ test("hub#487: no DNS warning when hostname resolves at Cloudflare's edge", async () => {
817
+ const env = makeEnv();
818
+ try {
819
+ const uuid = "ffffffff-0000-0000-0000-000000000007";
820
+ const { runner } = queueRunner([
821
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
822
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
823
+ { code: 0, stdout: "", stderr: "" },
824
+ ]);
825
+ const { spawner } = fakeSpawner(42022);
826
+ const logs: string[] = [];
827
+
828
+ const code = await exposeCloudflareUp("vault.example.com", {
829
+ runner,
830
+ spawner,
831
+ alive: () => false,
832
+ kill: () => {},
833
+ connectorPids: () => [],
834
+ resolveHost: async () => ["104.18.32.7"], // 104.16.0.0/13 — Cloudflare
835
+ log: (l) => logs.push(l),
836
+ manifestPath: env.manifestPath,
837
+ statePath: env.statePath,
838
+ exposeStatePath: env.exposeStatePath,
839
+ configPath: env.configPath,
840
+ logPath: env.logPath,
841
+ cloudflaredHome: env.cloudflaredHome,
842
+ configDir: env.configDir,
843
+ skipHub: true,
844
+ });
845
+
846
+ expect(code).toBe(0);
847
+ const joined = logs.join("\n");
848
+ expect(joined).not.toContain("DNS isn't live yet");
849
+ expect(joined).not.toContain("not to Cloudflare's edge");
850
+ } finally {
851
+ env.cleanup();
852
+ }
853
+ });
854
+
439
855
  test("two tunnels with different --tunnel-name coexist in state", async () => {
440
856
  const env = makeEnv();
441
857
  try {
@@ -461,8 +877,14 @@ describe("exposeCloudflareUp", () => {
461
877
  log: () => {},
462
878
  manifestPath: env.manifestPath,
463
879
  statePath: env.statePath,
880
+ exposeStatePath: env.exposeStatePath,
464
881
  cloudflaredHome: env.cloudflaredHome,
465
- // Use defaults for configPath/logPath so they're per-tunnel-derived.
882
+ configDir: env.configDir,
883
+ skipHub: true,
884
+ // Omit configPath/logPath so they're per-tunnel-derived — but the
885
+ // derivation now resolves against the tmp `configDir` above, so the
886
+ // generated config.yml lands under tmp, not the operator's real
887
+ // ~/.parachute/cloudflared/parachute/.
466
888
  });
467
889
  expect(code1).toBe(0);
468
890
 
@@ -486,7 +908,10 @@ describe("exposeCloudflareUp", () => {
486
908
  log: () => {},
487
909
  manifestPath: env.manifestPath,
488
910
  statePath: env.statePath,
911
+ exposeStatePath: env.exposeStatePath,
489
912
  cloudflaredHome: env.cloudflaredHome,
913
+ configDir: env.configDir,
914
+ skipHub: true,
490
915
  tunnelName: "second",
491
916
  });
492
917
  expect(code2).toBe(0);
@@ -541,9 +966,12 @@ describe("exposeCloudflareUp", () => {
541
966
  log: (l) => logs.push(l),
542
967
  manifestPath: env.manifestPath,
543
968
  statePath: env.statePath,
969
+ exposeStatePath: env.exposeStatePath,
544
970
  configPath: env.configPath,
545
971
  logPath: env.logPath,
546
972
  cloudflaredHome: env.cloudflaredHome,
973
+ configDir: env.configDir,
974
+ skipHub: true,
547
975
  // No password, no 2FA — fully wide open. The warning should still
548
976
  // fire; password-recovery copy already lives in `printAuthGuidance`.
549
977
  vaultAuthStatus: {
@@ -556,7 +984,9 @@ describe("exposeCloudflareUp", () => {
556
984
 
557
985
  expect(code).toBe(0);
558
986
  const joined = logs.join("\n");
559
- expect(joined).toContain("2FA is not enrolled");
987
+ // hub#473: real hub-login 2FA the warning now recommends the real
988
+ // `parachute auth 2fa enroll` path.
989
+ expect(joined).toContain("/login is now reachable on the public internet");
560
990
  expect(joined).toContain("https://vault.example.com/login");
561
991
  expect(joined).toContain("parachute auth 2fa enroll");
562
992
  } finally {
@@ -564,7 +994,7 @@ describe("exposeCloudflareUp", () => {
564
994
  }
565
995
  });
566
996
 
567
- test("enrolled → warning suppressed (no '2FA is not enrolled' line)", async () => {
997
+ test("enrolled → warning suppressed (no public-/login warning line)", async () => {
568
998
  const env = makeEnv();
569
999
  try {
570
1000
  const uuid = "dddddddd-0000-0000-0000-000000000004";
@@ -589,9 +1019,12 @@ describe("exposeCloudflareUp", () => {
589
1019
  log: (l) => logs.push(l),
590
1020
  manifestPath: env.manifestPath,
591
1021
  statePath: env.statePath,
1022
+ exposeStatePath: env.exposeStatePath,
592
1023
  configPath: env.configPath,
593
1024
  logPath: env.logPath,
594
1025
  cloudflaredHome: env.cloudflaredHome,
1026
+ configDir: env.configDir,
1027
+ skipHub: true,
595
1028
  vaultAuthStatus: {
596
1029
  hasOwnerPassword: true,
597
1030
  hasTotp: true,
@@ -602,11 +1035,76 @@ describe("exposeCloudflareUp", () => {
602
1035
 
603
1036
  expect(code).toBe(0);
604
1037
  const joined = logs.join("\n");
605
- expect(joined).not.toContain("2FA is not enrolled");
606
- // The existing `printAuthGuidance` 2FA-recommend bullet is unrelated
607
- // to the new contextual warning and stays in place — assert it on a
608
- // shape that doesn't collide with the warning text.
609
- expect(joined).toContain("(recommended) TOTP + backup codes");
1038
+ expect(joined).not.toContain("/login is now reachable on the public internet");
1039
+ // The contextual 2FA warning is suppressed (2FA already enrolled); the
1040
+ // always-shown owner-password guidance from `printAuthGuidance` still
1041
+ // appears, and it now (hub#473) also surfaces the real `2fa enroll`
1042
+ // path in the humans section.
1043
+ expect(joined).toContain("parachute auth set-password");
1044
+ expect(joined).toContain("parachute auth 2fa enroll");
1045
+ } finally {
1046
+ env.cleanup();
1047
+ }
1048
+ });
1049
+ });
1050
+
1051
+ describe("routes through hub, not vault", () => {
1052
+ test("config.yml targets the hub port; success log mentions Admin + OAuth URLs", async () => {
1053
+ // Regression guard for the 2026-05-27 cut. Aaron ran `parachute expose
1054
+ // public` on a fresh EC2 box, configured Cloudflare with a custom
1055
+ // domain, and hit it — and got vault's 404 page rather than the hub's
1056
+ // discovery / admin. The pre-fix cloudflared config routed straight at
1057
+ // vault's port; the fix routes at the hub, mirroring the Tailscale
1058
+ // Funnel shape (single mount → hub catchall; hub dispatches per-request).
1059
+ const env = makeEnv();
1060
+ try {
1061
+ // Re-seed hub port to a non-default value so the assertion is
1062
+ // unambiguous about *which* port got into the yaml.
1063
+ writeHubPort(1949, env.configDir);
1064
+
1065
+ const uuid = "ffff0000-0000-0000-0000-00000000beef";
1066
+ const { runner } = queueRunner([
1067
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1068
+ { code: 0, stdout: "[]", stderr: "" },
1069
+ {
1070
+ code: 0,
1071
+ stdout: `Created tunnel parachute with id ${uuid}\n`,
1072
+ stderr: "",
1073
+ },
1074
+ { code: 0, stdout: "", stderr: "" },
1075
+ ]);
1076
+ const { spawner } = fakeSpawner(60001);
1077
+ const logs: string[] = [];
1078
+
1079
+ const code = await exposeCloudflareUp("gitcoin.parachute.computer", {
1080
+ runner,
1081
+ spawner,
1082
+ alive: () => false,
1083
+ kill: () => {},
1084
+ log: (l) => logs.push(l),
1085
+ manifestPath: env.manifestPath,
1086
+ statePath: env.statePath,
1087
+ exposeStatePath: env.exposeStatePath,
1088
+ configPath: env.configPath,
1089
+ logPath: env.logPath,
1090
+ cloudflaredHome: env.cloudflaredHome,
1091
+ configDir: env.configDir,
1092
+ skipHub: true,
1093
+ });
1094
+
1095
+ expect(code).toBe(0);
1096
+ const yaml = readFileSync(env.configPath, "utf8");
1097
+ // Routes through the hub on its loopback port.
1098
+ expect(yaml).toContain("service: http://localhost:1949");
1099
+ // Does NOT route directly at vault's port (1940 per makeEnv default).
1100
+ expect(yaml).not.toContain("service: http://localhost:1940");
1101
+
1102
+ const joined = logs.join("\n");
1103
+ // Discoverable surfaces: open / admin / vault / OAuth all surfaced.
1104
+ expect(joined).toContain("https://gitcoin.parachute.computer/");
1105
+ expect(joined).toContain("Admin: https://gitcoin.parachute.computer/admin/");
1106
+ expect(joined).toContain("Vault: https://gitcoin.parachute.computer/vault/default");
1107
+ expect(joined).toContain("OAuth: https://gitcoin.parachute.computer");
610
1108
  } finally {
611
1109
  env.cleanup();
612
1110
  }
@@ -621,6 +1119,7 @@ describe("exposeCloudflareOff", () => {
621
1119
  const logs: string[] = [];
622
1120
  const code = await exposeCloudflareOff({
623
1121
  statePath: env.statePath,
1122
+ exposeStatePath: env.exposeStatePath,
624
1123
  log: (l) => logs.push(l),
625
1124
  });
626
1125
  expect(code).toBe(0);
@@ -630,7 +1129,7 @@ describe("exposeCloudflareOff", () => {
630
1129
  }
631
1130
  });
632
1131
 
633
- test("SIGTERMs the process and clears state", async () => {
1132
+ test("SIGTERMs the process and clears state (incl. expose-state.json — Fix 1)", async () => {
634
1133
  const env = makeEnv();
635
1134
  try {
636
1135
  writeCloudflaredState(
@@ -649,10 +1148,36 @@ describe("exposeCloudflareOff", () => {
649
1148
  },
650
1149
  env.statePath,
651
1150
  );
1151
+ // Seed the shared expose-state.json the up-path would have written, so we
1152
+ // can assert teardown clears it (downstream consumers stop resolving the
1153
+ // now-dead public URL).
1154
+ writeFileSync(
1155
+ env.exposeStatePath,
1156
+ `${JSON.stringify({
1157
+ version: 1,
1158
+ layer: "public",
1159
+ mode: "subdomain",
1160
+ canonicalFqdn: "vault.example.com",
1161
+ port: TEST_HUB_PORT,
1162
+ funnel: false,
1163
+ entries: [
1164
+ {
1165
+ kind: "proxy",
1166
+ mount: "/",
1167
+ target: `http://localhost:${TEST_HUB_PORT}`,
1168
+ service: "hub",
1169
+ },
1170
+ ],
1171
+ hubOrigin: "https://vault.example.com",
1172
+ })}\n`,
1173
+ );
1174
+ expect(readExposeState(env.exposeStatePath)).toBeDefined();
1175
+
652
1176
  const killed: number[] = [];
653
1177
  const logs: string[] = [];
654
1178
  const code = await exposeCloudflareOff({
655
1179
  statePath: env.statePath,
1180
+ exposeStatePath: env.exposeStatePath,
656
1181
  alive: () => true,
657
1182
  kill: (pid) => killed.push(pid),
658
1183
  log: (l) => logs.push(l),
@@ -660,6 +1185,9 @@ describe("exposeCloudflareOff", () => {
660
1185
  expect(code).toBe(0);
661
1186
  expect(killed).toEqual([55555]);
662
1187
  expect(existsSync(env.statePath)).toBe(false);
1188
+ // Fix 1: the shared expose-state.json is cleared on the last tunnel down.
1189
+ expect(existsSync(env.exposeStatePath)).toBe(false);
1190
+ expect(readExposeState(env.exposeStatePath)).toBeUndefined();
663
1191
  // Reassures the user that the tunnel definition isn't lost.
664
1192
  expect(logs.join("\n")).toContain("remains defined in Cloudflare");
665
1193
  } finally {
@@ -689,6 +1217,7 @@ describe("exposeCloudflareOff", () => {
689
1217
  const killed: number[] = [];
690
1218
  const code = await exposeCloudflareOff({
691
1219
  statePath: env.statePath,
1220
+ exposeStatePath: env.exposeStatePath,
692
1221
  alive: () => false,
693
1222
  kill: (pid) => killed.push(pid),
694
1223
  log: () => {},
@@ -701,6 +1230,46 @@ describe("exposeCloudflareOff", () => {
701
1230
  }
702
1231
  });
703
1232
 
1233
+ test("hub#487: off sweeps orphan connectors the state record didn't track", async () => {
1234
+ const env = makeEnv();
1235
+ try {
1236
+ const uuid = "abababab-0000-0000-0000-000000000009";
1237
+ writeCloudflaredState(
1238
+ {
1239
+ version: 2,
1240
+ tunnels: {
1241
+ parachute: {
1242
+ pid: 55555,
1243
+ tunnelUuid: uuid,
1244
+ tunnelName: "parachute",
1245
+ hostname: "vault.example.com",
1246
+ startedAt: "2026-04-22T12:00:00.000Z",
1247
+ configPath: env.configPath,
1248
+ },
1249
+ },
1250
+ },
1251
+ env.statePath,
1252
+ );
1253
+ const killed: number[] = [];
1254
+ const code = await exposeCloudflareOff({
1255
+ statePath: env.statePath,
1256
+ exposeStatePath: env.exposeStatePath,
1257
+ alive: () => true,
1258
+ kill: (pid) => killed.push(pid),
1259
+ // pgrep finds the tracked pid (skipped — already signalled) plus an
1260
+ // untracked orphan 66666 serving the same tunnel.
1261
+ connectorPids: () => [55555, 66666],
1262
+ log: () => {},
1263
+ });
1264
+ expect(code).toBe(0);
1265
+ // Tracked pid stopped once, orphan also stopped — no double-kill of 55555.
1266
+ expect(killed.sort()).toEqual([55555, 66666]);
1267
+ expect(existsSync(env.statePath)).toBe(false);
1268
+ } finally {
1269
+ env.cleanup();
1270
+ }
1271
+ });
1272
+
704
1273
  test("targets the named tunnel and leaves siblings intact", async () => {
705
1274
  const env = makeEnv();
706
1275
  try {
@@ -728,6 +1297,7 @@ describe("exposeCloudflareOff", () => {
728
1297
  const killed: number[] = [];
729
1298
  const code = await exposeCloudflareOff({
730
1299
  statePath: env.statePath,
1300
+ exposeStatePath: env.exposeStatePath,
731
1301
  alive: () => true,
732
1302
  kill: (pid) => killed.push(pid),
733
1303
  log: () => {},
@@ -762,6 +1332,7 @@ describe("exposeCloudflareOff", () => {
762
1332
  const logs: string[] = [];
763
1333
  const code = await exposeCloudflareOff({
764
1334
  statePath: env.statePath,
1335
+ exposeStatePath: env.exposeStatePath,
765
1336
  alive: () => true,
766
1337
  kill: () => {},
767
1338
  log: (l) => logs.push(l),