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
+14
View File
@@ -0,0 +1,14 @@
/**
* Exact built files a deployable app may publish for the Ekklesia display host.
*
* They are file URLs relative to this package's compiled `dist/index.js`, not repository paths and
* not request-derived selectors. `apps/api` reads these exact two files before binding and serves
* their bytes at fixed host-owned routes; the module display artifact remains a separate, validated
* publication. Nothing here exposes a directory or a generic static-file root.
*/
const packageRoot = new URL("../", import.meta.url);
export const displayHostAssetUrls = Object.freeze({
html: new URL("dist/browser/display/index.html", packageRoot).href,
script: new URL("dist/browser/display/app.js", packageRoot).href,
});
+152
View File
@@ -0,0 +1,152 @@
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react";
import {
bootstrapDisplayHost,
type DisplayHostBootstrap,
type EkklesiaHostConfig,
HostProtocolError,
} from "../host-protocol.js";
import { DisplayUiFrame } from "./DisplayUiFrame.js";
type DisplayState =
| { readonly status: "loading"; readonly generation: number }
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
| { readonly status: "signed-out" }
| { readonly status: "unavailable" }
| { readonly status: "failed" };
export interface DisplayAppProps {
readonly config: EkklesiaHostConfig;
readonly moduleId: string;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter;
}
const displayStyles = {
host: {
height: "100%",
minHeight: "100dvh",
minWidth: 0,
overflow: "hidden",
width: "100%",
},
message: {
alignItems: "center",
color: "#57534e",
display: "flex",
flexDirection: "column",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
height: "100%",
justifyContent: "center",
margin: 0,
minHeight: "100dvh",
padding: "1rem",
textAlign: "center",
},
retry: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
},
} as const;
function DisplayMessage(props: {
readonly children: string;
readonly testId: string;
readonly onRetry?: () => void;
}): ReactElement {
return (
<div aria-live="polite" data-testid={props.testId} style={displayStyles.message}>
<p style={{ margin: 0 }}>{props.children}</p>
{props.onRetry ? (
<button onClick={props.onRetry} style={displayStyles.retry} type="button">
Retry
</button>
) : undefined}
</div>
);
}
/**
* The host for one display-capable module, with no Dashboard application mounted around it.
*
* Bootstrap is principal-first, so an anonymous browser never probes a module descriptor. The
* descriptor itself is permission-protected server-side; only after it succeeds can an artifact URL
* reach the generic loader. A changed `moduleId` or retry aborts the old fetch and keys a new frame,
* so neither a late descriptor nor a late import can replace the current display.
*/
export function DisplayApp(props: DisplayAppProps): ReactElement {
const [state, setState] = useState<DisplayState>({ generation: 0, status: "loading" });
const [attempt, setAttempt] = useState(0);
const { config, importModule, moduleId } = props;
useEffect(() => {
const abort = new AbortController();
setState({ generation: attempt, status: "loading" });
void bootstrapDisplayHost(config, moduleId, abort.signal)
.then((bootstrap) => {
if (abort.signal.aborted) {
return;
}
setState(
bootstrap.status === "signed-out"
? { status: "signed-out" }
: { bootstrap, status: "ready" },
);
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
return;
}
console.error("[ekklesia] display host bootstrap failed", error);
setState(
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "DISPLAY_NOT_AVAILABLE")
? { status: "unavailable" }
: error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED"
? { status: "signed-out" }
: { status: "failed" },
);
});
return () => abort.abort();
}, [attempt, config, moduleId]);
const retry = (): void => setAttempt((current) => current + 1);
return (
<div data-testid="display-host" style={displayStyles.host}>
{state.status === "loading" ? (
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
) : undefined}
{state.status === "signed-out" ? (
<DisplayMessage onRetry={retry} testId="display-host-signed-out">
Sign in to use this display.
</DisplayMessage>
) : undefined}
{state.status === "unavailable" ? (
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
This display is unavailable.
</DisplayMessage>
) : undefined}
{state.status === "failed" ? (
<DisplayMessage onRetry={retry} testId="display-host-error">
This display could not be reached.
</DisplayMessage>
) : undefined}
{state.status === "ready" ? (
<DisplayUiFrame
config={config}
descriptor={state.bootstrap.descriptor}
{...(importModule ? { importModule } : {})}
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
principal={state.bootstrap.principal}
/>
) : undefined}
</div>
);
}
@@ -0,0 +1,233 @@
import {
loadModuleUi,
ModuleUiHostError,
type ModuleUiHostErrorCode,
type ModuleUiImporter,
} from "@ekklesia/module-ui-host";
import {
ModuleUiContractError,
type ModuleUiContractErrorCode,
type ModuleUiPrincipal,
} from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
type FrameStatus = "loading" | "ready" | "failed";
type DisplayUiFailureCode =
| ModuleUiContractErrorCode
| ModuleUiHostErrorCode
| "DISPLAY_UI_LOAD_FAILED"
| "DISPLAY_UI_RUNTIME_ERROR"
| "DISPLAY_UI_UNHANDLED_REJECTION";
function loadFailureCode(error: unknown): DisplayUiFailureCode {
if (error instanceof ModuleUiHostError || error instanceof ModuleUiContractError) {
return error.code;
}
return "DISPLAY_UI_LOAD_FAILED";
}
export interface DisplayUiFrameProps {
readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter;
}
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
readonly onRetry: () => void;
}
const frameStyles = {
container: {
height: "100%",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
width: "100%",
},
fallback: {
alignItems: "center",
color: "#57534e",
display: "flex",
flexDirection: "column",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
inset: 0,
justifyContent: "center",
padding: "1rem",
position: "absolute",
textAlign: "center",
},
frame: {
height: "100%",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
position: "relative",
width: "100%",
},
message: { margin: 0 },
retry: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
},
} as const;
/**
* One disposable display mount attempt.
*
* Its container is never reused by another attempt. A dynamic import cannot be aborted, so this is
* what makes a late result harmless: cleanup detaches this whole attempt; if the import later
* resolves, it mounts only into that detached element and is immediately unmounted. A failed mount
* may have written partial DOM before throwing, and the generic loader intentionally leaves that to
* its host, so this boundary always clears the container before exposing its fixed failure state.
*/
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, onRetry, principal } = props;
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
let cancelled = false;
let failed = false;
let listening = true;
let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined;
setStatus("loading");
const stopListening = (): void => {
if (!listening) {
return;
}
listening = false;
window.removeEventListener("error", handlePageError);
window.removeEventListener("unhandledrejection", handleUnhandledRejection);
};
const failSafely = (code: DisplayUiFailureCode): void => {
if (cancelled || failed) {
return;
}
failed = true;
stopListening();
container.replaceChildren();
console.error(`[ekklesia] display UI failure module=${descriptor.id} code=${code}`);
setStatus("failed");
const cleanup = mounted?.unmount();
if (cleanup) {
void cleanup.catch(() => undefined).finally(() => container.replaceChildren());
}
};
// A display route is a dedicated, single-module page. Treat any uncaught page exception or
// rejection as fatal for that display attempt: browser frameworks may report render failures
// after their mount call has already returned, beyond the generic loader's synchronous/awaited
// boundary. This is intentionally page-wide rather than error attribution; there is no robust
// cross-framework way to map a global exception back to a particular component tree.
function handlePageError(event: ErrorEvent): void {
// Element-targeted `error` events represent resource/media failures. Those are module state,
// not a crashed runtime, and must not blank a whole display.
if (event.target !== event.currentTarget) {
return;
}
event.preventDefault();
failSafely("DISPLAY_UI_RUNTIME_ERROR");
}
function handleUnhandledRejection(event: PromiseRejectionEvent): void {
event.preventDefault();
failSafely("DISPLAY_UI_UNHANDLED_REJECTION");
}
window.addEventListener("error", handlePageError);
window.addEventListener("unhandledrejection", handleUnhandledRejection);
void (async () => {
try {
const instance = await loadModuleUi({
container,
context: createModuleUiContext({ config, descriptor, principal }),
...(importModule ? { importModule } : {}),
source: {
moduleId: descriptor.id,
url: moduleUiSourceUrl(config, descriptor),
version: descriptor.version,
},
});
if (cancelled || failed) {
await instance.unmount().catch(() => undefined);
container.replaceChildren();
return;
}
mounted = instance;
setStatus("ready");
} catch (error) {
failSafely(loadFailureCode(error));
}
})();
return () => {
cancelled = true;
stopListening();
const cleanup = mounted?.unmount();
if (cleanup) {
void cleanup.catch(() => undefined).finally(() => container.replaceChildren());
} else {
container.replaceChildren();
}
};
}, [config, descriptor, importModule, principal]);
return (
<section aria-live="polite" data-testid="display-frame" style={frameStyles.frame}>
<div
data-testid="display-module-container"
ref={containerRef}
style={frameStyles.container}
/>
{status === "loading" ? (
<div data-testid="display-loading" style={frameStyles.fallback}>
<p style={frameStyles.message}>Loading display…</p>
</div>
) : undefined}
{status === "failed" ? (
<div data-testid="display-error" style={frameStyles.fallback}>
<p style={frameStyles.message}>This display could not be loaded.</p>
<button
data-testid="display-retry"
onClick={onRetry}
style={frameStyles.retry}
type="button"
>
Retry
</button>
</div>
) : undefined}
</section>
);
}
/** Full-viewport module frame with a fresh container for every manual retry. */
export function DisplayUiFrame(props: DisplayUiFrameProps): ReactElement {
const [attempt, setAttempt] = useState(0);
return (
<DisplayUiAttempt
{...props}
key={attempt}
onRetry={() => setAttempt((current) => current + 1)}
/>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js";
declare const __EKKLESIA_API_BASE_URL__: string;
const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
};
const container = document.getElementById("ekklesia-display");
if (!container) {
throw new Error("The Ekklesia display host requires a #ekklesia-display element.");
}
let moduleId: string | undefined;
try {
moduleId = parseDisplayRoute(window.location.pathname);
} catch {
moduleId = undefined;
}
createRoot(container).render(
moduleId ? (
<DisplayApp config={config} moduleId={moduleId} />
) : (
<div
data-testid="display-route-invalid"
style={{
alignItems: "center",
color: "#57534e",
display: "flex",
fontFamily: "system-ui, sans-serif",
height: "100%",
justifyContent: "center",
minHeight: "100dvh",
padding: "1rem",
textAlign: "center",
}}
>
This display is unavailable.
</div>
),
);
+201 -2
View File
@@ -35,6 +35,12 @@ export type HostEventSourceFactory = (url: string, options: HostEventSourceOptio
* code for a consumer that does not exist. */
const PRINCIPAL_PATH = "/api/v1/principal";
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
const MODULE_DISPLAYS_PATH = "/api/v1/module-displays";
const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const MODULE_ID_MAX_LENGTH = 64;
const SEMANTIC_VERSION_PATTERN =
/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/u;
export type HostProtocolErrorCode =
/** The caller is not signed in, or lacks `core.modules.ui.read`. */
@@ -46,7 +52,13 @@ export type HostProtocolErrorCode =
/** A module UI asked for something outside its own API root. */
| "MODULE_API_PATH_INVALID"
/** A module UI asked for a live channel outside the public local-channel grammar. */
| "MODULE_REALTIME_CHANNEL_INVALID";
| "MODULE_REALTIME_CHANNEL_INVALID"
/** The browser was not opened at one exact, valid display route. */
| "DISPLAY_ROUTE_INVALID"
/** The requested module does not currently publish a display. */
| "DISPLAY_NOT_AVAILABLE"
/** The signed-in principal may not mount the requested display. */
| "DISPLAY_ACCESS_DENIED";
const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
@@ -54,6 +66,9 @@ const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.fr
HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.",
MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.",
MODULE_REALTIME_CHANNEL_INVALID: "A module asked for an invalid live channel.",
DISPLAY_ACCESS_DENIED: "You do not have access to this display.",
DISPLAY_NOT_AVAILABLE: "This display is not available.",
DISPLAY_ROUTE_INVALID: "This is not a valid display address.",
});
/**
@@ -103,6 +118,23 @@ function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function hasExactKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
if (!isRecord(value)) {
return false;
}
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function isModuleId(value: string): boolean {
return value.length <= MODULE_ID_MAX_LENGTH && MODULE_ID_PATTERN.test(value);
}
function isSemanticVersion(value: string): boolean {
return SEMANTIC_VERSION_PATTERN.test(value);
}
function nestedString(value: unknown, key: string): string | undefined {
return isRecord(value) ? nonEmptyString(value[key]) : undefined;
}
@@ -129,6 +161,26 @@ function rootRelativePath(value: unknown, key: string): string | undefined {
) {
return undefined;
}
const segments = candidate.slice(1).split("/");
if (segments.some((segment) => segment.length === 0)) {
return undefined;
}
try {
for (const segment of segments) {
const decoded = decodeURIComponent(segment);
if (
decoded === "." ||
decoded === ".." ||
decoded.includes("/") ||
decoded.includes("\\") ||
decoded.includes("\0")
) {
return undefined;
}
}
} catch {
return undefined;
}
return candidate;
}
@@ -166,6 +218,92 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
});
}
/**
* The display host's one-module response.
*
* The wire names the artifact `display`, not `ui`, so a server cannot accidentally hand the
* Dashboard artifact to the full-viewport host and still pass validation. Once validated, the
* result deliberately narrows to the same generic mount descriptor every host adapter already
* consumes: the generic loader neither knows nor needs to know which outer shell selected it.
*/
export interface HostModuleDisplayResponse {
readonly module: HostModuleUiDescriptor;
}
/** Strictly validates the display descriptor response, including every nested URL container. */
export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayResponse {
if (!hasExactKeys(payload, ["module"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const entry = payload.module;
if (
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
!hasExactKeys(entry.api, ["baseUrl"]) ||
!hasExactKeys(entry.display, ["url"]) ||
!hasExactKeys(entry.realtime, ["baseUrl"])
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const id = nonEmptyString(entry.id);
const version = nonEmptyString(entry.version);
const apiBaseUrl = rootRelativePath(entry.api, "baseUrl");
const uiUrl = rootRelativePath(entry.display, "url");
const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl");
const permissions = entry.permissions;
if (
id === undefined ||
!isModuleId(id) ||
version === undefined ||
!isSemanticVersion(version) ||
apiBaseUrl === undefined ||
uiUrl === undefined ||
realtimeBaseUrl === undefined ||
!Array.isArray(permissions) ||
permissions.some((permission) => typeof permission !== "string" || permission.length === 0) ||
new Set(permissions).size !== permissions.length
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
module: Object.freeze({
apiBaseUrl,
id,
permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
uiUrl,
version,
}),
});
}
/** Reads one exact `/display/<module-id>` pathname without accepting a trailing path or traversal. */
export function parseDisplayRoute(pathname: string): string {
const match = /^\/display\/([^/]+)$/u.exec(pathname);
if (!match?.[1]) {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
let moduleId: string;
try {
moduleId = decodeURIComponent(match[1]);
} catch {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
if (!isModuleId(moduleId)) {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
return moduleId;
}
/** The only platform path a display browser builds, from a module id already validated above. */
export function moduleDisplayDescriptorPath(moduleId: string): string {
if (!isModuleId(moduleId)) {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
return `${MODULE_DISPLAYS_PATH}/${encodeURIComponent(moduleId)}`;
}
/**
* One module as the Dashboard presents it.
*
@@ -271,6 +409,10 @@ async function requestPlatformJson(
config: EkklesiaHostConfig,
rootRelativePath: string,
signal?: AbortSignal,
statusCodes: {
readonly forbidden?: HostProtocolErrorCode;
readonly notFound?: HostProtocolErrorCode;
} = {},
): Promise<unknown> {
const performRequest = config.fetch ?? fetch;
let response: Response;
@@ -283,9 +425,15 @@ async function requestPlatformJson(
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
if (response.status === 401 || response.status === 403) {
if (response.status === 401) {
throw new HostProtocolError("HOST_UNAUTHENTICATED");
}
if (response.status === 403) {
throw new HostProtocolError(statusCodes.forbidden ?? "HOST_UNAUTHENTICATED");
}
if (response.status === 404 && statusCodes.notFound) {
throw new HostProtocolError(statusCodes.notFound);
}
if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED");
}
@@ -316,6 +464,57 @@ export async function loadDashboardNavigation(
);
}
/**
* Resolves the one display the outer host route named.
*
* The module id chooses only this host-owned descriptor resource. It never chooses a file: the
* response supplies the validated display artifact URL, API root, and realtime root, and the
* strict parser above rejects anything absolute, protocol-relative, or structurally ambiguous.
*/
export async function loadModuleDisplay(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<HostModuleUiDescriptor> {
const payload = await requestPlatformJson(config, moduleDisplayDescriptorPath(moduleId), signal, {
forbidden: "DISPLAY_ACCESS_DENIED",
notFound: "DISPLAY_NOT_AVAILABLE",
});
const descriptor = parseHostModuleDisplay(payload).module;
if (descriptor.id !== moduleId) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return descriptor;
}
export type DisplayHostBootstrap =
| {
readonly status: "signed-out";
readonly principal: Extract<ModuleUiPrincipal, { readonly kind: "anonymous" }>;
}
| {
readonly status: "ready";
readonly descriptor: HostModuleUiDescriptor;
readonly principal: Extract<ModuleUiPrincipal, { readonly kind: "authenticated" }>;
};
/** Principal first, then protected display metadata; anonymous browsers never probe a module id. */
export async function bootstrapDisplayHost(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<DisplayHostBootstrap> {
const principal = await loadHostPrincipal(config, signal);
if (principal.kind === "anonymous") {
return Object.freeze({ principal, status: "signed-out" });
}
return Object.freeze({
descriptor: await loadModuleDisplay(config, moduleId, signal),
principal,
status: "ready",
});
}
export interface HostBootstrap {
readonly principal: ModuleUiPrincipal;
readonly navigation: DashboardNavigation;
+7
View File
@@ -11,19 +11,26 @@
*/
export {
bootstrapHost,
bootstrapDisplayHost,
type DashboardNavigation,
type DashboardNavigationEntry,
type DashboardNavigationGroup,
type DisplayHostBootstrap,
type EkklesiaHostConfig,
type HostBootstrap,
type HostEventSourceFactory,
type HostEventSourceOptions,
type HostModuleDisplayResponse,
type HostModuleUiDescriptor,
HostProtocolError,
type HostProtocolErrorCode,
loadDashboardNavigation,
loadHostPrincipal,
loadModuleDisplay,
moduleDisplayDescriptorPath,
parseDashboardNavigation,
parseDisplayRoute,
parseHostModuleDisplay,
parseHostPrincipal,
resolveApiUrl,
} from "./host-protocol.js";