@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
@@ -40,40 +40,78 @@ function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
40
40
  };
41
41
  }
42
42
 
43
- describe("runAuthPreflight — wide open (no password, no tokens)", () => {
44
- test("warns loudly and offers password, 2FA, and token creation", async () => {
45
- const h = makeHarness(["y", "n", "n"]); // password yes, 2fa no, token no
43
+ describe("runAuthPreflight — wide open (no owner password)", () => {
44
+ test("warns loudly, offers password + 2FA, and prints hub-JWT token guidance", async () => {
45
+ const h = makeHarness(["y", "n"]); // password yes, 2fa no
46
46
  await runAuthPreflight({ status: status(), ...wire(h) });
47
47
  const joined = h.logs.join("\n");
48
- expect(joined).toContain("No owner password and no API tokens");
48
+ expect(joined).toContain("No owner password");
49
49
  expect(joined).toContain("public internet");
50
+ // Programmatic-client guidance points at the hub mint path, not pvt_*.
51
+ expect(joined).toContain("parachute auth mint-token --scope vault:default:read");
52
+ expect(joined).toContain("Bearer <hub-jwt>");
53
+ // Only password + 2FA are interactive offers; token guidance is printed,
54
+ // not prompted (no auto-mint).
50
55
  expect(h.commands).toHaveLength(1);
51
56
  expect(h.commands[0]).toEqual(["parachute", "auth", "set-password"]);
52
57
  });
53
58
 
59
+ test("token guidance uses the first discovered vault name", async () => {
60
+ const h = makeHarness(["n", "n"]);
61
+ await runAuthPreflight({ status: status({ vaultNames: ["work"] }), ...wire(h) });
62
+ expect(h.logs.join("\n")).toContain("--scope vault:work:read");
63
+ });
64
+
54
65
  test("user declines every prompt → no subprocesses run", async () => {
55
- const h = makeHarness(["", "", ""]); // all Enter = skip
66
+ const h = makeHarness(["", ""]); // all Enter = skip
56
67
  await runAuthPreflight({ status: status(), ...wire(h) });
57
68
  expect(h.commands).toHaveLength(0);
58
- // Still prompted on all three lines, even though each was declined.
59
- expect(h.prompts).toHaveLength(3);
69
+ // Prompted on password + 2FA (token guidance is not a prompt).
70
+ expect(h.prompts).toHaveLength(2);
60
71
  });
61
72
 
62
- test("user accepts all threeall three commands invoked in order", async () => {
63
- const h = makeHarness(["y", "y", "y"]);
73
+ test("user accepts password + 2FA both commands invoked in order", async () => {
74
+ const h = makeHarness(["y", "y"]);
64
75
  await runAuthPreflight({ status: status(), ...wire(h) });
65
76
  expect(h.commands.map((c) => c.join(" "))).toEqual([
66
77
  "parachute auth set-password",
67
78
  "parachute auth 2fa enroll",
68
- "parachute vault tokens create",
69
79
  ]);
70
80
  });
81
+
82
+ test("null tokenCount with no owner password still classifies wide-open", async () => {
83
+ // The `tokenCount: null` (unreadable vault DB) path is vestigial post-DROP
84
+ // — `classify()` gates on `hasOwnerPassword` alone. A box with no owner
85
+ // password AND an unreadable token DB must still take the loud wide-open
86
+ // branch, not silently fall through to a quieter state.
87
+ const h = makeHarness(["n", "n"]);
88
+ await runAuthPreflight({
89
+ status: status({ hasOwnerPassword: false, tokenCount: null }),
90
+ ...wire(h),
91
+ });
92
+ const joined = h.logs.join("\n");
93
+ expect(joined).toContain("No owner password");
94
+ expect(joined).toContain("public internet");
95
+ // Wide-open offers password + 2FA (two prompts); not the all-good path.
96
+ expect(h.prompts).toHaveLength(2);
97
+ });
98
+
99
+ test("never offers the removed `vault tokens create` command", async () => {
100
+ const h = makeHarness(["y", "y"]);
101
+ await runAuthPreflight({ status: status(), ...wire(h) });
102
+ const allCommands = h.commands.map((c) => c.join(" ")).join("\n");
103
+ expect(allCommands).not.toContain("vault tokens create");
104
+ // And no log line steers the operator at the dead command as guidance.
105
+ const guidance = h.logs.filter((l) => !l.includes("old affordance")).join("\n");
106
+ expect(guidance).not.toContain("parachute vault tokens create");
107
+ });
71
108
  });
72
109
 
73
110
  describe("runAuthPreflight — password set, no 2FA", () => {
74
- test("short nudge, offers 2FA only", async () => {
111
+ test("short nudge, offers 2FA only — ignores vestigial tokenCount", async () => {
75
112
  const h = makeHarness(["y"]);
76
113
  await runAuthPreflight({
114
+ // tokenCount is non-zero (vestigial pvt_* rows) but no longer consulted.
77
115
  status: status({ hasOwnerPassword: true, tokenCount: 3 }),
78
116
  ...wire(h),
79
117
  });
@@ -92,40 +130,8 @@ describe("runAuthPreflight — password set, no 2FA", () => {
92
130
  });
93
131
  expect(h.commands).toHaveLength(0);
94
132
  });
95
- });
96
-
97
- describe("runAuthPreflight — tokens exist, no password", () => {
98
- test("notes that OAuth is not set up, offers password", async () => {
99
- const h = makeHarness(["y"]);
100
- await runAuthPreflight({
101
- status: status({ hasOwnerPassword: false, tokenCount: 2 }),
102
- ...wire(h),
103
- });
104
- const joined = h.logs.join("\n");
105
- expect(joined).toContain("API tokens exist");
106
- expect(joined).toContain("no owner password");
107
- expect(h.prompts).toHaveLength(1);
108
- expect(h.commands).toEqual([["parachute", "auth", "set-password"]]);
109
- });
110
- });
111
-
112
- describe("runAuthPreflight — unknown token count (SQLite failed)", () => {
113
- test("advises running `tokens list`, no token-dependent prompts", async () => {
114
- const h = makeHarness([]);
115
- await runAuthPreflight({
116
- status: status({ hasOwnerPassword: false, hasTotp: false, tokenCount: null }),
117
- ...wire(h),
118
- });
119
- const joined = h.logs.join("\n");
120
- expect(joined).toContain("Couldn't read vault token state");
121
- expect(joined).toContain("parachute vault tokens list");
122
- // No prompts because we don't offer password/token flow when token
123
- // state is unknown (it'd be ambiguous whether we're dealing with the
124
- // wide-open or the tokens-only case).
125
- expect(h.prompts).toHaveLength(0);
126
- });
127
133
 
128
- test("password set + 2FA absent + tokens unknown still offers 2FA", async () => {
134
+ test("null tokenCount (DB unreadable) is irrelevant password gates the branch", async () => {
129
135
  const h = makeHarness([""]); // decline 2FA
130
136
  await runAuthPreflight({
131
137
  status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: null }),
@@ -137,22 +143,24 @@ describe("runAuthPreflight — unknown token count (SQLite failed)", () => {
137
143
  });
138
144
 
139
145
  describe("runAuthPreflight — all good", () => {
140
- test("single positive line, no prompts", async () => {
146
+ test("single positive line, no prompts (tokens not required)", async () => {
141
147
  const h = makeHarness([]);
142
148
  await runAuthPreflight({
143
- status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 1 }),
149
+ // tokenCount: 0 a hub JWT is minted on demand, not a standing need.
150
+ status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 0 }),
144
151
  ...wire(h),
145
152
  });
146
153
  const joined = h.logs.join("\n");
147
154
  expect(joined).toContain("looks good");
155
+ expect(joined).toContain("owner password + 2FA");
148
156
  expect(h.prompts).toHaveLength(0);
149
157
  expect(h.commands).toHaveLength(0);
150
158
  });
151
159
  });
152
160
 
153
161
  describe("runAuthPreflight — subprocess failure handling", () => {
154
- test("non-zero exit from auth command doesn't abort the rest of the preflight", async () => {
155
- const h = makeHarness(["y", "y", "y"]);
162
+ test("non-zero exit from an auth command doesn't abort the rest of the preflight", async () => {
163
+ const h = makeHarness(["y", "y"]);
156
164
  // Override the interactive runner to return non-zero on the first call.
157
165
  let first = true;
158
166
  const interactiveRunner = async (cmd: readonly string[]) => {
@@ -172,8 +180,8 @@ describe("runAuthPreflight — subprocess failure handling", () => {
172
180
  },
173
181
  interactiveRunner,
174
182
  });
175
- // All three commands still attempted, none aborted the flow.
176
- expect(h.commands.map((c) => c[0])).toEqual(["parachute", "parachute", "parachute"]);
183
+ // Both commands still attempted, neither aborted the flow.
184
+ expect(h.commands.map((c) => c[0])).toEqual(["parachute", "parachute"]);
177
185
  const joined = h.logs.join("\n");
178
186
  expect(joined).toContain("exited 7");
179
187
  });
@@ -14,8 +14,13 @@ import {
14
14
  exposeCloudflareOff,
15
15
  exposeCloudflareUp,
16
16
  } from "../commands/expose-cloudflare.ts";
17
+ import { writeHubPort } from "../hub-control.ts";
17
18
  import type { CommandResult, Runner } from "../tailscale/run.ts";
18
19
 
20
+ // Default seeded hub port used by tests with `skipHub: true`. The cloudflared
21
+ // path reads `<configDir>/hub/run/hub.port` instead of spawning a real hub.
22
+ const TEST_HUB_PORT = 1939;
23
+
19
24
  interface TestEnv {
20
25
  configDir: string;
21
26
  manifestPath: string;
@@ -41,6 +46,11 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
41
46
  require("node:fs").mkdirSync(configDir, { recursive: true });
42
47
  require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
43
48
 
49
+ // Seed the hub port so `skipHub: true` invocations can resolve a port
50
+ // without spawning the actual hub process. Matches the seam pattern used
51
+ // by expose.test.ts (which threads `hubEnsureOpts` for the same purpose).
52
+ writeHubPort(TEST_HUB_PORT, configDir);
53
+
44
54
  if (loggedIn) {
45
55
  writeFileSync(join(cloudflaredHome, "cert.pem"), "---");
46
56
  }
@@ -128,6 +138,8 @@ describe("exposeCloudflareUp", () => {
128
138
  configPath: env.configPath,
129
139
  logPath: env.logPath,
130
140
  cloudflaredHome: env.cloudflaredHome,
141
+ configDir: env.configDir,
142
+ skipHub: true,
131
143
  now: () => new Date("2026-04-22T12:00:00Z"),
132
144
  });
133
145
 
@@ -170,12 +182,22 @@ describe("exposeCloudflareUp", () => {
170
182
  const yaml = readFileSync(env.configPath, "utf8");
171
183
  expect(yaml).toContain(`tunnel: ${uuid}`);
172
184
  expect(yaml).toContain("- hostname: vault.example.com");
173
- expect(yaml).toContain("service: http://localhost:1940");
185
+ // Routes through the hub (not directly at vault). The hub dispatches
186
+ // discovery / admin / OAuth / per-vault proxy / generic /<svc>/* —
187
+ // same shape Tailscale Funnel uses. Pre-2026-05-27 this was
188
+ // http://localhost:1940 (vault's port), which served vault's own 404
189
+ // page on every request that wasn't /vault/<name>/...
190
+ expect(yaml).toContain(`service: http://localhost:${TEST_HUB_PORT}`);
174
191
 
175
192
  // Security copy surfaces both paths plus a pointer to the auth doc.
176
193
  const joined = logs.join("\n");
177
194
  expect(joined).toContain("parachute auth set-password");
178
- expect(joined).toContain("parachute vault tokens create");
195
+ // Scripts/machines path points at the hub-JWT mint (vault#412 / hub#466
196
+ // DROPped `vault tokens create`), not the removed pvt_* command.
197
+ expect(joined).toContain("parachute auth mint-token --scope vault:");
198
+ expect(joined).toContain("Bearer <hub-jwt>");
199
+ expect(joined).not.toContain("vault tokens create");
200
+ expect(joined).not.toContain("pvt_");
179
201
  expect(joined).toContain("auth-model.md");
180
202
  } finally {
181
203
  env.cleanup();
@@ -209,6 +231,8 @@ describe("exposeCloudflareUp", () => {
209
231
  configPath: env.configPath,
210
232
  logPath: env.logPath,
211
233
  cloudflaredHome: env.cloudflaredHome,
234
+ configDir: env.configDir,
235
+ skipHub: true,
212
236
  });
213
237
  expect(code).toBe(0);
214
238
  // No `tunnel create` — only list + route.
@@ -236,6 +260,8 @@ describe("exposeCloudflareUp", () => {
236
260
  configPath: env.configPath,
237
261
  logPath: env.logPath,
238
262
  cloudflaredHome: env.cloudflaredHome,
263
+ configDir: env.configDir,
264
+ skipHub: true,
239
265
  });
240
266
 
241
267
  expect(code).toBe(1);
@@ -262,6 +288,8 @@ describe("exposeCloudflareUp", () => {
262
288
  configPath: env.configPath,
263
289
  logPath: env.logPath,
264
290
  cloudflaredHome: env.cloudflaredHome,
291
+ configDir: env.configDir,
292
+ skipHub: true,
265
293
  tunnelName: "bad name with spaces",
266
294
  });
267
295
 
@@ -291,6 +319,8 @@ describe("exposeCloudflareUp", () => {
291
319
  configPath: env.configPath,
292
320
  logPath: env.logPath,
293
321
  cloudflaredHome: env.cloudflaredHome,
322
+ configDir: env.configDir,
323
+ skipHub: true,
294
324
  });
295
325
 
296
326
  expect(code).toBe(1);
@@ -317,6 +347,8 @@ describe("exposeCloudflareUp", () => {
317
347
  configPath: env.configPath,
318
348
  logPath: env.logPath,
319
349
  cloudflaredHome: env.cloudflaredHome,
350
+ configDir: env.configDir,
351
+ skipHub: true,
320
352
  });
321
353
 
322
354
  expect(code).toBe(1);
@@ -342,6 +374,8 @@ describe("exposeCloudflareUp", () => {
342
374
  configPath: env.configPath,
343
375
  logPath: env.logPath,
344
376
  cloudflaredHome: env.cloudflaredHome,
377
+ configDir: env.configDir,
378
+ skipHub: true,
345
379
  });
346
380
 
347
381
  expect(code).toBe(1);
@@ -376,6 +410,8 @@ describe("exposeCloudflareUp", () => {
376
410
  configPath: env.configPath,
377
411
  logPath: env.logPath,
378
412
  cloudflaredHome: env.cloudflaredHome,
413
+ configDir: env.configDir,
414
+ skipHub: true,
379
415
  });
380
416
 
381
417
  expect(code).toBe(1);
@@ -425,6 +461,8 @@ describe("exposeCloudflareUp", () => {
425
461
  configPath: env.configPath,
426
462
  logPath: env.logPath,
427
463
  cloudflaredHome: env.cloudflaredHome,
464
+ configDir: env.configDir,
465
+ skipHub: true,
428
466
  });
429
467
 
430
468
  expect(code).toBe(0);
@@ -462,7 +500,12 @@ describe("exposeCloudflareUp", () => {
462
500
  manifestPath: env.manifestPath,
463
501
  statePath: env.statePath,
464
502
  cloudflaredHome: env.cloudflaredHome,
465
- // Use defaults for configPath/logPath so they're per-tunnel-derived.
503
+ configDir: env.configDir,
504
+ skipHub: true,
505
+ // Omit configPath/logPath so they're per-tunnel-derived — but the
506
+ // derivation now resolves against the tmp `configDir` above, so the
507
+ // generated config.yml lands under tmp, not the operator's real
508
+ // ~/.parachute/cloudflared/parachute/.
466
509
  });
467
510
  expect(code1).toBe(0);
468
511
 
@@ -487,6 +530,8 @@ describe("exposeCloudflareUp", () => {
487
530
  manifestPath: env.manifestPath,
488
531
  statePath: env.statePath,
489
532
  cloudflaredHome: env.cloudflaredHome,
533
+ configDir: env.configDir,
534
+ skipHub: true,
490
535
  tunnelName: "second",
491
536
  });
492
537
  expect(code2).toBe(0);
@@ -544,6 +589,8 @@ describe("exposeCloudflareUp", () => {
544
589
  configPath: env.configPath,
545
590
  logPath: env.logPath,
546
591
  cloudflaredHome: env.cloudflaredHome,
592
+ configDir: env.configDir,
593
+ skipHub: true,
547
594
  // No password, no 2FA — fully wide open. The warning should still
548
595
  // fire; password-recovery copy already lives in `printAuthGuidance`.
549
596
  vaultAuthStatus: {
@@ -592,6 +639,8 @@ describe("exposeCloudflareUp", () => {
592
639
  configPath: env.configPath,
593
640
  logPath: env.logPath,
594
641
  cloudflaredHome: env.cloudflaredHome,
642
+ configDir: env.configDir,
643
+ skipHub: true,
595
644
  vaultAuthStatus: {
596
645
  hasOwnerPassword: true,
597
646
  hasTotp: true,
@@ -612,6 +661,68 @@ describe("exposeCloudflareUp", () => {
612
661
  }
613
662
  });
614
663
  });
664
+
665
+ describe("routes through hub, not vault", () => {
666
+ test("config.yml targets the hub port; success log mentions Admin + OAuth URLs", async () => {
667
+ // Regression guard for the 2026-05-27 cut. Aaron ran `parachute expose
668
+ // public` on a fresh EC2 box, configured Cloudflare with a custom
669
+ // domain, and hit it — and got vault's 404 page rather than the hub's
670
+ // discovery / admin. The pre-fix cloudflared config routed straight at
671
+ // vault's port; the fix routes at the hub, mirroring the Tailscale
672
+ // Funnel shape (single mount → hub catchall; hub dispatches per-request).
673
+ const env = makeEnv();
674
+ try {
675
+ // Re-seed hub port to a non-default value so the assertion is
676
+ // unambiguous about *which* port got into the yaml.
677
+ writeHubPort(1949, env.configDir);
678
+
679
+ const uuid = "ffff0000-0000-0000-0000-00000000beef";
680
+ const { runner } = queueRunner([
681
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
682
+ { code: 0, stdout: "[]", stderr: "" },
683
+ {
684
+ code: 0,
685
+ stdout: `Created tunnel parachute with id ${uuid}\n`,
686
+ stderr: "",
687
+ },
688
+ { code: 0, stdout: "", stderr: "" },
689
+ ]);
690
+ const { spawner } = fakeSpawner(60001);
691
+ const logs: string[] = [];
692
+
693
+ const code = await exposeCloudflareUp("gitcoin.parachute.computer", {
694
+ runner,
695
+ spawner,
696
+ alive: () => false,
697
+ kill: () => {},
698
+ log: (l) => logs.push(l),
699
+ manifestPath: env.manifestPath,
700
+ statePath: env.statePath,
701
+ configPath: env.configPath,
702
+ logPath: env.logPath,
703
+ cloudflaredHome: env.cloudflaredHome,
704
+ configDir: env.configDir,
705
+ skipHub: true,
706
+ });
707
+
708
+ expect(code).toBe(0);
709
+ const yaml = readFileSync(env.configPath, "utf8");
710
+ // Routes through the hub on its loopback port.
711
+ expect(yaml).toContain("service: http://localhost:1949");
712
+ // Does NOT route directly at vault's port (1940 per makeEnv default).
713
+ expect(yaml).not.toContain("service: http://localhost:1940");
714
+
715
+ const joined = logs.join("\n");
716
+ // Discoverable surfaces: open / admin / vault / OAuth all surfaced.
717
+ expect(joined).toContain("https://gitcoin.parachute.computer/");
718
+ expect(joined).toContain("Admin: https://gitcoin.parachute.computer/admin/");
719
+ expect(joined).toContain("Vault: https://gitcoin.parachute.computer/vault/default");
720
+ expect(joined).toContain("OAuth: https://gitcoin.parachute.computer");
721
+ } finally {
722
+ env.cleanup();
723
+ }
724
+ });
725
+ });
615
726
  });
616
727
 
617
728
  describe("exposeCloudflareOff", () => {
@@ -469,10 +469,16 @@ describe("exposePublicInteractive — neither ready", () => {
469
469
  expect(interactiveCalled).toBe(false);
470
470
  expect(cloudflareCalled).toBe(false);
471
471
  const joined = logs.join("\n");
472
- expect(joined).toMatch(/apt-get|dnf/);
473
- expect(joined).toContain(
474
- "developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads",
475
- );
472
+ // Post 2026-05-27 cloudflared-URL refresh: the install hint moved
473
+ // off apt-get / dnf / developers.cloudflare.com (all unreliable —
474
+ // Aaron hit `No match for argument: cloudflared` on AL2023 and
475
+ // 404s from the docs URL on the same box) onto the static binary
476
+ // from GitHub releases.
477
+ expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
478
+ expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
479
+ expect(joined).not.toContain("developers.cloudflare.com");
480
+ expect(joined).not.toContain("pkg.cloudflare.com");
481
+ expect(joined).not.toContain("sudo dnf install cloudflared");
476
482
  } finally {
477
483
  env.cleanup();
478
484
  }
@@ -126,7 +126,11 @@ describe("exposePublicAutoPick — neither ready", () => {
126
126
  expect(code).toBe(1);
127
127
  const joined = logs.join("\n");
128
128
  expect(joined).toContain("tailscale.com/download");
129
- expect(joined).toContain("developers.cloudflare.com");
129
+ // Post 2026-05-27 cloudflared-URL refresh: the install hint now points
130
+ // at GitHub releases (developers.cloudflare.com / pkg.cloudflare.com
131
+ // both returned HTML/404 on Aaron's fresh AL2023 EC2 box).
132
+ expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
133
+ expect(joined).not.toContain("developers.cloudflare.com");
130
134
  expect(joined).toContain("--skip-provider-check");
131
135
  });
132
136
 
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { exposePublic, exposeTailnet } from "../commands/expose.ts";
6
+ import { readEnvFileValues } from "../env-file.ts";
6
7
  import { readExposeState, writeExposeState } from "../expose-state.ts";
7
8
  import type { EnsureHubOpts, HubSpawner, StopHubOpts } from "../hub-control.ts";
8
9
  import { writePid } from "../process-state.ts";
@@ -696,6 +697,53 @@ describe("expose tailnet off", () => {
696
697
  }
697
698
  });
698
699
 
700
+ test("clears the persisted PARACHUTE_HUB_ORIGIN from vault/.env on teardown", async () => {
701
+ // OAuth issuer-mismatch fix: `expose up` persisted the public origin into
702
+ // vault/.env so the daemon validates `iss` against it. With exposure gone,
703
+ // a local-only hub mints loopback-`iss` tokens, so a stale public origin
704
+ // left in `.env` would itself cause the mismatch on the next daemon boot.
705
+ // Tearing it down reverts vault to its loopback default.
706
+ const h = makeHarness();
707
+ try {
708
+ writeExposeState(
709
+ {
710
+ version: 1,
711
+ layer: "tailnet",
712
+ mode: "path",
713
+ canonicalFqdn: "parachute.taildf9ce2.ts.net",
714
+ port: 443,
715
+ funnel: false,
716
+ entries: [{ kind: "proxy", mount: "/", target: "http://127.0.0.1:1939", service: "hub" }],
717
+ hubOrigin: "https://parachute.taildf9ce2.ts.net",
718
+ },
719
+ h.statePath,
720
+ );
721
+ mkdirSync(join(h.configDir, "vault"), { recursive: true });
722
+ writeFileSync(
723
+ join(h.configDir, "vault", ".env"),
724
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://parachute.taildf9ce2.ts.net\n",
725
+ );
726
+ const { runner } = makeRunner();
727
+ const code = await exposeTailnet("off", {
728
+ runner,
729
+ statePath: h.statePath,
730
+ wellKnownPath: h.wellKnownPath,
731
+ hubPath: h.hubPath,
732
+ wellKnownDir: h.wellKnownDir,
733
+ configDir: h.configDir,
734
+ skipHub: true,
735
+ log: () => {},
736
+ });
737
+ expect(code).toBe(0);
738
+ const values = readEnvFileValues(join(h.configDir, "vault", ".env"));
739
+ expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
740
+ // Sibling keys preserved.
741
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
742
+ } finally {
743
+ h.cleanup();
744
+ }
745
+ });
746
+
699
747
  test("leaves state in place on teardown failure", async () => {
700
748
  const h = makeHarness();
701
749
  try {