@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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +34 -0
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-server.ts +349 -59
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/api-modules-ops.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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 (!
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
797
|
-
// is the canonical source for startCmd. Re-resolve the spec
|
|
798
|
-
// `<installDir>/.parachute/module.json` when installDir is stamped so
|
|
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
|
|
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]) {
|
package/src/api-modules.ts
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `GET /api/modules` — admin SPA's module-management surface.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
* - **
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
-
*
|
|
112
|
-
*
|
|
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 =
|
|
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 (
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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.
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
574
|
+
const focus = focusForShort(short, declaredFocusByShort.get(short));
|
|
575
|
+
const row: ModuleWireShape = {
|
|
532
576
|
short,
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|