@openparachute/hub 0.6.5-rc.7 → 0.7.0

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +34 -0
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections.test.ts +1154 -0
  6. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  7. package/src/__tests__/admin-module-token.test.ts +311 -0
  8. package/src/__tests__/admin-vaults.test.ts +590 -0
  9. package/src/__tests__/api-modules-ops.test.ts +70 -5
  10. package/src/__tests__/api-modules.test.ts +262 -79
  11. package/src/__tests__/hub-db-liveness.test.ts +12 -7
  12. package/src/__tests__/hub-server.test.ts +319 -21
  13. package/src/__tests__/invites.test.ts +27 -0
  14. package/src/__tests__/module-manifest.test.ts +305 -8
  15. package/src/__tests__/serve-boot.test.ts +133 -2
  16. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  17. package/src/__tests__/setup-gate.test.ts +13 -7
  18. package/src/__tests__/setup-wizard.test.ts +228 -1
  19. package/src/__tests__/vault-name.test.ts +20 -5
  20. package/src/__tests__/well-known.test.ts +44 -8
  21. package/src/account-vault-admin-token.ts +43 -14
  22. package/src/admin-channel-token.ts +135 -0
  23. package/src/admin-connections.ts +980 -0
  24. package/src/admin-module-token.ts +197 -0
  25. package/src/admin-vaults.ts +390 -12
  26. package/src/api-hub-upgrade.ts +4 -3
  27. package/src/api-modules-ops.ts +41 -16
  28. package/src/api-modules.ts +238 -116
  29. package/src/api-tokens.ts +8 -5
  30. package/src/commands/serve-boot.ts +80 -3
  31. package/src/commands/setup.ts +4 -4
  32. package/src/connections-store.ts +161 -0
  33. package/src/grants.ts +50 -0
  34. package/src/hub-db-liveness.ts +33 -17
  35. package/src/hub-server.ts +354 -61
  36. package/src/invites.ts +22 -0
  37. package/src/jwt-sign.ts +41 -1
  38. package/src/module-manifest.ts +429 -23
  39. package/src/origin-check.ts +106 -0
  40. package/src/proxy-error-ui.ts +1 -1
  41. package/src/service-spec.ts +132 -41
  42. package/src/setup-wizard.ts +68 -6
  43. package/src/users.ts +11 -0
  44. package/src/vault-name.ts +27 -7
  45. package/src/well-known.ts +41 -33
  46. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  47. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  48. package/web/ui/dist/index.html +2 -2
  49. package/src/__tests__/api-modules-config.test.ts +0 -882
  50. package/src/api-modules-config.ts +0 -421
  51. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  52. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -214,14 +214,14 @@ describe("GET /api/modules", () => {
214
214
  expect(res.status).toBe(401);
215
215
  });
216
216
 
217
- test("200 + curated list on fresh container (empty services.json)", async () => {
217
+ test("200 + full self-registration catalog on fresh container (empty services.json)", async () => {
218
218
  // The v0.6 hot path: brand-new Render container, no services.json
219
- // yet. UI must render "install vault / scribe" cards even though
220
- // nothing's installed. Trimmed 2026-05-27 (Aaron-directed launch
221
- // focus): notes (notes-daemon), surface (host module), and runner
222
- // (experimental) are no longer curated notes.parachute.computer
223
- // is the hosted PWA, surface-client is the library for custom UI
224
- // builders, and runner isn't in the launch focus set.
219
+ // yet. Post-2026-06-09 (modular-UI architecture, P2) discovery is driven
220
+ // by the UNION of the bootstrap registries (KNOWN_MODULES
221
+ // FIRST_PARTY_FALLBACKS), NOT a curated whitelist. Every known module
222
+ // surfaces — core (vault/scribe/surface) in the headline tier, the rest
223
+ // (channel/runner/notes) as `experimental` so the channel-not-installed
224
+ // class (running but invisible) can't recur.
225
225
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
226
226
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
227
227
  db: h.db,
@@ -233,16 +233,32 @@ describe("GET /api/modules", () => {
233
233
  const body = (await res.json()) as {
234
234
  modules: Array<{
235
235
  short: string;
236
+ focus: "core" | "experimental";
236
237
  available: boolean;
237
238
  installed: boolean;
238
239
  latest_version: string | null;
239
240
  }>;
240
241
  supervisor_available: boolean;
241
242
  };
242
- // Curated order is preserved: vault → scribe (vault first per the
243
- // recommended install order the wizard's vault step already runs
244
- // before this catalog surfaces).
245
- expect(body.modules.map((m) => m.short)).toEqual(["vault", "scribe"]);
243
+ const shorts = body.modules.map((m) => m.short);
244
+ // The core tier leads, in the recommended install order (vault scribe),
245
+ // ahead of every experimental module.
246
+ expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
247
+ expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("channel"));
248
+ expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("runner"));
249
+ // Every known module is discoverable — vault/scribe/surface (core) +
250
+ // channel/runner/notes (experimental).
251
+ for (const s of ["vault", "scribe", "surface", "channel", "runner", "notes"]) {
252
+ expect(shorts).toContain(s);
253
+ }
254
+ // Focus tier resolves from the default map.
255
+ const byShort = new Map(body.modules.map((m) => [m.short, m]));
256
+ expect(byShort.get("vault")?.focus).toBe("core");
257
+ expect(byShort.get("scribe")?.focus).toBe("core");
258
+ expect(byShort.get("surface")?.focus).toBe("core");
259
+ expect(byShort.get("channel")?.focus).toBe("experimental");
260
+ expect(byShort.get("runner")?.focus).toBe("experimental");
261
+ expect(byShort.get("notes")?.focus).toBe("experimental");
246
262
  expect(body.modules.every((m) => m.available)).toBe(true);
247
263
  expect(body.modules.every((m) => !m.installed)).toBe(true);
248
264
  expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
@@ -288,39 +304,56 @@ describe("GET /api/modules", () => {
288
304
  expect(scribe?.available).toBe(true);
289
305
  });
290
306
 
291
- test("uncurated modules (notes / runner / surface) are NOT returned by GET /api/modules", async () => {
292
- // CURATED_MODULES was trimmed 2026-05-27 to [vault, scribe]. The
293
- // KNOWN_MODULES + FIRST_PARTY_FALLBACKS registries still carry
294
- // entries for notes / runner (install-bootstrap path), but
295
- // /api/modules only returns CURATED rows. Pins the boundary so a
296
- // future re-curation has to be intentional, not a stale registry
297
- // leak.
307
+ test("channel (running + self-registered) appears as installed + experimental regression for the channel-not-installed bug", async () => {
308
+ // THE bug this PR fixes (2026-06-09 modular-UI architecture, P2): channel
309
+ // was running, proxied, supervised, and self-registered in services.json
310
+ // yet invisible on the Modules screen because the old CURATED_MODULES =
311
+ // ["vault","scribe"] whitelist gated discovery. Now discovery is driven by
312
+ // self-registration the known registries, so a self-registered channel
313
+ // row surfaces as installed, in the experimental tier, with its run-state.
314
+ writeManifest(h.manifestPath, [
315
+ {
316
+ name: "parachute-channel",
317
+ port: 1941,
318
+ paths: ["/channel"],
319
+ health: "/channel/health",
320
+ version: "0.3.1",
321
+ },
322
+ ]);
323
+ const { supervisor } = makeIdleSupervisor();
324
+ await supervisor.start({ short: "channel", cmd: ["parachute-channel", "daemon"] });
325
+
298
326
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
299
327
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
300
328
  db: h.db,
301
329
  issuer: ISSUER,
302
330
  manifestPath: h.manifestPath,
331
+ supervisor,
303
332
  fetchLatestVersion: async () => null,
304
333
  });
305
- const body = (await res.json()) as { modules: Array<{ short: string }> };
306
- const shorts = body.modules.map((m) => m.short);
307
- // Positive shape assertion — stronger than `not.toContain` because
308
- // it also catches "we accidentally added a new uncurated entry"
309
- // and "we accidentally removed an existing curated entry." Update
310
- // this assertion intentionally when CURATED_MODULES changes.
311
- expect(shorts).toEqual(["vault", "scribe"]);
312
- // Belt + suspenders: explicit negatives for the modules dropped
313
- // 2026-05-27, so a developer regressing the curated list sees both
314
- // the shape failure AND the named-module failure messages.
315
- expect(shorts).not.toContain("notes");
316
- expect(shorts).not.toContain("runner");
317
- expect(shorts).not.toContain("surface");
334
+ const body = (await res.json()) as {
335
+ modules: Array<{
336
+ short: string;
337
+ focus: "core" | "experimental";
338
+ installed: boolean;
339
+ installed_version: string | null;
340
+ supervisor_status: string | null;
341
+ }>;
342
+ };
343
+ const channel = body.modules.find((m) => m.short === "channel");
344
+ expect(channel).toBeDefined();
345
+ expect(channel?.installed).toBe(true);
346
+ expect(channel?.installed_version).toBe("0.3.1");
347
+ expect(channel?.focus).toBe("experimental");
348
+ expect(channel?.supervisor_status).toBe("running");
318
349
  });
319
350
 
320
- test("non-curated supervised modules appear in `supervised` (not `modules`)hub#539", async () => {
321
- // surface (the UI host) is supervised but not curated. Its run-state must
322
- // surface in `supervised` so `parachute status` reads it `active`, while it
323
- // stays OUT of the curated `modules` catalog (which drives the install UI).
351
+ test("every self-registered + known module appears in `modules` — no running-but-invisible class", async () => {
352
+ // The two-registry-disagreement (services.json says installed, the curated
353
+ // whitelist says invisible) is gone: a self-registered surface row + a
354
+ // supervised channel both surface in `modules` (2026-06-09 modular-UI
355
+ // architecture). `supervised` still mirrors the run-state for every
356
+ // tracked module (hub#539) — consumers dedupe by short.
324
357
  writeManifest(h.manifestPath, [
325
358
  {
326
359
  name: "parachute-vault",
@@ -329,6 +362,13 @@ describe("GET /api/modules", () => {
329
362
  health: "/vault/default/health",
330
363
  version: "0.4.5",
331
364
  },
365
+ {
366
+ name: "parachute-surface",
367
+ port: 1946,
368
+ paths: ["/surface"],
369
+ health: "/surface/healthz",
370
+ version: "0.2.0",
371
+ },
332
372
  ]);
333
373
  const { supervisor } = makeIdleSupervisor();
334
374
  await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
@@ -343,15 +383,17 @@ describe("GET /api/modules", () => {
343
383
  fetchLatestVersion: async () => null,
344
384
  });
345
385
  const body = (await res.json()) as {
346
- modules: Array<{ short: string }>;
386
+ modules: Array<{ short: string; installed: boolean }>;
347
387
  supervised: Array<{ short: string; supervisor_status: string | null; pid: number | null }>;
348
388
  };
349
- // surface stays out of the curated catalog…
350
- expect(body.modules.map((m) => m.short)).not.toContain("surface");
351
- // …but its run-state is in `supervised`, marked running with a pid.
352
- const surf = body.supervised.find((m) => m.short === "surface");
353
- expect(surf?.supervisor_status).toBe("running");
354
- expect(typeof surf?.pid).toBe("number");
389
+ // surface is now IN the catalog (it was excluded under the whitelist), and
390
+ // reflects installed:true from its services.json row.
391
+ const surf = body.modules.find((m) => m.short === "surface");
392
+ expect(surf?.installed).toBe(true);
393
+ // …and its run-state is still in `supervised`, marked running with a pid.
394
+ const surfSup = body.supervised.find((m) => m.short === "surface");
395
+ expect(surfSup?.supervisor_status).toBe("running");
396
+ expect(typeof surfSup?.pid).toBe("number");
355
397
  // Curated modules appear in `supervised` too (consumers dedupe by short).
356
398
  expect(body.supervised.find((m) => m.short === "vault")?.supervisor_status).toBe("running");
357
399
  });
@@ -485,10 +527,12 @@ describe("GET /api/modules", () => {
485
527
  expect(scribe?.supervisor_start_error).toBeNull();
486
528
  });
487
529
 
488
- test("populates management_url from a relative managementUrl + module mount (hub#342)", async () => {
489
- // Vault declares `managementUrl: "/admin"` in its module.json — hub
490
- // resolves that against the entry's mount path (`/vault/default`)
491
- // to produce the absolute admin URL the SPA's "Open" button targets.
530
+ test("populates management_url from a RELATIVE managementUrl + module mount (B4 per-instance form)", async () => {
531
+ // Vault's new manifest declares `managementUrl: "admin/"` relative, no
532
+ // leading slash: the per-instance form under the B4 unified semantics
533
+ // (2026-06-09 hub-module-boundary). Hub joins it under the entry's mount
534
+ // path (`/vault/default`) to produce the absolute admin URL the SPA's
535
+ // "Open" button targets.
492
536
  writeManifest(h.manifestPath, [
493
537
  {
494
538
  name: "parachute-vault",
@@ -510,6 +554,55 @@ describe("GET /api/modules", () => {
510
554
  // shape via `as unknown as ...` because the test only exercises
511
555
  // the consumer-side resolver, not the validator (which lives in
512
556
  // module-manifest.ts and has its own test suite).
557
+ if (installDir === "/install/dir/vault") {
558
+ return {
559
+ name: "parachute-vault",
560
+ manifestName: "parachute-vault",
561
+ displayName: "Vault",
562
+ tagline: "",
563
+ port: 1940,
564
+ paths: ["/vault/default"],
565
+ health: "/health",
566
+ managementUrl: "admin/",
567
+ } as unknown as Awaited<
568
+ ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
569
+ >;
570
+ }
571
+ return null;
572
+ },
573
+ });
574
+ expect(res.status).toBe(200);
575
+ const body = (await res.json()) as {
576
+ modules: Array<{ short: string; management_url: string | null }>;
577
+ };
578
+ const vault = body.modules.find((m) => m.short === "vault");
579
+ expect(vault?.management_url).toBe("/vault/default/admin/");
580
+ });
581
+
582
+ test('COMPAT SHIM: the literal legacy "/admin" on a VAULT entry still mount-joins (one release)', async () => {
583
+ // Deployed vaults still declare `managementUrl: "/admin"` — the OLD
584
+ // per-instance relative form. Under the new semantics a leading-"/" is
585
+ // origin-absolute (which would point at the daemon-level /vault/admin
586
+ // mount, not the instance), so the literal "/admin"/"/admin/" on a vault
587
+ // entry keeps the old mount-join behavior for one release, with a
588
+ // deprecation log. Remove the shim once vault's new manifest is @latest.
589
+ writeManifest(h.manifestPath, [
590
+ {
591
+ name: "parachute-vault",
592
+ port: 1940,
593
+ paths: ["/vault/default"],
594
+ health: "/vault/default/health",
595
+ version: "0.4.5",
596
+ installDir: "/install/dir/vault",
597
+ },
598
+ ]);
599
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
600
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
601
+ db: h.db,
602
+ issuer: ISSUER,
603
+ manifestPath: h.manifestPath,
604
+ fetchLatestVersion: async () => null,
605
+ readModuleManifest: async (installDir) => {
513
606
  if (installDir === "/install/dir/vault") {
514
607
  return {
515
608
  name: "parachute-vault",
@@ -532,24 +625,117 @@ describe("GET /api/modules", () => {
532
625
  modules: Array<{ short: string; management_url: string | null }>;
533
626
  };
534
627
  const vault = body.modules.find((m) => m.short === "vault");
628
+ // Mount-joined (legacy behavior preserved), NOT origin-absolute "/admin".
535
629
  expect(vault?.management_url).toBe("/vault/default/admin");
536
630
  });
537
631
 
538
- test("management_url does not double-prepend mount when managementUrl is already mount-prefixed (hub#380)", async () => {
539
- // Audit caught 2026-05-25: surface declared `managementUrl: "/surface/admin/"`
540
- // (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
541
- // SPA's Services dropdown was navigating to `/surface/surface/admin/`
542
- // (404) because api-modules unconditionally prepended the mount onto
543
- // the candidate. Fix: detect already-mount-prefixed paths and pass
544
- // through.
545
- //
546
- // Single-instance modules conventionally declare the full path; only
547
- // multi-instance modules (vault) use the per-instance relative form.
548
- // Post 2026-05-27 CURATED trim the canonical single-instance example
549
- // is scribe (when scribe ships a managementUrl — scribe#53). For now
550
- // we exercise the same code path with vault declaring an
551
- // already-mount-prefixed managementUrl: any module whose declared
552
- // URL starts with its mount must pass through unchanged.
632
+ test("populates config_ui_url from a module's configUiUrl (2026-06-09 modular-UI P3)", async () => {
633
+ // Channel declares `configUiUrl: "/channel/admin"` (a single-instance,
634
+ // origin-absolute path) + `uiUrl: "/channel/ui"`. The hub surfaces
635
+ // both: `config_ui_url` drives the Modules page Configure action,
636
+ // `management_url` drives Open. configUiUrl resolves identically to
637
+ // managementUrl (same B4 unified semantics).
638
+ writeManifest(h.manifestPath, [
639
+ {
640
+ name: "channel",
641
+ port: 1941,
642
+ paths: ["/channel"],
643
+ health: "/health",
644
+ version: "0.1.0",
645
+ installDir: "/install/dir/channel",
646
+ },
647
+ ]);
648
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
649
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
650
+ db: h.db,
651
+ issuer: ISSUER,
652
+ manifestPath: h.manifestPath,
653
+ fetchLatestVersion: async () => null,
654
+ readModuleManifest: async (installDir) => {
655
+ if (installDir === "/install/dir/channel") {
656
+ return {
657
+ name: "channel",
658
+ manifestName: "parachute-channel",
659
+ displayName: "Channel",
660
+ tagline: "",
661
+ port: 1941,
662
+ paths: ["/channel"],
663
+ health: "/health",
664
+ uiUrl: "/channel/ui",
665
+ configUiUrl: "/channel/admin",
666
+ } as unknown as Awaited<
667
+ ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
668
+ >;
669
+ }
670
+ return null;
671
+ },
672
+ });
673
+ expect(res.status).toBe(200);
674
+ const body = (await res.json()) as {
675
+ modules: Array<{
676
+ short: string;
677
+ config_ui_url: string | null;
678
+ management_url: string | null;
679
+ }>;
680
+ };
681
+ const channel = body.modules.find((m) => m.short === "channel");
682
+ // Origin-absolute — verbatim, never double-prepends `/channel`.
683
+ expect(channel?.config_ui_url).toBe("/channel/admin");
684
+ // uiUrl (no managementUrl) drives the Open action's management_url.
685
+ expect(channel?.management_url).toBe("/channel/ui");
686
+ });
687
+
688
+ test("config_ui_url is null when the module declares no configUiUrl", async () => {
689
+ writeManifest(h.manifestPath, [
690
+ {
691
+ name: "parachute-vault",
692
+ port: 1940,
693
+ paths: ["/vault/default"],
694
+ health: "/vault/default/health",
695
+ version: "0.4.5",
696
+ installDir: "/install/dir/vault",
697
+ },
698
+ ]);
699
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
700
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
701
+ db: h.db,
702
+ issuer: ISSUER,
703
+ manifestPath: h.manifestPath,
704
+ fetchLatestVersion: async () => null,
705
+ readModuleManifest: async (installDir) => {
706
+ if (installDir === "/install/dir/vault") {
707
+ return {
708
+ name: "parachute-vault",
709
+ manifestName: "parachute-vault",
710
+ displayName: "Vault",
711
+ tagline: "",
712
+ port: 1940,
713
+ paths: ["/vault/default"],
714
+ health: "/health",
715
+ managementUrl: "/admin",
716
+ } as unknown as Awaited<
717
+ ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
718
+ >;
719
+ }
720
+ return null;
721
+ },
722
+ });
723
+ expect(res.status).toBe(200);
724
+ const body = (await res.json()) as {
725
+ modules: Array<{ short: string; config_ui_url: string | null }>;
726
+ };
727
+ const vault = body.modules.find((m) => m.short === "vault");
728
+ expect(vault?.config_ui_url).toBeNull();
729
+ });
730
+
731
+ test("management_url passes a leading-slash path through verbatim (origin-absolute, B4)", async () => {
732
+ // Historical context (hub#380): surface declared `managementUrl:
733
+ // "/surface/admin/"` (full hub-origin path) and the resolver
734
+ // double-prepended the mount (`/surface/surface/admin/` → 404), patched
735
+ // then by an already-mount-prefixed heuristic. Under the B4 unified
736
+ // semantics the heuristic is gone: ANY leading-"/" path is
737
+ // ORIGIN-ABSOLUTE and passes through verbatim (except the vault "/admin"
738
+ // compat shim) — same result here, simpler rule.
553
739
  writeManifest(h.manifestPath, [
554
740
  {
555
741
  name: "parachute-vault",
@@ -576,8 +762,8 @@ describe("GET /api/modules", () => {
576
762
  port: 1940,
577
763
  paths: ["/vault/default"],
578
764
  health: "/vault/default/health",
579
- // Already-mount-prefixed managementUrl — must NOT have the
580
- // mount prepended again.
765
+ // Origin-absolute managementUrl — passes through verbatim,
766
+ // never gets the mount prepended.
581
767
  managementUrl: "/vault/default/admin/",
582
768
  } as unknown as Awaited<
583
769
  ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
@@ -595,14 +781,12 @@ describe("GET /api/modules", () => {
595
781
  expect(vault?.management_url).toBe("/vault/default/admin/");
596
782
  });
597
783
 
598
- test("management_url prefix-ish names don't collide (hub#380 /app vs /app-foo)", async () => {
599
- // The detection uses `tail.startsWith(\`${mount}/\`)` with the trailing
600
- // slash specifically to avoid a false positive when a candidate
601
- // path looks like a sibling name (e.g. `/app-foo/admin` shouldn't be
602
- // treated as "already prefixed by /app"). Without the slash gate,
603
- // a future module named `app-foo` would silently inherit the
604
- // pass-through behavior and `/app` mount would skip its prepend.
605
- // Tests the trailing-slash discriminator stays load-bearing.
784
+ test("management_url: a leading-slash path NOT under the mount is still origin-absolute (B4 inverts hub#380)", async () => {
785
+ // INVERTED PIN (B4). Pre-B4 the resolver prepended the mount onto any
786
+ // leading-"/" candidate that didn't look already-mount-prefixed:
787
+ // mount=/surface + "/app-foo/admin" "/surface/app-foo/admin". Under
788
+ // the unified semantics a leading-"/" is ORIGIN-ABSOLUTE, verbatim
789
+ // the module says exactly where its surface lives on the origin.
606
790
  writeManifest(h.manifestPath, [
607
791
  {
608
792
  name: "parachute-vault",
@@ -629,8 +813,8 @@ describe("GET /api/modules", () => {
629
813
  port: 1940,
630
814
  paths: ["/surface"],
631
815
  health: "/surface/health",
632
- // candidate looks like a sibling-name prefix but is NOT a
633
- // mount-prefix of /app should still get prepended.
816
+ // Origin-absolute path outside this module's own mount
817
+ // verbatim under B4 (pre-B4 this was mount-prepended).
634
818
  managementUrl: "/app-foo/admin",
635
819
  } as unknown as Awaited<
636
820
  ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
@@ -644,14 +828,13 @@ describe("GET /api/modules", () => {
644
828
  modules: Array<{ short: string; management_url: string | null }>;
645
829
  };
646
830
  const vault = body.modules.find((m) => m.short === "vault");
647
- // /surface + /app-foo/admin /surface/app-foo/admin (prepend fires; not
648
- // treated as already-mount-prefixed because /app-foo/ doesn't start with /surface/).
649
- expect(vault?.management_url).toBe("/surface/app-foo/admin");
831
+ // Origin-absolute, verbatim NOT /surface/app-foo/admin.
832
+ expect(vault?.management_url).toBe("/app-foo/admin");
650
833
  });
651
834
 
652
- test("management_url equality edge: tail equals mount exactly (hub#380)", async () => {
653
- // mount=/foo, candidate=/foo → tail === mount pass through unchanged.
654
- // Not a "real" config but pins the equality branch of the detection.
835
+ test("management_url equality edge: candidate equals mount exactly", async () => {
836
+ // mount=/foo, candidate=/foo → origin-absolute, verbatim same output
837
+ // as the pre-B4 equality branch, simpler rule.
655
838
  writeManifest(h.manifestPath, [
656
839
  {
657
840
  name: "parachute-vault",
@@ -214,18 +214,23 @@ describe("DbHolder.probePath (#610 proactive detection)", () => {
214
214
  h.cleanup();
215
215
  });
216
216
 
217
- test("path GONE (ENOENT) → reopen attempted; reopen verify fails → exit(1)", () => {
218
- // Reopen returns a closed handle (the dir is still gone) SELECT 1 throws
219
- // exit. This is the genuine `rm -rf ~/.parachute` field shape.
220
- const dead = new Database(":memory:");
221
- dead.close();
217
+ test("path GONE (ENOENT) → exit(1) directly, NO reopen (#619 follow-up)", () => {
218
+ // The genuine `rm -rf ~/.parachute` field shape. We must NOT reopen here:
219
+ // reopen is openHubDb, which mkdir-recursive's the dir back + opens a fresh
220
+ // EMPTY db, so its SELECT-1 verify would PASS and the hub would "heal" into a
221
+ // half-recovered state (empty db, stale in-memory state, wiped well-known,
222
+ // un-respawned modules). A full wipe must exit so the platform manager does a
223
+ // clean restart that re-bootstraps everything. `onReopen` throws to PROVE the
224
+ // reopen path is never taken — if it were, this test would surface the throw.
222
225
  const h = makeHolder({
223
226
  initialInode: INODE_A,
224
227
  statInode: () => undefined, // ENOENT
225
- onReopen: () => dead,
228
+ onReopen: () => {
229
+ throw new Error("reopen must NOT be called on a gone verdict");
230
+ },
226
231
  });
227
232
  expect(h.holder.probePath()).toBe("gone");
228
- expect(h.stats().reopens).toBe(1);
233
+ expect(h.stats().reopens).toBe(0);
229
234
  expect(h.stats().exits).toBe(1);
230
235
  expect(h.stats().exitCode).toBe(1);
231
236
  h.cleanup();