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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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 {
@@ -11,9 +11,18 @@ import {
11
11
  start,
12
12
  stop,
13
13
  } from "../commands/lifecycle.ts";
14
+ import { readEnvFileValues } from "../env-file.ts";
14
15
  import { writeHubPort } from "../hub-control.ts";
16
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
17
+ import { validateAccessToken } from "../jwt-sign.ts";
18
+ import {
19
+ OPERATOR_TOKEN_SCOPE_SET_CLAIM,
20
+ issueOperatorToken,
21
+ readOperatorTokenFile,
22
+ } from "../operator-token.ts";
15
23
  import { ensureLogPath, logPath, readPid, writePid } from "../process-state.ts";
16
- import { upsertService } from "../services-manifest.ts";
24
+ import { readManifest, upsertService } from "../services-manifest.ts";
25
+ import { rotateSigningKey } from "../signing-keys.ts";
17
26
 
18
27
  interface Harness {
19
28
  configDir: string;
@@ -188,6 +197,87 @@ describe("parachute start", () => {
188
197
  }
189
198
  });
190
199
 
200
+ test("missing startCmd binary → friendly missing-dependency message + no spawn", async () => {
201
+ const h = makeHarness();
202
+ try {
203
+ seedVault(h.manifestPath);
204
+ const spawner = makeSpawner([4242]);
205
+ const logs: string[] = [];
206
+ const code = await start("vault", {
207
+ configDir: h.configDir,
208
+ manifestPath: h.manifestPath,
209
+ spawner,
210
+ // Force the preflight's missing-binary branch: parachute-vault not on PATH.
211
+ which: () => null,
212
+ log: (l) => logs.push(l),
213
+ });
214
+ expect(code).toBe(1);
215
+ // Preflight fired before the spawn — the stub spawner is never called.
216
+ expect(spawner.calls).toHaveLength(0);
217
+ const out = logs.join("\n");
218
+ expect(out).toMatch(/vault failed to start/);
219
+ // The friendly install block names the binary + its install path.
220
+ expect(out).toContain("parachute-vault is required to run the Vault module Hub supervises");
221
+ expect(out).toContain("parachute install vault");
222
+ expect(readPid("vault", h.configDir)).toBeUndefined();
223
+ } finally {
224
+ h.cleanup();
225
+ }
226
+ });
227
+
228
+ test("missing startCmd binary persists lastStartError so a later status surfaces it", async () => {
229
+ const h = makeHarness();
230
+ try {
231
+ seedVault(h.manifestPath);
232
+ await start("vault", {
233
+ configDir: h.configDir,
234
+ manifestPath: h.manifestPath,
235
+ spawner: makeSpawner([4242]),
236
+ which: () => null,
237
+ log: () => {},
238
+ });
239
+ const entry = readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault");
240
+ expect(entry?.lastStartError?.error_type).toBe("missing_dependency");
241
+ expect(entry?.lastStartError?.binary).toBe("parachute-vault");
242
+ expect(entry?.lastStartError?.at).toBeDefined();
243
+ } finally {
244
+ h.cleanup();
245
+ }
246
+ });
247
+
248
+ test("a successful start clears a previously-recorded lastStartError", async () => {
249
+ const h = makeHarness();
250
+ try {
251
+ seedVault(h.manifestPath);
252
+ // First start fails (binary missing) → records the error.
253
+ await start("vault", {
254
+ configDir: h.configDir,
255
+ manifestPath: h.manifestPath,
256
+ spawner: makeSpawner([1]),
257
+ which: () => null,
258
+ log: () => {},
259
+ });
260
+ expect(
261
+ readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
262
+ ?.lastStartError,
263
+ ).toBeDefined();
264
+ // Second start succeeds (binary present via the permissive default which
265
+ // — stub spawner path) → clears the recorded error.
266
+ await start("vault", {
267
+ configDir: h.configDir,
268
+ manifestPath: h.manifestPath,
269
+ spawner: makeSpawner([4242]),
270
+ log: () => {},
271
+ });
272
+ expect(
273
+ readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
274
+ ?.lastStartError,
275
+ ).toBeUndefined();
276
+ } finally {
277
+ h.cleanup();
278
+ }
279
+ });
280
+
191
281
  test("notes start command includes configured port and notes-serve shim path", async () => {
192
282
  const h = makeHarness();
193
283
  try {
@@ -348,6 +438,82 @@ describe("parachute start", () => {
348
438
  PORT: "1940",
349
439
  PARACHUTE_HUB_ORIGIN: "https://parachute.taildf9ce2.ts.net",
350
440
  });
441
+ // OAuth issuer-mismatch fix: the spawn-env injection above is ephemeral
442
+ // (lost on the next launchd / systemd boot). `start vault` ALSO persists
443
+ // the public origin into vault/.env so the out-of-band daemon validates
444
+ // hub-minted JWTs' `iss` against it. Without this, every reconnect after
445
+ // a reboot / crash-restart 401s.
446
+ expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
447
+ "https://parachute.taildf9ce2.ts.net",
448
+ );
449
+ } finally {
450
+ h.cleanup();
451
+ }
452
+ });
453
+
454
+ test("self-heals a stale-loopback vault/.env from a cloudflare expose-state on restart", async () => {
455
+ // Existing-broken-deploy shape: a Cloudflare deploy whose vault/.env had a
456
+ // loopback PARACHUTE_HUB_ORIGIN baked in (or was unset and a prior run
457
+ // wrote loopback). expose-state.json carries the real public origin. A
458
+ // plain `parachute start vault` must rewrite vault/.env to the public
459
+ // origin so the daemon stops 401ing hub tokens — the self-heal half of the
460
+ // Cloudflare 401 fix.
461
+ const h = makeHarness();
462
+ try {
463
+ seedVault(h.manifestPath);
464
+ writeFileSync(
465
+ join(h.configDir, "expose-state.json"),
466
+ JSON.stringify({
467
+ version: 1,
468
+ layer: "public",
469
+ mode: "subdomain",
470
+ canonicalFqdn: "gitcoin-parachute.unforced.dev",
471
+ port: 1939,
472
+ funnel: false,
473
+ entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
474
+ hubOrigin: "https://gitcoin-parachute.unforced.dev",
475
+ }),
476
+ );
477
+ // Pre-seed vault/.env with a stale loopback value (the broken state).
478
+ mkdirSync(join(h.configDir, "vault"), { recursive: true });
479
+ writeFileSync(
480
+ join(h.configDir, "vault", ".env"),
481
+ "PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n",
482
+ );
483
+ const spawner = makeSpawner([4242]);
484
+ const code = await start("vault", {
485
+ configDir: h.configDir,
486
+ manifestPath: h.manifestPath,
487
+ spawner,
488
+ log: () => {},
489
+ });
490
+ expect(code).toBe(0);
491
+ expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
492
+ "https://gitcoin-parachute.unforced.dev",
493
+ );
494
+ } finally {
495
+ h.cleanup();
496
+ }
497
+ });
498
+
499
+ test("does NOT persist a loopback origin into vault/.env (would shadow a later exposure)", async () => {
500
+ const h = makeHarness();
501
+ try {
502
+ seedVault(h.manifestPath);
503
+ writeHubPort(1939, h.configDir);
504
+ const spawner = makeSpawner([4242]);
505
+ const code = await start("vault", {
506
+ configDir: h.configDir,
507
+ manifestPath: h.manifestPath,
508
+ spawner,
509
+ log: () => {},
510
+ });
511
+ expect(code).toBe(0);
512
+ // Loopback is fine to inject into the ephemeral spawn env (local dev),
513
+ // but persisting it would brick the daemon path once exposure comes up:
514
+ // the baked loopback would shadow the real origin. So vault/.env stays
515
+ // absent of the key on a loopback-only start.
516
+ expect(existsSync(join(h.configDir, "vault", ".env"))).toBe(false);
351
517
  } finally {
352
518
  h.cleanup();
353
519
  }
@@ -751,6 +917,159 @@ describe("parachute start", () => {
751
917
  h.cleanup();
752
918
  }
753
919
  });
920
+
921
+ // hub#487 — readiness gating beyond the bare liveness settle. Aaron hit this
922
+ // on a fresh EC2 box: `parachute start vault` printed "✓ vault started" while
923
+ // the process died ~instantly on EADDRINUSE (an orphan held 1940), and
924
+ // `parachute status` then showed it inactive.
925
+
926
+ /**
927
+ * A stub spawner that also seeds the service's log file with `content`, so
928
+ * the readiness-failure path's log-tail + EADDRINUSE detection can read a
929
+ * realistic boot error. Mirrors how the real spawner appends stdout/stderr
930
+ * to the logfile.
931
+ */
932
+ function makeSpawnerWithLog(pid: number, content: string): SpawnerStub {
933
+ const calls: SpawnerStub["calls"] = [];
934
+ return {
935
+ calls,
936
+ spawn(cmd, logFile, opts) {
937
+ calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
938
+ // The start path calls ensureLogPath() before spawn, so logFile's
939
+ // parent dir already exists — just write the simulated boot output.
940
+ writeFileSync(logFile, content);
941
+ return pid;
942
+ },
943
+ };
944
+ }
945
+
946
+ test("hub#487: EADDRINUSE in the log → port-in-use message + log tail, not ✓", async () => {
947
+ const h = makeHarness();
948
+ try {
949
+ seedVault(h.manifestPath);
950
+ const spawner = makeSpawnerWithLog(
951
+ 4242,
952
+ "booting vault…\nerror: listen EADDRINUSE: address already in use 0.0.0.0:1940\n",
953
+ );
954
+ const lines: string[] = [];
955
+ const code = await start("vault", {
956
+ configDir: h.configDir,
957
+ manifestPath: h.manifestPath,
958
+ spawner,
959
+ alive: () => false, // process died right after the EADDRINUSE throw
960
+ sleep: async () => {},
961
+ startSettleMs: 1,
962
+ log: (l) => lines.push(l),
963
+ });
964
+ expect(code).toBe(1);
965
+ expect(readPid("vault", h.configDir)).toBeUndefined();
966
+ const out = lines.join("\n");
967
+ expect(out).toMatch(/port 1940 is already in use/);
968
+ expect(out).toMatch(/lsof -ti:1940/);
969
+ // The real boot error is surfaced inline so the operator doesn't have to
970
+ // go tail the log themselves.
971
+ expect(out).toMatch(/EADDRINUSE/);
972
+ expect(out).not.toMatch(/✓ vault started/);
973
+ } finally {
974
+ h.cleanup();
975
+ }
976
+ });
977
+
978
+ test("hub#487: process survives settle but never binds its port → failure with log tail", async () => {
979
+ const h = makeHarness();
980
+ try {
981
+ seedVault(h.manifestPath);
982
+ const spawner = makeSpawnerWithLog(4242, "vault crashed mid-boot\n");
983
+ const lines: string[] = [];
984
+ let aliveCalls = 0;
985
+ const code = await start("vault", {
986
+ configDir: h.configDir,
987
+ manifestPath: h.manifestPath,
988
+ spawner,
989
+ // Alive through the settle + first readiness poll, then dies — the
990
+ // slow-EADDRINUSE / crash-after-boot shape.
991
+ alive: () => {
992
+ aliveCalls++;
993
+ return aliveCalls <= 1;
994
+ },
995
+ sleep: async () => {},
996
+ startSettleMs: 1,
997
+ startReadyMs: 50,
998
+ startReadyPollMs: 1,
999
+ portListening: async () => false, // never binds
1000
+ log: (l) => lines.push(l),
1001
+ });
1002
+ expect(code).toBe(1);
1003
+ expect(readPid("vault", h.configDir)).toBeUndefined();
1004
+ const out = lines.join("\n");
1005
+ expect(out).toMatch(/✗ vault failed to start/);
1006
+ expect(out).toMatch(/exited during startup/);
1007
+ expect(out).not.toMatch(/✓ vault started/);
1008
+ } finally {
1009
+ h.cleanup();
1010
+ }
1011
+ });
1012
+
1013
+ test("hub#487: alive but port silent past the window → non-fatal warning, exit 0", async () => {
1014
+ const h = makeHarness();
1015
+ try {
1016
+ seedVault(h.manifestPath);
1017
+ const spawner = makeSpawner([4242]);
1018
+ const lines: string[] = [];
1019
+ const code = await start("vault", {
1020
+ configDir: h.configDir,
1021
+ manifestPath: h.manifestPath,
1022
+ spawner,
1023
+ alive: () => true, // stays up the whole time
1024
+ sleep: async () => {},
1025
+ startSettleMs: 1,
1026
+ startReadyMs: 10,
1027
+ startReadyPollMs: 1,
1028
+ portListening: async () => false, // slow boot — not listening yet
1029
+ log: (l) => lines.push(l),
1030
+ });
1031
+ // A slow-but-alive daemon isn't a hard failure — we warn rather than fail.
1032
+ expect(code).toBe(0);
1033
+ expect(readPid("vault", h.configDir)).toBe(4242);
1034
+ const out = lines.join("\n");
1035
+ expect(out).toMatch(/port 1940 isn't accepting connections yet/);
1036
+ expect(out).not.toMatch(/✓ vault started/);
1037
+ } finally {
1038
+ h.cleanup();
1039
+ }
1040
+ });
1041
+
1042
+ test("hub#487: alive + port listening → success", async () => {
1043
+ const h = makeHarness();
1044
+ try {
1045
+ seedVault(h.manifestPath);
1046
+ const spawner = makeSpawner([4242]);
1047
+ const lines: string[] = [];
1048
+ let probeCalls = 0;
1049
+ const code = await start("vault", {
1050
+ configDir: h.configDir,
1051
+ manifestPath: h.manifestPath,
1052
+ spawner,
1053
+ alive: () => true,
1054
+ sleep: async () => {},
1055
+ startSettleMs: 1,
1056
+ startReadyMs: 50,
1057
+ startReadyPollMs: 1,
1058
+ // Not listening on the first poll, bound on the second — exercises the
1059
+ // poll loop rather than an instant true.
1060
+ portListening: async () => {
1061
+ probeCalls++;
1062
+ return probeCalls >= 2;
1063
+ },
1064
+ log: (l) => lines.push(l),
1065
+ });
1066
+ expect(code).toBe(0);
1067
+ expect(readPid("vault", h.configDir)).toBe(4242);
1068
+ expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
1069
+ } finally {
1070
+ h.cleanup();
1071
+ }
1072
+ });
754
1073
  });
755
1074
 
756
1075
  describe("parachute stop", () => {
@@ -1295,6 +1614,149 @@ describe("parachute start|stop|restart hub", () => {
1295
1614
  }
1296
1615
  });
1297
1616
 
1617
+ // hub#481 — `start hub` self-heals a stale operator-token issuer. Tests use
1618
+ // the injectable `hub.selfHealOperatorToken` seam to assert the call happens
1619
+ // (and to make it throw without failing start); a separate test drives the
1620
+ // REAL self-heal against an on-disk operator token + hub.db.
1621
+ test("start hub: invokes operator-token self-heal with the resolved issuer + configDir", async () => {
1622
+ const h = makeHarness();
1623
+ try {
1624
+ const log: string[] = [];
1625
+ const calls: Array<{ issuer: string; configDir: string }> = [];
1626
+ const code = await start("hub", {
1627
+ configDir: h.configDir,
1628
+ manifestPath: h.manifestPath,
1629
+ hubOrigin: "https://hub.example.com",
1630
+ hub: {
1631
+ ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1632
+ selfHealOperatorToken: async (args) => {
1633
+ calls.push({ issuer: args.issuer, configDir: args.configDir });
1634
+ return {
1635
+ kind: "rotated",
1636
+ path: "/x/operator.token",
1637
+ scopeSet: "admin",
1638
+ expiresAt: "z",
1639
+ };
1640
+ },
1641
+ },
1642
+ log: (l) => log.push(l),
1643
+ });
1644
+ expect(code).toBe(0);
1645
+ expect(calls).toEqual([{ issuer: "https://hub.example.com", configDir: h.configDir }]);
1646
+ // Rotation emits an operator-facing line.
1647
+ expect(log.join("\n")).toMatch(
1648
+ /refreshed operator\.token issuer → https:\/\/hub\.example\.com/,
1649
+ );
1650
+ } finally {
1651
+ h.cleanup();
1652
+ }
1653
+ });
1654
+
1655
+ test("start hub: skips operator-token self-heal when no hub origin is resolvable", async () => {
1656
+ const h = makeHarness();
1657
+ try {
1658
+ let called = false;
1659
+ // No hubOrigin override, no expose-state, no hub.port file → resolveHubOrigin
1660
+ // yields undefined, so the self-heal seam must NOT be called.
1661
+ const code = await start("hub", {
1662
+ configDir: h.configDir,
1663
+ manifestPath: h.manifestPath,
1664
+ hub: {
1665
+ ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1666
+ selfHealOperatorToken: async () => {
1667
+ called = true;
1668
+ return { kind: "absent" };
1669
+ },
1670
+ },
1671
+ log: () => {},
1672
+ });
1673
+ expect(code).toBe(0);
1674
+ expect(called).toBe(false);
1675
+ } finally {
1676
+ h.cleanup();
1677
+ }
1678
+ });
1679
+
1680
+ test("start hub: a thrown error inside operator-token self-heal does NOT fail start", async () => {
1681
+ const h = makeHarness();
1682
+ try {
1683
+ const log: string[] = [];
1684
+ const code = await start("hub", {
1685
+ configDir: h.configDir,
1686
+ manifestPath: h.manifestPath,
1687
+ hubOrigin: "https://hub.example.com",
1688
+ hub: {
1689
+ ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1690
+ selfHealOperatorToken: async () => {
1691
+ throw new Error("hub.db is locked");
1692
+ },
1693
+ },
1694
+ log: (l) => log.push(l),
1695
+ });
1696
+ expect(code).toBe(0);
1697
+ // Degrades to a brief note, not a hard failure.
1698
+ expect(log.join("\n")).toMatch(
1699
+ /operator\.token issuer self-heal skipped \(hub\.db is locked\)/,
1700
+ );
1701
+ } finally {
1702
+ h.cleanup();
1703
+ }
1704
+ });
1705
+
1706
+ test("start hub: real self-heal re-mints a stale-iss operator token on disk", async () => {
1707
+ const h = makeHarness();
1708
+ try {
1709
+ // Seed signing keys + a stale-iss operator token in the harness configDir's
1710
+ // hub.db / operator.token, then drive the production self-heal seam.
1711
+ const db = openHubDb(hubDbPath(h.configDir));
1712
+ try {
1713
+ rotateSigningKey(db);
1714
+ await issueOperatorToken(db, "user-abc", {
1715
+ dir: h.configDir,
1716
+ issuer: "http://127.0.0.1:1939",
1717
+ scopeSet: "start",
1718
+ });
1719
+ } finally {
1720
+ db.close();
1721
+ }
1722
+
1723
+ const log: string[] = [];
1724
+ const code = await start("hub", {
1725
+ configDir: h.configDir,
1726
+ manifestPath: h.manifestPath,
1727
+ hubOrigin: "https://gitcoin-parachute.unforced.dev",
1728
+ // No selfHealOperatorToken override → exercises defaultSelfHealOperatorToken
1729
+ // (opens hub.db at <configDir>/hub.db).
1730
+ hub: {
1731
+ ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1732
+ },
1733
+ log: (l) => log.push(l),
1734
+ });
1735
+ expect(code).toBe(0);
1736
+ expect(log.join("\n")).toMatch(
1737
+ /refreshed operator\.token issuer → https:\/\/gitcoin-parachute\.unforced\.dev/,
1738
+ );
1739
+
1740
+ // The on-disk token now validates under the new issuer, scope-set preserved.
1741
+ const verifyDb = openHubDb(hubDbPath(h.configDir));
1742
+ try {
1743
+ const onDisk = await readOperatorTokenFile(h.configDir);
1744
+ expect(onDisk).not.toBeNull();
1745
+ const validated = await validateAccessToken(
1746
+ verifyDb,
1747
+ onDisk as string,
1748
+ "https://gitcoin-parachute.unforced.dev",
1749
+ );
1750
+ expect(validated.payload.iss).toBe("https://gitcoin-parachute.unforced.dev");
1751
+ expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
1752
+ } finally {
1753
+ verifyDb.close();
1754
+ }
1755
+ } finally {
1756
+ h.cleanup();
1757
+ }
1758
+ });
1759
+
1298
1760
  test("stop hub: dispatches to stopHub, true → '✓ hub stopped'", async () => {
1299
1761
  const h = makeHarness();
1300
1762
  try {