@openparachute/hub 0.6.3 → 0.6.4-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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -317,6 +317,32 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
317
317
  }
318
318
  });
319
319
 
320
+ // Item C — case-insensitive non-requestable guard. An uppercase casing of a
321
+ // host-level scope must NOT bypass the non-requestable membership check (which
322
+ // was exact-string before). `PARACHUTE:HOST:AUTH` is now correctly rejected.
323
+ test("400 invalid_scope when minting an uppercase host scope (item C)", async () => {
324
+ const h = makeHarness();
325
+ try {
326
+ const { db, userId } = await bootstrap(h.dir);
327
+ try {
328
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
329
+ for (const variant of ["PARACHUTE:HOST:AUTH", "Parachute:Host:Admin"]) {
330
+ const resp = await handleApiMintToken(
331
+ jsonRequest({ scope: variant }, { authorization: `Bearer ${op.token}` }),
332
+ { db, issuer: ISSUER },
333
+ );
334
+ expect(resp.status).toBe(400);
335
+ const body = (await resp.json()) as { error: string };
336
+ expect(body.error).toBe("invalid_scope");
337
+ }
338
+ } finally {
339
+ db.close();
340
+ }
341
+ } finally {
342
+ h.cleanup();
343
+ }
344
+ });
345
+
320
346
  test("400 invalid_scope when multi-scope includes a non-requestable", async () => {
321
347
  const h = makeHarness();
322
348
  try {
@@ -416,13 +442,13 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
416
442
  }
417
443
  });
418
444
 
419
- // A bare `vault:admin` (no vault name) is NOT a per-vault admin scope —
420
- // the de-escalation exception only covers `vault:<name>:admin`. It isn't
421
- // in the non-requestable set either, so it's treated as an ordinary
422
- // (unnamed) scope and mints but with the `vault` fallback audience, not
423
- // a per-vault one. Pinned so a future regex loosening can't silently let
424
- // an unnamed admin through the named-vault exemption.
425
- test("bare vault:admin (no name) is not caught by the de-escalation exemption", async () => {
445
+ // Item B / hub#451 — bare `vault:admin` (no vault name) is NOT mintable on
446
+ // the headless path. The unnamed broad-admin form has no resource pin; the
447
+ // mint endpoint refuses it with 400 `invalid_scope` (even for a full host:admin
448
+ // operator). The legitimate path for a vault admin token is a resource-narrowed
449
+ // `vault:<name>:admin`. The OAuth flow still accepts bare `vault:admin` and
450
+ // narrows it via the picker — that path is unaffected (see oauth-handlers).
451
+ test("bare vault:admin (no name) 400 (non-requestable headlessly, item B / #451)", async () => {
426
452
  const h = makeHarness();
427
453
  try {
428
454
  const { db, userId } = await bootstrap(h.dir);
@@ -432,9 +458,31 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
432
458
  jsonRequest({ scope: "vault:admin" }, { authorization: `Bearer ${op.token}` }),
433
459
  { db, issuer: ISSUER },
434
460
  );
435
- // `vault:admin` isn't a per-vault admin scope and isn't in the
436
- // non-requestable set, so it mints as an ordinary scope. The point
437
- // of this test is that it does NOT get a per-vault audience/pin.
461
+ expect(resp.status).toBe(400);
462
+ const body = (await resp.json()) as { error: string; error_description: string };
463
+ expect(body.error).toBe("invalid_scope");
464
+ expect(body.error_description).toContain("vault:admin");
465
+ } finally {
466
+ db.close();
467
+ }
468
+ } finally {
469
+ h.cleanup();
470
+ }
471
+ });
472
+
473
+ // Item B does NOT touch unnamed vault:read / vault:write — those carry no
474
+ // admin authority and remain mintable (regression guard for the narrow scope
475
+ // of the bare-admin block).
476
+ test("bare vault:read still mints headlessly (item B is admin-only)", async () => {
477
+ const h = makeHarness();
478
+ try {
479
+ const { db, userId } = await bootstrap(h.dir);
480
+ try {
481
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
482
+ const resp = await handleApiMintToken(
483
+ jsonRequest({ scope: "vault:read" }, { authorization: `Bearer ${op.token}` }),
484
+ { db, issuer: ISSUER },
485
+ );
438
486
  expect(resp.status).toBe(200);
439
487
  const body = (await resp.json()) as { token: string };
440
488
  const validated = await validateAccessToken(db, body.token, ISSUER);
@@ -745,6 +793,122 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
745
793
  h.cleanup();
746
794
  }
747
795
  });
796
+
797
+ // Item A (subject-pin) — audit-attribution forgery. A non-operator
798
+ // (vault-admin-only) bearer may NOT override the minted token's `sub`:
799
+ // forging a foreign subject would mis-attribute the registry + revocation
800
+ // rows. It may still mint under its OWN sub (subject omitted / equal).
801
+ test("subject override by non-operator bearer → 403 (forgery blocked)", async () => {
802
+ const h = makeHarness();
803
+ try {
804
+ const { db, userId } = await bootstrap(h.dir);
805
+ try {
806
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
807
+ const resp = await handleApiMintToken(
808
+ jsonRequest(
809
+ { scope: "vault:work:read", subject: "someone-else" },
810
+ { authorization: `Bearer ${bearer}` },
811
+ ),
812
+ { db, issuer: ISSUER },
813
+ );
814
+ expect(resp.status).toBe(403);
815
+ const body = (await resp.json()) as { error: string; error_description: string };
816
+ expect(body.error).toBe("insufficient_scope");
817
+ expect(body.error_description).toContain("non-operator");
818
+ } finally {
819
+ db.close();
820
+ }
821
+ } finally {
822
+ h.cleanup();
823
+ }
824
+ });
825
+
826
+ test("subject equal to own sub by non-operator bearer → 200 (no forgery)", async () => {
827
+ const h = makeHarness();
828
+ try {
829
+ const { db, userId } = await bootstrap(h.dir);
830
+ try {
831
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
832
+ const resp = await handleApiMintToken(
833
+ jsonRequest(
834
+ { scope: "vault:work:read", subject: userId },
835
+ { authorization: `Bearer ${bearer}` },
836
+ ),
837
+ { db, issuer: ISSUER },
838
+ );
839
+ expect(resp.status).toBe(200);
840
+ const body = (await resp.json()) as { jti: string };
841
+ const row = db
842
+ .query<{ subject: string }, [string]>("SELECT subject FROM tokens WHERE jti = ?")
843
+ .get(body.jti);
844
+ expect(row?.subject).toBe(userId);
845
+ } finally {
846
+ db.close();
847
+ }
848
+ } finally {
849
+ h.cleanup();
850
+ }
851
+ });
852
+ });
853
+
854
+ // Item A (subject-pin) — the operator carve-out: a host operator
855
+ // (parachute:host:auth / parachute:host:admin) MAY override `sub` to stamp a
856
+ // service-account subject. This is the documented service-account override
857
+ // that the non-operator pin above must NOT break.
858
+ describe("subject override — operator carve-out (item A)", () => {
859
+ test("host:auth operator overrides subject → 200, registry row carries override", async () => {
860
+ const h = makeHarness();
861
+ try {
862
+ const { db, userId } = await bootstrap(h.dir);
863
+ try {
864
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
865
+ const resp = await handleApiMintToken(
866
+ jsonRequest(
867
+ { scope: "vault:work:read", subject: "svc-account" },
868
+ { authorization: `Bearer ${op.token}` },
869
+ ),
870
+ { db, issuer: ISSUER },
871
+ );
872
+ expect(resp.status).toBe(200);
873
+ const body = (await resp.json()) as { jti: string; token: string };
874
+ const validated = await validateAccessToken(db, body.token, ISSUER);
875
+ expect(validated.payload.sub).toBe("svc-account");
876
+ const row = db
877
+ .query<{ subject: string }, [string]>("SELECT subject FROM tokens WHERE jti = ?")
878
+ .get(body.jti);
879
+ expect(row?.subject).toBe("svc-account");
880
+ } finally {
881
+ db.close();
882
+ }
883
+ } finally {
884
+ h.cleanup();
885
+ }
886
+ });
887
+
888
+ test("host:admin operator overrides subject → 200", async () => {
889
+ const h = makeHarness();
890
+ try {
891
+ const { db, userId } = await bootstrap(h.dir);
892
+ try {
893
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
894
+ const resp = await handleApiMintToken(
895
+ jsonRequest(
896
+ { scope: "vault:work:admin", subject: "svc-account" },
897
+ { authorization: `Bearer ${op.token}` },
898
+ ),
899
+ { db, issuer: ISSUER },
900
+ );
901
+ expect(resp.status).toBe(200);
902
+ const body = (await resp.json()) as { token: string };
903
+ const validated = await validateAccessToken(db, body.token, ISSUER);
904
+ expect(validated.payload.sub).toBe("svc-account");
905
+ } finally {
906
+ db.close();
907
+ }
908
+ } finally {
909
+ h.cleanup();
910
+ }
911
+ });
748
912
  });
749
913
 
750
914
  describe("capability attenuation — entry gate + regression", () => {
@@ -1051,6 +1215,91 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
1051
1215
  });
1052
1216
  });
1053
1217
 
1218
+ // Item D / hub#450 — vault-existence check on vault:<name>:admin mints.
1219
+ describe("vault-existence check on vault:<name>:admin (item D / #450)", () => {
1220
+ test("vault:typo:admin for an unknown vault → 400 when knownVaultNames is wired", async () => {
1221
+ const h = makeHarness();
1222
+ try {
1223
+ const { db, userId } = await bootstrap(h.dir);
1224
+ try {
1225
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
1226
+ const resp = await handleApiMintToken(
1227
+ jsonRequest({ scope: "vault:typo:admin" }, { authorization: `Bearer ${op.token}` }),
1228
+ { db, issuer: ISSUER, knownVaultNames: new Set(["work", "default"]) },
1229
+ );
1230
+ expect(resp.status).toBe(400);
1231
+ const body = (await resp.json()) as { error: string; error_description: string };
1232
+ expect(body.error).toBe("invalid_scope");
1233
+ expect(body.error_description).toContain("typo");
1234
+ } finally {
1235
+ db.close();
1236
+ }
1237
+ } finally {
1238
+ h.cleanup();
1239
+ }
1240
+ });
1241
+
1242
+ test("vault:work:admin for a KNOWN vault → 200", async () => {
1243
+ const h = makeHarness();
1244
+ try {
1245
+ const { db, userId } = await bootstrap(h.dir);
1246
+ try {
1247
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
1248
+ const resp = await handleApiMintToken(
1249
+ jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
1250
+ { db, issuer: ISSUER, knownVaultNames: new Set(["work", "default"]) },
1251
+ );
1252
+ expect(resp.status).toBe(200);
1253
+ const body = (await resp.json()) as { scope: string };
1254
+ expect(body.scope).toBe("vault:work:admin");
1255
+ } finally {
1256
+ db.close();
1257
+ }
1258
+ } finally {
1259
+ h.cleanup();
1260
+ }
1261
+ });
1262
+
1263
+ test("read/write for an unknown vault are NOT existence-checked (admin-only gate)", async () => {
1264
+ const h = makeHarness();
1265
+ try {
1266
+ const { db, userId } = await bootstrap(h.dir);
1267
+ try {
1268
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
1269
+ const resp = await handleApiMintToken(
1270
+ jsonRequest({ scope: "vault:typo:read" }, { authorization: `Bearer ${op.token}` }),
1271
+ { db, issuer: ISSUER, knownVaultNames: new Set(["work"]) },
1272
+ );
1273
+ expect(resp.status).toBe(200);
1274
+ } finally {
1275
+ db.close();
1276
+ }
1277
+ } finally {
1278
+ h.cleanup();
1279
+ }
1280
+ });
1281
+
1282
+ test("knownVaultNames omitted → existence check skipped (back-compat)", async () => {
1283
+ const h = makeHarness();
1284
+ try {
1285
+ const { db, userId } = await bootstrap(h.dir);
1286
+ try {
1287
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
1288
+ const resp = await handleApiMintToken(
1289
+ jsonRequest({ scope: "vault:typo:admin" }, { authorization: `Bearer ${op.token}` }),
1290
+ { db, issuer: ISSUER },
1291
+ );
1292
+ // No knownVaultNames → the documented "caller responsible" fallback.
1293
+ expect(resp.status).toBe(200);
1294
+ } finally {
1295
+ db.close();
1296
+ }
1297
+ } finally {
1298
+ h.cleanup();
1299
+ }
1300
+ });
1301
+ });
1302
+
1054
1303
  test("405 on non-POST", async () => {
1055
1304
  const h = makeHarness();
1056
1305
  try {
@@ -323,6 +323,13 @@ describe("POST /api/modules/:short/install", () => {
323
323
  expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
324
324
  // Supervisor was handed the spawn.
325
325
  expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
326
+ // The install-time spawn path (spawnSupervised) injects the enriched PATH
327
+ // so a module installed via /admin/modules can find operator tools (scribe's
328
+ // parakeet-mlx / ffmpeg) — same fix as the serve-boot path, second spawn
329
+ // site (hub launchd-PATH regression). It must carry the inherited PATH.
330
+ const vaultEnv = spawns.find((s) => s.short === "vault")?.env;
331
+ expect(vaultEnv?.PATH).toBeDefined();
332
+ expect(vaultEnv?.PATH?.length).toBeGreaterThan(0);
326
333
  });
327
334
 
328
335
  test("idempotent: already-installed + running returns succeeded immediately", async () => {
@@ -842,6 +849,31 @@ describe("POST /api/modules/:short/start", () => {
842
849
  expect(res.status).toBe(403);
843
850
  });
844
851
 
852
+ test("supervisor.start throw → structured 500 module_op_failed, not a naked 500 (hub#536)", async () => {
853
+ seedVault();
854
+ // Models the hub#536 wedge: Bun.spawn throwing because the module's
855
+ // installDir/cwd no longer exists (NOT a missing binary — the preflight
856
+ // + rethrowIfMissing nets don't catch it, so it propagates out of
857
+ // supervisor.start). Pre-fix this escaped the handler as a bodyless 500
858
+ // and the CLI showed an opaque "request failed".
859
+ const supervisor = new Supervisor({
860
+ spawnFn: () => {
861
+ throw new Error("simulated spawn failure: cwd /gone/parachute-app does not exist");
862
+ },
863
+ });
864
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
865
+ const res = await handleStart(
866
+ postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
867
+ "vault",
868
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
869
+ );
870
+ expect(res.status).toBe(500);
871
+ const body = (await res.json()) as { error: string; error_description: string };
872
+ expect(body.error).toBe("module_op_failed");
873
+ expect(body.error_description).toContain("vault start failed");
874
+ expect(body.error_description).toContain("cwd /gone/parachute-app does not exist");
875
+ });
876
+
845
877
  test("pure supervisor.start of an installed module — NOT install (no bun add)", async () => {
846
878
  seedVault();
847
879
  const { supervisor, spawns } = makeIdleSupervisor();
@@ -1096,7 +1128,44 @@ describe("POST /api/modules/:short/restart", () => {
1096
1128
  });
1097
1129
  afterEach(() => h.cleanup());
1098
1130
 
1099
- test("404 not_supervised when module isn't running", async () => {
1131
+ /** Seed a minimal installed vault row (in services.json). */
1132
+ function seedVault(port = 1940): void {
1133
+ writeManifest(h.manifestPath, [
1134
+ {
1135
+ name: "parachute-vault",
1136
+ port,
1137
+ paths: ["/vault/default"],
1138
+ health: "/vault/default/health",
1139
+ version: "0.0.0-linked",
1140
+ },
1141
+ ]);
1142
+ }
1143
+
1144
+ test("400 not_installed when the module isn't in services.json", async () => {
1145
+ // restart rebuilds the spawn req from services.json (hub#532), so a
1146
+ // missing row is a `not_installed` 400 (mirrors `start`) — before the
1147
+ // supervisor is even consulted.
1148
+ const { supervisor } = makeIdleSupervisor();
1149
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1150
+ const res = await handleRestart(
1151
+ postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
1152
+ "vault",
1153
+ {
1154
+ db: h.db,
1155
+ issuer: ISSUER,
1156
+ manifestPath: h.manifestPath,
1157
+ configDir: h.dir,
1158
+ supervisor,
1159
+ run: async () => 0,
1160
+ },
1161
+ );
1162
+ expect(res.status).toBe(400);
1163
+ const body = (await res.json()) as { error: string };
1164
+ expect(body.error).toBe("not_installed");
1165
+ });
1166
+
1167
+ test("404 not_supervised when installed but not currently running", async () => {
1168
+ seedVault();
1100
1169
  const { supervisor } = makeIdleSupervisor();
1101
1170
  const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1102
1171
  const res = await handleRestart(
@@ -1117,6 +1186,7 @@ describe("POST /api/modules/:short/restart", () => {
1117
1186
  });
1118
1187
 
1119
1188
  test("returns new state on success", async () => {
1189
+ seedVault();
1120
1190
  const { supervisor } = makeIdleSupervisor();
1121
1191
  await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
1122
1192
 
@@ -1140,6 +1210,122 @@ describe("POST /api/modules/:short/restart", () => {
1140
1210
  // on timing — either is acceptable here as long as it's not crashed/stopped.
1141
1211
  expect(["restarting", "running", "starting"]).toContain(body.state.status);
1142
1212
  });
1213
+
1214
+ test("re-injects the CURRENT hub origin on restart (hub#532)", async () => {
1215
+ // The core hub#532 fix: a module first started under one issuer, then
1216
+ // restarted after the canonical origin changed (e.g. post-expose), must
1217
+ // re-spawn with the NEW PARACHUTE_HUB_ORIGIN — not replay the stale
1218
+ // first-start snapshot. We drive the supervisor directly to control the
1219
+ // first-start env, then restart through the handler with a different
1220
+ // `deps.issuer` and assert the recorded spawn carries the new origin.
1221
+ seedVault(1940);
1222
+ const { supervisor, spawns } = makeIdleSupervisor();
1223
+ // First start with the OLD origin baked in (as boot would have).
1224
+ await supervisor.start({
1225
+ short: "vault",
1226
+ cmd: ["parachute-vault", "serve"],
1227
+ env: { PORT: "1940", PARACHUTE_HUB_ORIGIN: "https://old.example" },
1228
+ });
1229
+ const spawnsBefore = spawns.length;
1230
+
1231
+ const NEW_ORIGIN = "https://new.example";
1232
+ // The bearer was minted at the prior (loopback) ISSUER; the canonical
1233
+ // origin has since moved to NEW_ORIGIN. `knownIssuers` accepts the older
1234
+ // bearer iss while `deps.issuer` is the current canonical origin — exactly
1235
+ // the post-expose scenario hub#532 describes.
1236
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1237
+ const res = await handleRestart(
1238
+ postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
1239
+ "vault",
1240
+ {
1241
+ db: h.db,
1242
+ issuer: NEW_ORIGIN,
1243
+ knownIssuers: [ISSUER, NEW_ORIGIN],
1244
+ manifestPath: h.manifestPath,
1245
+ configDir: h.dir,
1246
+ supervisor,
1247
+ run: async () => 0,
1248
+ },
1249
+ );
1250
+ expect(res.status).toBe(200);
1251
+ // A fresh spawn happened, and it carries the NEW origin (rebuilt from the
1252
+ // live deps.issuer), not the stale one from first start.
1253
+ const reSpawn = spawns[spawnsBefore];
1254
+ expect(reSpawn?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
1255
+ expect(reSpawn?.env?.PORT).toBe("1940");
1256
+ });
1257
+
1258
+ test("crash-restart after a restart reuses the REFRESHED req (hub#532)", async () => {
1259
+ // The refreshed SpawnRequest must become the supervisor entry's `req` so
1260
+ // a SUBSEQUENT crash-restart (handleExit → spawnAndWatch, which replays
1261
+ // `entry.req`) also carries the current env — not the original snapshot.
1262
+ seedVault(1940);
1263
+ // Controllable supervisor: spawns record env; each fake exposes a `crash()`
1264
+ // resolving `exited` with code 1 (a crash, not an operator stop). `kill()`
1265
+ // (driven by the injected group-aware `killFn`) resolves with 0 so the
1266
+ // handler's restart-stop completes promptly. Distinct codes let us crash a
1267
+ // specific child without tripping the stop path.
1268
+ const spawns: SpawnRequest[] = [];
1269
+ const procs: Array<{ pid: number; crash: () => void; kill: () => void }> = [];
1270
+ let nextPid = 8000;
1271
+ const spawnFn = (req: SpawnRequest): SupervisedProc => {
1272
+ spawns.push(req);
1273
+ const pid = nextPid++;
1274
+ let resolveExit!: (c: number | null) => void;
1275
+ const exited = new Promise<number | null>((r) => {
1276
+ resolveExit = r;
1277
+ });
1278
+ const proc = {
1279
+ pid,
1280
+ exited,
1281
+ stdout: null,
1282
+ stderr: null,
1283
+ kill: () => resolveExit(0),
1284
+ };
1285
+ procs.push({ pid, crash: () => resolveExit(1), kill: proc.kill });
1286
+ return proc;
1287
+ };
1288
+ const killFn = (pid: number): void => {
1289
+ procs.find((p) => p.pid === Math.abs(pid))?.kill();
1290
+ };
1291
+ const supervisor = new Supervisor({ spawnFn, killFn, restartDelayMs: 1, startReadyMs: 0 });
1292
+
1293
+ // First start with the OLD origin.
1294
+ await supervisor.start({
1295
+ short: "vault",
1296
+ cmd: ["parachute-vault", "serve"],
1297
+ env: { PORT: "1940", PARACHUTE_HUB_ORIGIN: "https://old.example" },
1298
+ });
1299
+
1300
+ const NEW_ORIGIN = "https://new.example";
1301
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1302
+ await handleRestart(
1303
+ postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
1304
+ "vault",
1305
+ {
1306
+ db: h.db,
1307
+ issuer: NEW_ORIGIN,
1308
+ knownIssuers: [ISSUER, NEW_ORIGIN],
1309
+ manifestPath: h.manifestPath,
1310
+ configDir: h.dir,
1311
+ supervisor,
1312
+ run: async () => 0,
1313
+ },
1314
+ );
1315
+ // The restart re-spawn is the most recent — confirm the NEW origin landed.
1316
+ const restartSpawnIdx = spawns.length - 1;
1317
+ expect(spawns[restartSpawnIdx]?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
1318
+
1319
+ // Crash the restart-spawned child → the supervisor's crash-restart replays
1320
+ // entry.req (which `start` stored from the refreshed req).
1321
+ procs[restartSpawnIdx]?.crash();
1322
+ // Wait for the crash watcher's restartDelay + respawn.
1323
+ await new Promise((r) => setTimeout(r, 40));
1324
+ expect(spawns.length).toBeGreaterThan(restartSpawnIdx + 1);
1325
+ const crashRespawn = spawns[spawns.length - 1];
1326
+ // The crash-restart inherited the REFRESHED req → still the new origin.
1327
+ expect(crashRespawn?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
1328
+ });
1143
1329
  });
1144
1330
 
1145
1331
  describe("POST /api/modules/:short/upgrade", () => {
@@ -1485,13 +1671,19 @@ describe("well-known regen after module ops", () => {
1485
1671
  const vaultRow = manifest.services.find((s) => s.name === "parachute-vault");
1486
1672
  expect(vaultRow?.installDir).toBe(install.installDir);
1487
1673
 
1488
- // The on-disk well-known doc reflects the new module.
1674
+ // The on-disk well-known doc reflects the new module in `services`...
1489
1675
  const doc = JSON.parse(readFileSync(wkPath, "utf8")) as {
1490
1676
  services: Array<{ name: string; version: string }>;
1491
1677
  vaults: Array<{ name: string }>;
1492
1678
  };
1493
1679
  expect(doc.services.some((s) => s.name === "parachute-vault")).toBe(true);
1494
- expect(doc.vaults.some((v) => v.name === "default")).toBe(true);
1680
+ // ...but does NOT fabricate a phantom `default` vault row (hub#577). The
1681
+ // install seeds the entry at SEED_VERSION ("module installed, no instance
1682
+ // booted"); vault's own boot registers the real instance later. Until then
1683
+ // the vaults[] list is honestly empty so the management page reads "No
1684
+ // vaults yet" rather than showing a `default` that doesn't exist.
1685
+ expect(doc.vaults.some((v) => v.name === "default")).toBe(false);
1686
+ expect(doc.vaults).toEqual([]);
1495
1687
  });
1496
1688
 
1497
1689
  test("runInstall sets PORT in child env from services.json entry (hub#356)", async () => {
@@ -317,6 +317,45 @@ describe("GET /api/modules", () => {
317
317
  expect(shorts).not.toContain("surface");
318
318
  });
319
319
 
320
+ test("non-curated supervised modules appear in `supervised` (not `modules`) — hub#539", async () => {
321
+ // surface (the UI host) is supervised but not curated. Its run-state must
322
+ // surface in `supervised` so `parachute status` reads it `active`, while it
323
+ // stays OUT of the curated `modules` catalog (which drives the install UI).
324
+ writeManifest(h.manifestPath, [
325
+ {
326
+ name: "parachute-vault",
327
+ port: 1940,
328
+ paths: ["/vault/default"],
329
+ health: "/vault/default/health",
330
+ version: "0.4.5",
331
+ },
332
+ ]);
333
+ const { supervisor } = makeIdleSupervisor();
334
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
335
+ await supervisor.start({ short: "surface", cmd: ["parachute-surface", "serve"] });
336
+
337
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
338
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
339
+ db: h.db,
340
+ issuer: ISSUER,
341
+ manifestPath: h.manifestPath,
342
+ supervisor,
343
+ fetchLatestVersion: async () => null,
344
+ });
345
+ const body = (await res.json()) as {
346
+ modules: Array<{ short: string }>;
347
+ supervised: Array<{ short: string; supervisor_status: string | null; pid: number | null }>;
348
+ };
349
+ // surface stays out of the curated catalog…
350
+ expect(body.modules.map((m) => m.short)).not.toContain("surface");
351
+ // …but its run-state is in `supervised`, marked running with a pid.
352
+ const surf = body.supervised.find((m) => m.short === "surface");
353
+ expect(surf?.supervisor_status).toBe("running");
354
+ expect(typeof surf?.pid).toBe("number");
355
+ // Curated modules appear in `supervised` too (consumers dedupe by short).
356
+ expect(body.supervised.find((m) => m.short === "vault")?.supervisor_status).toBe("running");
357
+ });
358
+
320
359
  test("surfaces installed_version from services.json", async () => {
321
360
  writeManifest(h.manifestPath, [
322
361
  {
@@ -716,10 +755,7 @@ describe("GET /api/modules", () => {
716
755
  // Second back-to-back request must not re-hit the registry. The
717
756
  // UI may poll this endpoint; we don't want it to slam npm.
718
757
  let calls = 0;
719
- const probe = async (
720
- _pkg: string,
721
- _channel: "latest" | "rc",
722
- ): Promise<string | null> => {
758
+ const probe = async (_pkg: string, _channel: "latest" | "rc"): Promise<string | null> => {
723
759
  calls++;
724
760
  return "0.5.0";
725
761
  };
@@ -83,6 +83,11 @@ function putReq(body: unknown, headers: Record<string, string> = {}): Request {
83
83
  });
84
84
  }
85
85
 
86
+ // Stub "no exposure recorded" so resolveIssuer / resolveIssuerSource don't
87
+ // pick up the host's real ~/.parachute/expose-state.json (the #531 expose
88
+ // tier would otherwise shadow the request-origin source these tests assert).
89
+ const noExpose = (): string | undefined => undefined;
90
+
86
91
  function deps(
87
92
  h: Harness,
88
93
  overrides: Partial<Parameters<typeof handleApiSettingsHubOrigin>[1]> = {},
@@ -90,8 +95,8 @@ function deps(
90
95
  return {
91
96
  db: h.db,
92
97
  issuer: ISSUER,
93
- resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
94
- resolvedSource: resolveIssuerSource(h.db, undefined),
98
+ resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
99
+ resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
95
100
  ...overrides,
96
101
  };
97
102
  }
@@ -414,8 +419,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
414
419
  const g1 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
415
420
  db: h.db,
416
421
  issuer: ISSUER,
417
- resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
418
- resolvedSource: resolveIssuerSource(h.db, undefined),
422
+ resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
423
+ resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
419
424
  });
420
425
  const b1 = (await g1.json()) as { source: string; resolved_issuer: string };
421
426
  expect(b1.source).toBe("request");
@@ -426,8 +431,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
426
431
  {
427
432
  db: h.db,
428
433
  issuer: ISSUER,
429
- resolvedIssuer: resolveIssuer(putReq({}), h.db, undefined),
430
- resolvedSource: resolveIssuerSource(h.db, undefined),
434
+ resolvedIssuer: resolveIssuer(putReq({}), h.db, undefined, noExpose),
435
+ resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
431
436
  },
432
437
  );
433
438
  expect(p.status).toBe(200);
@@ -437,8 +442,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
437
442
  const g2 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
438
443
  db: h.db,
439
444
  issuer: ISSUER,
440
- resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
441
- resolvedSource: resolveIssuerSource(h.db, undefined),
445
+ resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
446
+ resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
442
447
  });
443
448
  const b2 = (await g2.json()) as {
444
449
  hub_origin: string | null;