@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.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,
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { MissingDependencyError, lookupDep } from "@openparachute/depcheck";
5
6
  import {
6
7
  API_MODULES_OPS_REQUIRED_SCOPE,
7
8
  _resetOperationsRegistryForTests,
@@ -629,6 +630,50 @@ describe("POST /api/modules/:short/install", () => {
629
630
  expect(op.error).toMatch(/bun add -g exited 1/);
630
631
  });
631
632
 
633
+ test("a MissingDependencyError during install attaches the structured error_detail wire", async () => {
634
+ const { supervisor } = makeIdleSupervisor();
635
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
636
+ const deps = {
637
+ db: h.db,
638
+ issuer: ISSUER,
639
+ manifestPath: h.manifestPath,
640
+ configDir: h.dir,
641
+ supervisor,
642
+ // Simulate `bun` not being on PATH: the install runner's shell-out
643
+ // throws the typed missing-dependency error.
644
+ run: async () => {
645
+ throw new MissingDependencyError("bun", lookupDep("bun"), {
646
+ platform: "linux",
647
+ arch: "x64",
648
+ });
649
+ },
650
+ findGlobalInstall: () => null,
651
+ isLinked: TEST_DEFAULT_NOT_LINKED,
652
+ };
653
+ const res = await handleInstall(
654
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
655
+ "vault",
656
+ deps,
657
+ );
658
+ const body = (await res.json()) as { operation_id: string };
659
+ await new Promise((r) => setTimeout(r, 10));
660
+ const opRes = await handleOperationGet(
661
+ getReq(`/api/modules/operations/${body.operation_id}`, {
662
+ authorization: `Bearer ${bearer}`,
663
+ }),
664
+ body.operation_id,
665
+ deps,
666
+ );
667
+ const op = (await opRes.json()) as {
668
+ status: string;
669
+ error?: string;
670
+ error_detail?: { error_type: string; binary: string };
671
+ };
672
+ expect(op.status).toBe("failed");
673
+ expect(op.error_detail?.error_type).toBe("missing_dependency");
674
+ expect(op.error_detail?.binary).toBe("bun");
675
+ });
676
+
632
677
  test("skips bun add -g when package is already bun-linked (smoke 2026-05-27 finding 1)", async () => {
633
678
  // Smoke finding 1: the wizard's parallel install path was unconditionally
634
679
  // invoking `bun add -g <pkg>` even when the package was already linked
@@ -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", "surface", "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 surface = body.modules.find((m) => m.short === "surface");
206
- expect(surface).toBeDefined();
207
- expect(surface?.package).toBe("@openparachute/surface");
208
- expect(surface?.display_name).toBe("Surface");
209
- expect(surface?.tagline).toContain("auto-installs Notes");
210
- expect(surface?.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: "/surface/admin/"`
379
+ // Audit caught 2026-05-25: surface declared `managementUrl: "/surface/admin/"`
370
380
  // (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
371
- // SPA's Services dropdown was navigating to `/app/surface/admin/` (404)
372
- // because api-modules unconditionally prepended the mount onto the
373
- // candidate. Fix: detect already-mount-prefixed paths and pass through.
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-surface",
380
- port: 1946,
381
- paths: ["/surface", "/.parachute"],
382
- health: "/surface/healthz",
383
- version: "0.2.0-rc.13",
384
- installDir: "/install/dir/surface",
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/surface") {
410
+ if (installDir === "/install/dir/vault") {
395
411
  return {
396
- name: "surface",
397
- manifestName: "parachute-surface",
398
- displayName: "Surface",
412
+ name: "parachute-vault",
413
+ manifestName: "parachute-vault",
414
+ displayName: "Vault",
399
415
  tagline: "",
400
- port: 1946,
401
- paths: ["/surface", "/.parachute"],
402
- health: "/surface/healthz",
403
- uiUrl: "/surface/admin/",
404
- managementUrl: "/surface/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 surface = body.modules.find((m) => m.short === "surface");
417
- // Single `/surface/`, not `/surface/surface/`.
418
- expect(surface?.management_url).toBe("/surface/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 () => {
@@ -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 () => {
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { handleApiReady } from "../api-ready.ts";
3
+ import type { ModuleState, Supervisor } from "../supervisor.ts";
4
+
5
+ function stubSupervisor(states: ModuleState[]): Supervisor {
6
+ return {
7
+ list: () => states,
8
+ get: (short: string) => states.find((s) => s.short === short),
9
+ start: async () => {
10
+ throw new Error("not implemented");
11
+ },
12
+ stop: async () => undefined,
13
+ restart: async () => undefined,
14
+ } as unknown as Supervisor;
15
+ }
16
+
17
+ function req(): Request {
18
+ return new Request("http://127.0.0.1/api/ready", {
19
+ headers: { accept: "application/json" },
20
+ });
21
+ }
22
+
23
+ function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
24
+ return {
25
+ status: "running",
26
+ restartsInWindow: 0,
27
+ ...partial,
28
+ };
29
+ }
30
+
31
+ describe("handleApiReady — no supervisor (CLI mode)", () => {
32
+ test("returns ready=true + empty arrays when supervisor absent", async () => {
33
+ const res = handleApiReady(req());
34
+ expect(res.status).toBe(200);
35
+ const body = (await res.json()) as {
36
+ ready: boolean;
37
+ ready_modules: string[];
38
+ transient_modules: string[];
39
+ persistent_modules: string[];
40
+ };
41
+ expect(body.ready).toBe(true);
42
+ expect(body.ready_modules).toEqual([]);
43
+ expect(body.transient_modules).toEqual([]);
44
+ expect(body.persistent_modules).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe("handleApiReady — supervisor mode", () => {
49
+ test("all modules running past boot window → ready=true", async () => {
50
+ const now = 1_700_000_000_000;
51
+ const startedAt = new Date(now - 60_000).toISOString();
52
+ const sup = stubSupervisor([
53
+ moduleState({ short: "vault", status: "running", startedAt }),
54
+ moduleState({ short: "scribe", status: "running", startedAt }),
55
+ ]);
56
+ const res = handleApiReady(req(), { supervisor: sup, now: () => now });
57
+ const body = (await res.json()) as {
58
+ ready: boolean;
59
+ ready_modules: string[];
60
+ transient_modules: string[];
61
+ persistent_modules: string[];
62
+ };
63
+ expect(body.ready).toBe(true);
64
+ expect(body.ready_modules.sort()).toEqual(["scribe", "vault"]);
65
+ expect(body.transient_modules).toEqual([]);
66
+ expect(body.persistent_modules).toEqual([]);
67
+ });
68
+
69
+ test("module inside boot window → transient, ready=false", async () => {
70
+ const now = 1_700_000_000_000;
71
+ const sup = stubSupervisor([
72
+ moduleState({
73
+ short: "vault",
74
+ status: "running",
75
+ startedAt: new Date(now - 10_000).toISOString(),
76
+ }),
77
+ ]);
78
+ const res = handleApiReady(req(), { supervisor: sup, now: () => now });
79
+ const body = (await res.json()) as {
80
+ ready: boolean;
81
+ ready_modules: string[];
82
+ transient_modules: string[];
83
+ };
84
+ expect(body.ready).toBe(false);
85
+ expect(body.transient_modules).toEqual(["vault"]);
86
+ expect(body.ready_modules).toEqual([]);
87
+ });
88
+
89
+ test("starting + restarting + crashed all classified correctly", async () => {
90
+ const now = 1_700_000_000_000;
91
+ const sup = stubSupervisor([
92
+ moduleState({ short: "vault", status: "starting" }),
93
+ moduleState({ short: "scribe", status: "restarting" }),
94
+ moduleState({ short: "notes", status: "crashed" }),
95
+ moduleState({ short: "channel", status: "stopped" }),
96
+ moduleState({
97
+ short: "runner",
98
+ status: "running",
99
+ startedAt: new Date(now - 60_000).toISOString(),
100
+ }),
101
+ ]);
102
+ const res = handleApiReady(req(), { supervisor: sup, now: () => now });
103
+ const body = (await res.json()) as {
104
+ ready: boolean;
105
+ ready_modules: string[];
106
+ transient_modules: string[];
107
+ persistent_modules: string[];
108
+ };
109
+ expect(body.ready).toBe(false);
110
+ expect(body.ready_modules).toEqual(["runner"]);
111
+ expect(body.transient_modules.sort()).toEqual(["scribe", "vault"]);
112
+ expect(body.persistent_modules.sort()).toEqual(["channel", "notes"]);
113
+ });
114
+
115
+ test("only crashed/stopped + nothing transient → ready=false (still failing)", async () => {
116
+ const sup = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
117
+ const res = handleApiReady(req(), { supervisor: sup });
118
+ const body = (await res.json()) as { ready: boolean };
119
+ expect(body.ready).toBe(false);
120
+ });
121
+ });
122
+
123
+ describe("handleApiReady — method check", () => {
124
+ test("rejects non-GET", () => {
125
+ const r = new Request("http://127.0.0.1/api/ready", { method: "POST" });
126
+ const res = handleApiReady(r);
127
+ expect(res.status).toBe(405);
128
+ });
129
+
130
+ test("accepts HEAD", () => {
131
+ const r = new Request("http://127.0.0.1/api/ready", { method: "HEAD" });
132
+ const res = handleApiReady(r);
133
+ expect(res.status).toBe(200);
134
+ });
135
+ });