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 @@
<!doctype html>
<html lang="en" style="height: 100%; margin: 0; overflow: hidden">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<title>Display</title>
</head>
<body style="height: 100%; margin: 0; overflow: hidden">
<!-- Structural canvas only. The module owns every visible pixel after mounting. -->
<div id="ekklesia-display" style="height: 100%; min-height: 100dvh; overflow: hidden"></div>
<!-- Fixed host asset, never a module-derived path and never an inline bootstrap script. -->
<script src="/assets/ekklesia-display/app.js" type="module"></script>
</body>
</html>
+4
View File
@@ -10,6 +10,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./display-assets": {
"types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js"
}
},
"scripts": {
+10 -2
View File
@@ -41,14 +41,17 @@ const buildOptions = {
// React reads this at module scope and a browser has no `process`.
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
},
entryPoints: [path.join(packageDirectory, "src", "browser", "main.tsx")],
entryPoints: {
app: path.join(packageDirectory, "src", "browser", "main.tsx"),
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
},
format: "esm",
jsx: "automatic",
logLevel: "info",
// Unminified, matching the module UI build: an operator is expected to be able to read what their
// installation actually serves, and nothing else in this repository minifies.
minify: false,
outfile: path.join(outputDirectory, "app.js"),
outdir: outputDirectory,
platform: "browser",
sourcemap: true,
// Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree.
@@ -57,7 +60,12 @@ const buildOptions = {
await rm(outputDirectory, { force: true, recursive: true });
await mkdir(outputDirectory, { recursive: true });
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
await copyFile(
path.join(packageDirectory, "display.html"),
path.join(outputDirectory, "display", "index.html"),
);
if (serve) {
const context = await esbuild.context(buildOptions);
+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";
+710
View File
@@ -0,0 +1,710 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act, Component, type ReactElement, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { DisplayApp } from "../src/display/DisplayApp.js";
import type {
EkklesiaHostConfig,
HostEventSourceFactory,
HostEventSourceOptions,
} from "../src/host-protocol.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
consoleError.mockRestore();
});
function descriptorPayload(id = "announcements", version = "1.2.0") {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
id,
permissions: [`${id}.slideshow.view`],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
version,
},
};
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
interface PlatformOptions {
readonly descriptorStatus?: number;
readonly principal?: unknown;
}
function platform(options: PlatformOptions = {}) {
const calls: Array<{ readonly init: RequestInit | undefined; readonly url: string }> = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
if (url.endsWith("/api/v1/principal")) {
return json(options.principal ?? { kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/module-displays/announcements")) {
return json(descriptorPayload(), options.descriptorStatus ?? 200);
}
if (url.endsWith("/api/v1/modules/announcements/slides")) {
return json([{ id: "slide-1" }]);
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { calls, fetch };
}
class FakeEventSource {
readonly CLOSED = 2 as const;
readonly CONNECTING = 0 as const;
readonly OPEN = 1 as const;
readonly listeners = new Map<string, Set<EventListener>>();
closeCalls = 0;
readyState: number = this.CONNECTING;
addEventListener(type: string, listener: EventListener): void {
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: EventListener): void {
this.listeners.get(type)?.delete(listener);
}
close(): void {
this.closeCalls += 1;
this.readyState = this.CLOSED;
}
emit(type: string, data?: string): void {
if (type === "open") {
this.readyState = this.OPEN;
}
const event = Object.freeze({ data, type }) as unknown as Event;
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
function eventSources() {
const calls: Array<{
readonly options: HostEventSourceOptions;
readonly source: FakeEventSource;
readonly url: string;
}> = [];
const factory = ((url: string, options: HostEventSourceOptions): EventSource => {
const source = new FakeEventSource();
calls.push({ options, source, url });
return source as unknown as EventSource;
}) as HostEventSourceFactory;
return { calls, factory };
}
function artifact(
id = "announcements",
version = "1.2.0",
options: {
readonly cleanup?: () => void;
readonly mount?: (container: Element, context: ModuleUiHostContext) => undefined | (() => void);
} = {},
) {
return {
default: {
id,
mount(element: Element, context: ModuleUiHostContext) {
if (options.mount) {
return options.mount(element, context);
}
element.textContent = `${context.moduleId}:display`;
return () => {
options.cleanup?.();
element.replaceChildren();
};
},
version,
},
};
}
async function render(
config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>,
moduleId = "announcements",
): Promise<void> {
await act(async () => {
root.render(
<DisplayApp
config={config}
{...(importModule ? { importModule } : {})}
moduleId={moduleId}
/>,
);
});
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
}
describe("display host — full-viewport boundary", () => {
it("mounts only the display artifact with no Dashboard chrome", async () => {
const server = platform();
const imported: string[] = [];
await render({ apiBaseUrl, fetch: server.fetch }, async (url) => {
imported.push(url);
return artifact();
});
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`]);
expect(query("display-module-container")?.textContent).toBe("announcements:display");
expect(container.querySelector("header")).toBeNull();
expect(container.querySelector("nav")).toBeNull();
expect(container.querySelector("aside")).toBeNull();
expect(container.textContent).not.toContain("Ekklesia");
expect(query("display-host")?.style.height).toBe("100%");
expect(query("display-host")?.style.minHeight).toBe("100dvh");
});
it("constructs the same browser-safe API, principal, permission, and realtime context", async () => {
const server = platform();
const live = eventSources();
let seenContext: ModuleUiHostContext | undefined;
await render({ apiBaseUrl, eventSourceFactory: live.factory, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", {
mount(element, context) {
seenContext = context;
void context.api.request("slides");
const subscription = context.realtime.subscribe("state", {});
element.textContent = context.permissions.has("announcements.slideshow.view")
? "permitted"
: "denied";
return () => subscription.close();
},
}),
);
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
"api",
"moduleId",
"permissions",
"principal",
"realtime",
]);
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(query("display-module-container")?.textContent).toBe("permitted");
expect(
server.calls.some((call) => call.url.endsWith("/api/v1/modules/announcements/slides")),
).toBe(true);
expect(live.calls[0]).toMatchObject({
options: { withCredentials: true },
url: `${apiBaseUrl}/api/v1/module-realtime/announcements/state`,
});
});
it("uses a genuine dynamic import and renders an Announcements snapshot with its direct object URL unchanged", async () => {
const server = platform();
const live = eventSources();
const requestedArtifacts: string[] = [];
const source = `
export default {
id: "announcements",
version: "1.2.0",
mount(container, context) {
const image = document.createElement("img");
image.setAttribute("data-testid", "announcement-slide");
container.append(image);
const subscription = context.realtime.subscribe("state", {
onSnapshot(state) {
image.src = state.mode === "on" ? state.slides[state.currentSlide].url : "";
},
onUpdate(state) {
image.src = state.mode === "on" ? state.slides[state.currentSlide].url : "";
},
});
return () => { subscription.close(); container.replaceChildren(); };
},
};
`;
await render(
{ apiBaseUrl, eventSourceFactory: live.factory, fetch: server.fetch },
async (url) => {
requestedArtifacts.push(url);
return import(moduleUrl(source));
},
);
const deliveryUrl =
"https://objects.example.test/ekklesia-announcements-assets/slides%2Fwelcome.jpg?X-Amz-Signature=opaque";
await act(async () => {
live.calls[0]?.source.emit("open");
live.calls[0]?.source.emit(
"snapshot",
JSON.stringify({
payload: {
currentSlide: 0,
mode: "on",
slideSeconds: 7,
slides: [{ url: deliveryUrl }],
},
sequence: 1,
}),
);
});
expect(requestedArtifacts).toEqual([
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
]);
expect(container.querySelector('[data-testid="announcement-slide"]')?.getAttribute("src")).toBe(
deliveryUrl,
);
expect(container.innerHTML).not.toContain("socket.io");
expect(container.innerHTML).not.toContain("mqtt");
});
it("keeps one Announcements-shaped module's management and slideshow roots distinct", async () => {
const live = eventSources();
const requestedArtifacts: string[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return json({
groups: [],
ungrouped: [
{
module: {
api: { baseUrl: "/api/v1/modules/announcements" },
id: "announcements",
permissions: ["announcements.slideshow.view"],
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
ui: { url: "/api/v1/module-ui/announcements/1.2.0" },
version: "1.2.0",
},
name: "Announcements",
},
],
});
}
if (url.endsWith("/api/v1/module-displays/announcements")) {
return json(descriptorPayload());
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
const config = { apiBaseUrl, eventSourceFactory: live.factory, fetch };
const importer = async (url: string): Promise<unknown> => {
requestedArtifacts.push(url);
if (!url.endsWith("/display")) {
return artifact("announcements", "1.2.0", {
mount(element) {
element.textContent = "announcement management";
return () => element.replaceChildren();
},
});
}
return artifact("announcements", "1.2.0", {
mount(element, context) {
const image = document.createElement("img");
image.dataset.testid = "both-role-slide";
element.append(image);
const subscription = context.realtime.subscribe<{
readonly currentSlide: number;
readonly mode: "off" | "on";
readonly slides: readonly { readonly url: string }[];
}>("state", {
onSnapshot(state) {
image.src = state.mode === "on" ? (state.slides[state.currentSlide]?.url ?? "") : "";
},
});
return () => {
subscription.close();
element.replaceChildren();
};
},
});
};
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
expect(container.textContent).toContain("announcement management");
expect(container.querySelector("nav")).not.toBeNull();
expect(requestedArtifacts).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`]);
await act(async () => root.unmount());
root = createRoot(container);
await act(async () =>
root.render(<DisplayApp config={config} importModule={importer} moduleId="announcements" />),
);
const deliveryUrl =
"https://objects.example.test/module-assets/slide.jpg?X-Amz-Signature=opaque";
await act(async () => {
live.calls[0]?.source.emit("open");
live.calls[0]?.source.emit(
"snapshot",
JSON.stringify({
payload: { currentSlide: 0, mode: "on", slides: [{ url: deliveryUrl }] },
sequence: 1,
}),
);
});
expect(requestedArtifacts).toEqual([
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`,
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
]);
expect(container.querySelector('[data-testid="both-role-slide"]')?.getAttribute("src")).toBe(
deliveryUrl,
);
expect(container.querySelector("nav")).toBeNull();
expect(container.querySelector("header")).toBeNull();
expect(JSON.stringify(requestedArtifacts)).not.toMatch(/socket\.io|mqtt|10\.|localhost/u);
});
});
describe("display host — authentication and controlled failures", () => {
it("mounts nothing and never requests a descriptor for an anonymous browser", async () => {
const server = platform({ principal: { kind: "anonymous" } });
const importer = vi.fn(async () => artifact());
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-host-signed-out")).not.toBeNull();
expect(importer).not.toHaveBeenCalled();
expect(server.calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
it("does not import protected display code when the descriptor is denied or unavailable", async () => {
for (const status of [403, 404]) {
const server = platform({ descriptorStatus: status });
const importer = vi.fn(async () => artifact());
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-host-unavailable"), String(status)).not.toBeNull();
expect(importer, String(status)).not.toHaveBeenCalled();
}
});
it("clears partial mount DOM, renders no caught detail, and retries in a fresh container", async () => {
const server = platform();
let attempts = 0;
const containers: Element[] = [];
const importer = vi.fn(async () => {
attempts += 1;
return artifact("announcements", "1.2.0", {
mount(element) {
containers.push(element);
if (attempts === 1) {
element.textContent = "secret at /srv/modules/announcements";
throw new Error("mount exploded with token=secret");
}
element.textContent = "recovered";
return () => element.replaceChildren();
},
});
});
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-error")).not.toBeNull();
expect(container.textContent).not.toContain("/srv/");
expect(container.textContent).not.toContain("secret");
await act(async () => query("display-retry")?.click());
expect(query("display-error")).toBeNull();
expect(query("display-module-container")?.textContent).toBe("recovered");
expect(containers).toHaveLength(2);
expect(containers[0]).not.toBe(containers[1]);
expect(containers[0]?.isConnected).toBe(false);
expect(consoleError).toHaveBeenCalledWith(
"[ekklesia] display UI failure module=announcements code=MODULE_UI_MOUNT_FAILED",
);
});
it.each([
"error",
"unhandledrejection",
] as const)("turns a post-mount page %s into one sanitized failure with manual retry", async (eventType) => {
const server = platform();
const cleanups: Element[] = [];
const mountedInto: Element[] = [];
const importer = vi.fn(async () =>
artifact("announcements", "1.2.0", {
mount(element) {
mountedInto.push(element);
element.textContent = "private render at /srv/modules/announcements";
return () => {
cleanups.push(element);
element.replaceChildren();
};
},
}),
);
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-module-container")?.textContent).toContain("/srv/modules");
consoleError.mockClear();
await act(async () => {
const event =
eventType === "error"
? new ErrorEvent("error", {
error: new Error("token=secret-render-error"),
message: "token=secret-render-error",
})
: new Event("unhandledrejection");
window.dispatchEvent(event);
// A second signal from the same failed render must not run cleanup or transition twice.
window.dispatchEvent(new Event(eventType));
});
expect(query("display-error")).not.toBeNull();
expect(query("display-module-container")?.textContent).toBe("");
expect(container.textContent).toBe("This display could not be loaded.Retry");
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
expect(importer).toHaveBeenCalledTimes(1);
expect(cleanups).toEqual([mountedInto[0]]);
expect(consoleError).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
`[ekklesia] display UI failure module=announcements code=${
eventType === "error" ? "DISPLAY_UI_RUNTIME_ERROR" : "DISPLAY_UI_UNHANDLED_REJECTION"
}`,
);
await act(async () => query("display-retry")?.click());
expect(importer).toHaveBeenCalledTimes(2);
expect(query("display-error")).toBeNull();
expect(query("display-module-container")?.textContent).toContain("/srv/modules");
expect(mountedInto[0]?.isConnected).toBe(false);
expect(mountedInto[1]?.isConnected).toBe(true);
});
it("catches a React render failure reported after the artifact mount has returned", async () => {
const server = platform();
const cleanup = vi.fn();
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", {
mount(element) {
const moduleRoot = createRoot(element);
class ReportRenderFailure extends Component<{ readonly children: ReactNode }> {
override componentDidCatch(error: unknown): void {
// React's browser default reports an uncaught render through the page's global error
// facility. This test boundary makes that report deterministic under happy-dom,
// whose test runner otherwise intercepts the global exception first.
window.dispatchEvent(
new ErrorEvent("error", {
cancelable: true,
error,
message: error instanceof Error ? error.message : "display render failure",
}),
);
}
override render(): ReactNode {
return this.props.children;
}
}
const BrokenDisplay = (): ReactElement => {
throw new Error("late component secret at /srv/modules/announcements");
};
moduleRoot.render(
<ReportRenderFailure>
<BrokenDisplay />
</ReportRenderFailure>,
);
return () => {
cleanup();
moduleRoot.unmount();
};
},
}),
);
await act(async () => undefined);
expect(query("display-error")).not.toBeNull();
expect(container.textContent).toBe("This display could not be loaded.Retry");
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("does not treat a module media/resource error as a crashed display runtime", async () => {
const server = platform();
const cleanup = vi.fn();
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", {
mount(element) {
const image = document.createElement("img");
image.dataset.testid = "display-media";
element.append(image);
return () => cleanup();
},
}),
);
await act(async () => {
container.querySelector('[data-testid="display-media"]')?.dispatchEvent(
new ErrorEvent("error", {
bubbles: true,
cancelable: true,
message: "a signed media URL expired",
}),
);
});
expect(query("display-error")).toBeNull();
expect(query("display-media")).not.toBeNull();
expect(cleanup).not.toHaveBeenCalled();
});
it("removes the page failure listeners when a display attempt is torn down", async () => {
const server = platform();
const cleanup = vi.fn();
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", { cleanup }),
);
await act(async () => root.render(null));
expect(cleanup).toHaveBeenCalledTimes(1);
consoleError.mockClear();
window.dispatchEvent(new ErrorEvent("error", { message: "after teardown" }));
window.dispatchEvent(new Event("unhandledrejection"));
expect(cleanup).toHaveBeenCalledTimes(1);
expect(consoleError).not.toHaveBeenCalled();
});
});
describe("display host — independent instances and stale work", () => {
it("mounts and unmounts two browser instances independently", async () => {
const server = platform();
const cleanups: string[] = [];
const importer = async () =>
artifact("announcements", "1.2.0", {
cleanup: () => cleanups.push("cleanup"),
});
const config = { apiBaseUrl, fetch: server.fetch };
await act(async () => {
root.render(
<>
<DisplayApp
config={config}
importModule={importer}
key="first"
moduleId="announcements"
/>
<DisplayApp
config={config}
importModule={importer}
key="second"
moduleId="announcements"
/>
</>,
);
});
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(2);
await act(async () => {
root.render(
<DisplayApp
config={config}
importModule={importer}
key="second"
moduleId="announcements"
/>,
);
});
expect(cleanups).toHaveLength(1);
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(1);
expect(query("display-module-container")?.textContent).toBe("announcements:display");
});
it("discards a late import into its detached container without disturbing the newer route", async () => {
let releaseAnnouncements: (() => void) | undefined;
const pendingAnnouncements = new Promise<void>((resolve) => {
releaseAnnouncements = resolve;
});
const mountedInto: Element[] = [];
const staleUnmounts: Element[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/module-displays/calendar")) {
return json(descriptorPayload("calendar", "2.0.0"));
}
return json(descriptorPayload());
}) as unknown as typeof globalThis.fetch;
const config = { apiBaseUrl, fetch };
const importer = async (url: string): Promise<unknown> => {
const isCalendar = url.includes("/calendar/");
if (!isCalendar) {
await pendingAnnouncements;
}
return artifact(isCalendar ? "calendar" : "announcements", isCalendar ? "2.0.0" : "1.2.0", {
mount(element) {
mountedInto.push(element);
element.textContent = isCalendar ? "calendar-live" : "announcements-stale";
return () => {
if (!isCalendar) {
staleUnmounts.push(element);
}
element.replaceChildren();
};
},
});
};
await render(config, importer);
await render(config, importer, "calendar");
expect(query("display-module-container")?.textContent).toBe("calendar-live");
const liveContainer = query("display-module-container");
await act(async () => {
releaseAnnouncements?.();
await pendingAnnouncements;
});
expect(query("display-module-container")).toBe(liveContainer);
expect(liveContainer?.textContent).toBe("calendar-live");
const staleContainer = mountedInto.find((element) => element !== liveContainer);
expect(staleContainer?.isConnected).toBe(false);
expect(staleUnmounts).toEqual([staleContainer]);
});
});
@@ -0,0 +1,48 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { displayHostAssetUrls } from "../src/display-assets.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("display host asset locator", () => {
it("exports only frozen immutable href strings for the two exact built files", () => {
expect(Object.isFrozen(displayHostAssetUrls)).toBe(true);
expect(displayHostAssetUrls).toEqual({
html: new URL("dist/browser/display/index.html", `file://${packageDirectory}/`).href,
script: new URL("dist/browser/display/app.js", `file://${packageDirectory}/`).href,
});
expect(typeof displayHostAssetUrls.html).toBe("string");
expect(typeof displayHostAssetUrls.script).toBe("string");
});
it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./display-assets"]).toEqual({
import: "./dist/display-assets.js",
types: "./dist/display-assets.d.ts",
});
const rootSource = await readFile(path.join(packageDirectory, "src/index.ts"), "utf8");
expect(rootSource).not.toContain("displayHostAssetUrls");
expect(rootSource).not.toContain("display-assets");
});
it("uses a fixed root script asset and only structural full-viewport page styles", async () => {
const html = await readFile(path.join(packageDirectory, "display.html"), "utf8");
expect(html).toContain('id="ekklesia-display"');
expect(html).toContain('src="/assets/ekklesia-display/app.js"');
expect(html).toContain('name="viewport"');
expect(html).toContain("height: 100%");
expect(html).toContain("margin: 0");
expect(html).not.toContain("<nav");
expect(html).not.toContain("<header");
expect(html).not.toContain("<aside");
expect(html).not.toContain("fullscreen");
});
});
@@ -0,0 +1,187 @@
import { describe, expect, it, vi } from "vitest";
import {
bootstrapDisplayHost,
loadModuleDisplay,
moduleDisplayDescriptorPath,
parseDisplayRoute,
parseHostModuleDisplay,
} from "../src/host-protocol.js";
const apiBaseUrl = "https://ekklesia.example.test";
function displayPayload(overrides: Record<string, unknown> = {}): {
readonly module: Record<string, unknown>;
} {
return {
module: {
api: { baseUrl: "/api/v1/modules/announcements" },
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
id: "announcements",
permissions: ["announcements.slideshow.view"],
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
version: "1.2.0",
...overrides,
},
};
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
describe("display host protocol — strict descriptor", () => {
it("parses the exact display role into the generic mount descriptor", () => {
const parsed = parseHostModuleDisplay(displayPayload());
expect(parsed).toEqual({
module: {
apiBaseUrl: "/api/v1/modules/announcements",
id: "announcements",
permissions: ["announcements.slideshow.view"],
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
uiUrl: "/api/v1/module-ui/announcements/1.2.0/display",
version: "1.2.0",
},
});
expect(Object.isFrozen(parsed)).toBe(true);
expect(Object.isFrozen(parsed.module)).toBe(true);
expect(Object.isFrozen(parsed.module.permissions)).toBe(true);
});
it("rejects malformed identities, shapes, permissions, and any non-root-relative URL", () => {
const invalid: unknown[] = [
{},
{ ...displayPayload(), extra: true },
displayPayload({ id: "Announcements" }),
displayPayload({ id: "announcements/other" }),
displayPayload({ id: "a".repeat(65) }),
displayPayload({ version: "v1.2.0" }),
displayPayload({ version: "01.2.0" }),
displayPayload({ permissions: "announcements.slideshow.view" }),
displayPayload({ permissions: [""] }),
displayPayload({
permissions: ["announcements.slideshow.view", "announcements.slideshow.view"],
}),
displayPayload({ api: { baseUrl: "https://evil.example.test/api" } }),
displayPayload({ display: { url: "//evil.example.test/display.js" } }),
displayPayload({ display: { url: "/display.js?token=secret" } }),
displayPayload({ display: { url: "/api/%2e%2e/grants" } }),
displayPayload({ display: { url: "/api/%2Fgrants" } }),
displayPayload({ display: { url: "/api//display.js" } }),
displayPayload({ realtime: { baseUrl: "/\\evil.example.test/live" } }),
displayPayload({ display: { url: "/display.js", extra: true } }),
];
for (const payload of invalid) {
expect(() => parseHostModuleDisplay(payload), JSON.stringify(payload)).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
}
});
it("does not accept a Dashboard artifact in place of a display artifact", () => {
const module = (displayPayload() as { module: Record<string, unknown> }).module;
const { display: _display, ...withoutDisplay } = module;
expect(() =>
parseHostModuleDisplay({
module: {
...withoutDisplay,
ui: { url: "/api/v1/module-ui/announcements/1.2.0" },
},
}),
).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }));
});
});
describe("display host protocol — outer route", () => {
it("accepts one exact stable version-independent module route", () => {
expect(parseDisplayRoute("/display/announcements")).toBe("announcements");
expect(moduleDisplayDescriptorPath("announcements")).toBe(
"/api/v1/module-displays/announcements",
);
});
it("rejects extra segments, trailing slashes, malformed escapes, and path escapes", () => {
for (const pathname of [
"/display",
"/display/",
"/display/announcements/",
"/display/announcements/settings",
"/display/%2fapi",
"/display/..",
"/display/%",
"/modules/announcements/display",
]) {
expect(() => parseDisplayRoute(pathname), pathname).toThrowError(
expect.objectContaining({ code: "DISPLAY_ROUTE_INVALID" }),
);
}
});
});
describe("display host protocol — authenticated bootstrap", () => {
it("loads principal first, then the exact descriptor, with host-owned credentials", async () => {
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
return url.endsWith("/api/v1/principal")
? json({ kind: "authenticated", userId: "user-1" })
: json(displayPayload());
}) as unknown as typeof globalThis.fetch;
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
expect(bootstrap.status).toBe("ready");
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/module-displays/announcements`,
]);
expect(calls.every((call) => call.init?.credentials === "include")).toBe(true);
});
it("does not probe a display descriptor for an anonymous principal", async () => {
const fetch = vi.fn(async () =>
json({ kind: "anonymous" }),
) as unknown as typeof globalThis.fetch;
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
expect(bootstrap).toEqual({ principal: { kind: "anonymous" }, status: "signed-out" });
expect(fetch).toHaveBeenCalledTimes(1);
});
it("distinguishes denied, unavailable, unauthenticated, and infrastructure failures safely", async () => {
for (const [status, code] of [
[401, "HOST_UNAUTHENTICATED"],
[403, "DISPLAY_ACCESS_DENIED"],
[404, "DISPLAY_NOT_AVAILABLE"],
[503, "HOST_REQUEST_FAILED"],
] as const) {
const fetch = vi.fn(async () =>
json({ detail: "/srv/modules/announcements secret-token" }, status),
) as unknown as typeof globalThis.fetch;
const error = await loadModuleDisplay({ apiBaseUrl, fetch }, "announcements").catch(
(caught: unknown) => caught,
);
expect(error).toMatchObject({ code });
expect((error as Error).message).not.toContain("/srv/");
expect((error as Error).message).not.toContain("secret-token");
}
});
it("rejects a stale or misrouted descriptor whose identity differs from the requested display", async () => {
const fetch = vi.fn(async () =>
json(displayPayload({ id: "calendar" })),
) as unknown as typeof globalThis.fetch;
await expect(loadModuleDisplay({ apiBaseUrl, fetch }, "announcements")).rejects.toMatchObject({
code: "HOST_RESPONSE_INVALID",
});
});
});
+50
View File
@@ -170,3 +170,53 @@ describe("the shell builds no module knowledge into its bundle", () => {
expect(bundle).not.toContain("createObjectURL");
});
});
describe("the display host is a separate generic browser bundle", () => {
let bundle: string;
beforeAll(async () => {
const result = await build({
absWorkingDir: packageDirectory,
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"',
},
entryPoints: [path.join(sourceDirectory, "display", "main.tsx")],
format: "esm",
jsx: "automatic",
logLevel: "silent",
minify: false,
platform: "browser",
target: "es2022",
write: false,
});
bundle = result.outputFiles.map((file) => file.text).join("\n");
}, 60_000);
it("contains the generic loader's one dynamic import and no statically known module", () => {
expect([...bundle.matchAll(/\bimport\s*\(/gu)]).toHaveLength(1);
expect(bundle).toContain("(url) => import(url)");
expect(bundle).not.toContain("ekklesia.module.json");
expect(bundle).not.toContain("/api/v1/modules");
for (const moduleId of ["announcements", "calendar", "livestream", "signage"]) {
expect(bundle.toLowerCase()).not.toContain(moduleId);
}
});
it("knows only its host route resources, with no Dashboard application or transport coupling", () => {
expect(bundle).toContain('"/api/v1/principal"');
expect(bundle).toContain('"/api/v1/module-displays"');
expect(bundle).not.toContain('"/api/v1/dashboard/navigation"');
expect(bundle).not.toContain("Loading Ekklesia");
expect(bundle).not.toContain("Sign in to Ekklesia");
expect(bundle.toLowerCase()).not.toContain("socket.io");
expect(bundle.toLowerCase()).not.toContain("mqtt");
});
it("uses neither eval nor a blob URL to load display code", () => {
expect(bundle).not.toContain("new Function(");
expect(bundle).not.toMatch(/\beval\s*\(/u);
expect(bundle).not.toContain("createObjectURL");
});
});
+6 -1
View File
@@ -8,5 +8,10 @@
"sourceMap": true,
"types": []
},
"include": ["src/index.ts", "src/host-protocol.ts", "src/module-context.ts"]
"include": [
"src/index.ts",
"src/display-assets.ts",
"src/host-protocol.ts",
"src/module-context.ts"
]
}