feat: add display kiosk host
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user