@openparachute/hub 0.5.14-rc.16 → 0.5.14-rc.17

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.16",
3
+ "version": "0.5.14-rc.17",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -14,6 +14,7 @@ import {
14
14
  exposeCloudflareOff,
15
15
  exposeCloudflareUp,
16
16
  } from "../commands/expose-cloudflare.ts";
17
+ import { readEnvFileValues } from "../env-file.ts";
17
18
  import { readExposeState } from "../expose-state.ts";
18
19
  import { writeHubPort } from "../hub-control.ts";
19
20
  import type { CommandResult, Runner } from "../tailscale/run.ts";
@@ -273,6 +274,121 @@ describe("exposeCloudflareUp", () => {
273
274
  }
274
275
  });
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
+
276
392
  test("reuses existing tunnel when name already present", async () => {
277
393
  const env = makeEnv();
278
394
  try {
@@ -362,6 +362,51 @@ describe("parachute start", () => {
362
362
  }
363
363
  });
364
364
 
365
+ test("self-heals a stale-loopback vault/.env from a cloudflare expose-state on restart", async () => {
366
+ // Existing-broken-deploy shape: a Cloudflare deploy whose vault/.env had a
367
+ // loopback PARACHUTE_HUB_ORIGIN baked in (or was unset and a prior run
368
+ // wrote loopback). expose-state.json carries the real public origin. A
369
+ // plain `parachute start vault` must rewrite vault/.env to the public
370
+ // origin so the daemon stops 401ing hub tokens — the self-heal half of the
371
+ // Cloudflare 401 fix.
372
+ const h = makeHarness();
373
+ try {
374
+ seedVault(h.manifestPath);
375
+ writeFileSync(
376
+ join(h.configDir, "expose-state.json"),
377
+ JSON.stringify({
378
+ version: 1,
379
+ layer: "public",
380
+ mode: "subdomain",
381
+ canonicalFqdn: "gitcoin-parachute.unforced.dev",
382
+ port: 1939,
383
+ funnel: false,
384
+ entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
385
+ hubOrigin: "https://gitcoin-parachute.unforced.dev",
386
+ }),
387
+ );
388
+ // Pre-seed vault/.env with a stale loopback value (the broken state).
389
+ mkdirSync(join(h.configDir, "vault"), { recursive: true });
390
+ writeFileSync(
391
+ join(h.configDir, "vault", ".env"),
392
+ "PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n",
393
+ );
394
+ const spawner = makeSpawner([4242]);
395
+ const code = await start("vault", {
396
+ configDir: h.configDir,
397
+ manifestPath: h.manifestPath,
398
+ spawner,
399
+ log: () => {},
400
+ });
401
+ expect(code).toBe(0);
402
+ expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
403
+ "https://gitcoin-parachute.unforced.dev",
404
+ );
405
+ } finally {
406
+ h.cleanup();
407
+ }
408
+ });
409
+
365
410
  test("does NOT persist a loopback origin into vault/.env (would shadow a later exposure)", async () => {
366
411
  const h = makeHarness();
367
412
  try {
@@ -10,10 +10,14 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { readEnvFileValues } from "../env-file.ts";
13
+ import type { ExposeState } from "../expose-state.ts";
14
+ import { writeExposeState } from "../expose-state.ts";
13
15
  import {
14
16
  clearVaultHubOrigin,
15
17
  isLoopbackOrigin,
16
18
  persistVaultHubOrigin,
19
+ publicOriginFromExposeState,
20
+ selfHealVaultHubOrigin,
17
21
  } from "../vault-hub-origin-env.ts";
18
22
 
19
23
  let dir: string;
@@ -124,3 +128,136 @@ function mkVaultDir(): string {
124
128
  mkdirSync(join(dir, "vault"), { recursive: true });
125
129
  return vaultEnv();
126
130
  }
131
+
132
+ function exposeStatePath(): string {
133
+ return join(dir, "expose-state.json");
134
+ }
135
+
136
+ /** Cloudflare-shaped expose state (subdomain mode, single hub-catchall entry). */
137
+ function cloudflareState(overrides: Partial<ExposeState> = {}): ExposeState {
138
+ return {
139
+ version: 1,
140
+ layer: "public",
141
+ mode: "subdomain",
142
+ canonicalFqdn: "gitcoin-parachute.unforced.dev",
143
+ port: 1939,
144
+ funnel: false,
145
+ entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
146
+ hubOrigin: "https://gitcoin-parachute.unforced.dev",
147
+ ...overrides,
148
+ };
149
+ }
150
+
151
+ /** Tailnet-shaped expose state (path mode). */
152
+ function tailnetState(overrides: Partial<ExposeState> = {}): ExposeState {
153
+ return {
154
+ version: 1,
155
+ layer: "tailnet",
156
+ mode: "path",
157
+ canonicalFqdn: "parachute-aaron.tailc75afc.ts.net",
158
+ port: 1939,
159
+ funnel: false,
160
+ entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
161
+ hubOrigin: "https://parachute-aaron.tailc75afc.ts.net",
162
+ ...overrides,
163
+ };
164
+ }
165
+
166
+ describe("publicOriginFromExposeState", () => {
167
+ test("undefined when no expose-state file exists", () => {
168
+ expect(publicOriginFromExposeState(exposeStatePath())).toBeUndefined();
169
+ });
170
+
171
+ test("returns the cloudflare hubOrigin", () => {
172
+ writeExposeState(cloudflareState(), exposeStatePath());
173
+ expect(publicOriginFromExposeState(exposeStatePath())).toBe(
174
+ "https://gitcoin-parachute.unforced.dev",
175
+ );
176
+ });
177
+
178
+ test("returns the tailnet hubOrigin", () => {
179
+ writeExposeState(tailnetState(), exposeStatePath());
180
+ expect(publicOriginFromExposeState(exposeStatePath())).toBe(
181
+ "https://parachute-aaron.tailc75afc.ts.net",
182
+ );
183
+ });
184
+
185
+ test("synthesizes https://<canonicalFqdn> when hubOrigin is absent (pre-Phase-0 state)", () => {
186
+ // hubOrigin is optional on older state files; canonicalFqdn is mandatory.
187
+ const { hubOrigin, ...rest } = cloudflareState();
188
+ void hubOrigin;
189
+ writeExposeState(rest as ExposeState, exposeStatePath());
190
+ expect(publicOriginFromExposeState(exposeStatePath())).toBe(
191
+ "https://gitcoin-parachute.unforced.dev",
192
+ );
193
+ });
194
+ });
195
+
196
+ describe("selfHealVaultHubOrigin (Cloudflare 401 self-heal)", () => {
197
+ test("writes the cloudflare public origin when vault/.env is UNSET", () => {
198
+ // The exact broken-deploy shape: expose-state carries a public cloudflare
199
+ // hubOrigin but vault/.env has no PARACHUTE_HUB_ORIGIN, so the daemon falls
200
+ // back to loopback and 401s every hub token. Restart self-corrects it.
201
+ writeExposeState(cloudflareState(), exposeStatePath());
202
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
203
+ expect(wrote).toBe(true);
204
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
205
+ "https://gitcoin-parachute.unforced.dev",
206
+ );
207
+ });
208
+
209
+ test("overwrites a LOOPBACK value already persisted in vault/.env", () => {
210
+ writeExposeState(cloudflareState(), exposeStatePath());
211
+ writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n");
212
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
213
+ expect(wrote).toBe(true);
214
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
215
+ "https://gitcoin-parachute.unforced.dev",
216
+ );
217
+ });
218
+
219
+ test("tailnet shape still self-heals (no regression)", () => {
220
+ writeExposeState(tailnetState(), exposeStatePath());
221
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
222
+ expect(wrote).toBe(true);
223
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe(
224
+ "https://parachute-aaron.tailc75afc.ts.net",
225
+ );
226
+ });
227
+
228
+ test("does NOT persist when there's no exposure (genuine loopback / local dev)", () => {
229
+ // No expose-state file → no public origin → vault keeps its loopback
230
+ // default. Persisting loopback would shadow a later exposure.
231
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
232
+ expect(wrote).toBe(false);
233
+ expect(existsSync(vaultEnv())).toBe(false);
234
+ });
235
+
236
+ test("leaves a DIFFERENT non-loopback value alone (deliberate --hub-origin override)", () => {
237
+ writeExposeState(cloudflareState(), exposeStatePath());
238
+ writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=https://custom.example.com\n");
239
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
240
+ expect(wrote).toBe(false);
241
+ // Untouched — self-heal only fixes unset/loopback, never clobbers a public
242
+ // value an operator may have set on purpose.
243
+ expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGIN).toBe("https://custom.example.com");
244
+ });
245
+
246
+ test("no-op (no double-write) when the persisted value already equals the public origin", () => {
247
+ writeExposeState(cloudflareState(), exposeStatePath());
248
+ writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGIN=https://gitcoin-parachute.unforced.dev\n");
249
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
250
+ expect(wrote).toBe(false);
251
+ });
252
+
253
+ test("expose-state with a loopback hubOrigin is treated as no public exposure", () => {
254
+ // A loopback hubOrigin (local-dev hub) must never be persisted — it would
255
+ // recreate the iss mismatch on the daemon boot path.
256
+ writeExposeState(cloudflareState({ hubOrigin: "http://127.0.0.1:1939" }), exposeStatePath());
257
+ // canonicalFqdn is still public here, but hubOrigin wins — we honor the
258
+ // explicit value the writer chose.
259
+ const wrote = selfHealVaultHubOrigin(dir, () => {}, exposeStatePath());
260
+ expect(wrote).toBe(false);
261
+ expect(existsSync(vaultEnv())).toBe(false);
262
+ });
263
+ });
@@ -41,12 +41,14 @@ import {
41
41
  readHubPort,
42
42
  } from "../hub-control.ts";
43
43
  import { deriveHubOrigin } from "../hub-origin.ts";
44
- import { type AliveFn, defaultAlive } from "../process-state.ts";
44
+ import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
45
45
  import { readManifest } from "../services-manifest.ts";
46
46
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
47
+ import { persistVaultHubOrigin } from "../vault-hub-origin-env.ts";
47
48
  import type { VaultAuthStatus } from "../vault/auth-status.ts";
48
49
  import { WELL_KNOWN_DIR } from "../well-known.ts";
49
50
  import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
51
+ import { restart } from "./lifecycle.ts";
50
52
 
51
53
  const AUTH_DOC_URL =
52
54
  "https://github.com/ParachuteComputer/parachute-vault/blob/main/docs/auth-model.md";
@@ -328,6 +330,14 @@ export interface ExposeCloudflareOpts {
328
330
  * `<vaultHome>/config.yaml` from disk. (#186)
329
331
  */
330
332
  vaultAuthStatus?: VaultAuthStatus;
333
+ /**
334
+ * Restart a hub-dependent service so it re-reads the new public hub origin.
335
+ * Mirrors the Tailscale path's `restartService` seam (`expose.ts`). Defaults
336
+ * to lifecycle `restart`; tests inject a fake to assert the call without
337
+ * spawning a real daemon. Only invoked for vault (the only `iss`-validating
338
+ * service) and only when it's already running.
339
+ */
340
+ restartService?: (short: string) => Promise<number>;
331
341
  }
332
342
 
333
343
  interface Resolved {
@@ -353,6 +363,7 @@ interface Resolved {
353
363
  now: () => Date;
354
364
  vaultHome: string | undefined;
355
365
  vaultAuthStatus: VaultAuthStatus | undefined;
366
+ restartService: (short: string) => Promise<number>;
356
367
  }
357
368
 
358
369
  function resolve(opts: ExposeCloudflareOpts): Resolved {
@@ -395,6 +406,14 @@ function resolve(opts: ExposeCloudflareOpts): Resolved {
395
406
  now: opts.now ?? (() => new Date()),
396
407
  vaultHome: opts.vaultHome,
397
408
  vaultAuthStatus: opts.vaultAuthStatus,
409
+ restartService:
410
+ opts.restartService ??
411
+ ((short: string) =>
412
+ restart(short, {
413
+ manifestPath: opts.manifestPath,
414
+ configDir,
415
+ log: opts.log ?? (() => {}),
416
+ })),
398
417
  };
399
418
  }
400
419
 
@@ -700,6 +719,35 @@ export async function exposeCloudflareUp(
700
719
  };
701
720
  writeExposeState(exposeState, r.exposeStatePath);
702
721
 
722
+ // Persist the public hub origin into vault's `.env` and restart vault — the
723
+ // durable half of the OAuth issuer-mismatch fix on Cloudflare deploys.
724
+ //
725
+ // The bug (vault 401s every hub token on a Cloudflare deploy): the Tailscale
726
+ // path gets this for free because it auto-restarts vault, and that restart
727
+ // flows the freshly-written expose-state `hubOrigin` into `vault/.env` via
728
+ // lifecycle's `persistVaultHubOrigin`. The Cloudflare path wrote expose-state
729
+ // but never touched vault's `.env` or restarted it, so the launchd / systemd
730
+ // daemon kept booting vault with NO `PARACHUTE_HUB_ORIGIN` → vault fell back
731
+ // to loopback as its expected issuer → every hub-minted token (whose `iss`
732
+ // is the public origin) failed the `iss` check → 401 → "You're not signed in
733
+ // to the hub." We mirror the Tailscale path here exactly.
734
+ //
735
+ // `persistVaultHubOrigin` writes the durable `.env` (skips loopback itself,
736
+ // so a `--hub-origin http://127.0.0.1` override never bakes a dead issuer in);
737
+ // the restart makes the running vault re-read it immediately rather than
738
+ // waiting for the next reboot.
739
+ persistVaultHubOrigin(r.configDir, hubOrigin, r.log);
740
+ if (processState("vault", r.configDir, r.alive).status === "running") {
741
+ r.log("");
742
+ r.log("Restarting vault to pick up new hub origin…");
743
+ const rcode = await r.restartService("vault");
744
+ if (rcode !== 0) {
745
+ r.log(
746
+ "⚠ vault restart failed. Run manually once the issue is resolved: parachute restart vault",
747
+ );
748
+ }
749
+ }
750
+
703
751
  const baseUrl = `https://${hostname}`;
704
752
  // A well-formed vault manifest always lists at least one mount path. If
705
753
  // it's empty, something went sideways in `parachute install vault` — warn
@@ -34,7 +34,7 @@ import {
34
34
  shortNameForManifest,
35
35
  } from "../service-spec.ts";
36
36
  import { type ServiceEntry, readManifest } from "../services-manifest.ts";
37
- import { persistVaultHubOrigin } from "../vault-hub-origin-env.ts";
37
+ import { persistVaultHubOrigin, selfHealVaultHubOrigin } from "../vault-hub-origin-env.ts";
38
38
 
39
39
  /**
40
40
  * Tiny seam over `Bun.spawn` for lifecycle tests. The real spawner opens the
@@ -657,30 +657,44 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
657
657
  ` It may still be coming up — check \`parachute status\` and \`parachute logs ${short}\`.`,
658
658
  );
659
659
  if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
660
- if (short === "vault" && r.hubOrigin) {
661
- persistVaultHubOrigin(r.configDir, r.hubOrigin, r.log);
662
- }
660
+ if (short === "vault") persistVaultHubOriginForStart(r);
663
661
  continue;
664
662
  }
665
663
  }
666
664
 
667
665
  r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
668
666
  if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
669
- // Persist the resolved public origin to vault's `.env` so the launchd /
670
- // systemd daemon (which boots vault out-of-band on reboot / crash-restart
671
- // and never sees this spawn env) validates hub-minted JWTs' `iss` against
672
- // the public origin. The spawn-env injection above is ephemeral; this is
673
- // the durable half of the OAuth issuer-mismatch fix. Vault is the only
674
- // hub-origin-dependent service with an OS-supervised autostart daemon
675
- // today — scribe/notes don't validate `iss`. See
676
- // `vault-hub-origin-env.ts:persistVaultHubOrigin`.
677
- if (short === "vault" && r.hubOrigin) {
678
- persistVaultHubOrigin(r.configDir, r.hubOrigin, r.log);
679
- }
667
+ if (short === "vault") persistVaultHubOriginForStart(r);
680
668
  }
681
669
  return failures === 0 ? 0 : 1;
682
670
  }
683
671
 
672
+ /**
673
+ * Durable-persist vault's `PARACHUTE_HUB_ORIGIN` on a vault `start`. Two cases,
674
+ * in order:
675
+ *
676
+ * 1. The resolved spawn origin (`r.hubOrigin`) is a real public origin — write
677
+ * it. This is the long-standing happy path: an exposure is live, the
678
+ * launchd / systemd daemon (which boots vault out-of-band and never sees
679
+ * this spawn env) needs it in `.env` to validate hub-minted JWTs' `iss`.
680
+ * `persistVaultHubOrigin` skips loopback / unchanged values itself.
681
+ *
682
+ * 2. Self-heal: even when `r.hubOrigin` resolved to loopback or undefined
683
+ * (e.g. the hub.port file outran the expose-state read, or this is a bare
684
+ * `restart vault` on a deploy whose `.env` was never written), consult
685
+ * `expose-state.json` directly. If it advertises a public origin and
686
+ * vault's persisted value is unset / loopback, write the public origin.
687
+ * This is what lets an EXISTING broken Cloudflare deploy self-correct on
688
+ * the next `parachute restart vault`, not only fresh exposes.
689
+ *
690
+ * Case 1 covers the override / freshly-resolved path; case 2 catches the gap
691
+ * the Cloudflare 401 P0 fell through. See `vault-hub-origin-env.ts`.
692
+ */
693
+ function persistVaultHubOriginForStart(r: Resolved): void {
694
+ if (r.hubOrigin) persistVaultHubOrigin(r.configDir, r.hubOrigin, r.log);
695
+ selfHealVaultHubOrigin(r.configDir, r.log, join(r.configDir, "expose-state.json"));
696
+ }
697
+
684
698
  export async function stop(svc: string | undefined, opts: LifecycleOpts = {}): Promise<number> {
685
699
  const r = resolve(opts);
686
700
  if (svc === HUB_SVC) return stopHubSvc(r);
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { join } from "node:path";
30
30
  import { parseEnvFile, removeEnvLine, upsertEnvLine, writeEnvFile } from "./env-file.ts";
31
+ import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
31
32
  import { HUB_ORIGIN_ENV } from "./hub-origin.ts";
32
33
 
33
34
  /**
@@ -98,3 +99,65 @@ export function clearVaultHubOrigin(configDir: string, log: (line: string) => vo
98
99
  log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
99
100
  return true;
100
101
  }
102
+
103
+ /**
104
+ * The public origin a live exposure advertises, or undefined when no exposure
105
+ * is active. Both the Tailscale and Cloudflare expose paths populate
106
+ * `expose-state.json` with a `hubOrigin` (the URL stamped into OAuth tokens'
107
+ * `iss` claim); older state files predating Phase 0 may carry only
108
+ * `canonicalFqdn`, so we synthesize `https://<fqdn>` as a fallback. Loopback /
109
+ * empty values resolve to undefined — there's no public origin to persist.
110
+ */
111
+ export function publicOriginFromExposeState(
112
+ exposeStatePath: string = EXPOSE_STATE_PATH,
113
+ ): string | undefined {
114
+ let state: ReturnType<typeof readExposeState>;
115
+ try {
116
+ state = readExposeState(exposeStatePath);
117
+ } catch {
118
+ // A malformed expose-state must never block a vault start — treat it as
119
+ // "no exposure" and let the loopback default stand.
120
+ return undefined;
121
+ }
122
+ if (!state) return undefined;
123
+ const origin = state.hubOrigin ?? (state.canonicalFqdn ? `https://${state.canonicalFqdn}` : "");
124
+ if (!origin || isLoopbackOrigin(origin)) return undefined;
125
+ return origin.replace(/\/+$/, "");
126
+ }
127
+
128
+ /**
129
+ * Self-heal vault's persisted `PARACHUTE_HUB_ORIGIN` from `expose-state.json`.
130
+ *
131
+ * The bug this closes (the Cloudflare 401 P0): on a Cloudflare-tunnel deploy the
132
+ * expose path writes a public `hubOrigin` into `expose-state.json`, but — unlike
133
+ * the Tailscale path, which auto-restarts vault and so flows the public origin
134
+ * into `vault/.env` via `persistVaultHubOrigin` — it never wrote vault's `.env`.
135
+ * So the launchd / systemd daemon kept booting vault with NO `PARACHUTE_HUB_ORIGIN`,
136
+ * vault fell back to loopback as its expected issuer, and every hub-minted token
137
+ * (whose `iss` is the public origin) failed the `iss` check → 401 on every vault
138
+ * request → "You're not signed in to the hub."
139
+ *
140
+ * Called on `parachute start|restart vault`: when expose-state advertises a
141
+ * public origin AND vault's persisted value is unset or loopback, write the
142
+ * public origin. Existing broken deploys self-correct on the next restart, not
143
+ * just fresh ones. We deliberately do NOT overwrite a *different* non-loopback
144
+ * value already in `.env` — that could be a deliberate `--hub-origin` override;
145
+ * `persistVaultHubOrigin` (the explicit, resolved-origin path) owns that case.
146
+ *
147
+ * Returns true iff `.env` was written this call.
148
+ */
149
+ export function selfHealVaultHubOrigin(
150
+ configDir: string,
151
+ log: (line: string) => void,
152
+ exposeStatePath: string = EXPOSE_STATE_PATH,
153
+ ): boolean {
154
+ const publicOrigin = publicOriginFromExposeState(exposeStatePath);
155
+ if (!publicOrigin) return false;
156
+ const current = parseEnvFile(vaultEnvPath(configDir)).values[HUB_ORIGIN_ENV];
157
+ // Only heal the broken shapes: unset (daemon falls back to loopback) or an
158
+ // already-persisted loopback (a value that itself causes the iss mismatch).
159
+ // A current public value — including one equal to publicOrigin — is left to
160
+ // persistVaultHubOrigin's idempotent path so we don't double-log.
161
+ if (current !== undefined && !isLoopbackOrigin(current)) return false;
162
+ return persistVaultHubOrigin(configDir, publicOrigin, log);
163
+ }