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
+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;