@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 +3 -10
- package/src/__tests__/admin-vaults.test.ts +164 -1
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/admin-vaults.ts +7 -12
- package/src/vault-names.ts +15 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.7.4-rc.
|
|
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
|
-
|
|
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 {
|
|
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("
|
|
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
|
|
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([
|
|
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", () => {
|
package/src/admin-vaults.ts
CHANGED
|
@@ -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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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;
|
package/src/vault-names.ts
CHANGED
|
@@ -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`.
|
|
27
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
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
|
}
|