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