feat: add display kiosk host
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s

This commit is contained in:
ekklesia
2026-08-23 20:50:56 -05:00
parent be5ed7853d
commit 2e0fe059c3
80 changed files with 4868 additions and 397 deletions
+80 -12
View File
@@ -5,6 +5,7 @@ import {
isVersionCompatible,
type ModuleCapabilityDeclaration,
type ModuleManifest,
type PermissionId,
safeParseModuleManifest,
semanticVersionSchema,
} from "@ekklesia/contracts";
@@ -61,10 +62,16 @@ export interface ResolvedModuleEntrypoints {
readonly schema?: string;
readonly migrations?: string;
readonly jobs?: string;
/** A module's declared presentation artifact (ADR 0021), resolved and validated exactly like the
* other entrypoints. Discovery only proves the file exists, is compiled ESM, and stays inside the
* module package — nothing in this repository imports, evaluates, mounts, or renders it yet. */
/** A module's primary presentation artifact (ADR 0021), resolved and validated exactly like the
* other executable entrypoints. Discovery never imports or evaluates browser artifacts. */
readonly ui?: string;
/** The independently mountable full-viewport artifact and its statically declared access policy.
* `artifactPath` is canonical and package-contained; `permission` already matched one of this
* module's own manifest declarations during manifest parsing. */
readonly display?: {
readonly artifactPath: string;
readonly permission: PermissionId;
};
}
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
@@ -258,11 +265,49 @@ async function resolveEntrypoints(
): Promise<ResolvedModuleEntrypoints | undefined> {
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
const canonicalModuleDirectory = await realpath(moduleDirectory);
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
.filter((entry): entry is [string, string] => entry[1] !== undefined)
.map(([entrypointName, relativeEntrypoint]) => ({
entrypointName,
issuePath: `entrypoints.${entrypointName}`,
relativeEntrypoint,
}));
const configuredEntrypoints = [
...ordinaryConfiguredEntrypoints,
...(display === undefined
? []
: [
{
entrypointName: "display",
issuePath: "entrypoints.display.artifact",
relativeEntrypoint: display.artifact,
},
]),
];
let entrypointCollision = false;
for (const [entrypointName, relativeEntrypoint] of Object.entries(manifest.entrypoints)) {
if (relativeEntrypoint === undefined) {
continue;
if (display !== undefined) {
const configuredDisplayPath = path.resolve(moduleDirectory, display.artifact);
for (const ordinary of ordinaryConfiguredEntrypoints) {
if (path.resolve(moduleDirectory, ordinary.relativeEntrypoint) === configuredDisplayPath) {
entrypointCollision = true;
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `display must not resolve to the same configured file as ${ordinary.entrypointName}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "entrypoints.display.artifact",
});
}
}
}
for (const {
entrypointName,
issuePath: entrypointIssuePath,
relativeEntrypoint,
} of configuredEntrypoints) {
if (
isCompiledEntrypoint(entrypointName) &&
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
@@ -272,7 +317,7 @@ async function resolveEntrypoints(
message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
path: entrypointIssuePath,
});
continue;
}
@@ -283,7 +328,7 @@ async function resolveEntrypoints(
message: `${entrypointName} resolves outside its module directory.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
path: entrypointIssuePath,
});
continue;
}
@@ -296,7 +341,7 @@ async function resolveEntrypoints(
message: `${entrypointName} resolves through a link outside its module directory.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
path: entrypointIssuePath,
});
continue;
}
@@ -312,7 +357,7 @@ async function resolveEntrypoints(
: `${entrypointName} must resolve to a file.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
path: entrypointIssuePath,
});
continue;
}
@@ -329,12 +374,27 @@ async function resolveEntrypoints(
}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
path: entrypointIssuePath,
});
}
}
if (!resolved.server) {
if (resolved.display !== undefined) {
for (const [entrypointName, canonicalEntrypoint] of Object.entries(resolved)) {
if (entrypointName !== "display" && canonicalEntrypoint === resolved.display) {
entrypointCollision = true;
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `display must not resolve through a link to the same file as ${entrypointName}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "entrypoints.display.artifact",
});
}
}
}
if (!resolved.server || entrypointCollision) {
return undefined;
}
@@ -344,6 +404,14 @@ async function resolveEntrypoints(
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
...(resolved.jobs ? { jobs: resolved.jobs } : {}),
...(resolved.ui ? { ui: resolved.ui } : {}),
...(resolved.display && display
? {
display: {
artifactPath: resolved.display,
permission: display.permission,
},
}
: {}),
};
}
+71 -17
View File
@@ -1,4 +1,4 @@
import type { PermissionDeclaration } from "@ekklesia/contracts";
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts";
import type { DiscoveredModule } from "./discovery.js";
/**
@@ -40,6 +40,26 @@ export interface PublishableModuleUi {
readonly permissions: readonly PermissionDeclaration[];
}
/**
* One installed module whose dedicated full-viewport display artifact may be published.
*
* This is deliberately a separate record from {@link PublishableModuleUi}. A primary UI and a
* display UI are two independently declared artifacts with different host roles, and a module may
* publish either one without publishing the other. `displayPermission` is the module-owned
* permission the manifest places beside `entrypoints.display`; it travels with the exact artifact
* identity so a display descriptor cannot accidentally authorize one artifact with another
* module's policy. The complete declaration list supports the same browser-safe permission hints
* as the primary UI while conferring no authority by itself.
*/
export interface PublishableModuleDisplay {
readonly moduleId: string;
readonly version: string;
readonly name: string;
readonly artifactPath: string;
readonly displayPermission: PermissionId;
readonly permissions: readonly PermissionDeclaration[];
}
/**
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
*
@@ -60,6 +80,15 @@ export interface ModuleUiPublicationRegistry {
* version are all indistinguishable here — each returns `undefined`.
*/
resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined;
/** Every dedicated display artifact, in discovery order. Stable and frozen. */
listDisplays(): readonly PublishableModuleDisplay[];
/**
* The dedicated display record for an exact module id and installed version, or `undefined`.
*
* This lookup never falls back to `entrypoints.ui`: a primary/dashboard artifact cannot become
* a display merely because a caller appends `/display` to its publication URL.
*/
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
}
/**
@@ -70,8 +99,9 @@ export interface ModuleUiPublicationRegistry {
* 1. the module is in the **eligible** set the platform started — it survived discovery *and*
* the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) —
* and
* 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file
* inside the module package.
* 2. for each role independently, its manifest declares `entrypoints.ui` or
* `entrypoints.display`, which discovery resolved to a real compiled file inside the module
* package.
*
* A module directory sitting on disk satisfies neither on its own — it has to have survived
* discovery — which is the property `docs/development/security.md` requires: *the existence of an
@@ -93,30 +123,47 @@ export function createModuleUiPublicationRegistry(
modules: readonly DiscoveredModule[],
): ModuleUiPublicationRegistry {
const byModuleId = new Map<string, PublishableModuleUi>();
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
for (const module of modules) {
const artifactPath = module.entrypoints.ui;
if (artifactPath === undefined) {
continue;
if (artifactPath !== undefined) {
byModuleId.set(
module.manifest.id,
Object.freeze({
artifactPath,
moduleId: module.manifest.id,
name: module.manifest.name,
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
// this record can never reach the manifest object graph itself.
permissions: Object.freeze([...module.manifest.permissions]),
version: module.manifest.version,
}),
);
}
const display = module.entrypoints.display;
if (display !== undefined) {
displaysByModuleId.set(
module.manifest.id,
Object.freeze({
artifactPath: display.artifactPath,
displayPermission: display.permission,
moduleId: module.manifest.id,
name: module.manifest.name,
permissions: Object.freeze([...module.manifest.permissions]),
version: module.manifest.version,
}),
);
}
byModuleId.set(
module.manifest.id,
Object.freeze({
artifactPath,
moduleId: module.manifest.id,
name: module.manifest.name,
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
// this record can never reach the manifest object graph itself.
permissions: Object.freeze([...module.manifest.permissions]),
version: module.manifest.version,
}),
);
}
const published = Object.freeze([...byModuleId.values()]);
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
return Object.freeze({
list: (): readonly PublishableModuleUi[] => published,
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
if (typeof moduleId !== "string" || typeof version !== "string") {
return undefined;
@@ -127,5 +174,12 @@ export function createModuleUiPublicationRegistry(
// than to a different artifact that happens to exist.
return candidate?.version === version ? candidate : undefined;
},
resolveDisplay: (moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined => {
if (typeof moduleId !== "string" || typeof version !== "string") {
return undefined;
}
const candidate = displaysByModuleId.get(moduleId);
return candidate?.version === version ? candidate : undefined;
},
});
}