@openparachute/hub 0.7.4-rc.5 → 0.7.4-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.5",
3
+ "version": "0.7.4-rc.6",
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"
@@ -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";
@@ -1304,3 +1310,160 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1304
1310
  }
1305
1311
  });
1306
1312
  });
1313
+
1314
+ // ===========================================================================
1315
+ // #478 — empty-paths vault rows must not resolve to phantom "default"
1316
+ // ===========================================================================
1317
+
1318
+ describe("#478 — empty-paths vault row tolerance", () => {
1319
+ test("findExistingVault: empty-paths vault row does NOT match 'default'", () => {
1320
+ // A vault module registered in services.json with paths:[] is "installed
1321
+ // but no servable vault instance". Hub must skip it — never synthesize a
1322
+ // phantom "default" — so provisionVault can proceed to a real create.
1323
+ const h = makeHarness();
1324
+ try {
1325
+ // Write a services.json with a parachute-vault entry carrying paths:[].
1326
+ writeManifest(
1327
+ {
1328
+ services: [
1329
+ {
1330
+ name: "parachute-vault",
1331
+ port: 1940,
1332
+ paths: [],
1333
+ health: "/health",
1334
+ version: "0.5.0",
1335
+ },
1336
+ ],
1337
+ },
1338
+ h.manifestPath,
1339
+ );
1340
+ // Calling provisionVault("default") internally calls findExistingVault.
1341
+ // We verify the behaviour indirectly via listVaultInstanceNames (exported
1342
+ // for this test) and via provisionVault's created:true path below.
1343
+ const names = listVaultInstanceNames(h.manifestPath);
1344
+ expect(names.has("default")).toBe(false);
1345
+ } finally {
1346
+ h.cleanup();
1347
+ }
1348
+ });
1349
+
1350
+ test("listVaultInstanceNames: empty-paths vault row is omitted from the Set", () => {
1351
+ const h = makeHarness();
1352
+ try {
1353
+ writeManifest(
1354
+ {
1355
+ services: [
1356
+ {
1357
+ name: "parachute-vault",
1358
+ port: 1940,
1359
+ paths: [],
1360
+ health: "/health",
1361
+ version: "0.5.0",
1362
+ },
1363
+ ],
1364
+ },
1365
+ h.manifestPath,
1366
+ );
1367
+ const names = listVaultInstanceNames(h.manifestPath);
1368
+ expect(names.size).toBe(0);
1369
+ } finally {
1370
+ h.cleanup();
1371
+ }
1372
+ });
1373
+
1374
+ test("provisionVault: empty-paths row → created:true (proceeds to orchestrate, not false 'already exists')", async () => {
1375
+ // Core regression test for #478: before the fix, an empty-paths row
1376
+ // resolved to phantom "default" → findExistingVault returned non-null →
1377
+ // provisionVault short-circuited to created:false with "already exists".
1378
+ // After the fix: findExistingVault returns null → orchestrate runs →
1379
+ // created:true.
1380
+ const h = makeHarness();
1381
+ try {
1382
+ const db = openHubDb(hubDbPath(h.dir));
1383
+ try {
1384
+ rotateSigningKey(db);
1385
+ // Seed an empty-paths vault row (what vault's self-register emits at
1386
+ // zero vaults, per the #478 contract).
1387
+ writeManifest(
1388
+ {
1389
+ services: [
1390
+ {
1391
+ name: "parachute-vault",
1392
+ port: 1940,
1393
+ paths: [],
1394
+ health: "/health",
1395
+ version: "0.5.0",
1396
+ },
1397
+ ],
1398
+ },
1399
+ h.manifestPath,
1400
+ );
1401
+
1402
+ const calls: Array<readonly string[]> = [];
1403
+ const runCommand = async (cmd: readonly string[]): Promise<RunResult> => {
1404
+ calls.push(cmd);
1405
+ // Simulate vault CLI writing the real path into services.json after
1406
+ // a successful create. Because vault IS already registered (paths:[]),
1407
+ // orchestrate picks the `parachute-vault create --json` branch and
1408
+ // expects JSON stdout.
1409
+ upsertService(
1410
+ {
1411
+ name: "parachute-vault",
1412
+ port: 1940,
1413
+ paths: ["/vault/default"],
1414
+ health: "/health",
1415
+ version: "0.5.0",
1416
+ },
1417
+ h.manifestPath,
1418
+ );
1419
+ return { exitCode: 0, stdout: vaultCreateJson("default"), stderr: "" };
1420
+ };
1421
+
1422
+ const result = await provisionVault("default", {
1423
+ issuer: ISSUER,
1424
+ manifestPath: h.manifestPath,
1425
+ runCommand,
1426
+ });
1427
+
1428
+ // Must have proceeded to orchestrate and returned created:true.
1429
+ expect(result.ok).toBe(true);
1430
+ if (!result.ok) return; // narrow for TS
1431
+ expect(result.created).toBe(true);
1432
+ // The orchestration command ran (not short-circuited).
1433
+ expect(calls.length).toBeGreaterThan(0);
1434
+ } finally {
1435
+ db.close();
1436
+ }
1437
+ } finally {
1438
+ h.cleanup();
1439
+ }
1440
+ });
1441
+
1442
+ test("listVaultInstanceNames: real paths still enumerate correctly (empty-paths does not break them)", () => {
1443
+ // Sanity: mixing an empty-paths row with a real-paths row — the real
1444
+ // paths are still found, the empty one is still skipped.
1445
+ const h = makeHarness();
1446
+ try {
1447
+ writeManifest(
1448
+ {
1449
+ services: [
1450
+ {
1451
+ name: "parachute-vault",
1452
+ port: 1940,
1453
+ paths: ["/vault/default", "/vault/work"],
1454
+ health: "/health",
1455
+ version: "0.5.0",
1456
+ },
1457
+ ],
1458
+ },
1459
+ h.manifestPath,
1460
+ );
1461
+ const names = listVaultInstanceNames(h.manifestPath);
1462
+ expect(names.has("default")).toBe(true);
1463
+ expect(names.has("work")).toBe(true);
1464
+ expect(names.size).toBe(2);
1465
+ } finally {
1466
+ h.cleanup();
1467
+ }
1468
+ });
1469
+ });
@@ -68,11 +68,16 @@ describe("listVaultNames", () => {
68
68
  expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
69
69
  });
70
70
 
71
- test("entry with no paths falls back to the manifest-suffix name (hub#143)", () => {
71
+ test("#478: bare empty-paths `parachute-vault` row yields NO name (no phantom 'default')", () => {
72
+ // What vault's self-register emits at zero vaults: the row stays present
73
+ // (installed-detection) but advertises no `/vault/<name>` path. It must not
74
+ // leak a selectable/assignable "default" before any vault exists. Mirrors
75
+ // the empty-paths skip in admin-vaults.ts (findExistingVault /
76
+ // listVaultInstanceNames).
72
77
  const manifest: ServicesManifest = {
73
78
  services: [
74
79
  {
75
- name: "parachute-vault-archived",
80
+ name: "parachute-vault",
76
81
  port: 1940,
77
82
  paths: [],
78
83
  health: "/h",
@@ -80,7 +85,31 @@ describe("listVaultNames", () => {
80
85
  },
81
86
  ],
82
87
  };
83
- expect(listVaultNames(manifest)).toEqual(["archived"]);
88
+ expect(listVaultNames(manifest)).toEqual([]);
89
+ });
90
+
91
+ test("#478: empty-paths row is skipped; real-paths vault rows are unchanged (positive control)", () => {
92
+ // An empty-paths row alongside a real-paths row: the real instance still
93
+ // resolves, the empty one contributes nothing.
94
+ const manifest: ServicesManifest = {
95
+ services: [
96
+ {
97
+ name: "parachute-vault",
98
+ port: 1940,
99
+ paths: [],
100
+ health: "/h",
101
+ version: "0.1.0",
102
+ },
103
+ {
104
+ name: "parachute-vault-work",
105
+ port: 1941,
106
+ paths: ["/vault/work"],
107
+ health: "/h",
108
+ version: "0.1.0",
109
+ },
110
+ ],
111
+ };
112
+ expect(listVaultNames(manifest)).toEqual(["work"]);
84
113
  });
85
114
 
86
115
  test("deduplicates collisions across single-entry + per-vault shapes", () => {
@@ -207,15 +207,11 @@ function findExistingVault(
207
207
  } catch {
208
208
  return null;
209
209
  }
210
- const target = `/vault/${name}`;
211
210
  for (const svc of manifest.services) {
212
211
  if (!isVaultEntry(svc)) continue;
213
- if (svc.paths.length === 0) {
214
- if (vaultInstanceNameFor(svc.name, undefined) === name) {
215
- return { url: target, version: svc.version, path: target };
216
- }
217
- continue;
218
- }
212
+ // #478: an empty-paths vault row means "installed but no servable vault
213
+ // instance" skip it entirely so it never resolves to a phantom "default".
214
+ if (svc.paths.length === 0) continue;
219
215
  for (const path of svc.paths) {
220
216
  if (vaultInstanceNameFor(svc.name, path) === name) {
221
217
  return { url: path, version: svc.version, path };
@@ -607,7 +603,7 @@ function emptyCascadeSummary(): CascadeSummary {
607
603
  }
608
604
 
609
605
  /** Every vault instance name currently registered in services.json. */
610
- function listVaultInstanceNames(manifestPath: string): Set<string> {
606
+ export function listVaultInstanceNames(manifestPath: string): Set<string> {
611
607
  const names = new Set<string>();
612
608
  let manifest: ReturnType<typeof readManifest>;
613
609
  try {
@@ -617,10 +613,9 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
617
613
  }
618
614
  for (const svc of manifest.services) {
619
615
  if (!isVaultEntry(svc)) continue;
620
- if (svc.paths.length === 0) {
621
- names.add(vaultInstanceNameFor(svc.name, undefined));
622
- continue;
623
- }
616
+ // #478: an empty-paths vault row means "installed but no servable vault
617
+ // instance" — skip it so no phantom "default" is synthesized.
618
+ if (svc.paths.length === 0) continue;
624
619
  for (const path of svc.paths) names.add(vaultInstanceNameFor(svc.name, path));
625
620
  }
626
621
  return names;
@@ -23,8 +23,17 @@
23
23
  * Walks both manifest shapes: single-entry-multi-path (`parachute-vault`
24
24
  * with `paths: ["/vault/work", "/vault/personal"]`) and per-vault entries
25
25
  * (`parachute-vault-work`) by delegating each (name, path) pair to
26
- * `vaultInstanceNameFor`. Entries with no paths still resolve to a name via
27
- * the helper's manifest-suffix fallback (hub#143).
26
+ * `vaultInstanceNameFor`.
27
+ *
28
+ * #478: an empty-paths vault row (e.g. `parachute-vault` with `paths: []`,
29
+ * which vault's self-register emits at zero vaults) is "installed but no
30
+ * servable vault instance" and is SKIPPED entirely — it must not synthesize a
31
+ * name (the bare `parachute-vault` would otherwise resolve to a phantom
32
+ * "default"). This mirrors the empty-paths `continue` in `admin-vaults.ts`'s
33
+ * `findExistingVault`/`listVaultInstanceNames`, so every read path agrees: a
34
+ * vault instance is named only by a real `/vault/<name>` mount path. This
35
+ * supersedes the prior hub#143 manifest-suffix fallback for path-less entries
36
+ * — a registered vault carries its mount path once a vault exists.
28
37
  */
29
38
  import { type ServicesManifest, readManifestLenient } from "./services-manifest.ts";
30
39
  import { isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
@@ -39,8 +48,10 @@ export function listVaultNames(manifest: ServicesManifest): string[] {
39
48
  const names = new Set<string>();
40
49
  for (const svc of manifest.services) {
41
50
  if (!isVaultEntry(svc)) continue;
42
- const paths = svc.paths.length > 0 ? svc.paths : [undefined];
43
- for (const path of paths) {
51
+ // #478: an empty-paths vault row means "installed but no servable vault
52
+ // instance" skip it so it never synthesizes a phantom "default".
53
+ if (svc.paths.length === 0) continue;
54
+ for (const path of svc.paths) {
44
55
  names.add(vaultInstanceNameFor(svc.name, path));
45
56
  }
46
57
  }