@openparachute/hub 0.6.3-rc.1 → 0.6.3-rc.3

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 (49) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/api-modules-ops.test.ts +121 -0
  5. package/src/__tests__/api-modules.test.ts +67 -0
  6. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  7. package/src/__tests__/expose-off-auto.test.ts +26 -1
  8. package/src/__tests__/expose.test.ts +260 -240
  9. package/src/__tests__/host-admin-token-validation.test.ts +218 -0
  10. package/src/__tests__/hub-control.test.ts +1 -242
  11. package/src/__tests__/hub-server.test.ts +64 -0
  12. package/src/__tests__/lifecycle.test.ts +431 -1886
  13. package/src/__tests__/migrate-cutover.test.ts +840 -0
  14. package/src/__tests__/migrate-offer.test.ts +240 -0
  15. package/src/__tests__/migrate.test.ts +148 -0
  16. package/src/__tests__/operator-token.test.ts +277 -0
  17. package/src/__tests__/status-supervisor.test.ts +12 -77
  18. package/src/__tests__/status.test.ts +157 -708
  19. package/src/__tests__/upgrade.test.ts +351 -5
  20. package/src/api-hub-upgrade.ts +384 -0
  21. package/src/api-hub.ts +2 -1
  22. package/src/api-modules-ops.ts +28 -2
  23. package/src/api-modules.ts +25 -2
  24. package/src/cli.ts +85 -10
  25. package/src/commands/expose-cloudflare.ts +63 -71
  26. package/src/commands/expose-supervisor.ts +247 -0
  27. package/src/commands/expose.ts +59 -48
  28. package/src/commands/lifecycle.ts +184 -873
  29. package/src/commands/migrate-cutover.ts +837 -0
  30. package/src/commands/migrate.ts +71 -2
  31. package/src/commands/status.ts +35 -282
  32. package/src/commands/upgrade.ts +100 -2
  33. package/src/help.ts +128 -68
  34. package/src/host-admin-token-validation.ts +96 -0
  35. package/src/hub-control.ts +23 -162
  36. package/src/hub-server.ts +47 -3
  37. package/src/hub-upgrade-helper.ts +306 -0
  38. package/src/hub-upgrade-mode.ts +209 -0
  39. package/src/hub-upgrade-status.ts +150 -0
  40. package/src/managed-unit.ts +20 -2
  41. package/src/migrate-offer.ts +186 -0
  42. package/src/operator-token.ts +96 -5
  43. package/src/origin-check.ts +10 -0
  44. package/src/process-state.ts +19 -3
  45. package/src/supervisor.ts +29 -24
  46. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  47. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  48. package/web/ui/dist/index.html +2 -2
  49. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -70,6 +70,36 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
70
70
  return signed.token;
71
71
  }
72
72
 
73
+ /**
74
+ * Mint a host-admin bearer at a chosen `iss` (hub#516). The CLI presents the
75
+ * operator token on loopback; after `expose` its `iss` is the public origin
76
+ * while the loopback request resolves the loopback issuer.
77
+ */
78
+ async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
79
+ const signed = await signAccessToken(h.db, {
80
+ sub: h.userId,
81
+ scopes,
82
+ audience: "operator",
83
+ clientId: "parachute-hub",
84
+ issuer: iss,
85
+ ttlSeconds: 3600,
86
+ });
87
+ recordTokenMint(h.db, {
88
+ jti: signed.jti,
89
+ createdVia: "operator_mint",
90
+ subject: "operator",
91
+ clientId: "parachute-hub",
92
+ scopes,
93
+ expiresAt: signed.expiresAt,
94
+ });
95
+ return signed.token;
96
+ }
97
+
98
+ /** The hub's public origin after `expose` — what the operator token's `iss` becomes. */
99
+ const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
100
+ /** A foreign origin the hub never answers on. */
101
+ const FOREIGN_ORIGIN = "https://evil.example.com";
102
+
73
103
  function postReq(path: string, headers: Record<string, string>): Request {
74
104
  return new Request(`http://localhost${path}`, { method: "POST", headers });
75
105
  }
@@ -967,6 +997,97 @@ describe("POST /api/modules/:short/stop", () => {
967
997
  });
968
998
  });
969
999
 
1000
+ /**
1001
+ * hub#516 — the module-ops `authorize` accepts the operator token's `iss`
1002
+ * against the SET of origins the hub answers on (knownIssuers), not just the
1003
+ * single per-request issuer. Exercised through `handleStop` (the simplest sync
1004
+ * op that goes through `authorize`). The per-request `issuer` here is loopback
1005
+ * (mirroring a loopback CLI request); `knownIssuers` carries loopback + the
1006
+ * public expose-state origin.
1007
+ */
1008
+ describe("operator-token iss validation against knownIssuers (hub#516)", () => {
1009
+ let h: Harness;
1010
+ beforeEach(async () => {
1011
+ h = await makeHarness();
1012
+ _resetOperationsRegistryForTests();
1013
+ });
1014
+ afterEach(() => h.cleanup());
1015
+
1016
+ // The live repro: operator token's iss = PUBLIC origin, loopback request
1017
+ // (per-request issuer = loopback), knownIssuers includes the public origin
1018
+ // → ACCEPTED. Was rejected (`unexpected "iss" claim value`) pre-fix.
1019
+ test("live repro: public-iss operator token on a loopback request → ACCEPTED", async () => {
1020
+ const { supervisor } = makeIdleSupervisor();
1021
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
1022
+ const res = await handleStop(
1023
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1024
+ "vault",
1025
+ {
1026
+ db: h.db,
1027
+ issuer: ISSUER, // loopback per-request issuer
1028
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
1029
+ manifestPath: h.manifestPath,
1030
+ configDir: h.dir,
1031
+ supervisor,
1032
+ },
1033
+ );
1034
+ // Not a 401 — authorize passed. (stopped:false because nothing's running.)
1035
+ expect(res.status).toBe(200);
1036
+ });
1037
+
1038
+ test("loopback-iss operator token, loopback knownIssuers → accepted (unchanged)", async () => {
1039
+ const { supervisor } = makeIdleSupervisor();
1040
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], ISSUER);
1041
+ const res = await handleStop(
1042
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1043
+ "vault",
1044
+ {
1045
+ db: h.db,
1046
+ issuer: ISSUER,
1047
+ knownIssuers: [ISSUER, "http://localhost:1939"],
1048
+ manifestPath: h.manifestPath,
1049
+ configDir: h.dir,
1050
+ supervisor,
1051
+ },
1052
+ );
1053
+ expect(res.status).toBe(200);
1054
+ });
1055
+
1056
+ test("FOREIGN-iss operator token → 401 (no widening to arbitrary issuers)", async () => {
1057
+ const { supervisor } = makeIdleSupervisor();
1058
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], FOREIGN_ORIGIN);
1059
+ const res = await handleStop(
1060
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1061
+ "vault",
1062
+ {
1063
+ db: h.db,
1064
+ issuer: ISSUER,
1065
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
1066
+ manifestPath: h.manifestPath,
1067
+ configDir: h.dir,
1068
+ supervisor,
1069
+ },
1070
+ );
1071
+ expect(res.status).toBe(401);
1072
+ const body = (await res.json()) as { error_description: string };
1073
+ expect(body.error_description).toMatch(/unexpected "iss" claim value/);
1074
+ });
1075
+
1076
+ test("knownIssuers absent → falls back to strict per-request issuer (back-compat)", async () => {
1077
+ const { supervisor } = makeIdleSupervisor();
1078
+ // Public-iss token, loopback per-request issuer, NO knownIssuers wired →
1079
+ // the strict single-issuer fallback rejects (the pre-fix behavior the
1080
+ // non-HTTP install path relies on).
1081
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
1082
+ const res = await handleStop(
1083
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
1084
+ "vault",
1085
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
1086
+ );
1087
+ expect(res.status).toBe(401);
1088
+ });
1089
+ });
1090
+
970
1091
  describe("POST /api/modules/:short/restart", () => {
971
1092
  let h: Harness;
972
1093
  beforeEach(async () => {
@@ -63,6 +63,32 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
63
63
  return signed.token;
64
64
  }
65
65
 
66
+ /** The hub's public origin after `expose` — what the operator token's `iss` becomes (hub#516). */
67
+ const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
68
+ /** A foreign origin the hub never answers on (hub#516). */
69
+ const FOREIGN_ORIGIN = "https://evil.example.com";
70
+
71
+ /** Mint a host-admin (operator-shaped) bearer at a chosen `iss` (hub#516). */
72
+ async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
73
+ const signed = await signAccessToken(h.db, {
74
+ sub: h.userId,
75
+ scopes,
76
+ audience: "operator",
77
+ clientId: "parachute-hub",
78
+ issuer: iss,
79
+ ttlSeconds: 3600,
80
+ });
81
+ recordTokenMint(h.db, {
82
+ jti: signed.jti,
83
+ createdVia: "operator_mint",
84
+ subject: "operator",
85
+ clientId: "parachute-hub",
86
+ scopes,
87
+ expiresAt: signed.expiresAt,
88
+ });
89
+ return signed.token;
90
+ }
91
+
66
92
  function writeManifest(path: string, services: unknown[]): void {
67
93
  writeFileSync(path, JSON.stringify({ services }));
68
94
  }
@@ -147,6 +173,47 @@ describe("GET /api/modules", () => {
147
173
  expect(body.error).toBe("insufficient_scope");
148
174
  });
149
175
 
176
+ // hub#516: `parachute status` reads /api/modules on loopback presenting the
177
+ // operator token, whose `iss` is the PUBLIC origin after `expose`. The
178
+ // host-admin bearer's iss is validated against `knownIssuers` (loopback ∪
179
+ // expose-state public ∪ env), not the single per-request loopback issuer.
180
+ test("live repro: public-iss operator token on a loopback request → 200 (hub#516)", async () => {
181
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
182
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
183
+ db: h.db,
184
+ issuer: ISSUER, // loopback per-request issuer
185
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
186
+ manifestPath: h.manifestPath,
187
+ fetchLatestVersion: async () => null,
188
+ });
189
+ expect(res.status).toBe(200);
190
+ });
191
+
192
+ test("FOREIGN-iss operator token → 401 (no widening) (hub#516)", async () => {
193
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], FOREIGN_ORIGIN);
194
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
195
+ db: h.db,
196
+ issuer: ISSUER,
197
+ knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
198
+ manifestPath: h.manifestPath,
199
+ fetchLatestVersion: async () => null,
200
+ });
201
+ expect(res.status).toBe(401);
202
+ const body = (await res.json()) as { error_description: string };
203
+ expect(body.error_description).toMatch(/unexpected "iss" claim value/);
204
+ });
205
+
206
+ test("knownIssuers absent → strict per-request issuer fallback rejects public-iss (hub#516)", async () => {
207
+ const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
208
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
209
+ db: h.db,
210
+ issuer: ISSUER, // no knownIssuers → falls back to [issuer]
211
+ manifestPath: h.manifestPath,
212
+ fetchLatestVersion: async () => null,
213
+ });
214
+ expect(res.status).toBe(401);
215
+ });
216
+
150
217
  test("200 + curated list on fresh container (empty services.json)", async () => {
151
218
  // The v0.6 hot path: brand-new Render container, no services.json
152
219
  // yet. UI must render "install vault / scribe" cards even though
@@ -15,9 +15,12 @@ import {
15
15
  exposeCloudflareOff,
16
16
  exposeCloudflareUp,
17
17
  } from "../commands/expose-cloudflare.ts";
18
+ import type { ExposeSupervisorOpts } from "../commands/expose-supervisor.ts";
18
19
  import { readEnvFileValues } from "../env-file.ts";
19
20
  import { readExposeState } from "../expose-state.ts";
20
21
  import { writeHubPort } from "../hub-control.ts";
22
+ import type { EnsureHubUnitOpts } from "../hub-unit.ts";
23
+ import { type ModuleOp, ModuleOpHttpError } from "../module-ops-client.ts";
21
24
  import type { CommandResult, Runner } from "../tailscale/run.ts";
22
25
 
23
26
  // Default seeded hub port used by tests with `skipHub: true`. The cloudflared
@@ -283,23 +286,22 @@ describe("exposeCloudflareUp", () => {
283
286
  }
284
287
  });
285
288
 
286
- test("persists the public hub origin to vault/.env + restarts vault (Cloudflare 401 fix)", async () => {
289
+ test("persists the public hub origin to vault/.env + restarts vault via the supervisor (Cloudflare 401 fix)", async () => {
287
290
  // The Cloudflare 401 P0: the cloudflare path wrote expose-state.json but —
288
291
  // unlike the Tailscale path, which auto-restarts vault and so flows the
289
- // public origin into vault/.env via lifecycle's persistVaultHubOrigin
290
- // never touched vault's .env or restarted it. The launchd/systemd daemon
291
- // kept booting vault with NO PARACHUTE_HUB_ORIGIN vault fell back to
292
- // loopback as its expected issuer → every hub-minted token (iss=public)
293
- // failed the iss check → 401. This asserts the durable .env write + the
294
- // running-vault restart that mirrors the Tailscale path.
292
+ // public origin into vault/.env never touched vault's .env or restarted
293
+ // it. The launchd/systemd daemon kept booting vault with NO
294
+ // PARACHUTE_HUB_ORIGIN vault fell back to loopback as its expected issuer →
295
+ // every hub-minted token (iss=public) failed the iss check → 401.
296
+ //
297
+ // Phase 5b: the restart goes through the running Supervisor
298
+ // (`driveModuleOp("vault", "restart")`) — the helper also persists the
299
+ // durable .env. This asserts the durable .env write + the supervised restart
300
+ // (no longer a pidfile-gated detached restart; the supervisor decides
301
+ // liveness, and a not-supervised vault surfaces as a tolerated 404 — see the
302
+ // Phase 4 dual-dispatch suite).
295
303
  const env = makeEnv();
296
304
  try {
297
- // Seed vault as "running" so the restart branch fires. PID lives at
298
- // <configDir>/vault/run/vault.pid (see process-state.ts:pidPath).
299
- const vaultRun = join(env.configDir, "vault", "run");
300
- require("node:fs").mkdirSync(vaultRun, { recursive: true });
301
- writeFileSync(join(vaultRun, "vault.pid"), "99001");
302
-
303
305
  const uuid = "ffffffff-0000-0000-0000-000000000006";
304
306
  const { runner } = queueRunner([
305
307
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
@@ -312,14 +314,12 @@ describe("exposeCloudflareUp", () => {
312
314
  { code: 0, stdout: "", stderr: "" },
313
315
  ]);
314
316
  const { spawner } = fakeSpawner(42300);
315
- const restarted: string[] = [];
317
+ const sup = makeCfSupervisorStub();
316
318
 
317
319
  const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
318
320
  runner,
319
321
  spawner,
320
- // `alive` reports the seeded vault pid as running so processState() ===
321
- // "running" and the restart branch executes.
322
- alive: (pid) => pid === 99001,
322
+ alive: () => false,
323
323
  kill: () => {},
324
324
  log: () => {},
325
325
  manifestPath: env.manifestPath,
@@ -330,10 +330,7 @@ describe("exposeCloudflareUp", () => {
330
330
  cloudflaredHome: env.cloudflaredHome,
331
331
  configDir: env.configDir,
332
332
  skipHub: true,
333
- restartService: async (short) => {
334
- restarted.push(short);
335
- return 0;
336
- },
333
+ supervisor: sup.opts,
337
334
  });
338
335
 
339
336
  expect(code).toBe(0);
@@ -342,57 +339,8 @@ describe("exposeCloudflareUp", () => {
342
339
  expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
343
340
  "https://gitcoin-parachute.unforced.dev",
344
341
  );
345
- // Live half: the running vault is restarted to re-read the new origin.
346
- expect(restarted).toEqual(["vault"]);
347
- } finally {
348
- env.cleanup();
349
- }
350
- });
351
-
352
- test("persists vault/.env but does NOT restart when vault isn't running", async () => {
353
- // No vault pidfile → processState() !== "running" → no restart, but the
354
- // durable .env write still happens so the next daemon boot is correct.
355
- const env = makeEnv();
356
- try {
357
- const uuid = "ffffffff-0000-0000-0000-000000000007";
358
- const { runner } = queueRunner([
359
- { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
360
- { code: 0, stdout: "[]", stderr: "" },
361
- {
362
- code: 0,
363
- stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
364
- stderr: "",
365
- },
366
- { code: 0, stdout: "", stderr: "" },
367
- ]);
368
- const { spawner } = fakeSpawner(42301);
369
- const restarted: string[] = [];
370
-
371
- const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
372
- runner,
373
- spawner,
374
- alive: () => false,
375
- kill: () => {},
376
- log: () => {},
377
- manifestPath: env.manifestPath,
378
- statePath: env.statePath,
379
- exposeStatePath: env.exposeStatePath,
380
- configPath: env.configPath,
381
- logPath: env.logPath,
382
- cloudflaredHome: env.cloudflaredHome,
383
- configDir: env.configDir,
384
- skipHub: true,
385
- restartService: async (short) => {
386
- restarted.push(short);
387
- return 0;
388
- },
389
- });
390
-
391
- expect(code).toBe(0);
392
- expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
393
- "https://gitcoin-parachute.unforced.dev",
394
- );
395
- expect(restarted).toEqual([]);
342
+ // Live half: vault is restarted via the supervisor to re-read the new origin.
343
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
396
344
  } finally {
397
345
  env.cleanup();
398
346
  }
@@ -2017,3 +1965,146 @@ describe("reboot-persistent connector service wiring", () => {
2017
1965
  }
2018
1966
  });
2019
1967
  });
1968
+
1969
+ // ---------------------------------------------------------------------------
1970
+ // Phase 4 dual-dispatch (design §4.3): unit-managed → "ensure the hub" ensures
1971
+ // the UNIT (not a detached spawn) and the post-route vault restart drives the
1972
+ // running Supervisor over the loopback module-ops API (firing the operator-
1973
+ // token self-heal). The cloudflared CONNECTOR unit is unchanged. The no-unit
1974
+ // arm keeps today's `persistVaultHubOrigin` + `restartService` behavior.
1975
+ // ---------------------------------------------------------------------------
1976
+
1977
+ interface CfExposeSupervisorStub {
1978
+ opts: ExposeSupervisorOpts;
1979
+ ensureCalls: number;
1980
+ driveCalls: Array<{ short: string; op: ModuleOp }>;
1981
+ selfHealCalls: Array<{ issuer: string }>;
1982
+ }
1983
+
1984
+ function makeCfSupervisorStub(opts?: {
1985
+ ensureOutcome?: "already-up" | "started" | "no-manager";
1986
+ driveThrows?: () => unknown;
1987
+ }): CfExposeSupervisorStub {
1988
+ const stub: CfExposeSupervisorStub = {
1989
+ ensureCalls: 0,
1990
+ driveCalls: [],
1991
+ selfHealCalls: [],
1992
+ opts: {
1993
+ openDb: () => ({ close() {} }) as unknown as import("bun:sqlite").Database,
1994
+ ensureHubUnit: async (o: EnsureHubUnitOpts) => {
1995
+ stub.ensureCalls++;
1996
+ return { outcome: opts?.ensureOutcome ?? "already-up", port: o.port ?? 1939, messages: [] };
1997
+ },
1998
+ driveModuleOp: async (short, op) => {
1999
+ stub.driveCalls.push({ short, op });
2000
+ if (opts?.driveThrows) throw opts.driveThrows();
2001
+ return { status: 200, body: { short, state: { status: "running" } } };
2002
+ },
2003
+ selfHealOperatorTokenIssuer: async (_db, o) => {
2004
+ stub.selfHealCalls.push({ issuer: o.issuer });
2005
+ return { kind: "fresh" };
2006
+ },
2007
+ },
2008
+ };
2009
+ return stub;
2010
+ }
2011
+
2012
+ describe("Phase 4 cloudflare expose dual-dispatch — unit-managed", () => {
2013
+ test("unit-managed → ensureHubUnit (not detached) + supervised vault restart + operator-token self-heal", async () => {
2014
+ const env = makeEnv();
2015
+ try {
2016
+ const uuid = "ffffffff-0000-0000-0000-0000000000a4";
2017
+ const { runner } = queueRunner([
2018
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
2019
+ { code: 0, stdout: "[]", stderr: "" }, // tunnel list
2020
+ {
2021
+ code: 0,
2022
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
2023
+ stderr: "",
2024
+ }, // tunnel create
2025
+ { code: 0, stdout: "", stderr: "" }, // route dns
2026
+ ]);
2027
+ const { spawner } = fakeSpawner(42400);
2028
+ const sup = makeCfSupervisorStub();
2029
+ const logs: string[] = [];
2030
+
2031
+ const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
2032
+ runner,
2033
+ spawner,
2034
+ alive: () => false,
2035
+ kill: () => {},
2036
+ log: (l) => logs.push(l),
2037
+ manifestPath: env.manifestPath,
2038
+ statePath: env.statePath,
2039
+ exposeStatePath: env.exposeStatePath,
2040
+ configPath: env.configPath,
2041
+ logPath: env.logPath,
2042
+ cloudflaredHome: env.cloudflaredHome,
2043
+ configDir: env.configDir,
2044
+ // No skipHub → the hub-unit ensure runs (via the stub, no real hub).
2045
+ // Inert connector install so the cloudflared connector path is unchanged.
2046
+ installService: () => ({ outcome: "fallback", messages: [] }),
2047
+ supervisor: sup.opts,
2048
+ });
2049
+
2050
+ expect(code).toBe(0);
2051
+ // Ensured the hub UNIT (the stub), never a detached spawn.
2052
+ expect(sup.ensureCalls).toBe(1);
2053
+ // Vault restart drove the running Supervisor.
2054
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
2055
+ // Operator-token issuer self-heal fired toward the public origin.
2056
+ expect(sup.selfHealCalls).toHaveLength(1);
2057
+ expect(sup.selfHealCalls[0]?.issuer).toBe("https://gitcoin-parachute.unforced.dev");
2058
+ // Durable .env still written (the helper persists it for vault).
2059
+ expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
2060
+ "https://gitcoin-parachute.unforced.dev",
2061
+ );
2062
+ expect(logs.join("\n")).toMatch(/hub unit up/);
2063
+ } finally {
2064
+ env.cleanup();
2065
+ }
2066
+ });
2067
+
2068
+ test("unit-managed: a not_supervised vault (404) is not a failure", async () => {
2069
+ const env = makeEnv();
2070
+ try {
2071
+ const uuid = "ffffffff-0000-0000-0000-0000000000a5";
2072
+ const { runner } = queueRunner([
2073
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
2074
+ { code: 0, stdout: "[]", stderr: "" },
2075
+ {
2076
+ code: 0,
2077
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
2078
+ stderr: "",
2079
+ },
2080
+ { code: 0, stdout: "", stderr: "" },
2081
+ ]);
2082
+ const { spawner } = fakeSpawner(42401);
2083
+ const sup = makeCfSupervisorStub({
2084
+ driveThrows: () => new ModuleOpHttpError(404, "not_supervised", "vault is not supervised"),
2085
+ });
2086
+
2087
+ const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
2088
+ runner,
2089
+ spawner,
2090
+ alive: () => false,
2091
+ kill: () => {},
2092
+ log: () => {},
2093
+ manifestPath: env.manifestPath,
2094
+ statePath: env.statePath,
2095
+ exposeStatePath: env.exposeStatePath,
2096
+ configPath: env.configPath,
2097
+ logPath: env.logPath,
2098
+ cloudflaredHome: env.cloudflaredHome,
2099
+ configDir: env.configDir,
2100
+ installService: () => ({ outcome: "fallback", messages: [] }),
2101
+ supervisor: sup.opts,
2102
+ });
2103
+
2104
+ expect(code).toBe(0);
2105
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
2106
+ } finally {
2107
+ env.cleanup();
2108
+ }
2109
+ });
2110
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { CloudflaredState } from "../cloudflare/state.ts";
3
+ import type { ExposeCloudflareOpts } from "../commands/expose-cloudflare.ts";
3
4
  import {
4
5
  type ExposePublicOffAutoOpts,
5
6
  runExposePublicOffAutoDetect,
@@ -50,6 +51,7 @@ interface Harness {
50
51
  prompts: string[];
51
52
  tailscaleCalls: number;
52
53
  cloudflareCalls: number;
54
+ cloudflareOpts: ExposeCloudflareOpts[];
53
55
  }
54
56
 
55
57
  function makeHarness(
@@ -70,6 +72,7 @@ function makeHarness(
70
72
  prompts: [],
71
73
  tailscaleCalls: 0,
72
74
  cloudflareCalls: 0,
75
+ cloudflareOpts: [],
73
76
  };
74
77
  const answers = [...(input.promptAnswers ?? [])];
75
78
  let i = 0;
@@ -88,8 +91,9 @@ function makeHarness(
88
91
  harness.tailscaleCalls++;
89
92
  return input.tsExitCode ?? 0;
90
93
  },
91
- exposeCloudflareOffImpl: async () => {
94
+ exposeCloudflareOffImpl: async (cfOpts) => {
92
95
  harness.cloudflareCalls++;
96
+ harness.cloudflareOpts.push(cfOpts);
93
97
  return input.cfExitCode ?? 0;
94
98
  },
95
99
  };
@@ -273,3 +277,24 @@ describe("runExposePublicOffAutoDetect — both live (non-TTY)", () => {
273
277
  expect(harness.logs).toContain("(non-TTY: tearing down both.)");
274
278
  });
275
279
  });
280
+
281
+ describe("runExposePublicOffAutoDetect — supervisor threading (Phase 4 consistency)", () => {
282
+ test("cloudflareOffOpts.supervisor reaches the cloudflare teardown leg", async () => {
283
+ // cli.ts threads `cloudflareOffOpts: { supervisor: {} }` into the
284
+ // auto-detect off path so the Phase 4 supervisor resolution is consistent
285
+ // across both providers (matching the explicit `--cloudflare off` branch).
286
+ // Assert the supervisor block survives the spread-with-tunnelName wrapper
287
+ // and arrives at the leaf cloudflare-off impl.
288
+ const { harness, opts } = makeHarness({ cfState: cloudflaredState() });
289
+ const code = await runExposePublicOffAutoDetect({
290
+ ...opts,
291
+ cloudflareOffOpts: { supervisor: {} },
292
+ });
293
+ expect(code).toBe(0);
294
+ expect(harness.cloudflareCalls).toBe(1);
295
+ expect(harness.cloudflareOpts).toHaveLength(1);
296
+ expect(harness.cloudflareOpts[0]?.supervisor).toEqual({});
297
+ // The per-record wrapper still stamps the tunnelName onto the leaf opts.
298
+ expect(harness.cloudflareOpts[0]?.tunnelName).toBe("vault-tunnel");
299
+ });
300
+ });