@openparachute/hub 0.6.5-rc.8 → 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 (50) 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-server.test.ts +319 -21
  12. package/src/__tests__/invites.test.ts +27 -0
  13. package/src/__tests__/module-manifest.test.ts +305 -8
  14. package/src/__tests__/serve-boot.test.ts +133 -2
  15. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  16. package/src/__tests__/setup-gate.test.ts +13 -7
  17. package/src/__tests__/setup-wizard.test.ts +228 -1
  18. package/src/__tests__/vault-name.test.ts +20 -5
  19. package/src/__tests__/well-known.test.ts +44 -8
  20. package/src/account-vault-admin-token.ts +43 -14
  21. package/src/admin-channel-token.ts +135 -0
  22. package/src/admin-connections.ts +980 -0
  23. package/src/admin-module-token.ts +197 -0
  24. package/src/admin-vaults.ts +390 -12
  25. package/src/api-hub-upgrade.ts +4 -3
  26. package/src/api-modules-ops.ts +41 -16
  27. package/src/api-modules.ts +238 -116
  28. package/src/api-tokens.ts +8 -5
  29. package/src/commands/serve-boot.ts +80 -3
  30. package/src/commands/setup.ts +4 -4
  31. package/src/connections-store.ts +161 -0
  32. package/src/grants.ts +50 -0
  33. package/src/hub-server.ts +349 -59
  34. package/src/invites.ts +22 -0
  35. package/src/jwt-sign.ts +41 -1
  36. package/src/module-manifest.ts +429 -23
  37. package/src/origin-check.ts +106 -0
  38. package/src/proxy-error-ui.ts +1 -1
  39. package/src/service-spec.ts +132 -41
  40. package/src/setup-wizard.ts +68 -6
  41. package/src/users.ts +11 -0
  42. package/src/vault-name.ts +27 -7
  43. package/src/well-known.ts +41 -33
  44. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  45. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  46. package/web/ui/dist/index.html +2 -2
  47. package/src/__tests__/api-modules-config.test.ts +0 -882
  48. package/src/api-modules-config.ts +0 -421
  49. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  50. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -36,10 +36,10 @@ import type { Database } from "bun:sqlite";
36
36
  import { randomUUID } from "node:crypto";
37
37
  import { dirname } from "node:path";
38
38
  import { MissingDependencyError, type MissingDependencyWire } from "@openparachute/depcheck";
39
- import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
39
+ import type { CuratedModuleShort } from "./api-modules.ts";
40
40
  import { isLinked as defaultIsLinked } from "./bun-link.ts";
41
41
  import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
42
- import { buildModuleSpawnRequest } from "./commands/serve-boot.ts";
42
+ import { buildModuleSpawnRequest, reconcilePortToCanonical } from "./commands/serve-boot.ts";
43
43
  import { validateHostAdminToken } from "./host-admin-token-validation.ts";
44
44
  import { getModuleInstallChannel } from "./hub-settings.ts";
45
45
  import { readModuleManifest } from "./module-manifest.ts";
@@ -49,6 +49,7 @@ import {
49
49
  type ServiceSpec,
50
50
  composeKnownModuleSpec,
51
51
  getSpec,
52
+ isKnownModuleShort,
52
53
  synthesizeManifestForKnownModule,
53
54
  } from "./service-spec.ts";
54
55
  import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
@@ -263,6 +264,15 @@ export interface ApiModulesOpsDeps {
263
264
  * per-request HTTP build stay aligned.
264
265
  */
265
266
  readModuleManifest?: Parameters<typeof regenerateWellKnown>[0]["readModuleManifest"];
267
+ /**
268
+ * Diagnostic log sink for spawn-path events that aren't tied to an op-log
269
+ * (e.g. the canonical-port reconcile in `resolveSpawnRequest`: a drift fixed,
270
+ * a held-canonical decline, or a persist failure). Without this, those events
271
+ * are swallowed on an operator-triggered start/restart while the boot path
272
+ * logs them. Defaults to `console.error` (stderr — same destination as the
273
+ * serve-boot path's logger). Tests inject a collector to assert the events.
274
+ */
275
+ log?: (line: string) => void;
266
276
  }
267
277
 
268
278
  interface PathMatch {
@@ -272,9 +282,15 @@ interface PathMatch {
272
282
 
273
283
  /**
274
284
  * Parse `/api/modules/<short>/<rest>` into the canonical short name +
275
- * the action suffix. Rejects unknown shorts to keep arbitrary
276
- * services.json names from driving the install pathway (curated-only
277
- * for v0.6).
285
+ * the action suffix. Rejects shorts the hub can't resolve a package/manifest
286
+ * for but the gate is now "is this a KNOWN module" (`isKnownModuleShort`:
287
+ * KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old `CURATED_MODULES`
288
+ * whitelist (2026-06-09 modular-UI architecture, P2). This is what makes
289
+ * `POST /api/modules/channel/install` resolve — channel was discoverable +
290
+ * running yet un-installable because it sat outside the whitelist.
291
+ *
292
+ * The hub (`hub`) and genuinely third-party services.json rows still fall
293
+ * through to undefined: there's no install package to act on.
278
294
  */
279
295
  export function parseModulesPath(pathname: string): PathMatch | undefined {
280
296
  const prefix = "/api/modules/";
@@ -284,7 +300,7 @@ export function parseModulesPath(pathname: string): PathMatch | undefined {
284
300
  if (slash <= 0) return undefined;
285
301
  const short = tail.slice(0, slash);
286
302
  const rest = tail.slice(slash + 1);
287
- if (!CURATED_MODULES.includes(short as CuratedModuleShort)) return undefined;
303
+ if (!isKnownModuleShort(short)) return undefined;
288
304
  return { short: short as CuratedModuleShort, rest };
289
305
  }
290
306
 
@@ -336,12 +352,14 @@ async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Respons
336
352
  * reach the same spec the API handlers use without duplicating the
337
353
  * curated-table lookup.
338
354
  *
339
- * Two source paths (hub#310, post-FALLBACK-retirement for vault/scribe/runner):
355
+ * Two source paths (post-FALLBACK-retirement: vault/scribe/runner in
356
+ * hub#310, channel in boundary D3):
340
357
  *
341
- * - **FIRST_PARTY_FALLBACKS** (notes / channel): vendored manifest is
358
+ * - **FIRST_PARTY_FALLBACKS** (notes): vendored manifest is
342
359
  * authoritative pre-install — the embedded `manifest.startCmd` /
343
360
  * `manifest.paths` / etc. drive the install + spawn flow.
344
- * - **KNOWN_MODULES** (vault / scribe / runner): no vendored manifest.
361
+ * - **KNOWN_MODULES** (vault / scribe / runner / channel / surface): no
362
+ * vendored manifest.
345
363
  * Pre-install we know only the npm package + manifestName + canonical
346
364
  * port + imperative `extras` (init, postInstallFooter, urlForEntry,
347
365
  * hasAuth). Post-install, `runInstall` reads `<installDir>/.parachute/module.json`
@@ -440,7 +458,14 @@ async function resolveSpawnRequest(
440
458
  if (resolved) spawnSpec = resolved;
441
459
  }
442
460
 
443
- const cmd = spawnSpec.startCmd?.(entry);
461
+ // Snap a drifted fixed-port row back to canonical before deriving startCmd
462
+ // (notes' startCmd embeds the port) / PORT / probe / proxy from it
463
+ // (channel#41). No-op on the common path. Mirror of the serve-boot path so a
464
+ // module started from the admin SPA gets the same canonical-port reconciliation
465
+ // — including the same diagnostic logging (deps.log, default stderr).
466
+ const spawnEntry = reconcilePortToCanonical(entry, deps.manifestPath, deps.log ?? console.error);
467
+
468
+ const cmd = spawnSpec.startCmd?.(spawnEntry);
444
469
  if (!cmd || cmd.length === 0) {
445
470
  return {
446
471
  ok: false,
@@ -455,7 +480,7 @@ async function resolveSpawnRequest(
455
480
  // / live HUB_ORIGIN / enriched PATH). The test-seam / first-boot `spawnEnv`
456
481
  // rides the shared helper's `extraEnv` and wins last, matching
457
482
  // `spawnSupervised`'s precedence.
458
- const req = buildModuleSpawnRequest(short, entry, cmd, {
483
+ const req = buildModuleSpawnRequest(short, spawnEntry, cmd, {
459
484
  configDir: deps.configDir,
460
485
  ...(deps.issuer ? { hubOrigin: deps.issuer } : {}),
461
486
  ...(deps.spawnEnv ? { extraEnv: deps.spawnEnv } : {}),
@@ -793,13 +818,13 @@ export async function runInstall(
793
818
  });
794
819
  }
795
820
 
796
- // KNOWN_MODULES shorts (vault / scribe / runner hub#310): module.json
797
- // is the canonical source for startCmd. Re-resolve the spec from
798
- // `<installDir>/.parachute/module.json` when installDir is stamped so the
799
- // module is authoritative for its own spawn cmd. Falls back to the
821
+ // KNOWN_MODULES shorts (vault / scribe / runner / channel / surface):
822
+ // module.json is the canonical source for startCmd. Re-resolve the spec
823
+ // from `<installDir>/.parachute/module.json` when installDir is stamped so
824
+ // the module is authoritative for its own spawn cmd. Falls back to the
800
825
  // imperative `extras.startCmd` carried by `spec` (from `specFor`) when
801
826
  // installDir is absent or module.json is unreadable. FIRST_PARTY_FALLBACKS
802
- // shorts (notes / channel) don't take this path — they're already in
827
+ // shorts (notes) don't take this path — they're already in
803
828
  // KNOWN_MODULES[short] === undefined so the short-circuit applies.
804
829
  let spawnSpec: ServiceSpec = spec;
805
830
  if (installDir && KNOWN_MODULES[short]) {
@@ -1,19 +1,26 @@
1
1
  /**
2
2
  * `GET /api/modules` — admin SPA's module-management surface.
3
3
  *
4
- * Combines three sources into a single per-module row:
4
+ * Discovery is driven by SELF-REGISTRATION, not a whitelist (2026-06-09
5
+ * modular-UI architecture, P2). The catalog is the UNION of three sources,
6
+ * deduped by short name, each row carrying a `focus` tier:
5
7
  *
6
- * - **Curated availability** — vault, scribe (the launch focus per
7
- * Aaron 2026-05-27). The list was previously broader; trimmed for
8
- * the launch arc. The Phase-2 marketplace will broaden this; for
9
- * now it's hardcoded so the admin UI has a stable "what can I
10
- * install?" list even on a fresh container where services.json is
11
- * empty.
12
- * - **Installed state** — services.json reads (version, installDir).
8
+ * - **Known/discoverable registry** — `discoverableShorts()` (KNOWN_MODULES
9
+ * FIRST_PARTY_FALLBACKS): every module the hub can resolve a package +
10
+ * manifest for, so a fresh container shows the full "what can I install?"
11
+ * catalog even with an empty services.json.
12
+ * - **Installed state** services.json reads (version, installDir). Any
13
+ * self-registered row surfaces here even if it isn't in the known registry.
13
14
  * - **Supervisor state** — per-module run status (`running` / `stopped`
14
15
  * / `crashed` / `starting` / `restarting`) + pid. Absent when the
15
16
  * hub is in CLI mode (no supervisor injected through HubFetchDeps).
16
17
  *
18
+ * `focus` ("core" | "experimental") comes from each module's `module.json`
19
+ * when declared, else `focusForShort`'s default map. The SPA groups core first
20
+ * + de-emphasizes experimental — it NEVER hides a module. This is what makes a
21
+ * running, self-registered module (channel) visible + installable; the old
22
+ * `CURATED_MODULES = ["vault","scribe"]` whitelist made it invisible.
23
+ *
17
24
  * Bearer-gated on `parachute:host:auth` to match the rest of `/api/auth/*`
18
25
  * and `/api/grants` — the admin SPA mints this scope via
19
26
  * `/admin/host-admin-token` and threads it as `Authorization: Bearer`.
@@ -37,12 +44,21 @@ import {
37
44
  type ModuleManifest,
38
45
  readModuleManifest as defaultReadModuleManifest,
39
46
  } from "./module-manifest.ts";
40
- import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
47
+ import type { ModuleFocus } from "./module-manifest.ts";
48
+ import {
49
+ FIRST_PARTY_FALLBACKS,
50
+ KNOWN_MODULES,
51
+ discoverableShorts,
52
+ focusForShort,
53
+ shortNameForManifest,
54
+ } from "./service-spec.ts";
41
55
  // `FIRST_PARTY_FALLBACKS` and `KNOWN_MODULES` are both consulted by
42
56
  // `lookupModule` below — the former for notes/channel (vendored manifests
43
57
  // still required) and the latter for vault/scribe/runner (post-FALLBACK
44
58
  // retirement, hub#310). The local helper hides the split from the rest of
45
- // this file.
59
+ // this file. `discoverableShorts` enumerates their UNION — the
60
+ // self-registration-driven discovery surface that replaced the old
61
+ // `CURATED_MODULES` whitelist (2026-06-09 modular-UI architecture, P2).
46
62
  import {
47
63
  type UiSubUnit,
48
64
  type UiSubUnitStatus,
@@ -52,13 +68,14 @@ import {
52
68
  import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts";
53
69
 
54
70
  /**
55
- * Resolve a curated module to the display + install bootstrap data the
56
- * admin SPA renders. Reads from FIRST_PARTY_FALLBACKS (notes / channel)
57
- * first, KNOWN_MODULES (vault / scribe / runner) second.
71
+ * Resolve a known module to the display + install bootstrap data the admin SPA
72
+ * renders. Reads from FIRST_PARTY_FALLBACKS (notes) first,
73
+ * KNOWN_MODULES (vault / scribe / runner / channel / surface) second.
58
74
  *
59
- * Returns `undefined` if the short isn't curated `CURATED_MODULES` is a
60
- * const tuple intersected with both tables, so undefined here is a programmer
61
- * error (caught by the type system in practice).
75
+ * Returns `undefined` if the short is in neither table a genuinely
76
+ * third-party module discovered only via services.json / the supervisor. The
77
+ * discovery handler synthesizes a minimal row for those rather than dropping
78
+ * them (2026-06-09 modular-UI architecture — show all self-registered modules).
62
79
  */
63
80
  function lookupModule(
64
81
  short: string,
@@ -88,31 +105,26 @@ function lookupModule(
88
105
  export const API_MODULES_REQUIRED_SCOPE = "parachute:host:auth";
89
106
 
90
107
  /**
91
- * Curated module short-names. The admin UI offers exactly these for install
92
- * + management. Order is the recommended install order (vault first, scribe
93
- * second).
94
- *
95
- * Trimmed 2026-05-27 (Aaron-directed launch focus) from the prior set of
96
- * `["vault", "surface", "notes", "scribe", "runner"]`. The dropped modules
97
- * are still published on npm and still work — they're just not the focus:
108
+ * Recommended fresh-install ORDER for the `core`-tier modules. NO LONGER a
109
+ * discovery/install whitelist (2026-06-09 modular-UI architecture, P2 —
110
+ * retired the gating role). Discovery now enumerates the UNION of
111
+ * `services.json` ∪ `discoverableShorts()` (KNOWN_MODULES ∪
112
+ * FIRST_PARTY_FALLBACKS) supervisor, so every self-registered/known
113
+ * module appears the channel-not-installed bug (running but invisible)
114
+ * is gone.
98
115
  *
99
- * - `notes` (notes-daemon): retired. Notes-UI now lives at
100
- * `notes.parachute.computer` as a hosted SPA operators don't install
101
- * a notes daemon anymore. The npm package `@openparachute/notes-ui`
102
- * is a library imported by `parachute-surface` and by custom-surface
103
- * builders.
104
- * - `surface` (host module): de-emphasized. `@openparachute/surface-client`
105
- * remains the canonical library for folks building their own UIs
106
- * against a Parachute hub; running the surface-host module on your
107
- * own box is no longer the headline path (use notes.parachute.computer
108
- * or build your own).
109
- * - `runner`: experimental, not in the focus set for launch.
116
+ * This constant survives only as a sort hint: shorts listed here float to the
117
+ * top of the `core` group in the given order (vault first, scribe second).
118
+ * Any `core`-tier module not named here still appears, sorted after these.
119
+ * The install-path gate (`parseModulesPath`) now accepts any known short via
120
+ * `isKnownModuleShort`, NOT membership in this tuple.
110
121
  *
111
- * Re-adding any of these is one line keep the list small until use
112
- * cases demand otherwise.
122
+ * `CuratedModuleShort` is retained as a loose typed-string key for the
123
+ * lookup helpers (`getSpec`, `lookupModule`) that consume a short name — it
124
+ * is no longer a closed enum of the only installable modules.
113
125
  */
114
126
  export const CURATED_MODULES = ["vault", "scribe"] as const;
115
- export type CuratedModuleShort = (typeof CURATED_MODULES)[number];
127
+ export type CuratedModuleShort = string;
116
128
 
117
129
  export interface ApiModulesDeps {
118
130
  db: Database;
@@ -186,6 +198,14 @@ interface ModuleWireShape {
186
198
  package: string;
187
199
  display_name: string;
188
200
  tagline: string;
201
+ /**
202
+ * Discovery tier (2026-06-09 modular-UI architecture). `core` modules render
203
+ * in the headline group; `experimental` modules render in a de-emphasized
204
+ * "Experimental" group below — never hidden. Resolved from the module's
205
+ * `module.json` `focus` when declared, else `focusForShort`'s default map
206
+ * (vault/scribe/hub/surface → core, channel/runner/others → experimental).
207
+ */
208
+ focus: ModuleFocus;
189
209
  available: boolean;
190
210
  installed: boolean;
191
211
  installed_version: string | null;
@@ -231,9 +251,9 @@ interface ModuleWireShape {
231
251
  * declared surface — for modules where the user-facing UI IS
232
252
  * the operator UI (App today).
233
253
  * 3. Null when the module hasn't declared either field — the SPA
234
- * renders a disabled "Open" tooltip ("module hasn't shipped an
235
- * admin UI yet"). Tracked as follow-up issues per module
236
- * (scribe#53, runner#8 today).
254
+ * renders a disabled "Open" tooltip (pointing at Configure when
255
+ * `config_ui_url` is set runner's shape today or "module
256
+ * hasn't shipped an admin UI yet" otherwise).
237
257
  *
238
258
  * Always an absolute path on the hub origin (leading `/`) — the SPA
239
259
  * navigates same-origin, no need to worry about cross-origin
@@ -241,6 +261,23 @@ interface ModuleWireShape {
241
261
  * surfaces, unused by first-party modules today).
242
262
  */
243
263
  management_url: string | null;
264
+ /**
265
+ * Where the module's OWN config/admin surface lives (2026-06-09 modular-UI
266
+ * architecture, P3). Resolved server-side from the module's
267
+ * `.parachute/module.json` `configUiUrl`, joined against its mount path the
268
+ * same way `management_url` resolves `managementUrl`/`uiUrl`. Drives the
269
+ * Modules page's consistent **Configure** action — clicking lands the
270
+ * operator on the module's own config UI (channel `/channel/admin`, scribe
271
+ * `/scribe/admin`, …), which mints its admin Bearer from the hub's
272
+ * cookie-gated `/admin/module-token/<short>` (or `/admin/channel-token`).
273
+ *
274
+ * Null when the module hasn't declared `configUiUrl` — the SPA omits the
275
+ * Configure action for that module rather than rendering a dead button.
276
+ * Distinct from `management_url`: a module may declare one, both, or
277
+ * neither. Channel declares `configUiUrl: "/channel/admin"` + `uiUrl`;
278
+ * vault declares `managementUrl` (its admin SPA is the config surface).
279
+ */
280
+ config_ui_url: string | null;
244
281
  }
245
282
 
246
283
  /**
@@ -395,34 +432,33 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
395
432
  }
396
433
  >();
397
434
  for (const entry of manifest.services) {
398
- // Join services.json rows to CURATED_MODULES by manifestName. The
399
- // mapping table lives in lookupModule (which consults both
400
- // FIRST_PARTY_FALLBACKS and KNOWN_MODULES) so a row written by a
401
- // self-registered vault / scribe / runner matches even though those
402
- // shorts no longer have FALLBACK entries (hub#310).
403
- for (const short of CURATED_MODULES) {
404
- const m = lookupModule(short);
405
- if (m?.manifestName === entry.name) {
406
- const value: {
407
- version: string;
408
- installDir?: string;
409
- uis?: Record<string, UiSubUnit>;
410
- mountPath?: string;
411
- } = { version: entry.version };
412
- if (entry.installDir !== undefined) value.installDir = entry.installDir;
413
- if (entry.uis !== undefined) value.uis = entry.uis;
414
- // First non-`.parachute` path is the module's user-facing mount
415
- // (`/app`, `/scribe`, `/vault/<name>`). Used below to resolve
416
- // a relative `managementUrl` to a full hub-origin path. Skips
417
- // `.parachute` entries because those are protocol mounts, not
418
- // user surfaces — every module declares one.
419
- const userPath = (entry.paths ?? []).find(
420
- (p) => p !== "/.parachute" && !p.startsWith("/.parachute/"),
421
- );
422
- if (userPath !== undefined) value.mountPath = userPath;
423
- installedByShort.set(short, value);
424
- }
425
- }
435
+ // Join services.json rows to a short via `shortNameForManifest` — covers
436
+ // every known module (FIRST_PARTY_FALLBACKS KNOWN_MODULES legacy
437
+ // aliases), so a self-registered channel / runner / surface row matches
438
+ // and becomes discoverable. Rows whose manifestName resolves to no known
439
+ // short (genuinely third-party) fall back to the row's own name as the
440
+ // short they still surface as installed, de-emphasized (2026-06-09
441
+ // modular-UI architecture, P2 — discovery from self-registration, not the
442
+ // CURATED_MODULES whitelist).
443
+ const short = shortNameForManifest(entry.name) ?? entry.name;
444
+ const value: {
445
+ version: string;
446
+ installDir?: string;
447
+ uis?: Record<string, UiSubUnit>;
448
+ mountPath?: string;
449
+ } = { version: entry.version };
450
+ if (entry.installDir !== undefined) value.installDir = entry.installDir;
451
+ if (entry.uis !== undefined) value.uis = entry.uis;
452
+ // First non-`.parachute` path is the module's user-facing mount
453
+ // (`/surface`, `/scribe`, `/vault/<name>`). Used below to resolve a
454
+ // relative `managementUrl` to a full hub-origin path. Skips `.parachute`
455
+ // entries because those are protocol mounts, not user surfaces — every
456
+ // module declares one.
457
+ const userPath = (entry.paths ?? []).find(
458
+ (p) => p !== "/.parachute" && !p.startsWith("/.parachute/"),
459
+ );
460
+ if (userPath !== undefined) value.mountPath = userPath;
461
+ installedByShort.set(short, value);
426
462
  }
427
463
 
428
464
  // Read each installed module's `.parachute/module.json` so we can
@@ -432,49 +468,39 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
432
468
  // management_url and the SPA shows the disabled "Open" tooltip.
433
469
  const readModuleManifestFn = deps.readModuleManifest ?? defaultReadModuleManifest;
434
470
  const managementUrlByShort = new Map<string, string>();
471
+ // The module's OWN config surface (2026-06-09 modular-UI architecture, P3) —
472
+ // resolved from `configUiUrl` the same way `managementUrl` is. Drives the
473
+ // consistent Configure action.
474
+ const configUiUrlByShort = new Map<string, string>();
475
+ // Manifest-declared `focus` per installed short. Prefer this over the default
476
+ // map when composing the wire shape (2026-06-09 modular-UI architecture).
477
+ const declaredFocusByShort = new Map<string, ModuleFocus>();
435
478
  await Promise.all(
436
479
  Array.from(installedByShort.entries()).map(async ([short, value]) => {
437
480
  if (!value.installDir) return;
438
481
  try {
439
482
  const m = await readModuleManifestFn(value.installDir);
440
483
  if (!m) return;
484
+ if (m.focus !== undefined) declaredFocusByShort.set(short, m.focus);
441
485
  // Resolution per the module-ui-declaration.md hierarchy:
442
- // managementUrl > uiUrl. Both are EITHER an absolute
443
- // http(s) URL OR a relative path. Relative paths are joined
444
- // against the module's mount path (entry.paths[0]) since both
445
- // surfaces conventionally live under it (vault's `/admin`,
446
- // app's `/admin`). Absolute URLs pass through verbatim.
447
- const candidate = m.managementUrl ?? m.uiUrl;
448
- if (candidate === undefined) return;
449
- if (/^https?:\/\//i.test(candidate)) {
450
- managementUrlByShort.set(short, candidate);
451
- return;
452
- }
453
- const mount = value.mountPath;
454
- if (mount === undefined) {
455
- // No user-facing mount declared — we can't resolve a relative
456
- // path. Skip rather than guess. Vault rows hit this when
457
- // services.json was hand-edited to remove the mount; the
458
- // disabled-tooltip state in the SPA is the right surface.
459
- return;
460
- }
461
- // Resolution rule (per module-ui-declaration.md):
462
- // - Multi-instance modules (vault) declare a per-instance
463
- // relative path (e.g. `/admin/`); hub prepends the mount
464
- // (e.g. `/vault/default` + `/admin/` → `/vault/default/admin/`).
465
- // - Single-instance modules (app, scribe, runner) declare a
466
- // full hub-origin path that ALREADY includes the mount
467
- // (e.g. `/surface/admin/`, `/scribe/admin`); the mount must NOT
468
- // be prepended again or the result is `/app/surface/admin/`
469
- // (the audit bug caught 2026-05-25 on the SPA's Services
470
- // dropdown).
471
- // Detect by checking if candidate is already mount-prefixed.
472
- const tail = candidate.startsWith("/") ? candidate : `/${candidate}`;
473
- const alreadyMountPrefixed = tail === mount || tail.startsWith(`${mount}/`);
474
- managementUrlByShort.set(short, alreadyMountPrefixed ? tail : `${mount}${tail}`);
486
+ // managementUrl > uiUrl. Unified semantics (B4): http(s):// verbatim ·
487
+ // leading-"/" = origin-absolute verbatim · relative = joined under the
488
+ // module's mount path (entry.paths[0]) the per-instance form.
489
+ const resolvedManagement = resolveModuleUrl(
490
+ m.managementUrl ?? m.uiUrl,
491
+ value.mountPath,
492
+ short,
493
+ );
494
+ if (resolvedManagement !== undefined) managementUrlByShort.set(short, resolvedManagement);
495
+ // The config surface resolves with the SAME rule. A module may declare
496
+ // `configUiUrl` independently of `managementUrl` — channel ships
497
+ // `configUiUrl: "/channel/admin"` (single-instance, origin-absolute)
498
+ // alongside a separate `uiUrl`.
499
+ const resolvedConfig = resolveModuleUrl(m.configUiUrl, value.mountPath, short);
500
+ if (resolvedConfig !== undefined) configUiUrlByShort.set(short, resolvedConfig);
475
501
  } catch (err) {
476
502
  const msg = err instanceof Error ? err.message : String(err);
477
- console.warn(`api-modules: skipping managementUrl for ${short}: ${msg}`);
503
+ console.warn(`api-modules: skipping module URLs for ${short}: ${msg}`);
478
504
  }
479
505
  }),
480
506
  );
@@ -488,6 +514,17 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
488
514
  }
489
515
  }
490
516
 
517
+ // Discovery short list = UNION of the bootstrap registries (KNOWN_MODULES ∪
518
+ // FIRST_PARTY_FALLBACKS via `discoverableShorts`), every services.json row's
519
+ // short, and every supervised module's short — deduped, preserving registry
520
+ // order first so the canonical modules lead (2026-06-09 modular-UI
521
+ // architecture, P2). Every self-registered/known module appears; the
522
+ // running-but-invisible class (channel) is gone.
523
+ const discoverySet = new Set<string>(discoverableShorts());
524
+ for (const short of installedByShort.keys()) discoverySet.add(short);
525
+ for (const short of stateByShort.keys()) discoverySet.add(short);
526
+ const discoveryShorts = Array.from(discoverySet);
527
+
491
528
  // Resolve npm dist-tag in parallel — short timeout per request, cache
492
529
  // shared across requests so a fast UI poll doesn't slam the registry.
493
530
  // Channel-aware: an operator on @rc sees the rc-tagged version as the
@@ -500,9 +537,11 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
500
537
 
501
538
  const latestByShort = new Map<string, string | null>();
502
539
  await Promise.all(
503
- CURATED_MODULES.map(async (short) => {
540
+ discoveryShorts.map(async (short) => {
504
541
  const m = lookupModule(short);
505
542
  if (!m) {
543
+ // Third-party module known only from services.json / supervisor — no
544
+ // npm package to probe.
506
545
  latestByShort.set(short, null);
507
546
  return;
508
547
  }
@@ -519,21 +558,33 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
519
558
  }),
520
559
  );
521
560
 
522
- // Compose the wire shape. Curated order is the recommended install order;
523
- // installed modules outside the curated list (uncommon only third-party)
524
- // are appended at the end with `available: false`.
525
- const modules: ModuleWireShape[] = [];
526
- for (const short of CURATED_MODULES) {
561
+ // Compose one wire row per discovered short. `focus` resolution: the
562
+ // module's manifest-declared `focus` (when installed + declared) wins; else
563
+ // the `focusForShort` default map. Sort: `core` group first (with the
564
+ // CURATED_MODULES recommended-install order floated to the top of that
565
+ // group), then `experimental` the SPA renders the two groups; `focus`
566
+ // never hides a module.
567
+ const recommendedOrder = new Map<string, number>(
568
+ (CURATED_MODULES as readonly string[]).map((s, i) => [s, i]),
569
+ );
570
+ const rows = discoveryShorts.map((short) => {
527
571
  const m = lookupModule(short);
528
- if (!m) continue;
529
572
  const installed = installedByShort.get(short);
530
573
  const state = stateByShort.get(short);
531
- modules.push({
574
+ const focus = focusForShort(short, declaredFocusByShort.get(short));
575
+ const row: ModuleWireShape = {
532
576
  short,
533
- package: m.package,
534
- display_name: m.displayName,
535
- tagline: m.tagline,
536
- available: true,
577
+ // Third-party (no known table entry) → fall back to the short as the
578
+ // package label + the row's own display fields.
579
+ package: m?.package ?? short,
580
+ display_name: m?.displayName ?? short,
581
+ tagline: m?.tagline ?? "",
582
+ focus,
583
+ // `available` historically meant "in the curated install catalog". Every
584
+ // discovered short is now installable/managed, so it's always true for
585
+ // known modules; a purely third-party services.json row (no install
586
+ // package) is not hub-installable → false.
587
+ available: m !== undefined,
537
588
  installed: installed !== undefined,
538
589
  installed_version: installed?.version ?? null,
539
590
  latest_version: latestByShort.get(short) ?? null,
@@ -543,8 +594,19 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
543
594
  install_dir: installed?.installDir ?? null,
544
595
  uis: toUisWireShape(installed?.uis),
545
596
  management_url: managementUrlByShort.get(short) ?? null,
546
- });
547
- }
597
+ config_ui_url: configUiUrlByShort.get(short) ?? null,
598
+ };
599
+ return row;
600
+ });
601
+ const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : 1);
602
+ rows.sort((a, b) => {
603
+ if (a.focus !== b.focus) return focusRank(a.focus) - focusRank(b.focus);
604
+ const ai = recommendedOrder.get(a.short) ?? Number.POSITIVE_INFINITY;
605
+ const bi = recommendedOrder.get(b.short) ?? Number.POSITIVE_INFINITY;
606
+ if (ai !== bi) return ai - bi;
607
+ return a.short.localeCompare(b.short);
608
+ });
609
+ const modules: ModuleWireShape[] = rows;
548
610
 
549
611
  // Every supervised module's run-state — curated AND non-curated (hub#539).
550
612
  // Built from the same supervisor.list() snapshot already in `stateByShort`.
@@ -663,6 +725,66 @@ function jsonError(status: number, code: string, description: string): Response
663
725
  });
664
726
  }
665
727
 
728
+ /**
729
+ * The literal legacy per-instance candidates the COMPAT SHIM recognizes on a
730
+ * vault entry (see `resolveModuleUrl`). One-release shim — remove once vault's
731
+ * new manifest (`managementUrl: "admin/"`) reaches @latest.
732
+ */
733
+ const LEGACY_VAULT_ADMIN_CANDIDATES = new Set(["/admin", "/admin/"]);
734
+ let warnedLegacyVaultAdminCandidate = false;
735
+
736
+ /**
737
+ * Resolve a module-declared "path or http(s) URL" surface field (`managementUrl`,
738
+ * `uiUrl`, `configUiUrl`) to a full hub-origin path the SPA can navigate to.
739
+ *
740
+ * Unified URL-resolution semantics (B4 of the 2026-06-09 hub-module-boundary
741
+ * migration — same doctrine as `buildWellKnown`'s uiUrl branch and the
742
+ * `resolveManagementUrl` pair):
743
+ *
744
+ * - `undefined` candidate → `undefined` (the module didn't declare it).
745
+ * - Absolute http(s) URL → returned verbatim (off-origin escape hatch).
746
+ * - Leading-`/` path → ORIGIN-ABSOLUTE, returned verbatim. Single-instance
747
+ * modules (surface, scribe, runner, channel) declare their full hub-origin
748
+ * path this way (`/surface/admin/`, `/scribe/admin`, `/channel/admin`);
749
+ * vault's daemon-level surface is `/vault/admin/`.
750
+ * - Relative path (no leading slash) → MOUNT-JOINED: the per-instance form
751
+ * (`admin/` on a `/vault/default` mount → `/vault/default/admin/`). With
752
+ * no mount to join → `undefined` (can't resolve; the SPA renders the
753
+ * disabled/omitted state rather than guessing).
754
+ *
755
+ * COMPAT SHIM (one release): the literal legacy `"/admin"`/`"/admin/"` on a
756
+ * VAULT entry is the OLD per-instance relative declaration — deployed vaults
757
+ * still ship it until the vault wave's manifest lands — and mount-joins with
758
+ * a one-time deprecation log instead of resolving origin-absolute (which
759
+ * would point at the daemon-level `/vault/admin` mount, not the instance).
760
+ *
761
+ * Shared by `managementUrl`/`uiUrl` (the Open action) and `configUiUrl` (the
762
+ * Configure action, 2026-06-09 modular-UI architecture P3) so the two resolve
763
+ * identically.
764
+ */
765
+ function resolveModuleUrl(
766
+ candidate: string | undefined,
767
+ mount: string | undefined,
768
+ short: string,
769
+ ): string | undefined {
770
+ if (candidate === undefined) return undefined;
771
+ if (/^https?:\/\//i.test(candidate)) return candidate;
772
+ const mountBase = mount?.replace(/\/+$/, "");
773
+ if (short === "vault" && LEGACY_VAULT_ADMIN_CANDIDATES.has(candidate)) {
774
+ if (!warnedLegacyVaultAdminCandidate) {
775
+ warnedLegacyVaultAdminCandidate = true;
776
+ console.warn(
777
+ `api-modules: vault declares the legacy per-instance form ${JSON.stringify(candidate)}; mount-joining for one release. New semantics: relative ("admin/") = per-instance mount-join, leading-"/" = origin-absolute. Upgrade the vault module to clear this.`,
778
+ );
779
+ }
780
+ if (mountBase === undefined) return undefined;
781
+ return `${mountBase}${candidate}`;
782
+ }
783
+ if (candidate.startsWith("/")) return candidate;
784
+ if (mountBase === undefined) return undefined;
785
+ return `${mountBase}/${candidate}`;
786
+ }
787
+
666
788
  /**
667
789
  * Map a services.json `uis` record to the snake-case wire shape the SPA
668
790
  * consumes. Each missing optional field on the source becomes an explicit