@openparachute/hub 0.7.4-rc.2 → 0.7.4-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 (75) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-lock.test.ts +7 -1
  5. package/src/__tests__/admin-vaults.test.ts +216 -10
  6. package/src/__tests__/api-account-2fa.test.ts +453 -0
  7. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  8. package/src/__tests__/api-mint-token.test.ts +75 -0
  9. package/src/__tests__/api-modules.test.ts +143 -0
  10. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  11. package/src/__tests__/auth.test.ts +336 -0
  12. package/src/__tests__/clients.test.ts +326 -8
  13. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-server.test.ts +127 -5
  18. package/src/__tests__/hub-settings.test.ts +188 -0
  19. package/src/__tests__/init.test.ts +153 -0
  20. package/src/__tests__/jwt-sign.test.ts +27 -0
  21. package/src/__tests__/managed-unit.test.ts +62 -0
  22. package/src/__tests__/oauth-handlers.test.ts +626 -0
  23. package/src/__tests__/oauth-ui.test.ts +107 -1
  24. package/src/__tests__/scope-explanations.test.ts +19 -0
  25. package/src/__tests__/setup-gate.test.ts +111 -3
  26. package/src/__tests__/setup-wizard.test.ts +124 -7
  27. package/src/__tests__/supervisor.test.ts +25 -0
  28. package/src/__tests__/vault-names.test.ts +32 -3
  29. package/src/__tests__/vault-remove.test.ts +40 -19
  30. package/src/__tests__/well-known.test.ts +37 -2
  31. package/src/admin-agent-grants.ts +16 -1
  32. package/src/admin-auth.ts +13 -4
  33. package/src/admin-clients.ts +66 -5
  34. package/src/admin-grants.ts +11 -2
  35. package/src/admin-vaults.ts +77 -27
  36. package/src/api-account-2fa.ts +395 -0
  37. package/src/api-admin-lock.ts +7 -0
  38. package/src/api-hub-upgrade.ts +52 -4
  39. package/src/api-hub.ts +10 -1
  40. package/src/api-invites.ts +18 -3
  41. package/src/api-me.ts +11 -2
  42. package/src/api-mint-token.ts +16 -1
  43. package/src/api-modules.ts +119 -1
  44. package/src/api-revoke-token.ts +14 -1
  45. package/src/api-settings-hub-origin.ts +14 -1
  46. package/src/api-settings-root-redirect.ts +201 -0
  47. package/src/api-tokens.ts +14 -1
  48. package/src/api-users.ts +15 -6
  49. package/src/api-vault-caps.ts +11 -2
  50. package/src/cli.ts +56 -5
  51. package/src/clients.ts +178 -0
  52. package/src/commands/auth.ts +263 -1
  53. package/src/commands/doctor.ts +1250 -0
  54. package/src/commands/hub.ts +102 -1
  55. package/src/commands/init.ts +108 -0
  56. package/src/commands/vault-remove.ts +16 -24
  57. package/src/cors.ts +7 -3
  58. package/src/help.ts +65 -1
  59. package/src/hub-db.ts +14 -0
  60. package/src/hub-server.ts +173 -25
  61. package/src/hub-settings.ts +163 -1
  62. package/src/jwt-sign.ts +25 -6
  63. package/src/managed-unit.ts +30 -1
  64. package/src/oauth-handlers.ts +110 -7
  65. package/src/oauth-ui.ts +174 -0
  66. package/src/rate-limit.ts +28 -0
  67. package/src/scope-explanations.ts +2 -1
  68. package/src/setup-wizard.ts +40 -21
  69. package/src/supervisor.ts +46 -2
  70. package/src/vault-names.ts +15 -4
  71. package/src/well-known.ts +10 -1
  72. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  73. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  74. package/web/ui/dist/index.html +2 -2
  75. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.2",
3
+ "version": "0.7.4-rc.21",
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": {
@@ -11,15 +11,8 @@
11
11
  "bin": {
12
12
  "parachute": "src/cli.ts"
13
13
  },
14
- "workspaces": [
15
- "packages/*"
16
- ],
17
- "files": [
18
- "src",
19
- "web/ui/dist",
20
- "README.md",
21
- "LICENSE"
22
- ],
14
+ "workspaces": ["packages/*"],
15
+ "files": ["src", "web/ui/dist", "README.md", "LICENSE"],
23
16
  "repository": {
24
17
  "type": "git",
25
18
  "url": "https://github.com/ParachuteComputer/parachute-hub.git"
@@ -47,7 +40,7 @@
47
40
  },
48
41
  "dependencies": {
49
42
  "@node-rs/argon2": "^2.0.2",
50
- "@openparachute/depcheck": "0.1.0",
43
+ "@openparachute/depcheck": "0.1.1",
51
44
  "jose": "^6.2.2",
52
45
  "otpauth": "^9.5.0",
53
46
  "qrcode": "^1.5.4"
@@ -172,6 +172,134 @@ describe("requireScope", () => {
172
172
  });
173
173
  });
174
174
 
175
+ describe("requireScope multi-origin issuer set (hub#516 parity)", () => {
176
+ // The hub answers on several legitimate origins after `parachute hub
177
+ // set-origin` / `expose` (loopback ∪ tunnel ∪ public). A credential minted
178
+ // under a still-valid prior origin must keep validating at admin-auth even
179
+ // when the per-request canonical issuer is now a different member of the set.
180
+ const LOOPBACK = "http://127.0.0.1:1939";
181
+ const TUNNEL = "https://brain.gitcoin.co";
182
+ const FOREIGN = "https://evil.example.com";
183
+
184
+ test("accepts a token whose iss is in the set but ≠ the per-request canonical", async () => {
185
+ const h = makeHarness();
186
+ try {
187
+ const db = openHubDb(hubDbPath(h.dir));
188
+ try {
189
+ rotateSigningKey(db);
190
+ // Token minted under the TUNNEL origin (e.g. before `set-origin`)…
191
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
192
+ // …presented to admin-auth where the canonical per-request issuer is now
193
+ // LOOPBACK, but the bound-origin set still includes TUNNEL.
194
+ const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
195
+ LOOPBACK,
196
+ TUNNEL,
197
+ ]);
198
+ expect(ctx.sub).toBe("user-test");
199
+ expect(ctx.scopes).toContain("parachute:host:admin");
200
+ } finally {
201
+ db.close();
202
+ }
203
+ } finally {
204
+ h.cleanup();
205
+ }
206
+ });
207
+
208
+ test("rejects 401 when iss is OUTSIDE the set", async () => {
209
+ const h = makeHarness();
210
+ try {
211
+ const db = openHubDb(hubDbPath(h.dir));
212
+ try {
213
+ rotateSigningKey(db);
214
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: FOREIGN });
215
+ let caught: AdminAuthError | null = null;
216
+ try {
217
+ await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
218
+ LOOPBACK,
219
+ TUNNEL,
220
+ ]);
221
+ } catch (err) {
222
+ caught = err as AdminAuthError;
223
+ }
224
+ expect(caught?.status).toBe(401);
225
+ } finally {
226
+ db.close();
227
+ }
228
+ } finally {
229
+ h.cleanup();
230
+ }
231
+ });
232
+
233
+ test("rejects 401 on signature failure regardless of the set (signature-first)", async () => {
234
+ const h = makeHarness();
235
+ try {
236
+ const db = openHubDb(hubDbPath(h.dir));
237
+ try {
238
+ rotateSigningKey(db);
239
+ // A hub-signed token, then rotate keys + retire so the original kid no
240
+ // longer verifies — the JWKS signature gate must fire before any iss
241
+ // membership check, so an in-set iss can't rescue an unverifiable token.
242
+ let caught: AdminAuthError | null = null;
243
+ try {
244
+ await requireScope(db, reqWithAuth("Bearer not-a-real-jwt"), "parachute:host:admin", [
245
+ LOOPBACK,
246
+ TUNNEL,
247
+ ]);
248
+ } catch (err) {
249
+ caught = err as AdminAuthError;
250
+ }
251
+ expect(caught?.status).toBe(401);
252
+ } finally {
253
+ db.close();
254
+ }
255
+ } finally {
256
+ h.cleanup();
257
+ }
258
+ });
259
+
260
+ test("single-string back-compat: in-set iss as a lone string still validates", async () => {
261
+ const h = makeHarness();
262
+ try {
263
+ const db = openHubDb(hubDbPath(h.dir));
264
+ try {
265
+ rotateSigningKey(db);
266
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: ISSUER });
267
+ // A single-element set behaves exactly like the prior single-string form.
268
+ const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
269
+ ISSUER,
270
+ ]);
271
+ expect(ctx.sub).toBe("user-test");
272
+ } finally {
273
+ db.close();
274
+ }
275
+ } finally {
276
+ h.cleanup();
277
+ }
278
+ });
279
+
280
+ test("single-element set rejects a non-matching iss (no accidental widening)", async () => {
281
+ const h = makeHarness();
282
+ try {
283
+ const db = openHubDb(hubDbPath(h.dir));
284
+ try {
285
+ rotateSigningKey(db);
286
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
287
+ let caught: AdminAuthError | null = null;
288
+ try {
289
+ await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [ISSUER]);
290
+ } catch (err) {
291
+ caught = err as AdminAuthError;
292
+ }
293
+ expect(caught?.status).toBe(401);
294
+ } finally {
295
+ db.close();
296
+ }
297
+ } finally {
298
+ h.cleanup();
299
+ }
300
+ });
301
+ });
302
+
175
303
  describe("adminAuthErrorResponse", () => {
176
304
  test("403 → insufficient_scope with WWW-Authenticate", async () => {
177
305
  const res = adminAuthErrorResponse(new AdminAuthError(403, "needs admin"));
@@ -12,8 +12,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
12
  import { mkdtempSync, rmSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
- import { handleApproveClient, handleGetClient } from "../admin-clients.ts";
15
+ import { handleApproveClient, handleDeleteClient, handleGetClient } from "../admin-clients.ts";
16
16
  import { approveClient, getClient, registerClient } from "../clients.ts";
17
+ import { findGrant, recordGrant } from "../grants.ts";
17
18
  import { hubDbPath, openHubDb } from "../hub-db.ts";
18
19
  import { signAccessToken } from "../jwt-sign.ts";
19
20
  import { createUser } from "../users.ts";
@@ -83,6 +84,23 @@ function approveReq(clientId: string, bearer?: string, method = "POST"): Request
83
84
  return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}/approve`, init);
84
85
  }
85
86
 
87
+ // DELETE hits the TOP-LEVEL /oauth/clients/<id> prefix (the path the surface
88
+ // remove-flow calls), not the /api/... admin prefix the GET/approve use.
89
+ function deleteReq(clientId: string, bearer?: string, method = "DELETE"): Request {
90
+ const init: RequestInit = { method };
91
+ if (bearer) init.headers = { authorization: `Bearer ${bearer}` };
92
+ return new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(clientId)}`, init);
93
+ }
94
+
95
+ function regApproved(name?: string): string {
96
+ const r = registerClient(harness.db, {
97
+ redirectUris: ["https://app.example/cb"],
98
+ scopes: ["vault:work:read"],
99
+ ...(name !== undefined ? { clientName: name } : {}),
100
+ });
101
+ return r.client.clientId;
102
+ }
103
+
86
104
  describe("handleGetClient", () => {
87
105
  test("401 without Bearer", async () => {
88
106
  const id = regPending("App");
@@ -460,3 +478,87 @@ describe("handleApproveClient", () => {
460
478
  });
461
479
  });
462
480
  });
481
+
482
+ // hub#640 — RFC 7592 client deregistration. The surface fires DELETE on the
483
+ // top-level /oauth/clients/<id> path; the handler is operator-bearer-gated,
484
+ // returns 204 on delete (cascading grants + auth_codes), 404 when absent.
485
+ describe("handleDeleteClient", () => {
486
+ test("401 without Bearer (row survives)", async () => {
487
+ const id = regApproved("App");
488
+ const res = await handleDeleteClient(deleteReq(id), id, {
489
+ db: harness.db,
490
+ issuer: ISSUER,
491
+ });
492
+ expect(res.status).toBe(401);
493
+ expect(getClient(harness.db, id)).not.toBeNull();
494
+ });
495
+
496
+ test("403 with the wrong scope (row survives)", async () => {
497
+ const { bearer } = await makeOperatorBearer(["parachute:host:auth"]);
498
+ const id = regApproved("App");
499
+ const res = await handleDeleteClient(deleteReq(id, bearer), id, {
500
+ db: harness.db,
501
+ issuer: ISSUER,
502
+ });
503
+ expect(res.status).toBe(403);
504
+ expect(getClient(harness.db, id)).not.toBeNull();
505
+ });
506
+
507
+ test("204 on an existing client + cascade gone + audit line", async () => {
508
+ const { bearer, userId } = await makeOperatorBearer();
509
+ const id = regApproved("Notes");
510
+ // Plant a grant so the cascade has something to remove.
511
+ recordGrant(harness.db, userId, id, ["vault:work:read"]);
512
+ expect(findGrant(harness.db, userId, id)).not.toBeNull();
513
+
514
+ const logs: string[] = [];
515
+ const originalLog = console.log;
516
+ console.log = (...args: unknown[]) => {
517
+ logs.push(args.map(String).join(" "));
518
+ };
519
+ let res: Response;
520
+ try {
521
+ res = await handleDeleteClient(deleteReq(id, bearer), id, {
522
+ db: harness.db,
523
+ issuer: ISSUER,
524
+ });
525
+ } finally {
526
+ console.log = originalLog;
527
+ }
528
+ expect(res.status).toBe(204);
529
+ // 204 carries no body.
530
+ expect(await res.text()).toBe("");
531
+ // Client + its grant are gone.
532
+ expect(getClient(harness.db, id)).toBeNull();
533
+ expect(findGrant(harness.db, userId, id)).toBeNull();
534
+
535
+ const line = logs.find((l) => l.startsWith("client deleted:"));
536
+ expect(line).toBeDefined();
537
+ expect(line).toContain(`client_id=${id}`);
538
+ expect(line).toContain("client_name=Notes");
539
+ expect(line).toContain(`remover_sub=${userId}`);
540
+ });
541
+
542
+ test("404 on an absent client_id", async () => {
543
+ const { bearer } = await makeOperatorBearer();
544
+ const res = await handleDeleteClient(deleteReq("nope", bearer), "nope", {
545
+ db: harness.db,
546
+ issuer: ISSUER,
547
+ });
548
+ expect(res.status).toBe(404);
549
+ const body = (await res.json()) as Record<string, unknown>;
550
+ expect(body.error).toBe("not_found");
551
+ });
552
+
553
+ test("405 on a non-DELETE method", async () => {
554
+ const { bearer } = await makeOperatorBearer();
555
+ const id = regApproved();
556
+ const res = await handleDeleteClient(deleteReq(id, bearer, "GET"), id, {
557
+ db: harness.db,
558
+ issuer: ISSUER,
559
+ });
560
+ expect(res.status).toBe(405);
561
+ // Row untouched.
562
+ expect(getClient(harness.db, id)).not.toBeNull();
563
+ });
564
+ });
@@ -527,7 +527,13 @@ describe("admin-lock management API", () => {
527
527
  const r2 = await handleAdminLock(lockReq("/heartbeat", cookie, {}), "/heartbeat", {
528
528
  db: harness.db,
529
529
  });
530
- expect(((await r2.json()) as { locked: boolean }).locked).toBe(false);
530
+ const body2 = (await r2.json()) as { locked: boolean; idle_seconds?: number };
531
+ expect(body2.locked).toBe(false);
532
+ // The heartbeat MUST carry idle_seconds — the client re-anchors its local
533
+ // idle timer from it on every heartbeat. Omitting it poisoned the timer
534
+ // (undefined → NaN → instant re-lock). Regression guard for the PIN
535
+ // re-prompt loop.
536
+ expect(body2.idle_seconds).toBe(getIdleSeconds(harness.db));
531
537
  });
532
538
 
533
539
  test("unlock brute-force limiter: 6th attempt is 429", async () => {
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { HOST_ADMIN_SCOPE, type RunResult, handleCreateVault } from "../admin-vaults.ts";
5
+ import {
6
+ HOST_ADMIN_SCOPE,
7
+ type RunResult,
8
+ handleCreateVault,
9
+ listVaultInstanceNames,
10
+ provisionVault,
11
+ } from "../admin-vaults.ts";
6
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
7
13
  import { signAccessToken } from "../jwt-sign.ts";
8
14
  import { upsertService, writeManifest } from "../services-manifest.ts";
@@ -934,27 +940,70 @@ describe("DELETE /vaults/<name> — gates", () => {
934
940
  }
935
941
  });
936
942
 
937
- test("409 last_vault on the only remaining vault (CLI never runs)", async () => {
943
+ test("#678: deleting the LAST vault cascades + deletes (no 409), with a last_vault warning", async () => {
938
944
  const h = makeHarness();
939
945
  try {
940
946
  const db = openHubDb(hubDbPath(h.dir));
941
947
  try {
942
948
  rotateSigningKey(db);
943
- writeVaults(h.manifestPath, ["default"]);
949
+ // Only one vault on the hub — the previously-refused last-vault case.
950
+ writeVaults(h.manifestPath, ["solo"]);
951
+
952
+ // Seed identity artifacts naming the soon-to-be-deleted last vault.
953
+ registryRow(db, "jti-solo", ["vault:solo:write"]);
954
+ const carol = await createUser(db, "carol", "carol-passphrase-123");
955
+ const client = registerClient(db, { redirectUris: ["https://d.example/cb"] }).client
956
+ .clientId;
957
+ recordGrant(db, carol.id, client, ["vault:solo:admin"]);
958
+ setUserVaults(db, carol.id, ["solo"]);
959
+
944
960
  const runner = stubRun();
945
961
  const res = await callDelete({
946
- name: "default",
962
+ name: "solo",
947
963
  db,
948
964
  manifestPath: h.manifestPath,
949
965
  connectionsStorePath: join(h.dir, "connections.json"),
950
966
  runCommand: runner.run,
951
967
  });
952
- expect(res.status).toBe(409);
953
- const out = (await res.json()) as { error: string; error_description: string };
954
- expect(out.error).toBe("last_vault");
955
- // Guidance names the CLI escape hatch + the resurrection reason.
956
- expect(out.error_description).toContain("parachute-vault remove");
957
- expect(runner.calls.length).toBe(0);
968
+
969
+ // No 409 the delete completes.
970
+ expect(res.status).toBe(200);
971
+ const out = (await res.json()) as {
972
+ ok: boolean;
973
+ cascade: {
974
+ tokens_revoked: number;
975
+ grants_dropped: number;
976
+ user_vaults_removed: number;
977
+ vault_removed: boolean;
978
+ };
979
+ warnings?: { step: string; detail: string }[];
980
+ };
981
+ expect(out.ok).toBe(true);
982
+
983
+ // The cascade ran for the last vault: tokens/grants/assignments gone.
984
+ expect(out.cascade.tokens_revoked).toBe(1);
985
+ expect(findTokenRowByJti(db, "jti-solo")?.revokedAt).not.toBeNull();
986
+ expect(out.cascade.grants_dropped).toBe(1);
987
+ expect(findGrant(db, carol.id, client)).toBeNull();
988
+ expect(out.cascade.user_vaults_removed).toBe(1);
989
+ expect(
990
+ db
991
+ .query<{ vault_name: string }, [string]>(
992
+ "SELECT vault_name FROM user_vaults WHERE user_id = ?",
993
+ )
994
+ .all(carol.id),
995
+ ).toEqual([]);
996
+
997
+ // The underlying vault remove ran (the cascade no longer skips it).
998
+ expect(runner.calls).toContainEqual(["parachute-vault", "remove", "solo", "--yes"]);
999
+ expect(out.cascade.vault_removed).toBe(true);
1000
+
1001
+ // A last_vault heads-up warning is surfaced (name-agnostic — does not
1002
+ // assume "default"); the auto_create:false marker prevents resurrection.
1003
+ const lastVaultWarning = out.warnings?.find((w) => w.step === "last_vault");
1004
+ expect(lastVaultWarning).toBeDefined();
1005
+ expect(lastVaultWarning?.detail).toContain("auto_create: false");
1006
+ expect(lastVaultWarning?.detail).not.toContain('"default"');
958
1007
  } finally {
959
1008
  db.close();
960
1009
  }
@@ -1304,3 +1353,160 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1304
1353
  }
1305
1354
  });
1306
1355
  });
1356
+
1357
+ // ===========================================================================
1358
+ // #478 — empty-paths vault rows must not resolve to phantom "default"
1359
+ // ===========================================================================
1360
+
1361
+ describe("#478 — empty-paths vault row tolerance", () => {
1362
+ test("findExistingVault: empty-paths vault row does NOT match 'default'", () => {
1363
+ // A vault module registered in services.json with paths:[] is "installed
1364
+ // but no servable vault instance". Hub must skip it — never synthesize a
1365
+ // phantom "default" — so provisionVault can proceed to a real create.
1366
+ const h = makeHarness();
1367
+ try {
1368
+ // Write a services.json with a parachute-vault entry carrying paths:[].
1369
+ writeManifest(
1370
+ {
1371
+ services: [
1372
+ {
1373
+ name: "parachute-vault",
1374
+ port: 1940,
1375
+ paths: [],
1376
+ health: "/health",
1377
+ version: "0.5.0",
1378
+ },
1379
+ ],
1380
+ },
1381
+ h.manifestPath,
1382
+ );
1383
+ // Calling provisionVault("default") internally calls findExistingVault.
1384
+ // We verify the behaviour indirectly via listVaultInstanceNames (exported
1385
+ // for this test) and via provisionVault's created:true path below.
1386
+ const names = listVaultInstanceNames(h.manifestPath);
1387
+ expect(names.has("default")).toBe(false);
1388
+ } finally {
1389
+ h.cleanup();
1390
+ }
1391
+ });
1392
+
1393
+ test("listVaultInstanceNames: empty-paths vault row is omitted from the Set", () => {
1394
+ const h = makeHarness();
1395
+ try {
1396
+ writeManifest(
1397
+ {
1398
+ services: [
1399
+ {
1400
+ name: "parachute-vault",
1401
+ port: 1940,
1402
+ paths: [],
1403
+ health: "/health",
1404
+ version: "0.5.0",
1405
+ },
1406
+ ],
1407
+ },
1408
+ h.manifestPath,
1409
+ );
1410
+ const names = listVaultInstanceNames(h.manifestPath);
1411
+ expect(names.size).toBe(0);
1412
+ } finally {
1413
+ h.cleanup();
1414
+ }
1415
+ });
1416
+
1417
+ test("provisionVault: empty-paths row → created:true (proceeds to orchestrate, not false 'already exists')", async () => {
1418
+ // Core regression test for #478: before the fix, an empty-paths row
1419
+ // resolved to phantom "default" → findExistingVault returned non-null →
1420
+ // provisionVault short-circuited to created:false with "already exists".
1421
+ // After the fix: findExistingVault returns null → orchestrate runs →
1422
+ // created:true.
1423
+ const h = makeHarness();
1424
+ try {
1425
+ const db = openHubDb(hubDbPath(h.dir));
1426
+ try {
1427
+ rotateSigningKey(db);
1428
+ // Seed an empty-paths vault row (what vault's self-register emits at
1429
+ // zero vaults, per the #478 contract).
1430
+ writeManifest(
1431
+ {
1432
+ services: [
1433
+ {
1434
+ name: "parachute-vault",
1435
+ port: 1940,
1436
+ paths: [],
1437
+ health: "/health",
1438
+ version: "0.5.0",
1439
+ },
1440
+ ],
1441
+ },
1442
+ h.manifestPath,
1443
+ );
1444
+
1445
+ const calls: Array<readonly string[]> = [];
1446
+ const runCommand = async (cmd: readonly string[]): Promise<RunResult> => {
1447
+ calls.push(cmd);
1448
+ // Simulate vault CLI writing the real path into services.json after
1449
+ // a successful create. Because vault IS already registered (paths:[]),
1450
+ // orchestrate picks the `parachute-vault create --json` branch and
1451
+ // expects JSON stdout.
1452
+ upsertService(
1453
+ {
1454
+ name: "parachute-vault",
1455
+ port: 1940,
1456
+ paths: ["/vault/default"],
1457
+ health: "/health",
1458
+ version: "0.5.0",
1459
+ },
1460
+ h.manifestPath,
1461
+ );
1462
+ return { exitCode: 0, stdout: vaultCreateJson("default"), stderr: "" };
1463
+ };
1464
+
1465
+ const result = await provisionVault("default", {
1466
+ issuer: ISSUER,
1467
+ manifestPath: h.manifestPath,
1468
+ runCommand,
1469
+ });
1470
+
1471
+ // Must have proceeded to orchestrate and returned created:true.
1472
+ expect(result.ok).toBe(true);
1473
+ if (!result.ok) return; // narrow for TS
1474
+ expect(result.created).toBe(true);
1475
+ // The orchestration command ran (not short-circuited).
1476
+ expect(calls.length).toBeGreaterThan(0);
1477
+ } finally {
1478
+ db.close();
1479
+ }
1480
+ } finally {
1481
+ h.cleanup();
1482
+ }
1483
+ });
1484
+
1485
+ test("listVaultInstanceNames: real paths still enumerate correctly (empty-paths does not break them)", () => {
1486
+ // Sanity: mixing an empty-paths row with a real-paths row — the real
1487
+ // paths are still found, the empty one is still skipped.
1488
+ const h = makeHarness();
1489
+ try {
1490
+ writeManifest(
1491
+ {
1492
+ services: [
1493
+ {
1494
+ name: "parachute-vault",
1495
+ port: 1940,
1496
+ paths: ["/vault/default", "/vault/work"],
1497
+ health: "/health",
1498
+ version: "0.5.0",
1499
+ },
1500
+ ],
1501
+ },
1502
+ h.manifestPath,
1503
+ );
1504
+ const names = listVaultInstanceNames(h.manifestPath);
1505
+ expect(names.has("default")).toBe(true);
1506
+ expect(names.has("work")).toBe(true);
1507
+ expect(names.size).toBe(2);
1508
+ } finally {
1509
+ h.cleanup();
1510
+ }
1511
+ });
1512
+ });