@openparachute/hub 0.5.13 → 0.5.14-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.
Files changed (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -23,6 +23,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import { decodeJwt } from "jose";
26
+ import type { CuratedModuleShort } from "../api-modules.ts";
26
27
  import {
27
28
  API_MODULES_CONFIG_REQUIRED_SCOPE,
28
29
  MODULE_CONFIG_PROXY_CLIENT_ID,
@@ -152,14 +153,19 @@ describe("parseModulesConfigPath", () => {
152
153
  });
153
154
  });
154
155
 
155
- test("matches vault and notes (curated modules)", () => {
156
+ test("matches vault and scribe (curated modules)", () => {
156
157
  expect(parseModulesConfigPath("/api/modules/vault/config")?.short).toBe("vault");
157
- expect(parseModulesConfigPath("/api/modules/notes/config/schema")?.short).toBe("notes");
158
+ expect(parseModulesConfigPath("/api/modules/scribe/config/schema")?.short).toBe("scribe");
158
159
  });
159
160
 
160
161
  test("rejects unknown short (non-curated)", () => {
161
162
  expect(parseModulesConfigPath("/api/modules/unknown/config")).toBeUndefined();
162
163
  expect(parseModulesConfigPath("/api/modules/channel/config")).toBeUndefined();
164
+ // Curated list trimmed 2026-05-27: notes / runner / surface are no
165
+ // longer curated and reject at the parse boundary.
166
+ expect(parseModulesConfigPath("/api/modules/notes/config")).toBeUndefined();
167
+ expect(parseModulesConfigPath("/api/modules/runner/config")).toBeUndefined();
168
+ expect(parseModulesConfigPath("/api/modules/surface/config")).toBeUndefined();
163
169
  });
164
170
 
165
171
  test("rejects non-config suffix shapes", () => {
@@ -302,7 +308,7 @@ describe("handleApiModulesConfig — FALLBACK retirement (hub#310)", () => {
302
308
  makeReq("/api/modules/runner/config/schema", {
303
309
  headers: { authorization: `Bearer ${bearer}` },
304
310
  }),
305
- { short: "runner", suffix: "schema" },
311
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
306
312
  { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
307
313
  );
308
314
  expect(res.status).toBe(404);
@@ -365,7 +371,7 @@ describe("handleApiModulesConfig — FALLBACK retirement (hub#310)", () => {
365
371
  makeReq("/api/modules/runner/config/schema", {
366
372
  headers: { authorization: `Bearer ${bearer}` },
367
373
  }),
368
- { short: "runner", suffix: "schema" },
374
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
369
375
  {
370
376
  db: h.db,
371
377
  issuer: ISSUER,
@@ -614,7 +620,7 @@ describe("handleApiModulesConfig — stripPrefix=false (notes-shape)", () => {
614
620
  makeReq("/api/modules/notes/config/schema", {
615
621
  headers: { authorization: `Bearer ${bearer}` },
616
622
  }),
617
- { short: "notes", suffix: "schema" },
623
+ { short: "notes" as CuratedModuleShort, suffix: "schema" },
618
624
  {
619
625
  db: h.db,
620
626
  issuer: ISSUER,
@@ -668,7 +674,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
668
674
  makeReq("/api/modules/runner/config/schema", {
669
675
  headers: { authorization: `Bearer ${bearer}` },
670
676
  }),
671
- { short: "runner", suffix: "schema" },
677
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
672
678
  {
673
679
  db: h.db,
674
680
  issuer: ISSUER,
@@ -700,7 +706,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
700
706
  makeReq("/api/modules/runner/config", {
701
707
  headers: { authorization: `Bearer ${bearer}` },
702
708
  }),
703
- { short: "runner", suffix: "" },
709
+ { short: "runner" as CuratedModuleShort, suffix: "" },
704
710
  {
705
711
  db: h.db,
706
712
  issuer: ISSUER,
@@ -733,7 +739,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
733
739
  },
734
740
  body: JSON.stringify({ intervalSeconds: 120 }),
735
741
  }),
736
- { short: "runner", suffix: "" },
742
+ { short: "runner" as CuratedModuleShort, suffix: "" },
737
743
  {
738
744
  db: h.db,
739
745
  issuer: ISSUER,
@@ -760,7 +766,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
760
766
  makeReq("/api/modules/runner/config", {
761
767
  headers: { authorization: `Bearer ${bearer}` },
762
768
  }),
763
- { short: "runner", suffix: "" },
769
+ { short: "runner" as CuratedModuleShort, suffix: "" },
764
770
  {
765
771
  db: h.db,
766
772
  issuer: ISSUER,
@@ -863,7 +869,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
863
869
  makeReq("/api/modules/runner/config/schema", {
864
870
  headers: { authorization: `Bearer ${bearer}` },
865
871
  }),
866
- { short: "runner", suffix: "schema" },
872
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
867
873
  {
868
874
  db: h.db,
869
875
  issuer: ISSUER,
@@ -126,6 +126,19 @@ function alwaysOkRun(): {
126
126
  };
127
127
  }
128
128
 
129
+ /**
130
+ * Test default for `isLinked`: assume the package is NOT bun-linked so
131
+ * `runInstall` exercises the `bun add -g` path (which the stubbed runner
132
+ * captures into `calls`). The production default at
133
+ * `src/bun-link.ts` reads the contributor's real `~/.bun/install/global/`
134
+ * symlinks; on Aaron's machine vault/scribe/notes/hub are all linked
135
+ * (the canonical local-dev shape — smoke 2026-05-27 finding 1 caps the
136
+ * fix). Tests asserting "bun add WAS called" must opt out of that leakage
137
+ * by passing this stub. Tests specifically exercising the skip path use
138
+ * an inline `isLinked: () => true` or a per-pkg discriminator.
139
+ */
140
+ const TEST_DEFAULT_NOT_LINKED = (_pkg: string): boolean => false;
141
+
129
142
  describe("parseModulesPath", () => {
130
143
  test("recognizes curated short + action", () => {
131
144
  expect(parseModulesPath("/api/modules/vault/install")).toEqual({
@@ -231,6 +244,7 @@ describe("POST /api/modules/:short/install", () => {
231
244
  configDir: h.dir,
232
245
  supervisor,
233
246
  run,
247
+ isLinked: TEST_DEFAULT_NOT_LINKED,
234
248
  },
235
249
  );
236
250
  expect(res.status).toBe(202);
@@ -280,6 +294,7 @@ describe("POST /api/modules/:short/install", () => {
280
294
  configDir: h.dir,
281
295
  supervisor,
282
296
  run,
297
+ isLinked: TEST_DEFAULT_NOT_LINKED,
283
298
  },
284
299
  );
285
300
  expect(res.status).toBe(202);
@@ -301,6 +316,7 @@ describe("POST /api/modules/:short/install", () => {
301
316
  configDir: h.dir,
302
317
  supervisor,
303
318
  run,
319
+ isLinked: TEST_DEFAULT_NOT_LINKED,
304
320
  },
305
321
  );
306
322
  const op = (await opRes.json()) as { status: string };
@@ -324,6 +340,7 @@ describe("POST /api/modules/:short/install", () => {
324
340
  configDir: h.dir,
325
341
  supervisor,
326
342
  run,
343
+ isLinked: TEST_DEFAULT_NOT_LINKED,
327
344
  },
328
345
  );
329
346
  expect(res.status).toBe(202);
@@ -348,6 +365,7 @@ describe("POST /api/modules/:short/install", () => {
348
365
  configDir: h.dir,
349
366
  supervisor,
350
367
  run,
368
+ isLinked: TEST_DEFAULT_NOT_LINKED,
351
369
  },
352
370
  );
353
371
  await new Promise((r) => setTimeout(r, 10));
@@ -379,6 +397,7 @@ describe("POST /api/modules/:short/install", () => {
379
397
  configDir: h.dir,
380
398
  supervisor,
381
399
  run,
400
+ isLinked: TEST_DEFAULT_NOT_LINKED,
382
401
  },
383
402
  );
384
403
  expect(res.status).toBe(202);
@@ -406,6 +425,7 @@ describe("POST /api/modules/:short/install", () => {
406
425
  configDir: h.dir,
407
426
  supervisor,
408
427
  run,
428
+ isLinked: TEST_DEFAULT_NOT_LINKED,
409
429
  },
410
430
  );
411
431
  await new Promise((r) => setTimeout(r, 10));
@@ -433,6 +453,7 @@ describe("POST /api/modules/:short/install", () => {
433
453
  configDir: h.dir,
434
454
  supervisor,
435
455
  run,
456
+ isLinked: TEST_DEFAULT_NOT_LINKED,
436
457
  },
437
458
  );
438
459
  expect(res.status).toBe(400);
@@ -458,6 +479,7 @@ describe("POST /api/modules/:short/install", () => {
458
479
  configDir: h.dir,
459
480
  supervisor,
460
481
  run,
482
+ isLinked: TEST_DEFAULT_NOT_LINKED,
461
483
  },
462
484
  );
463
485
  await new Promise((r) => setTimeout(r, 10));
@@ -487,6 +509,7 @@ describe("POST /api/modules/:short/install", () => {
487
509
  configDir: h.dir,
488
510
  supervisor,
489
511
  run,
512
+ isLinked: TEST_DEFAULT_NOT_LINKED,
490
513
  },
491
514
  );
492
515
  await new Promise((r) => setTimeout(r, 10));
@@ -524,6 +547,7 @@ describe("POST /api/modules/:short/install", () => {
524
547
  configDir: h.dir,
525
548
  supervisor,
526
549
  run,
550
+ isLinked: TEST_DEFAULT_NOT_LINKED,
527
551
  },
528
552
  );
529
553
  await new Promise((r) => setTimeout(r, 10));
@@ -557,6 +581,7 @@ describe("POST /api/modules/:short/install", () => {
557
581
  configDir: h.dir,
558
582
  supervisor,
559
583
  run,
584
+ isLinked: TEST_DEFAULT_NOT_LINKED,
560
585
  },
561
586
  );
562
587
  expect(res.status).toBe(202);
@@ -583,6 +608,7 @@ describe("POST /api/modules/:short/install", () => {
583
608
  supervisor,
584
609
  run: async () => 1,
585
610
  findGlobalInstall: () => null,
611
+ isLinked: TEST_DEFAULT_NOT_LINKED,
586
612
  };
587
613
  const res = await handleInstall(
588
614
  postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
@@ -602,6 +628,71 @@ describe("POST /api/modules/:short/install", () => {
602
628
  expect(op.status).toBe("failed");
603
629
  expect(op.error).toMatch(/bun add -g exited 1/);
604
630
  });
631
+
632
+ test("skips bun add -g when package is already bun-linked (smoke 2026-05-27 finding 1)", async () => {
633
+ // Smoke finding 1: the wizard's parallel install path was unconditionally
634
+ // invoking `bun add -g <pkg>` even when the package was already linked
635
+ // via `bun link <abspath>` (the standard local-dev shape). At best a
636
+ // wasted ~3s npm round-trip per install; at worst the global bun.lock
637
+ // had unrelated noise and the install failed outright, taking the
638
+ // wizard's vault step with it. Fix: mirror the CLI install path's
639
+ // `isLinked` short-circuit. Regression guard.
640
+ const { supervisor, spawns } = makeIdleSupervisor();
641
+ const { run, calls } = alwaysOkRun();
642
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
643
+ const res = await handleInstall(
644
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
645
+ "vault",
646
+ {
647
+ db: h.db,
648
+ issuer: ISSUER,
649
+ manifestPath: h.manifestPath,
650
+ configDir: h.dir,
651
+ supervisor,
652
+ run,
653
+ // The bug shape: package IS linked locally. Without the
654
+ // short-circuit, runInstall would still call bun add -g.
655
+ isLinked: (pkg) => pkg === "@openparachute/vault",
656
+ },
657
+ );
658
+ expect(res.status).toBe(202);
659
+ await new Promise((r) => setTimeout(r, 10));
660
+
661
+ // The fix: bun add -g was NOT invoked for the linked package.
662
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
663
+ // Downstream of the skip, the seed + spawn still happen — the install
664
+ // op completes successfully against the locally-linked checkout.
665
+ const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
666
+ services: Array<{ name: string }>;
667
+ };
668
+ expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
669
+ expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
670
+ });
671
+
672
+ test("still runs bun add -g when package is NOT bun-linked", async () => {
673
+ // Companion to the above — confirms the short-circuit doesn't
674
+ // unconditionally skip. On a friend's fresh machine (no bun link),
675
+ // bun add -g IS what installs the package from npm. `isLinked: () => false`
676
+ // is the production default behavior for a non-linked package.
677
+ const { supervisor } = makeIdleSupervisor();
678
+ const { run, calls } = alwaysOkRun();
679
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
680
+ await handleInstall(
681
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
682
+ "vault",
683
+ {
684
+ db: h.db,
685
+ issuer: ISSUER,
686
+ manifestPath: h.manifestPath,
687
+ configDir: h.dir,
688
+ supervisor,
689
+ run,
690
+ isLinked: () => false,
691
+ },
692
+ );
693
+ await new Promise((r) => setTimeout(r, 10));
694
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
695
+ });
605
696
  });
606
697
 
607
698
  describe("POST /api/modules/:short/restart", () => {
@@ -682,6 +773,7 @@ describe("POST /api/modules/:short/upgrade", () => {
682
773
  configDir: h.dir,
683
774
  supervisor,
684
775
  run,
776
+ isLinked: TEST_DEFAULT_NOT_LINKED,
685
777
  },
686
778
  );
687
779
  expect(res.status).toBe(202);
@@ -706,6 +798,7 @@ describe("POST /api/modules/:short/upgrade", () => {
706
798
  configDir: h.dir,
707
799
  supervisor,
708
800
  run,
801
+ isLinked: TEST_DEFAULT_NOT_LINKED,
709
802
  },
710
803
  );
711
804
  expect(res.status).toBe(202);
@@ -736,6 +829,7 @@ describe("POST /api/modules/:short/upgrade", () => {
736
829
  configDir: h.dir,
737
830
  supervisor,
738
831
  run,
832
+ isLinked: TEST_DEFAULT_NOT_LINKED,
739
833
  },
740
834
  );
741
835
  await new Promise((r) => setTimeout(r, 10));
@@ -846,6 +940,7 @@ describe("POST /api/modules/:short/uninstall", () => {
846
940
  configDir: h.dir,
847
941
  supervisor,
848
942
  run,
943
+ isLinked: TEST_DEFAULT_NOT_LINKED,
849
944
  },
850
945
  );
851
946
  expect(res.status).toBe(200);
@@ -878,6 +973,7 @@ describe("POST /api/modules/:short/uninstall", () => {
878
973
  configDir: h.dir,
879
974
  supervisor,
880
975
  run,
976
+ isLinked: TEST_DEFAULT_NOT_LINKED,
881
977
  },
882
978
  );
883
979
  expect(res.status).toBe(200);
@@ -1099,6 +1195,7 @@ describe("well-known regen after module ops", () => {
1099
1195
  supervisor,
1100
1196
  run: async () => 1,
1101
1197
  findGlobalInstall: () => null,
1198
+ isLinked: TEST_DEFAULT_NOT_LINKED,
1102
1199
  wellKnownPath: wkPath,
1103
1200
  };
1104
1201
  const res = await handleInstall(
@@ -149,10 +149,12 @@ describe("GET /api/modules", () => {
149
149
 
150
150
  test("200 + curated list on fresh container (empty services.json)", async () => {
151
151
  // The v0.6 hot path: brand-new Render container, no services.json
152
- // yet. UI must render "install vault / app / notes / scribe / runner"
153
- // cards even though nothing's installed. hub#323 inserted `app` between
154
- // `vault` and `notes` app auto-bootstraps notes-ui as a sub-unit;
155
- // `notes` (notes-daemon) stays curated for back-compat install paths.
152
+ // yet. UI must render "install vault / scribe" cards even though
153
+ // nothing's installed. Trimmed 2026-05-27 (Aaron-directed launch
154
+ // focus): notes (notes-daemon), surface (host module), and runner
155
+ // (experimental) are no longer curated notes.parachute.computer
156
+ // is the hosted PWA, surface-client is the library for custom UI
157
+ // builders, and runner isn't in the launch focus set.
156
158
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
157
159
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
158
160
  db: h.db,
@@ -170,8 +172,10 @@ describe("GET /api/modules", () => {
170
172
  }>;
171
173
  supervisor_available: boolean;
172
174
  };
173
- // Curated order is preserved: vault → app notes scribe → runner.
174
- expect(body.modules.map((m) => m.short)).toEqual(["vault", "app", "notes", "scribe", "runner"]);
175
+ // Curated order is preserved: vault → scribe (vault first per the
176
+ // recommended install order — the wizard's vault step already runs
177
+ // before this catalog surfaces).
178
+ expect(body.modules.map((m) => m.short)).toEqual(["vault", "scribe"]);
175
179
  expect(body.modules.every((m) => m.available)).toBe(true);
176
180
  expect(body.modules.every((m) => !m.installed)).toBe(true);
177
181
  expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
@@ -179,18 +183,25 @@ describe("GET /api/modules", () => {
179
183
  expect(body.supervisor_available).toBe(false);
180
184
  });
181
185
 
182
- test("app row carries package + display props from KNOWN_MODULES (#323)", async () => {
183
- // hub#323 added app to CURATED_MODULES + KNOWN_MODULES so the admin SPA
184
- // install catalog + setup-wizard install tile surface it. Spot-check the
185
- // wire shape resolves app-specific fields (package, displayName, tagline)
186
- // from KNOWN_MODULES rather than a stale default — same shape as the
187
- // runner row test below.
186
+ test("scribe row carries package + display props from KNOWN_MODULES", async () => {
187
+ // Spot-check the wire shape resolves scribe-specific fields
188
+ // (package, displayName, tagline) from KNOWN_MODULES rather than a
189
+ // stale default. Vault is exercised via the install-state test below;
190
+ // this pins the other curated row's KNOWN_MODULES round-trip.
191
+ //
192
+ // Pre-2026-05-27 this test pinned the `surface` row (added by
193
+ // hub#323), and a sibling pinned the `runner` FIRST_PARTY_FALLBACKS
194
+ // row (hub#305). Both modules retired from CURATED_MODULES — the
195
+ // FIRST_PARTY_FALLBACKS / KNOWN_MODULES entries persist for the
196
+ // install-bootstrap path but `/api/modules` doesn't return them.
197
+ // The "uncurated modules don't surface here" test below pins that
198
+ // boundary.
188
199
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
189
200
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
190
201
  db: h.db,
191
202
  issuer: ISSUER,
192
203
  manifestPath: h.manifestPath,
193
- fetchLatestVersion: async () => "0.2.0",
204
+ fetchLatestVersion: async () => "0.4.4",
194
205
  });
195
206
  expect(res.status).toBe(200);
196
207
  const body = (await res.json()) as {
@@ -202,42 +213,41 @@ describe("GET /api/modules", () => {
202
213
  available: boolean;
203
214
  }>;
204
215
  };
205
- const app = body.modules.find((m) => m.short === "app");
206
- expect(app).toBeDefined();
207
- expect(app?.package).toBe("@openparachute/app");
208
- expect(app?.display_name).toBe("App");
209
- expect(app?.tagline).toContain("auto-installs Notes");
210
- expect(app?.available).toBe(true);
211
- });
212
-
213
- test("runner row carries package + display props from FIRST_PARTY_FALLBACKS (#305)", async () => {
214
- // hub#305 added runner to CURATED_MODULES + FIRST_PARTY_FALLBACKS so
215
- // the admin SPA install catalog surfaces it. Spot-check the wire
216
- // shape resolves the runner-specific fields (package, displayName,
217
- // tagline) from the vendored fallback rather than a stale default.
216
+ const scribe = body.modules.find((m) => m.short === "scribe");
217
+ expect(scribe).toBeDefined();
218
+ expect(scribe?.package).toBe("@openparachute/scribe");
219
+ expect(scribe?.display_name).toBe("Scribe");
220
+ expect(scribe?.tagline).toContain("transcription");
221
+ expect(scribe?.available).toBe(true);
222
+ });
223
+
224
+ test("uncurated modules (notes / runner / surface) are NOT returned by GET /api/modules", async () => {
225
+ // CURATED_MODULES was trimmed 2026-05-27 to [vault, scribe]. The
226
+ // KNOWN_MODULES + FIRST_PARTY_FALLBACKS registries still carry
227
+ // entries for notes / runner (install-bootstrap path), but
228
+ // /api/modules only returns CURATED rows. Pins the boundary so a
229
+ // future re-curation has to be intentional, not a stale registry
230
+ // leak.
218
231
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
219
232
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
220
233
  db: h.db,
221
234
  issuer: ISSUER,
222
235
  manifestPath: h.manifestPath,
223
- fetchLatestVersion: async () => "0.1.0",
236
+ fetchLatestVersion: async () => null,
224
237
  });
225
- expect(res.status).toBe(200);
226
- const body = (await res.json()) as {
227
- modules: Array<{
228
- short: string;
229
- package: string;
230
- display_name: string;
231
- tagline: string;
232
- available: boolean;
233
- }>;
234
- };
235
- const runner = body.modules.find((m) => m.short === "runner");
236
- expect(runner).toBeDefined();
237
- expect(runner?.package).toBe("@openparachute/runner");
238
- expect(runner?.display_name).toBe("Runner");
239
- expect(runner?.tagline).toContain("Vault-as-job-substrate");
240
- expect(runner?.available).toBe(true);
238
+ const body = (await res.json()) as { modules: Array<{ short: string }> };
239
+ const shorts = body.modules.map((m) => m.short);
240
+ // Positive shape assertion — stronger than `not.toContain` because
241
+ // it also catches "we accidentally added a new uncurated entry"
242
+ // and "we accidentally removed an existing curated entry." Update
243
+ // this assertion intentionally when CURATED_MODULES changes.
244
+ expect(shorts).toEqual(["vault", "scribe"]);
245
+ // Belt + suspenders: explicit negatives for the modules dropped
246
+ // 2026-05-27, so a developer regressing the curated list sees both
247
+ // the shape failure AND the named-module failure messages.
248
+ expect(shorts).not.toContain("notes");
249
+ expect(shorts).not.toContain("runner");
250
+ expect(shorts).not.toContain("surface");
241
251
  });
242
252
 
243
253
  test("surfaces installed_version from services.json", async () => {
@@ -272,11 +282,11 @@ describe("GET /api/modules", () => {
272
282
  expect(vault?.installed_version).toBe("0.4.5");
273
283
  expect(vault?.latest_version).toBe("0.5.0");
274
284
  expect(vault?.install_dir).toBe("/parachute/modules/node_modules/@openparachute/vault");
275
- // The other curated rows stay installed:false — the test installed
276
- // only vault, so notes + scribe still render as available-but-not-installed.
277
- const notes = body.modules.find((m) => m.short === "notes");
278
- expect(notes?.installed).toBe(false);
279
- expect(notes?.installed_version).toBeNull();
285
+ // The other curated row stays installed:false — the test installed
286
+ // only vault, so scribe still renders as available-but-not-installed.
287
+ const scribe = body.modules.find((m) => m.short === "scribe");
288
+ expect(scribe?.installed).toBe(false);
289
+ expect(scribe?.installed_version).toBeNull();
280
290
  });
281
291
 
282
292
  test("includes supervisor status + pid when a supervisor is injected", async () => {
@@ -309,9 +319,9 @@ describe("GET /api/modules", () => {
309
319
  expect(vault?.pid).toBe(12345);
310
320
  // Modules without a supervisor entry get null status — the UI
311
321
  // disables Restart/Stop for those since there's no live process.
312
- const notes = body.modules.find((m) => m.short === "notes");
313
- expect(notes?.supervisor_status).toBeNull();
314
- expect(notes?.pid).toBeNull();
322
+ const scribe = body.modules.find((m) => m.short === "scribe");
323
+ expect(scribe?.supervisor_status).toBeNull();
324
+ expect(scribe?.pid).toBeNull();
315
325
  expect(body.supervisor_available).toBe(true);
316
326
  });
317
327
 
@@ -366,22 +376,28 @@ describe("GET /api/modules", () => {
366
376
  });
367
377
 
368
378
  test("management_url does not double-prepend mount when managementUrl is already mount-prefixed (hub#380)", async () => {
369
- // Audit caught 2026-05-25: app declares `managementUrl: "/app/admin/"`
370
- // (full hub-origin path) and `paths: ["/app", "/.parachute"]`. The
371
- // SPA's Services dropdown was navigating to `/app/app/admin/` (404)
372
- // because api-modules unconditionally prepended the mount onto the
373
- // candidate. Fix: detect already-mount-prefixed paths and pass through.
379
+ // Audit caught 2026-05-25: surface declared `managementUrl: "/surface/admin/"`
380
+ // (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
381
+ // SPA's Services dropdown was navigating to `/surface/surface/admin/`
382
+ // (404) because api-modules unconditionally prepended the mount onto
383
+ // the candidate. Fix: detect already-mount-prefixed paths and pass
384
+ // through.
374
385
  //
375
386
  // Single-instance modules conventionally declare the full path; only
376
387
  // multi-instance modules (vault) use the per-instance relative form.
388
+ // Post 2026-05-27 CURATED trim the canonical single-instance example
389
+ // is scribe (when scribe ships a managementUrl — scribe#53). For now
390
+ // we exercise the same code path with vault declaring an
391
+ // already-mount-prefixed managementUrl: any module whose declared
392
+ // URL starts with its mount must pass through unchanged.
377
393
  writeManifest(h.manifestPath, [
378
394
  {
379
- name: "parachute-app",
380
- port: 1946,
381
- paths: ["/app", "/.parachute"],
382
- health: "/app/healthz",
383
- version: "0.2.0-rc.13",
384
- installDir: "/install/dir/app",
395
+ name: "parachute-vault",
396
+ port: 1940,
397
+ paths: ["/vault/default"],
398
+ health: "/vault/default/health",
399
+ version: "0.4.5",
400
+ installDir: "/install/dir/vault",
385
401
  },
386
402
  ]);
387
403
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
@@ -391,17 +407,18 @@ describe("GET /api/modules", () => {
391
407
  manifestPath: h.manifestPath,
392
408
  fetchLatestVersion: async () => null,
393
409
  readModuleManifest: async (installDir) => {
394
- if (installDir === "/install/dir/app") {
410
+ if (installDir === "/install/dir/vault") {
395
411
  return {
396
- name: "app",
397
- manifestName: "parachute-app",
398
- displayName: "App",
412
+ name: "parachute-vault",
413
+ manifestName: "parachute-vault",
414
+ displayName: "Vault",
399
415
  tagline: "",
400
- port: 1946,
401
- paths: ["/app", "/.parachute"],
402
- health: "/app/healthz",
403
- uiUrl: "/app/admin/",
404
- managementUrl: "/app/admin/",
416
+ port: 1940,
417
+ paths: ["/vault/default"],
418
+ health: "/vault/default/health",
419
+ // Already-mount-prefixed managementUrl — must NOT have the
420
+ // mount prepended again.
421
+ managementUrl: "/vault/default/admin/",
405
422
  } as unknown as Awaited<
406
423
  ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
407
424
  >;
@@ -413,9 +430,9 @@ describe("GET /api/modules", () => {
413
430
  const body = (await res.json()) as {
414
431
  modules: Array<{ short: string; management_url: string | null }>;
415
432
  };
416
- const app = body.modules.find((m) => m.short === "app");
417
- // Single `/app/`, not `/app/app/`.
418
- expect(app?.management_url).toBe("/app/admin/");
433
+ const vault = body.modules.find((m) => m.short === "vault");
434
+ // Single `/vault/default/`, not `/vault/default/vault/default/`.
435
+ expect(vault?.management_url).toBe("/vault/default/admin/");
419
436
  });
420
437
 
421
438
  test("management_url prefix-ish names don't collide (hub#380 — /app vs /app-foo)", async () => {
@@ -430,8 +447,8 @@ describe("GET /api/modules", () => {
430
447
  {
431
448
  name: "parachute-vault",
432
449
  port: 1940,
433
- paths: ["/app"], // mount is /app (using vault as a stand-in installable)
434
- health: "/app/health",
450
+ paths: ["/surface"], // mount is /app (using vault as a stand-in installable)
451
+ health: "/surface/health",
435
452
  version: "0.4.5",
436
453
  installDir: "/install/dir/contrived",
437
454
  },
@@ -450,8 +467,8 @@ describe("GET /api/modules", () => {
450
467
  displayName: "Vault",
451
468
  tagline: "",
452
469
  port: 1940,
453
- paths: ["/app"],
454
- health: "/app/health",
470
+ paths: ["/surface"],
471
+ health: "/surface/health",
455
472
  // candidate looks like a sibling-name prefix but is NOT a
456
473
  // mount-prefix of /app — should still get prepended.
457
474
  managementUrl: "/app-foo/admin",
@@ -467,9 +484,9 @@ describe("GET /api/modules", () => {
467
484
  modules: Array<{ short: string; management_url: string | null }>;
468
485
  };
469
486
  const vault = body.modules.find((m) => m.short === "vault");
470
- // /app + /app-foo/admin → /app/app-foo/admin (prepend fires; not treated
471
- // as already-mount-prefixed because /app-foo/ doesn't start with /app/).
472
- expect(vault?.management_url).toBe("/app/app-foo/admin");
487
+ // /surface + /app-foo/admin → /surface/app-foo/admin (prepend fires; not
488
+ // treated as already-mount-prefixed because /app-foo/ doesn't start with /surface/).
489
+ expect(vault?.management_url).toBe("/surface/app-foo/admin");
473
490
  });
474
491
 
475
492
  test("management_url equality edge: tail equals mount exactly (hub#380)", async () => {
@@ -786,8 +803,8 @@ describe("GET /api/modules", () => {
786
803
  },
787
804
  ]);
788
805
  // Other curated rows stay empty — uis is per-row, not global.
789
- const notes = body.modules.find((m) => m.short === "notes");
790
- expect(notes?.uis).toEqual([]);
806
+ const scribe = body.modules.find((m) => m.short === "scribe");
807
+ expect(scribe?.uis).toEqual([]);
791
808
  });
792
809
 
793
810
  test("optional fields ride through verbatim, missing fields become null on the wire", async () => {