@openparachute/hub 0.5.14-rc.9 → 0.6.0

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 (83) hide show
  1. package/README.md +23 -0
  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 +30 -21
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-users.test.ts +7 -2
  9. package/src/__tests__/auth.test.ts +157 -30
  10. package/src/__tests__/cli.test.ts +44 -5
  11. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  12. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  13. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  14. package/src/__tests__/expose.test.ts +52 -2
  15. package/src/__tests__/hub-server.test.ts +97 -0
  16. package/src/__tests__/hub.test.ts +85 -6
  17. package/src/__tests__/init.test.ts +102 -1
  18. package/src/__tests__/lifecycle.test.ts +464 -2
  19. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  20. package/src/__tests__/oauth-ui.test.ts +12 -1
  21. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  22. package/src/__tests__/resource-binding.test.ts +97 -0
  23. package/src/__tests__/scope-explanations.test.ts +41 -12
  24. package/src/__tests__/services-manifest.test.ts +122 -4
  25. package/src/__tests__/setup-wizard.test.ts +335 -15
  26. package/src/__tests__/status.test.ts +36 -0
  27. package/src/__tests__/two-factor-flow.test.ts +602 -0
  28. package/src/__tests__/two-factor.test.ts +183 -0
  29. package/src/__tests__/upgrade.test.ts +78 -1
  30. package/src/__tests__/users.test.ts +68 -0
  31. package/src/__tests__/vault-auth-status.test.ts +47 -6
  32. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  33. package/src/account-home-ui.ts +488 -38
  34. package/src/account-vault-token.ts +282 -0
  35. package/src/admin-handlers.ts +159 -4
  36. package/src/admin-login-ui.ts +49 -5
  37. package/src/admin-vaults.ts +48 -15
  38. package/src/api-account.ts +14 -0
  39. package/src/api-modules-ops.ts +49 -11
  40. package/src/api-users.ts +29 -3
  41. package/src/cli.ts +26 -21
  42. package/src/clients.ts +18 -6
  43. package/src/cloudflare/config.ts +10 -4
  44. package/src/cloudflare/detect.ts +39 -44
  45. package/src/commands/auth.ts +165 -24
  46. package/src/commands/expose-2fa-warning.ts +34 -32
  47. package/src/commands/expose-auth-preflight.ts +89 -78
  48. package/src/commands/expose-cloudflare.ts +370 -12
  49. package/src/commands/expose.ts +8 -0
  50. package/src/commands/init.ts +33 -2
  51. package/src/commands/lifecycle.ts +386 -17
  52. package/src/commands/status.ts +22 -0
  53. package/src/commands/upgrade.ts +55 -11
  54. package/src/commands/wizard.ts +8 -4
  55. package/src/env-file.ts +10 -0
  56. package/src/help.ts +3 -1
  57. package/src/hub-db.ts +39 -1
  58. package/src/hub-server.ts +52 -0
  59. package/src/hub.ts +82 -14
  60. package/src/oauth-handlers.ts +298 -21
  61. package/src/oauth-ui.ts +10 -0
  62. package/src/operator-token.ts +151 -0
  63. package/src/pending-login.ts +116 -0
  64. package/src/rate-limit.ts +51 -0
  65. package/src/resource-binding.ts +134 -0
  66. package/src/scope-explanations.ts +46 -18
  67. package/src/services-manifest.ts +112 -0
  68. package/src/setup-wizard.ts +77 -7
  69. package/src/tailscale/run.ts +28 -11
  70. package/src/totp.ts +201 -0
  71. package/src/two-factor-handlers.ts +287 -0
  72. package/src/two-factor-store.ts +181 -0
  73. package/src/two-factor-ui.ts +462 -0
  74. package/src/users.ts +58 -0
  75. package/src/vault/auth-status.ts +71 -19
  76. package/src/vault-hub-origin-env.ts +163 -0
  77. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  78. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  79. package/web/ui/dist/index.html +2 -2
  80. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  81. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  82. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  83. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -14,6 +14,8 @@ 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";
17
19
  import { writeHubPort } from "../hub-control.ts";
18
20
  import type { CommandResult, Runner } from "../tailscale/run.ts";
19
21
 
@@ -25,6 +27,7 @@ interface TestEnv {
25
27
  configDir: string;
26
28
  manifestPath: string;
27
29
  statePath: string;
30
+ exposeStatePath: string;
28
31
  configPath: string;
29
32
  logPath: string;
30
33
  cloudflaredHome: string;
@@ -40,6 +43,7 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
40
43
  const cloudflaredHome = join(dir, "cloudflared");
41
44
  const manifestPath = join(configDir, "services.json");
42
45
  const statePath = join(configDir, "cloudflared-state.json");
46
+ const exposeStatePath = join(configDir, "expose-state.json");
43
47
  const configPath = join(configDir, "cloudflared", "parachute", "config.yml");
44
48
  const logPath = join(configDir, "cloudflared", "parachute", "cloudflared.log");
45
49
 
@@ -72,6 +76,7 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
72
76
  configDir,
73
77
  manifestPath,
74
78
  statePath,
79
+ exposeStatePath,
75
80
  configPath,
76
81
  logPath,
77
82
  cloudflaredHome,
@@ -135,6 +140,7 @@ describe("exposeCloudflareUp", () => {
135
140
  log: (l) => logs.push(l),
136
141
  manifestPath: env.manifestPath,
137
142
  statePath: env.statePath,
143
+ exposeStatePath: env.exposeStatePath,
138
144
  configPath: env.configPath,
139
145
  logPath: env.logPath,
140
146
  cloudflaredHome: env.cloudflaredHome,
@@ -192,13 +198,197 @@ describe("exposeCloudflareUp", () => {
192
198
  // Security copy surfaces both paths plus a pointer to the auth doc.
193
199
  const joined = logs.join("\n");
194
200
  expect(joined).toContain("parachute auth set-password");
195
- 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_");
196
207
  expect(joined).toContain("auth-model.md");
197
208
  } finally {
198
209
  env.cleanup();
199
210
  }
200
211
  });
201
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
+
202
392
  test("reuses existing tunnel when name already present", async () => {
203
393
  const env = makeEnv();
204
394
  try {
@@ -223,6 +413,7 @@ describe("exposeCloudflareUp", () => {
223
413
  log: (l) => logs.push(l),
224
414
  manifestPath: env.manifestPath,
225
415
  statePath: env.statePath,
416
+ exposeStatePath: env.exposeStatePath,
226
417
  configPath: env.configPath,
227
418
  logPath: env.logPath,
228
419
  cloudflaredHome: env.cloudflaredHome,
@@ -252,6 +443,7 @@ describe("exposeCloudflareUp", () => {
252
443
  log: (l) => logs.push(l),
253
444
  manifestPath: env.manifestPath,
254
445
  statePath: env.statePath,
446
+ exposeStatePath: env.exposeStatePath,
255
447
  configPath: env.configPath,
256
448
  logPath: env.logPath,
257
449
  cloudflaredHome: env.cloudflaredHome,
@@ -280,6 +472,7 @@ describe("exposeCloudflareUp", () => {
280
472
  log: (l) => logs.push(l),
281
473
  manifestPath: env.manifestPath,
282
474
  statePath: env.statePath,
475
+ exposeStatePath: env.exposeStatePath,
283
476
  configPath: env.configPath,
284
477
  logPath: env.logPath,
285
478
  cloudflaredHome: env.cloudflaredHome,
@@ -311,6 +504,7 @@ describe("exposeCloudflareUp", () => {
311
504
  log: (l) => logs.push(l),
312
505
  manifestPath: env.manifestPath,
313
506
  statePath: env.statePath,
507
+ exposeStatePath: env.exposeStatePath,
314
508
  configPath: env.configPath,
315
509
  logPath: env.logPath,
316
510
  cloudflaredHome: env.cloudflaredHome,
@@ -339,6 +533,7 @@ describe("exposeCloudflareUp", () => {
339
533
  log: (l) => logs.push(l),
340
534
  manifestPath: env.manifestPath,
341
535
  statePath: env.statePath,
536
+ exposeStatePath: env.exposeStatePath,
342
537
  configPath: env.configPath,
343
538
  logPath: env.logPath,
344
539
  cloudflaredHome: env.cloudflaredHome,
@@ -366,6 +561,7 @@ describe("exposeCloudflareUp", () => {
366
561
  log: (l) => logs.push(l),
367
562
  manifestPath: env.manifestPath,
368
563
  statePath: env.statePath,
564
+ exposeStatePath: env.exposeStatePath,
369
565
  configPath: env.configPath,
370
566
  logPath: env.logPath,
371
567
  cloudflaredHome: env.cloudflaredHome,
@@ -402,6 +598,7 @@ describe("exposeCloudflareUp", () => {
402
598
  log: (l) => logs.push(l),
403
599
  manifestPath: env.manifestPath,
404
600
  statePath: env.statePath,
601
+ exposeStatePath: env.exposeStatePath,
405
602
  configPath: env.configPath,
406
603
  logPath: env.logPath,
407
604
  cloudflaredHome: env.cloudflaredHome,
@@ -453,6 +650,7 @@ describe("exposeCloudflareUp", () => {
453
650
  log: () => {},
454
651
  manifestPath: env.manifestPath,
455
652
  statePath: env.statePath,
653
+ exposeStatePath: env.exposeStatePath,
456
654
  configPath: env.configPath,
457
655
  logPath: env.logPath,
458
656
  cloudflaredHome: env.cloudflaredHome,
@@ -469,6 +667,191 @@ describe("exposeCloudflareUp", () => {
469
667
  }
470
668
  });
471
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
+
472
855
  test("two tunnels with different --tunnel-name coexist in state", async () => {
473
856
  const env = makeEnv();
474
857
  try {
@@ -494,10 +877,14 @@ describe("exposeCloudflareUp", () => {
494
877
  log: () => {},
495
878
  manifestPath: env.manifestPath,
496
879
  statePath: env.statePath,
880
+ exposeStatePath: env.exposeStatePath,
497
881
  cloudflaredHome: env.cloudflaredHome,
498
882
  configDir: env.configDir,
499
883
  skipHub: true,
500
- // Use defaults for configPath/logPath so they're per-tunnel-derived.
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/.
501
888
  });
502
889
  expect(code1).toBe(0);
503
890
 
@@ -521,6 +908,7 @@ describe("exposeCloudflareUp", () => {
521
908
  log: () => {},
522
909
  manifestPath: env.manifestPath,
523
910
  statePath: env.statePath,
911
+ exposeStatePath: env.exposeStatePath,
524
912
  cloudflaredHome: env.cloudflaredHome,
525
913
  configDir: env.configDir,
526
914
  skipHub: true,
@@ -578,11 +966,12 @@ describe("exposeCloudflareUp", () => {
578
966
  log: (l) => logs.push(l),
579
967
  manifestPath: env.manifestPath,
580
968
  statePath: env.statePath,
969
+ exposeStatePath: env.exposeStatePath,
581
970
  configPath: env.configPath,
582
971
  logPath: env.logPath,
583
972
  cloudflaredHome: env.cloudflaredHome,
584
- configDir: env.configDir,
585
- skipHub: true,
973
+ configDir: env.configDir,
974
+ skipHub: true,
586
975
  // No password, no 2FA — fully wide open. The warning should still
587
976
  // fire; password-recovery copy already lives in `printAuthGuidance`.
588
977
  vaultAuthStatus: {
@@ -595,7 +984,9 @@ describe("exposeCloudflareUp", () => {
595
984
 
596
985
  expect(code).toBe(0);
597
986
  const joined = logs.join("\n");
598
- 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");
599
990
  expect(joined).toContain("https://vault.example.com/login");
600
991
  expect(joined).toContain("parachute auth 2fa enroll");
601
992
  } finally {
@@ -603,7 +994,7 @@ describe("exposeCloudflareUp", () => {
603
994
  }
604
995
  });
605
996
 
606
- test("enrolled → warning suppressed (no '2FA is not enrolled' line)", async () => {
997
+ test("enrolled → warning suppressed (no public-/login warning line)", async () => {
607
998
  const env = makeEnv();
608
999
  try {
609
1000
  const uuid = "dddddddd-0000-0000-0000-000000000004";
@@ -628,11 +1019,12 @@ describe("exposeCloudflareUp", () => {
628
1019
  log: (l) => logs.push(l),
629
1020
  manifestPath: env.manifestPath,
630
1021
  statePath: env.statePath,
1022
+ exposeStatePath: env.exposeStatePath,
631
1023
  configPath: env.configPath,
632
1024
  logPath: env.logPath,
633
1025
  cloudflaredHome: env.cloudflaredHome,
634
- configDir: env.configDir,
635
- skipHub: true,
1026
+ configDir: env.configDir,
1027
+ skipHub: true,
636
1028
  vaultAuthStatus: {
637
1029
  hasOwnerPassword: true,
638
1030
  hasTotp: true,
@@ -643,11 +1035,13 @@ describe("exposeCloudflareUp", () => {
643
1035
 
644
1036
  expect(code).toBe(0);
645
1037
  const joined = logs.join("\n");
646
- expect(joined).not.toContain("2FA is not enrolled");
647
- // The existing `printAuthGuidance` 2FA-recommend bullet is unrelated
648
- // to the new contextual warning and stays in place — assert it on a
649
- // shape that doesn't collide with the warning text.
650
- 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");
651
1045
  } finally {
652
1046
  env.cleanup();
653
1047
  }
@@ -690,6 +1084,7 @@ describe("exposeCloudflareUp", () => {
690
1084
  log: (l) => logs.push(l),
691
1085
  manifestPath: env.manifestPath,
692
1086
  statePath: env.statePath,
1087
+ exposeStatePath: env.exposeStatePath,
693
1088
  configPath: env.configPath,
694
1089
  logPath: env.logPath,
695
1090
  cloudflaredHome: env.cloudflaredHome,
@@ -724,6 +1119,7 @@ describe("exposeCloudflareOff", () => {
724
1119
  const logs: string[] = [];
725
1120
  const code = await exposeCloudflareOff({
726
1121
  statePath: env.statePath,
1122
+ exposeStatePath: env.exposeStatePath,
727
1123
  log: (l) => logs.push(l),
728
1124
  });
729
1125
  expect(code).toBe(0);
@@ -733,7 +1129,7 @@ describe("exposeCloudflareOff", () => {
733
1129
  }
734
1130
  });
735
1131
 
736
- test("SIGTERMs the process and clears state", async () => {
1132
+ test("SIGTERMs the process and clears state (incl. expose-state.json — Fix 1)", async () => {
737
1133
  const env = makeEnv();
738
1134
  try {
739
1135
  writeCloudflaredState(
@@ -752,10 +1148,36 @@ describe("exposeCloudflareOff", () => {
752
1148
  },
753
1149
  env.statePath,
754
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
+
755
1176
  const killed: number[] = [];
756
1177
  const logs: string[] = [];
757
1178
  const code = await exposeCloudflareOff({
758
1179
  statePath: env.statePath,
1180
+ exposeStatePath: env.exposeStatePath,
759
1181
  alive: () => true,
760
1182
  kill: (pid) => killed.push(pid),
761
1183
  log: (l) => logs.push(l),
@@ -763,6 +1185,9 @@ describe("exposeCloudflareOff", () => {
763
1185
  expect(code).toBe(0);
764
1186
  expect(killed).toEqual([55555]);
765
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();
766
1191
  // Reassures the user that the tunnel definition isn't lost.
767
1192
  expect(logs.join("\n")).toContain("remains defined in Cloudflare");
768
1193
  } finally {
@@ -792,6 +1217,7 @@ describe("exposeCloudflareOff", () => {
792
1217
  const killed: number[] = [];
793
1218
  const code = await exposeCloudflareOff({
794
1219
  statePath: env.statePath,
1220
+ exposeStatePath: env.exposeStatePath,
795
1221
  alive: () => false,
796
1222
  kill: (pid) => killed.push(pid),
797
1223
  log: () => {},
@@ -804,6 +1230,46 @@ describe("exposeCloudflareOff", () => {
804
1230
  }
805
1231
  });
806
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
+
807
1273
  test("targets the named tunnel and leaves siblings intact", async () => {
808
1274
  const env = makeEnv();
809
1275
  try {
@@ -831,6 +1297,7 @@ describe("exposeCloudflareOff", () => {
831
1297
  const killed: number[] = [];
832
1298
  const code = await exposeCloudflareOff({
833
1299
  statePath: env.statePath,
1300
+ exposeStatePath: env.exposeStatePath,
834
1301
  alive: () => true,
835
1302
  kill: (pid) => killed.push(pid),
836
1303
  log: () => {},
@@ -865,6 +1332,7 @@ describe("exposeCloudflareOff", () => {
865
1332
  const logs: string[] = [];
866
1333
  const code = await exposeCloudflareOff({
867
1334
  statePath: env.statePath,
1335
+ exposeStatePath: env.exposeStatePath,
868
1336
  alive: () => true,
869
1337
  kill: () => {},
870
1338
  log: (l) => logs.push(l),