@openparachute/hub 0.7.4-rc.8 → 0.7.4-rc.9

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.8",
3
+ "version": "0.7.4-rc.9",
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": {
@@ -2914,6 +2914,75 @@ describe("handleToken — full OAuth dance", () => {
2914
2914
  notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
2915
2915
  });
2916
2916
  });
2917
+
2918
+ // closes #478 — an empty-paths vault row ("installed but no servable
2919
+ // instance"; vault's self-register emits `paths: []` at zero vaults) must
2920
+ // NOT synthesize a phantom `vault` / `vault:default` entry pointing at
2921
+ // root in the /oauth/token services catalog. Pre-fix the `["/"]` fallback
2922
+ // resolved `vaultInstanceNameFor(name, "/")` → "default" and advertised
2923
+ // `${ISSUER}/` as the vault. Mirrors the skip in well-known.ts /
2924
+ // admin-vaults.ts / vault-names.ts.
2925
+ test("empty-paths vault row produces NO catalog entry — no phantom default (#478)", () => {
2926
+ const emptyPathsManifest: ServicesManifest = {
2927
+ services: [
2928
+ {
2929
+ name: "parachute-vault",
2930
+ port: 1940,
2931
+ paths: [],
2932
+ health: "/vault/default/health",
2933
+ version: "0.7.0",
2934
+ },
2935
+ ],
2936
+ };
2937
+ // Broad scope: would have leaked `vault` + `vault:default` at `/`.
2938
+ expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:read"])).toEqual({});
2939
+ // Per-vault-narrowed scope for the phantom name: also nothing.
2940
+ expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:default:read"])).toEqual({});
2941
+ });
2942
+
2943
+ test("positive control: a vault row WITH a path is still cataloged (#478)", () => {
2944
+ const realManifest: ServicesManifest = {
2945
+ services: [
2946
+ {
2947
+ name: "parachute-vault",
2948
+ port: 1940,
2949
+ paths: ["/vault/default"],
2950
+ health: "/vault/default/health",
2951
+ version: "0.7.0",
2952
+ },
2953
+ ],
2954
+ };
2955
+ expect(buildServicesCatalog(realManifest, ISSUER, ["vault:read"])).toEqual({
2956
+ vault: { url: `${ISSUER}/vault/default`, version: "0.7.0" },
2957
+ });
2958
+ });
2959
+
2960
+ test("empty-paths vault row alongside a real vault: only the real one is cataloged (#478)", () => {
2961
+ // A transitional manifest could carry both a path-less bare row and a
2962
+ // real instance row. The empty-paths row must contribute nothing; the
2963
+ // real vault is unaffected.
2964
+ const mixedManifest: ServicesManifest = {
2965
+ services: [
2966
+ {
2967
+ name: "parachute-vault",
2968
+ port: 1940,
2969
+ paths: [],
2970
+ health: "/vault/default/health",
2971
+ version: "0.7.0",
2972
+ },
2973
+ {
2974
+ name: "parachute-vault-work",
2975
+ port: 1941,
2976
+ paths: ["/vault/work"],
2977
+ health: "/vault/work/health",
2978
+ version: "0.7.0",
2979
+ },
2980
+ ],
2981
+ };
2982
+ expect(buildServicesCatalog(mixedManifest, ISSUER, ["vault:read"])).toEqual({
2983
+ vault: { url: `${ISSUER}/vault/work`, version: "0.7.0" },
2984
+ });
2985
+ });
2917
2986
  });
2918
2987
  });
2919
2988
 
@@ -472,13 +472,48 @@ describe("buildWellKnown", () => {
472
472
  );
473
473
  });
474
474
 
475
- test("falls back to / for empty paths", () => {
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[0]?.url).toBe("https://x.example/");
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
@@ -294,8 +294,11 @@ export function buildServicesCatalog(
294
294
  if (audiences.has("vault")) {
295
295
  for (const entry of manifest.services) {
296
296
  if (!isVaultEntry(entry)) continue;
297
- const paths = entry.paths.length > 0 ? entry.paths : ["/"];
298
- for (const path of paths) {
297
+ // #478: an empty-paths vault row is "installed but no servable instance"
298
+ // — skip it so it never counts toward (or, below, fabricates) a phantom
299
+ // vault. Mirrors the continue in well-known.ts / admin-vaults.ts.
300
+ if (entry.paths.length === 0) continue;
301
+ for (const path of entry.paths) {
299
302
  const instance = vaultInstanceNameFor(entry.name, path);
300
303
  if (broadVaultScope || namedVaults.has(instance)) admittedVaultPathCount++;
301
304
  }
@@ -308,12 +311,16 @@ export function buildServicesCatalog(
308
311
  for (const entry of manifest.services) {
309
312
  if (isVaultEntry(entry)) {
310
313
  if (!audiences.has("vault")) continue;
314
+ // #478: an empty-paths vault row is "installed but no servable instance"
315
+ // — skip it so the catalog never offers a phantom `vault` / `vault:default`
316
+ // entry pointing at root before any vault exists. Mirrors well-known.ts /
317
+ // admin-vaults.ts / vault-names.ts.
318
+ if (entry.paths.length === 0) continue;
311
319
  // Walk every path the row exposes. Real multi-vault on the hub is a
312
320
  // single `parachute-vault` row with N paths (one per vault instance);
313
321
  // legacy per-vault rows (`parachute-vault-<name>`) are handled by the
314
322
  // same loop because each contributes one path.
315
- const paths = entry.paths.length > 0 ? entry.paths : ["/"];
316
- for (const path of paths) {
323
+ for (const path of entry.paths) {
317
324
  const instance = vaultInstanceNameFor(entry.name, path);
318
325
  const admit = broadVaultScope || namedVaults.has(instance);
319
326
  if (!admit) continue;
package/src/well-known.ts CHANGED
@@ -247,7 +247,16 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
247
247
  // multi-path on those is treated as aliases rather than separate
248
248
  // installs.
249
249
  const isVault = isVaultEntry(s);
250
- const pathsToEmit = isVault && s.paths.length > 0 ? s.paths : [s.paths[0] ?? "/"];
250
+ // #478: an empty-paths VAULT row means "installed but no servable vault
251
+ // instance" — vault's self-register emits `paths: []` at zero vaults.
252
+ // Skip it entirely: emitting `["/"]` here would fabricate a phantom vault
253
+ // entry at root in both the `services` catalog and the `vaults` array.
254
+ // This mirrors the empty-paths `continue` in admin-vaults.ts / vault-names.ts
255
+ // so every read path agrees: a vault instance exists only by a real
256
+ // `/vault/<name>` mount path. Non-vault services keep the `paths[0] ?? "/"`
257
+ // fallback (a path-less non-vault row legitimately mounts at root).
258
+ if (isVault && s.paths.length === 0) continue;
259
+ const pathsToEmit = isVault ? s.paths : [s.paths[0] ?? "/"];
251
260
  for (const path of pathsToEmit) {
252
261
  const url = new URL(path, `${base}/`).toString();
253
262
  const infoUrl = new URL(joinInfoPath(path), `${base}/`).toString();