feat: add display kiosk host
This commit is contained in:
@@ -32,6 +32,8 @@ import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-rou
|
||||
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||
|
||||
@@ -66,6 +68,10 @@ export interface CreateApiAppOptions {
|
||||
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
|
||||
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||
/** Surface-owned display host assets, read once by production startup. Optional only when the
|
||||
* eligible publication registry contains no display entrypoint; route composition rejects an
|
||||
* inconsistent display-bearing registry synchronously. */
|
||||
readonly displayHostAssets?: DisplayHostAssets;
|
||||
/**
|
||||
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
|
||||
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
|
||||
@@ -264,6 +270,17 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}),
|
||||
);
|
||||
|
||||
// A separate, chrome-free browser host. Exact page and descriptor routes are derived from the
|
||||
// same eligible/running publication registry as artifact bytes; a display-only module never
|
||||
// enters Dashboard navigation, and no request value can select a file or permission.
|
||||
mountModuleDisplayRoutes(app, {
|
||||
evaluator: options.authorization.evaluator,
|
||||
moduleUi: options.moduleUi,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||
});
|
||||
|
||||
// Layered deliberately on top of the generic inventory above rather than merged into it: the
|
||||
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
|
||||
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||
|
||||
/**
|
||||
* The two immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||
*
|
||||
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
||||
* surface package. Request input is never involved in this read and the package is never exposed as
|
||||
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
|
||||
* genuinely immutable value that every response can reuse safely.
|
||||
*/
|
||||
export interface DisplayHostAssets {
|
||||
readonly html: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
* location, and machine path are intentionally not retained or surfaced. */
|
||||
export class DisplayHostAssetError extends Error {
|
||||
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
|
||||
|
||||
constructor() {
|
||||
super("The display host browser assets are unavailable.");
|
||||
this.name = "DisplayHostAssetError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures one immutable, internally consistent display-host asset snapshot.
|
||||
*
|
||||
* The strong validator is always derived from the exact script text in the same snapshot; callers
|
||||
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||
* failed host build rather than a valid page that can never mount anything.
|
||||
*/
|
||||
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
||||
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||
try {
|
||||
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
|
||||
// rejected sibling promise is left running after this function has already failed.
|
||||
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||
return createDisplayHostAssets(html, script);
|
||||
} catch {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||
import type { ModuleUiPublicationRegistry, PublishableModuleDisplay } from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||
import { grantedModulePermissionIds } from "./module-ui-inventory.js";
|
||||
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||
|
||||
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
||||
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
|
||||
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||
|
||||
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
|
||||
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
|
||||
* management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the
|
||||
* boundary. */
|
||||
export interface HostModuleDisplayDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly display: { readonly url: string };
|
||||
readonly api: { readonly baseUrl: string };
|
||||
readonly realtime: { readonly baseUrl: string };
|
||||
readonly permissions: readonly string[];
|
||||
}
|
||||
|
||||
export interface HostModuleDisplayResource {
|
||||
readonly module: HostModuleDisplayDescriptor;
|
||||
}
|
||||
|
||||
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The same immutable, eligible/running publication registry used for artifact bytes. */
|
||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||
* surface-owned files before binding and supplies their frozen contents here. */
|
||||
readonly assets?: DisplayHostAssets;
|
||||
}
|
||||
|
||||
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||
* permission id, path, or package location. */
|
||||
export class ModuleDisplayConfigurationError extends Error {
|
||||
constructor() {
|
||||
super("The module display host configuration is inconsistent.");
|
||||
this.name = "ModuleDisplayConfigurationError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function displayFailure(c: Context): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||
}
|
||||
|
||||
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
|
||||
function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||
if (headerValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (headerValue.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
return headerValue.split(",").some((candidate) => {
|
||||
const value = candidate.trim();
|
||||
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||
});
|
||||
}
|
||||
|
||||
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||
* bytes with a stale validator. */
|
||||
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||
try {
|
||||
const snapshot = createDisplayHostAssets(input.html, input.script);
|
||||
if (snapshot.scriptEtag !== input.scriptEtag) {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
}
|
||||
|
||||
async function describeDisplay(
|
||||
options: ModuleDisplayRoutesOptions,
|
||||
principal: EkklesiaPrincipal,
|
||||
display: PublishableModuleDisplay,
|
||||
): Promise<HostModuleDisplayResource> {
|
||||
return {
|
||||
module: {
|
||||
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||
id: display.moduleId,
|
||||
permissions: await grantedModulePermissionIds(
|
||||
options.evaluator,
|
||||
principal,
|
||||
display.permissions,
|
||||
),
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl(display.moduleId) },
|
||||
version: display.version,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mountDisplay(
|
||||
app: Hono,
|
||||
options: ModuleDisplayRoutesOptions & { readonly assets: DisplayHostAssets },
|
||||
display: PublishableModuleDisplay,
|
||||
): void {
|
||||
// The permission comes from an already-validated manifest/publication record. A request selects
|
||||
// neither the permission nor the artifact; it can only hit this exact pre-composed route.
|
||||
const requireDisplayPermission = requirePermission(options, display.displayPermission);
|
||||
|
||||
app.get(moduleDisplayPageUrl(display.moduleId), requireDisplayPermission, () => {
|
||||
return new Response(options.assets.html, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
app.get(moduleDisplayDescriptorUrl(display.moduleId), requireDisplayPermission, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
try {
|
||||
return c.json(await describeDisplay(options, getAuthorizedPrincipal(c), display), 200);
|
||||
} catch {
|
||||
return displayFailure(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts one stable page and one descriptor route per eligible, running display declaration.
|
||||
*
|
||||
* There is deliberately no display inventory in this pass. A management UI may need one later,
|
||||
* but a bookmarked TV needs only its version-independent page URL. Unknown, dashboard-only,
|
||||
* disabled, and dependency-blocked modules have no registered route and collapse into the
|
||||
* application's ordinary sanitized 404.
|
||||
*
|
||||
* The page is an inert, module-agnostic shell: its bytes contain no module id and its one absolute
|
||||
* script URL cannot be rebased by a deep `/display/<id>` path. The browser reads the validated id
|
||||
* from its outer URL, requests the permission-gated descriptor, and only then imports the exact
|
||||
* versioned display artifact that the server supplied.
|
||||
*/
|
||||
export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void {
|
||||
const displays = options.moduleUi.listDisplays();
|
||||
if (displays.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!options.assets) {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
const assets = snapshotAssets(options.assets);
|
||||
for (const display of displays) {
|
||||
if (!options.registry.has(display.displayPermission)) {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
}
|
||||
|
||||
// One fixed host bundle, never a directory and never a request-selected package file. Like module
|
||||
// artifact bytes, fetching this platform code grants no authority; the page and descriptor are
|
||||
// where identity and the module-owned display permission are enforced.
|
||||
app.get(DISPLAY_HOST_SCRIPT_PATH, (c) => {
|
||||
if (etagMatches(c.req.header("if-none-match"), assets.scriptEtag)) {
|
||||
return new Response(null, {
|
||||
headers: { "Cache-Control": "no-cache", ETag: assets.scriptEtag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
return new Response(assets.script, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/javascript; charset=utf-8",
|
||||
ETag: assets.scriptEtag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
for (const display of displays) {
|
||||
mountDisplay(app, { ...options, assets }, display);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,9 @@ export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddl
|
||||
* silently read "denied" during a database outage would blank the whole UI and look like a
|
||||
* permissions problem.
|
||||
*/
|
||||
async function grantedPermissionIds(
|
||||
/** Shared by the primary-UI inventory and the singular display descriptor. Both provide the same
|
||||
* presentation-only subset of a module's own declarations and use Core's one evaluator. */
|
||||
export async function grantedModulePermissionIds(
|
||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||
principal: EkklesiaPrincipal,
|
||||
declarations: readonly PermissionDeclaration[],
|
||||
@@ -118,7 +120,7 @@ async function describeModule(
|
||||
return {
|
||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||
id: published.moduleId,
|
||||
permissions: await grantedPermissionIds(evaluator, principal, published.permissions),
|
||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||
ui: { url: moduleUiArtifactUrl(published) },
|
||||
version: published.version,
|
||||
|
||||
@@ -77,9 +77,22 @@ export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical browser URL for a module's independently declared display artifact.
|
||||
*
|
||||
* The literal trailing segment is host-owned, not a caller-selected role. Keeping this as a
|
||||
* separate helper and an exact route means neither request input nor a generic role parameter can
|
||||
* select a neighbouring artifact. A future standalone host may expose a different outer display
|
||||
* page while consuming this same published module artifact internally.
|
||||
*/
|
||||
export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||
return `${moduleUiArtifactUrl(identity)}/display`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||
* installed module declares no `entrypoints.ui`, or the requested version is not the installed one.
|
||||
* installed module declares no artifact for the requested UI role, or the requested version is not
|
||||
* the installed one.
|
||||
*
|
||||
* They are deliberately indistinguishable. The route requires no authentication (see
|
||||
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
|
||||
@@ -122,6 +135,48 @@ function isNotModified(headerValue: string | undefined, etag: string): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
interface PublishedArtifact {
|
||||
readonly artifactPath: string;
|
||||
}
|
||||
|
||||
/** Serve one exact path Core already validated, with one response contract for both UI roles. */
|
||||
async function serveArtifact(c: Context, published: PublishedArtifact): Promise<Response> {
|
||||
// Discovery validated this exact path earlier; the file may still have changed or vanished
|
||||
// since. The response is produced from whatever a single read of that one path returns right
|
||||
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
|
||||
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
|
||||
// something else.
|
||||
let artifact: Buffer;
|
||||
try {
|
||||
artifact = await readFile(published.artifactPath);
|
||||
} catch {
|
||||
return artifactUnavailable(c);
|
||||
}
|
||||
|
||||
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
|
||||
// validator can never describe a different revision than the body does.
|
||||
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
|
||||
if (isNotModified(c.req.header("if-none-match"), etag)) {
|
||||
return new Response(null, {
|
||||
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(artifact, {
|
||||
headers: {
|
||||
"Cache-Control": MODULE_UI_CACHE_CONTROL,
|
||||
"Content-Type": MODULE_UI_CONTENT_TYPE,
|
||||
ETag: etag,
|
||||
// The declared type is the only type: a browser must not be allowed to sniff a module
|
||||
// artifact into some other interpretation, and `import()` requires the JavaScript type
|
||||
// anyway.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateModuleUiRoutesOptions {
|
||||
/** The platform's publication policy, built by Core from the discovered, started module
|
||||
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
|
||||
@@ -173,46 +228,23 @@ export interface CreateModuleUiRoutesOptions {
|
||||
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||
const published = options.registry.resolveDisplay(
|
||||
c.req.param("moduleId"),
|
||||
c.req.param("version"),
|
||||
);
|
||||
if (!published) {
|
||||
return notPublished(c);
|
||||
}
|
||||
return serveArtifact(c, published);
|
||||
});
|
||||
|
||||
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
|
||||
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
|
||||
if (!published) {
|
||||
return notPublished(c);
|
||||
}
|
||||
|
||||
// Discovery validated this exact path earlier; the file may still have changed or vanished
|
||||
// since. The response is produced from whatever a single read of that one path returns right
|
||||
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
|
||||
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
|
||||
// something else.
|
||||
let artifact: Buffer;
|
||||
try {
|
||||
artifact = await readFile(published.artifactPath);
|
||||
} catch {
|
||||
return artifactUnavailable(c);
|
||||
}
|
||||
|
||||
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
|
||||
// validator can never describe a different revision than the body does.
|
||||
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
|
||||
if (isNotModified(c.req.header("if-none-match"), etag)) {
|
||||
return new Response(null, {
|
||||
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(artifact, {
|
||||
headers: {
|
||||
"Cache-Control": MODULE_UI_CACHE_CONTROL,
|
||||
"Content-Type": MODULE_UI_CONTENT_TYPE,
|
||||
ETag: etag,
|
||||
// The declared type is the only type: a browser must not be allowed to sniff a module
|
||||
// artifact into some other interpretation, and `import()` requires the JavaScript type
|
||||
// anyway.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
return serveArtifact(c, published);
|
||||
});
|
||||
|
||||
return routes;
|
||||
|
||||
+22
-5
@@ -32,6 +32,7 @@ import {
|
||||
import { type ServerType, serve } from "@hono/node-server";
|
||||
import type { Hono } from "hono";
|
||||
import { createApiApp } from "./app.js";
|
||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||
import {
|
||||
type ClientAddressConfigInput,
|
||||
type ClientAddressResolver,
|
||||
@@ -489,6 +490,21 @@ export async function startApi(
|
||||
})),
|
||||
);
|
||||
|
||||
// This snapshot is inert metadata only: construction reads no artifact and executes no module.
|
||||
// It lets the API prove that a display host build is available *before* importing any eligible
|
||||
// module code. The snapshot is not exposed anywhere unless the complete runtime subsequently
|
||||
// starts and application composition succeeds.
|
||||
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
||||
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||
try {
|
||||
displayHostAssets =
|
||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Module load/register/start failures use Core's own existing rollback (already-started
|
||||
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
||||
// handle to stop here, only the pool this failure leaves open.
|
||||
@@ -524,11 +540,12 @@ export async function startApi(
|
||||
moduleRealtime: moduleRuntime.realtime,
|
||||
moduleServices: services,
|
||||
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||
// inventory, and only once startup has succeeded — so the published set is the running set. A
|
||||
// disabled or dependency-blocked module therefore publishes no artifact, appears in no browser
|
||||
// inventory, and appears in no resolved Dashboard navigation, by construction rather than by a
|
||||
// filter somewhere downstream. Modules declaring no `entrypoints.ui` are simply absent.
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
// inventory, and exposed only after startup succeeded — so the published set is the running
|
||||
// set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no
|
||||
// browser inventory, and appears in no resolved Dashboard navigation, by construction rather
|
||||
// than by a filter somewhere downstream. Modules declaring no UI role are simply absent.
|
||||
moduleUi,
|
||||
...(displayHostAssets === undefined ? {} : { displayHostAssets }),
|
||||
moduleLifecycle: {
|
||||
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
|
||||
// reports it actually started — read from the runtime rather than assumed equal to the
|
||||
|
||||
Reference in New Issue
Block a user