@openparachute/hub 0.7.4-rc.1 → 0.7.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.
- package/package.json +4 -11
- package/src/__tests__/admin-vaults.test.ts +164 -1
- package/src/__tests__/api-account-2fa.test.ts +381 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/clients.test.ts +28 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +488 -0
- package/src/__tests__/oauth-ui.test.ts +55 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/status-supervisor.test.ts +152 -3
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-vaults.ts +7 -12
- package/src/api-account-2fa.ts +373 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/cli.ts +27 -5
- package/src/clients.ts +14 -0
- package/src/commands/init.ts +108 -0
- package/src/commands/status.ts +108 -5
- package/src/help.ts +12 -1
- package/src/hub-server.ts +72 -7
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +98 -6
- package/src/oauth-ui.ts +123 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
- package/web/ui/dist/index.html +2 -2
|
@@ -990,6 +990,123 @@ describe("handleSetupGet", () => {
|
|
|
990
990
|
db.close();
|
|
991
991
|
}
|
|
992
992
|
});
|
|
993
|
+
|
|
994
|
+
// hub#618: gate the JSON `?op=` op-snapshot once setup is complete.
|
|
995
|
+
// Mid-setup it stays OPEN (the unauth CLI wizard + brand-new-operator
|
|
996
|
+
// browser both poll it before any session exists); post-complete it
|
|
997
|
+
// requires a session or loopback (it's a post-setup admin surface, and
|
|
998
|
+
// `/admin/setup` is always lockout-exempt so it's otherwise unauth-reachable).
|
|
999
|
+
|
|
1000
|
+
test("mid-setup unauth ?op= still returns the op snapshot (hub#618 regression guard)", async () => {
|
|
1001
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1002
|
+
try {
|
|
1003
|
+
// No admin yet → setup INCOMPLETE → the surface stays open.
|
|
1004
|
+
const reg = getDefaultOperationsRegistry();
|
|
1005
|
+
const op = reg.create("install", "vault");
|
|
1006
|
+
reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/vault@latest");
|
|
1007
|
+
const res = handleSetupGet(
|
|
1008
|
+
req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
|
|
1009
|
+
{
|
|
1010
|
+
db,
|
|
1011
|
+
manifestPath: h.manifestPath,
|
|
1012
|
+
configDir: h.dir,
|
|
1013
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
1014
|
+
issuer: "https://hub.example",
|
|
1015
|
+
registry: reg,
|
|
1016
|
+
// No loopback flag, no session — the unauth first-boot poll.
|
|
1017
|
+
},
|
|
1018
|
+
);
|
|
1019
|
+
expect(res.status).toBe(200);
|
|
1020
|
+
const body = (await res.json()) as {
|
|
1021
|
+
hasAdmin: boolean;
|
|
1022
|
+
operation?: { id: string; status: string; log: readonly string[] };
|
|
1023
|
+
};
|
|
1024
|
+
expect(body.hasAdmin).toBe(false);
|
|
1025
|
+
expect(body.operation).toBeDefined();
|
|
1026
|
+
expect(body.operation?.id).toBe(op.id);
|
|
1027
|
+
expect(body.operation?.status).toBe("running");
|
|
1028
|
+
} finally {
|
|
1029
|
+
db.close();
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("post-complete unauth ?op= omits the op snapshot; session OR loopback restores it (hub#618)", async () => {
|
|
1034
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1035
|
+
try {
|
|
1036
|
+
// Drive state to COMPLETE: admin + vault + expose mode.
|
|
1037
|
+
const user = await createUser(db, "owner", "pw");
|
|
1038
|
+
writeManifest(
|
|
1039
|
+
{
|
|
1040
|
+
services: [
|
|
1041
|
+
{
|
|
1042
|
+
name: "parachute-vault",
|
|
1043
|
+
version: "0.1.0",
|
|
1044
|
+
port: 1940,
|
|
1045
|
+
paths: ["/vault/default"],
|
|
1046
|
+
health: "/health",
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
},
|
|
1050
|
+
h.manifestPath,
|
|
1051
|
+
);
|
|
1052
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1053
|
+
const reg = getDefaultOperationsRegistry();
|
|
1054
|
+
const op = reg.create("install", "vault");
|
|
1055
|
+
reg.update(op.id, { status: "running" }, "still running");
|
|
1056
|
+
|
|
1057
|
+
const deps = {
|
|
1058
|
+
db,
|
|
1059
|
+
manifestPath: h.manifestPath,
|
|
1060
|
+
configDir: h.dir,
|
|
1061
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
1062
|
+
issuer: "https://hub.example",
|
|
1063
|
+
registry: reg,
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// (a) Unauth, non-loopback → operation omitted.
|
|
1067
|
+
const unauth = handleSetupGet(
|
|
1068
|
+
req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
|
|
1069
|
+
deps,
|
|
1070
|
+
);
|
|
1071
|
+
expect(unauth.status).toBe(200);
|
|
1072
|
+
const unauthBody = (await unauth.json()) as {
|
|
1073
|
+
hasAdmin: boolean;
|
|
1074
|
+
hasVault: boolean;
|
|
1075
|
+
hasExposeMode: boolean;
|
|
1076
|
+
operation?: unknown;
|
|
1077
|
+
};
|
|
1078
|
+
// Confirm setup actually derived as complete (else the gate is vacuous).
|
|
1079
|
+
expect(unauthBody.hasAdmin).toBe(true);
|
|
1080
|
+
expect(unauthBody.hasVault).toBe(true);
|
|
1081
|
+
expect(unauthBody.hasExposeMode).toBe(true);
|
|
1082
|
+
expect(unauthBody.operation).toBeUndefined();
|
|
1083
|
+
|
|
1084
|
+
// (b) Valid session → operation restored.
|
|
1085
|
+
const { createSession } = await import("../sessions.ts");
|
|
1086
|
+
const session = createSession(db, { userId: user.id });
|
|
1087
|
+
const authed = handleSetupGet(
|
|
1088
|
+
req(`/admin/setup?op=${op.id}`, {
|
|
1089
|
+
headers: {
|
|
1090
|
+
accept: "application/json",
|
|
1091
|
+
cookie: `${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1092
|
+
},
|
|
1093
|
+
}),
|
|
1094
|
+
deps,
|
|
1095
|
+
);
|
|
1096
|
+
const authedBody = (await authed.json()) as { operation?: { id: string } };
|
|
1097
|
+
expect(authedBody.operation?.id).toBe(op.id);
|
|
1098
|
+
|
|
1099
|
+
// (c) Loopback (no session) → operation restored.
|
|
1100
|
+
const loopback = handleSetupGet(
|
|
1101
|
+
req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
|
|
1102
|
+
{ ...deps, requestIsLoopback: true },
|
|
1103
|
+
);
|
|
1104
|
+
const loopbackBody = (await loopback.json()) as { operation?: { id: string } };
|
|
1105
|
+
expect(loopbackBody.operation?.id).toBe(op.id);
|
|
1106
|
+
} finally {
|
|
1107
|
+
db.close();
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
993
1110
|
});
|
|
994
1111
|
|
|
995
1112
|
// --- POST /admin/setup/account -------------------------------------------
|
|
@@ -3150,7 +3267,7 @@ describe("typed vault name (hub#267)", () => {
|
|
|
3150
3267
|
}
|
|
3151
3268
|
});
|
|
3152
3269
|
|
|
3153
|
-
test("vault POST with empty name falls back to 'default' +
|
|
3270
|
+
test("vault POST with empty name falls back to 'default' + ALWAYS passes PARACHUTE_VAULT_NAME (#478 Part 2)", async () => {
|
|
3154
3271
|
const db = openHubDb(hubDbPath(h.dir));
|
|
3155
3272
|
try {
|
|
3156
3273
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -3213,12 +3330,12 @@ describe("typed vault name (hub#267)", () => {
|
|
|
3213
3330
|
await new Promise((r) => setTimeout(r, 50));
|
|
3214
3331
|
const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
|
|
3215
3332
|
expect(vaultSpawn).toBeDefined();
|
|
3216
|
-
//
|
|
3217
|
-
//
|
|
3218
|
-
//
|
|
3219
|
-
//
|
|
3220
|
-
//
|
|
3221
|
-
expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).
|
|
3333
|
+
// #478 Part 2: wizard ALWAYS passes PARACHUTE_VAULT_NAME so vault's
|
|
3334
|
+
// first-boot knows which vault to create once silent auto-create is
|
|
3335
|
+
// removed. Even for the default name, the env var must be set to
|
|
3336
|
+
// "default" — not omitted. PORT is set by the supervisor (hub#356)
|
|
3337
|
+
// for every supervised child regardless.
|
|
3338
|
+
expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBe("default");
|
|
3222
3339
|
expect(vaultSpawn?.env?.PORT).toBe("1940");
|
|
3223
3340
|
} finally {
|
|
3224
3341
|
db.close();
|
|
@@ -80,6 +80,12 @@ interface SupervisorArmOpts {
|
|
|
80
80
|
hubHealthy: boolean;
|
|
81
81
|
moduleStates?: ModuleStatesResult;
|
|
82
82
|
fetchModuleStatesImpl?: () => Promise<ModuleStatesResult>;
|
|
83
|
+
/**
|
|
84
|
+
* Inject the unauthenticated module-liveness probe (#700). Defaults to "every
|
|
85
|
+
* module is down" so the degraded-read tests don't accidentally hit the
|
|
86
|
+
* network; specific tests override to mark a module live.
|
|
87
|
+
*/
|
|
88
|
+
probeModuleHealth?: (port: number, health: string) => Promise<boolean>;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
/** Drive `status` through the supervisor arm with fully stubbed seams. */
|
|
@@ -96,6 +102,7 @@ function supervisorOpts(configDir: string, path: string, o: SupervisorArmOpts) {
|
|
|
96
102
|
fetchModuleStates:
|
|
97
103
|
o.fetchModuleStatesImpl ??
|
|
98
104
|
(async () => o.moduleStates ?? { supervisorAvailable: true, modules: [] }),
|
|
105
|
+
probeModuleHealth: o.probeModuleHealth ?? (async () => false),
|
|
99
106
|
openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
|
|
100
107
|
},
|
|
101
108
|
};
|
|
@@ -377,7 +384,7 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
|
|
|
377
384
|
}
|
|
378
385
|
});
|
|
379
386
|
|
|
380
|
-
test("no operator token
|
|
387
|
+
test("no operator token (fresh box, no admin) → note targets set-password, NOT rotate-operator (#700)", async () => {
|
|
381
388
|
const { path, configDir, cleanup } = makeTempPath();
|
|
382
389
|
try {
|
|
383
390
|
upsertService(
|
|
@@ -392,15 +399,121 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
|
|
|
392
399
|
fetchModuleStatesImpl: async () => {
|
|
393
400
|
throw new NoOperatorTokenError();
|
|
394
401
|
},
|
|
402
|
+
// No probe-live module here → row stays inactive (exit 0).
|
|
403
|
+
probeModuleHealth: async () => false,
|
|
395
404
|
}),
|
|
396
405
|
print: (l) => lines.push(l),
|
|
397
406
|
});
|
|
398
407
|
// We could not read run-state, but didn't crash. The module row falls back
|
|
399
|
-
// to `inactive` (no supervisor snapshot) — a stopped row is exit 0.
|
|
408
|
+
// to `inactive` (no supervisor snapshot, probe down) — a stopped row is exit 0.
|
|
400
409
|
expect(code).toBe(0);
|
|
401
410
|
const out = lines.join("\n");
|
|
402
411
|
expect(out).toMatch(/parachute-vault/);
|
|
403
|
-
|
|
412
|
+
// #700: a fresh box has no admin, so rotate-operator would itself error.
|
|
413
|
+
// The note must point at set-password and must NOT be the bare
|
|
414
|
+
// rotate-operator guidance.
|
|
415
|
+
expect(out).toMatch(/parachute auth set-password/);
|
|
416
|
+
expect(out).not.toMatch(/run `parachute auth rotate-operator` to mint an operator token/);
|
|
417
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
418
|
+
expect(vaultLine).toMatch(/\binactive\b/);
|
|
419
|
+
} finally {
|
|
420
|
+
cleanup();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("no operator token + module answers /health probe → LIVE (active), not inactive (#700)", async () => {
|
|
425
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
426
|
+
try {
|
|
427
|
+
upsertService(
|
|
428
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
429
|
+
path,
|
|
430
|
+
);
|
|
431
|
+
const probed: Array<{ port: number; health: string }> = [];
|
|
432
|
+
const lines: string[] = [];
|
|
433
|
+
const code = await status({
|
|
434
|
+
...supervisorOpts(configDir, path, {
|
|
435
|
+
managerState: { state: "active" },
|
|
436
|
+
hubHealthy: true,
|
|
437
|
+
fetchModuleStatesImpl: async () => {
|
|
438
|
+
throw new NoOperatorTokenError();
|
|
439
|
+
},
|
|
440
|
+
// vault is genuinely up — its /health answers (2xx or 401 → live).
|
|
441
|
+
probeModuleHealth: async (port, health) => {
|
|
442
|
+
probed.push({ port, health });
|
|
443
|
+
return true;
|
|
444
|
+
},
|
|
445
|
+
}),
|
|
446
|
+
print: (l) => lines.push(l),
|
|
447
|
+
});
|
|
448
|
+
expect(code).toBe(0);
|
|
449
|
+
// The probe targeted the module's own port + health path from the manifest.
|
|
450
|
+
expect(probed).toEqual([{ port: 1940, health: "/health" }]);
|
|
451
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
452
|
+
expect(vaultLine).toMatch(/\bactive\b/);
|
|
453
|
+
expect(vaultLine).not.toMatch(/\binactive\b/);
|
|
454
|
+
const out = lines.join("\n");
|
|
455
|
+
// The row is labelled as probe-derived so the operator knows it's thin.
|
|
456
|
+
expect(out).toMatch(/live via unauthenticated health probe/);
|
|
457
|
+
// The degraded-read hint still appears (why PID/uptime are absent).
|
|
458
|
+
expect(out).toMatch(/parachute auth set-password/);
|
|
459
|
+
} finally {
|
|
460
|
+
cleanup();
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("degraded read + module probe FAILS → row stays inactive (#700)", async () => {
|
|
465
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
466
|
+
try {
|
|
467
|
+
upsertService(
|
|
468
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
469
|
+
path,
|
|
470
|
+
);
|
|
471
|
+
const lines: string[] = [];
|
|
472
|
+
const code = await status({
|
|
473
|
+
...supervisorOpts(configDir, path, {
|
|
474
|
+
managerState: { state: "active" },
|
|
475
|
+
hubHealthy: true,
|
|
476
|
+
fetchModuleStatesImpl: async () => {
|
|
477
|
+
throw new NoOperatorTokenError();
|
|
478
|
+
},
|
|
479
|
+
probeModuleHealth: async () => false,
|
|
480
|
+
}),
|
|
481
|
+
print: (l) => lines.push(l),
|
|
482
|
+
});
|
|
483
|
+
expect(code).toBe(0);
|
|
484
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
485
|
+
expect(vaultLine).toMatch(/\binactive\b/);
|
|
486
|
+
const out = lines.join("\n");
|
|
487
|
+
expect(out).not.toMatch(/live via unauthenticated health probe/);
|
|
488
|
+
} finally {
|
|
489
|
+
cleanup();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("a throwing module probe never crashes status — row degrades to inactive (#700)", async () => {
|
|
494
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
495
|
+
try {
|
|
496
|
+
upsertService(
|
|
497
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
498
|
+
path,
|
|
499
|
+
);
|
|
500
|
+
const lines: string[] = [];
|
|
501
|
+
const code = await status({
|
|
502
|
+
...supervisorOpts(configDir, path, {
|
|
503
|
+
managerState: { state: "active" },
|
|
504
|
+
hubHealthy: true,
|
|
505
|
+
fetchModuleStatesImpl: async () => {
|
|
506
|
+
throw new NoOperatorTokenError();
|
|
507
|
+
},
|
|
508
|
+
probeModuleHealth: async () => {
|
|
509
|
+
throw new Error("probe exploded");
|
|
510
|
+
},
|
|
511
|
+
}),
|
|
512
|
+
print: (l) => lines.push(l),
|
|
513
|
+
});
|
|
514
|
+
expect(code).toBe(0);
|
|
515
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
516
|
+
expect(vaultLine).toMatch(/\binactive\b/);
|
|
404
517
|
} finally {
|
|
405
518
|
cleanup();
|
|
406
519
|
}
|
|
@@ -433,6 +546,42 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
|
|
|
433
546
|
}
|
|
434
547
|
});
|
|
435
548
|
|
|
549
|
+
test("expired operator token + module answers /health probe → LIVE (active) (#700)", async () => {
|
|
550
|
+
// Symmetry with the no-token case: the unauthenticated probe fallback fires
|
|
551
|
+
// on ANY degraded read where the hub is up + run-state is missing, so an
|
|
552
|
+
// expired-token box still shows a genuinely-serving module as `active`.
|
|
553
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
554
|
+
try {
|
|
555
|
+
upsertService(
|
|
556
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
557
|
+
path,
|
|
558
|
+
);
|
|
559
|
+
const lines: string[] = [];
|
|
560
|
+
const code = await status({
|
|
561
|
+
...supervisorOpts(configDir, path, {
|
|
562
|
+
managerState: { state: "active" },
|
|
563
|
+
hubHealthy: true,
|
|
564
|
+
fetchModuleStatesImpl: async () => {
|
|
565
|
+
throw new OperatorTokenExpiredError(
|
|
566
|
+
"token expired — run `parachute auth rotate-operator`",
|
|
567
|
+
);
|
|
568
|
+
},
|
|
569
|
+
probeModuleHealth: async () => true,
|
|
570
|
+
}),
|
|
571
|
+
print: (l) => lines.push(l),
|
|
572
|
+
});
|
|
573
|
+
expect(code).toBe(0);
|
|
574
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
575
|
+
expect(vaultLine).toMatch(/\bactive\b/);
|
|
576
|
+
const out = lines.join("\n");
|
|
577
|
+
expect(out).toMatch(/live via unauthenticated health probe/);
|
|
578
|
+
// The expired-token degraded-read hint still points at rotate-operator.
|
|
579
|
+
expect(out).toMatch(/rotate-operator/);
|
|
580
|
+
} finally {
|
|
581
|
+
cleanup();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
436
585
|
test("API error reading module states → degrade with the message, no crash", async () => {
|
|
437
586
|
const { path, configDir, cleanup } = makeTempPath();
|
|
438
587
|
try {
|
|
@@ -1591,6 +1591,31 @@ describe("Supervisor port-readiness + structured start-error (§6.5)", () => {
|
|
|
1591
1591
|
expect(spawner.calls).toHaveLength(0);
|
|
1592
1592
|
});
|
|
1593
1593
|
|
|
1594
|
+
test("(#634) preflight non-executable binary → non_executable start-error, NO spawn", async () => {
|
|
1595
|
+
const spawner = makeQueueSpawner();
|
|
1596
|
+
const sup = new Supervisor({
|
|
1597
|
+
spawnFn: spawner.spawn,
|
|
1598
|
+
killFn: noopKill,
|
|
1599
|
+
// `which` requires X_OK so it returns null for a 100644 bin...
|
|
1600
|
+
which: () => null,
|
|
1601
|
+
// ...but the secondary probe finds it present-but-non-executable.
|
|
1602
|
+
findNonExecutable: () => "/x/vault/bin/parachute-vault",
|
|
1603
|
+
portListening: async () => true,
|
|
1604
|
+
startReadyMs: 50,
|
|
1605
|
+
sleep: () => Promise.resolve(),
|
|
1606
|
+
});
|
|
1607
|
+
const state = await sup.start(reqWithPort("vault", 1940));
|
|
1608
|
+
|
|
1609
|
+
expect(state.status).toBe("crashed");
|
|
1610
|
+
expect(state.startError?.error_type).toBe("non_executable");
|
|
1611
|
+
expect(state.startError?.error_description).toContain(
|
|
1612
|
+
"but is not executable — run chmod +x /x/vault/bin/parachute-vault",
|
|
1613
|
+
);
|
|
1614
|
+
// No misleading "not installed" install card, and never spawned.
|
|
1615
|
+
expect(state.startError?.binary).toBe("parachute-vault");
|
|
1616
|
+
expect(spawner.calls).toHaveLength(0);
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1594
1619
|
test("a clean re-start clears a prior started-but-unbound start-error", async () => {
|
|
1595
1620
|
const first = makeFakeProc(201);
|
|
1596
1621
|
const second = makeFakeProc(202);
|
|
@@ -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", () => {
|
|
@@ -472,13 +472,48 @@ describe("buildWellKnown", () => {
|
|
|
472
472
|
);
|
|
473
473
|
});
|
|
474
474
|
|
|
475
|
-
test("
|
|
475
|
+
test("an empty-paths VAULT row is skipped entirely — no phantom default (#478)", () => {
|
|
476
|
+
// A vault services row with `paths: []` means "module installed but no
|
|
477
|
+
// servable vault instance" (vault's self-register emits this at zero
|
|
478
|
+
// vaults). It must NOT fabricate a vault entry at root in either the
|
|
479
|
+
// `vaults` array or the flat `services` catalog. Mirrors the empty-paths
|
|
480
|
+
// skip in admin-vaults.ts / vault-names.ts / oauth-handlers.ts.
|
|
476
481
|
const entry: ServiceEntry = { ...vault, paths: [] };
|
|
477
482
|
const doc = buildWellKnown({
|
|
478
483
|
services: [entry],
|
|
479
484
|
canonicalOrigin: "https://x.example",
|
|
480
485
|
});
|
|
481
|
-
expect(doc.vaults
|
|
486
|
+
expect(doc.vaults).toEqual([]);
|
|
487
|
+
// The row contributes nothing to the flat services list either — no
|
|
488
|
+
// phantom `/` mount advertised.
|
|
489
|
+
expect(doc.services).toEqual([]);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("positive control: a vault row WITH a path still emits its vault + services entries (#478)", () => {
|
|
493
|
+
const doc = buildWellKnown({
|
|
494
|
+
services: [{ ...vault, paths: ["/vault/default"] }],
|
|
495
|
+
canonicalOrigin: "https://x.example",
|
|
496
|
+
});
|
|
497
|
+
expect(doc.vaults).toEqual([
|
|
498
|
+
{
|
|
499
|
+
name: "default",
|
|
500
|
+
url: "https://x.example/vault/default",
|
|
501
|
+
version: "0.2.4",
|
|
502
|
+
},
|
|
503
|
+
]);
|
|
504
|
+
expect(doc.services.map((s) => s.name)).toEqual(["parachute-vault"]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("a NON-vault row with empty paths still falls back to / (#478 scope guard)", () => {
|
|
508
|
+
// The empty-paths skip is vault-only. A non-vault service legitimately
|
|
509
|
+
// mounts at root when path-less — that behavior is unchanged.
|
|
510
|
+
const entry: ServiceEntry = { ...notes, paths: [] };
|
|
511
|
+
const doc = buildWellKnown({
|
|
512
|
+
services: [entry],
|
|
513
|
+
canonicalOrigin: "https://x.example",
|
|
514
|
+
});
|
|
515
|
+
expect(doc.services.map((s) => s.path)).toEqual(["/"]);
|
|
516
|
+
expect(doc.notes).toEqual([{ url: "https://x.example/", version: "0.0.1" }]);
|
|
482
517
|
});
|
|
483
518
|
|
|
484
519
|
// Hierarchical sub-units (hub#313 — parachute-app design doc §12). Each
|
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;
|