Files
Ekklesia/surfaces/shell/src/host-protocol.ts
T
ekklesiaandClaude Opus 5 c444ff570b feat: let one module contribute a browser renderer to another's capability
The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.

Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.

Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.

A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.

The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.

The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.

The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 07:47:10 -05:00

837 lines
32 KiB
TypeScript

import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
/**
* How this shell reaches the Ekklesia API.
*
* `apiBaseUrl` is an absolute **origin** (`https://ekklesia.example.test`, `http://127.0.0.1:3000`)
* rather than something read from a global. The browser entry point supplies it — from a build-time
* value when the shell and the API are served from different origins in development, and from
* `window.location.origin` otherwise — so every function below is a pure function of its arguments
* and can be exercised without a DOM, a `window`, or a page. Nothing here reads a global.
*/
export interface EkklesiaHostConfig {
readonly apiBaseUrl: string;
/** Injected so tests can drive the real protocol against a real in-process app without a socket.
* Production composition leaves this unset and the platform `fetch` is used. */
readonly fetch?: typeof fetch;
/** Test/composition seam for the browser's native live-event client. Production leaves this
* unset and the platform `EventSource` constructor is used. This never enters module UI. */
readonly eventSourceFactory?: HostEventSourceFactory;
}
export interface HostEventSourceOptions {
readonly withCredentials: true;
}
export type HostEventSourceFactory = (url: string, options: HostEventSourceOptions) => EventSource;
/** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API
* URL — the server produces both and hands them over in the navigation view — so these two are the
* only URLs it knows, and they describe the platform itself rather than any module.
*
* `/api/v1/module-ui` is deliberately absent: that host-neutral inventory (ADR 0026) remains the
* endpoint a standalone host would consume, but Ekklesia's own shell needs the arrangement too, so
* it reads the navigation view instead. Keeping an unused client for it here would be speculative
* 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. */
| "HOST_UNAUTHENTICATED"
/** The caller is signed in but may not read the Dashboard resource. */
| "HOST_ACCESS_DENIED"
/** The API answered, but not with something this shell understands. */
| "HOST_RESPONSE_INVALID"
/** The API could not be reached, or failed. */
| "HOST_REQUEST_FAILED"
/** 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"
/** 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_ACCESS_DENIED: "You do not have access to the Ekklesia Dashboard.",
HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.",
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.",
});
/**
* Carries a stable code and a fixed, user-safe message — never a URL, a response body, a server
* path, or a caught cause — matching every other Ekklesia error boundary. The originating error is
* attached as `cause` for browser-console diagnostics and is never rendered.
*/
export class HostProtocolError extends Error {
readonly code: HostProtocolErrorCode;
constructor(code: HostProtocolErrorCode, options?: { readonly cause?: unknown }) {
super(errorMessages[code], options);
this.name = "HostProtocolError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
/**
* One module UI this host may mount, exactly as the server described it.
*
* This mirrors the server's `HostModuleUiDescriptor` (`apps/api/src/module-ui-inventory.ts`) rather
* than importing it: a surface may not depend on an app, and the type on either side of an HTTP
* boundary should be validated on arrival rather than assumed. `parseHostModuleUiInventory` below is
* that validation, and the end-to-end test feeds a real server response through it so the two
* declarations are proven to agree instead of merely looking similar.
*/
export interface HostModuleUiDescriptor {
readonly id: string;
readonly version: string;
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic loader
* unchanged; it never constructs, parses, or pattern-matches this URL. */
readonly uiUrl: string;
/** Server-produced root of this module's own HTTP API. */
readonly apiBaseUrl: string;
/** Server-produced root for this module's own live channels. */
readonly realtimeBaseUrl: string;
/** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
readonly permissions: readonly string[];
/** Renderers other modules contributed to capabilities this one provides (ADR 0041). The shell
* hands identity to the module and resolves the URL itself; the module never sees this URL. */
readonly contributions: readonly HostModuleContributionDescriptor[];
}
/** One contributed renderer this module may mount, exactly as the server described it. Mirrors the
* server's `HostModuleContributionDescriptor` rather than importing it, for the same reason the
* module descriptor does. */
export interface HostModuleContributionDescriptor {
readonly id: string;
readonly version: string;
readonly capability: string;
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic
* contribution loader unchanged. */
readonly url: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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;
}
/**
* Every URL in a descriptor must be a **root-relative** path, exactly as the server produces it.
*
* Enforced rather than assumed. The shell resolves each against the configured API origin, so an
* absolute or protocol-relative value arriving here would silently redirect a module's script import
* or its API calls to another origin. This does not defend against a compromised server — one could
* serve anything at the real URL — but it does pin the wire contract, so a proxy rewrite, a
* misconfiguration, or a future server-side bug fails loudly here instead of quietly changing where
* browser code is fetched from.
*/
function rootRelativePath(value: unknown, key: string): string | undefined {
const candidate = nestedString(value, key);
if (
candidate === undefined ||
!candidate.startsWith("/") ||
candidate.startsWith("//") ||
candidate.includes("\\") ||
candidate.includes("?") ||
candidate.includes("#")
) {
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;
}
/**
* Strict, total validation of the inventory document. Anything unexpected — a missing field, a
* non-array `modules`, a permission entry that is not a string — fails the whole parse rather than
* yielding a half-built descriptor, because a descriptor with a missing URL would surface much later
* as an inexplicable mount failure.
*/
function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
const id = nestedString(entry, "id");
const version = nestedString(entry, "version");
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
const realtimeBaseUrl = isRecord(entry) ? rootRelativePath(entry.realtime, "baseUrl") : undefined;
const permissions = isRecord(entry) ? entry.permissions : undefined;
if (
id === undefined ||
version === undefined ||
uiUrl === undefined ||
apiBaseUrl === undefined ||
realtimeBaseUrl === undefined ||
!Array.isArray(permissions) ||
permissions.some((permission) => typeof permission !== "string")
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
apiBaseUrl,
contributions: parseContributions(isRecord(entry) ? entry.contributions : undefined),
id,
permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
uiUrl,
version,
});
}
/**
* Strict, total validation of the contribution list.
*
* Every entry must carry an exact key set, a well-formed identity, and a root-relative URL that
* passes the same containment checks every other server-produced URL does — a contribution URL is a
* thing the browser will `import()`, so it gets no weaker treatment than the module artifact URL.
* Anything unexpected fails the whole parse rather than yielding a half-built list, because a
* descriptor with a missing URL surfaces much later as an inexplicable mount failure.
*/
function parseContributions(value: unknown): readonly HostModuleContributionDescriptor[] {
if (!Array.isArray(value)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const descriptors = value.map((entry) => {
if (!hasExactKeys(entry, ["capability", "id", "url", "version"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const id = nonEmptyString(entry.id);
const version = nonEmptyString(entry.version);
const capability = nonEmptyString(entry.capability);
const url = rootRelativePath({ url: entry.url }, "url");
if (
id === undefined ||
version === undefined ||
!isSemanticVersion(version) ||
capability === undefined ||
url === undefined
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ capability, id, url, version });
});
if (new Set(descriptors.map((descriptor) => descriptor.id)).size !== descriptors.length) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(descriptors);
}
/**
* 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;
/** Who the host resolved as the viewer. Read from the response that already authorized this
* display rather than from `/api/v1/principal`, which answers only about people. */
readonly viewer: ModuleUiPrincipal;
}
/** Strictly validates the host's statement of who is viewing. Only the two principal kinds a
* `restricted` display permission can admit are accepted — an anonymous viewer here would mean the
* server authorized a display for nobody, which is a protocol violation rather than a state to
* render. */
function parseDisplayViewer(payload: unknown): ModuleUiPrincipal {
if (hasExactKeys(payload, ["kind", "userId"]) && payload.kind === "authenticated") {
const userId = nonEmptyString(payload.userId);
if (userId !== undefined) {
return Object.freeze({ kind: "authenticated" as const, userId });
}
}
if (hasExactKeys(payload, ["deviceId", "kind"]) && payload.kind === "device") {
const deviceId = nonEmptyString(payload.deviceId);
if (deviceId !== undefined) {
return Object.freeze({ deviceId, kind: "device" as const });
}
}
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
/** Strictly validates the display descriptor response, including every nested URL container. */
export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayResponse {
if (!hasExactKeys(payload, ["module", "viewer"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const viewer = parseDisplayViewer(payload.viewer);
const entry = payload.module;
if (
!hasExactKeys(entry, [
"api",
"contributions",
"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,
contributions: parseContributions(entry.contributions),
id,
permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
uiUrl,
version,
}),
viewer,
});
}
/** A same-origin, credential-carrying platform mutation. Mirrors `auth-protocol.ts`'s `postJson`:
* `redirect: "error"` so a misconfigured deployment cannot silently follow a redirect with the
* device's cookies attached, and no thrown value ever carries the request body. */
async function requestPlatformMutation(
config: EkklesiaHostConfig,
rootRelativePath: string,
body: unknown,
signal?: AbortSignal,
): Promise<unknown> {
const performRequest = config.fetch ?? fetch;
let response: Response;
try {
response = await performRequest(resolveApiUrl(config, rootRelativePath), {
body: JSON.stringify(body),
cache: "no-store",
credentials: "include",
headers: { accept: "application/json", "content-type": "application/json" },
method: "POST",
redirect: "error",
...(signal ? { signal } : {}),
});
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
if (response.status === 401 || response.status === 403) {
throw new HostProtocolError("HOST_UNAUTHENTICATED");
}
if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED");
}
try {
return (await response.json()) as unknown;
} catch {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
}
/**
* Asks the host to start pairing this screen, and returns the code to put on it.
*
* The response deliberately carries no secret. The value that proves this browser is the one that
* asked travels in an HttpOnly cookie the host sets, so nothing here — and nothing anyone reads off
* the screen — can be used to collect the device's credential (ADR 0036).
*/
export async function requestDevicePairing(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<{ readonly code: string; readonly pollIntervalMs: number }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/requests",
{ moduleId },
signal,
);
if (!hasExactKeys(payload, ["code", "expiresAt", "pollIntervalMs"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const code = nonEmptyString(payload.code);
const pollIntervalMs = payload.pollIntervalMs;
if (
code === undefined ||
typeof pollIntervalMs !== "number" ||
!Number.isFinite(pollIntervalMs)
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ code, pollIntervalMs: Math.max(1000, Math.trunc(pollIntervalMs)) });
}
/** Asks whether this screen has been approved yet. When it has, the host issues the device session
* in a `Set-Cookie` the browser stores and this code never sees. */
export async function pollDevicePairing(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<string> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/claims",
{},
signal,
);
if (!hasExactKeys(payload, ["status"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const status = nonEmptyString(payload.status);
if (status === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return status;
}
/** One paired display, as the administrator's panel shows it. Deliberately no credential, no hash,
* and no pairing history — none of that is anything an operator can act on. */
export interface HostDeviceSummary {
readonly id: string;
readonly name: string;
readonly permissions: readonly string[];
readonly revoked: boolean;
}
export async function lookupDevicePairing(
config: EkklesiaHostConfig,
code: string,
signal?: AbortSignal,
): Promise<{ readonly moduleId: string; readonly permissionId: string }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/lookups",
{ code },
signal,
);
if (!hasExactKeys(payload, ["expiresAt", "moduleId", "permissionId", "requestedAt"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const moduleId = nonEmptyString(payload.moduleId);
const permissionId = nonEmptyString(payload.permissionId);
if (moduleId === undefined || permissionId === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, permissionId });
}
export async function approveDevicePairing(
config: EkklesiaHostConfig,
code: string,
deviceName: string,
signal?: AbortSignal,
): Promise<void> {
await requestPlatformMutation(
config,
"/api/v1/device-pairing/approvals",
{ code, deviceName },
signal,
);
}
export async function listHostDevices(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostDeviceSummary[]> {
const payload = await requestPlatformJson(config, "/api/v1/devices", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
if (typeof payload !== "object" || payload === null || !("devices" in payload)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const entries = (payload as { devices: unknown }).devices;
if (!Array.isArray(entries)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(
entries.map((entry) => {
const id = nonEmptyString((entry as { id?: unknown }).id);
const name = nonEmptyString((entry as { name?: unknown }).name);
const permissions = (entry as { permissions?: unknown }).permissions;
if (id === undefined || name === undefined || !Array.isArray(permissions)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
id,
name,
permissions: Object.freeze(permissions.map((permission) => String(permission))),
revoked: (entry as { revoked?: unknown }).revoked === true,
});
}),
);
}
export async function revokeHostDevice(
config: EkklesiaHostConfig,
deviceId: string,
signal?: AbortSignal,
): Promise<void> {
const performRequest = config.fetch ?? fetch;
let response: Response;
try {
response = await performRequest(
resolveApiUrl(config, `/api/v1/devices/${encodeURIComponent(deviceId)}/revoke`),
{
body: "{}",
cache: "no-store",
credentials: "include",
headers: { accept: "application/json", "content-type": "application/json" },
method: "POST",
redirect: "error",
...(signal ? { signal } : {}),
},
);
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED");
}
}
/** 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.
*
* The mount descriptor is nested exactly as the server sends it, not flattened into this object.
* That keeps the two concerns visibly separate in the browser as well as on the wire: `descriptor`
* is the host-neutral "what and how to mount", and `name` is Ekklesia's presentation of it. Nothing
* about a group is stored on the module — a module does not know, and must not know, where it has
* been placed.
*/
export interface DashboardNavigationEntry {
readonly name: string;
readonly descriptor: HostModuleUiDescriptor;
}
export interface DashboardNavigationGroup {
/** Opaque and stable; the shell uses it as a React key and never displays it. */
readonly id: string;
readonly label: string;
readonly modules: readonly DashboardNavigationEntry[];
}
/**
* The whole navigation the shell renders, already resolved and ordered by the server.
*
* The browser performs no grouping, no sorting, and no joining of its own: it does not read
* manifests, does not know which module belongs where, and could not compute this view if it wanted
* to. Ordering arrives as array order, so a change to the server's ordering rules needs no shell
* change at all.
*/
export interface DashboardNavigation {
readonly groups: readonly DashboardNavigationGroup[];
readonly ungrouped: readonly DashboardNavigationEntry[];
}
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
const name = nestedString(entry, "name");
if (name === undefined || !isRecord(entry)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ descriptor: parseDescriptor(entry.module), name });
}
/** Strict, total validation, exactly like the inventory parser: a malformed group or entry fails
* the whole document rather than rendering a half-built navigation. */
export function parseDashboardNavigation(payload: unknown): DashboardNavigation {
const groups = isRecord(payload) ? payload.groups : undefined;
const ungrouped = isRecord(payload) ? payload.ungrouped : undefined;
if (!Array.isArray(groups) || !Array.isArray(ungrouped)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
groups: Object.freeze(
groups.map((group): DashboardNavigationGroup => {
const id = nestedString(group, "id");
const label = nestedString(group, "label");
const modules = isRecord(group) ? group.modules : undefined;
if (id === undefined || label === undefined || !Array.isArray(modules)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
id,
label,
modules: Object.freeze(modules.map((entry) => parseNavigationEntry(entry))),
});
}),
),
ungrouped: Object.freeze(ungrouped.map((entry) => parseNavigationEntry(entry))),
});
}
/** `/api/v1/principal`'s two documented shapes, narrowed to the browser-owned `ModuleUiPrincipal`
* the UI SDK defines. Nothing else the endpoint might ever return is carried into module UI. */
export function parseHostPrincipal(payload: unknown): ModuleUiPrincipal {
if (!isRecord(payload)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
if (payload.kind === "anonymous") {
return { kind: "anonymous" };
}
const userId = nonEmptyString(payload.userId);
if (payload.kind === "authenticated" && userId !== undefined) {
return { kind: "authenticated", userId };
}
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
/** Resolves a root-relative platform path against the configured API origin. The one place the
* shell turns a server-produced path into an absolute URL. */
export function resolveApiUrl(config: EkklesiaHostConfig, rootRelativePath: string): string {
return new URL(rootRelativePath, config.apiBaseUrl).href;
}
/**
* Every request this shell makes to Ekklesia carries credentials.
*
* Same-origin that is simply the session cookie. Split-origin — the development shape the platform's
* trusted-origin configuration already supports — it is what makes the cookie travel at all, and it
* works only because the API reflects an exact trusted origin with
* `Access-Control-Allow-Credentials: true` and never a wildcard. This does not weaken that policy;
* it is the client half of it.
*/
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;
try {
response = await performRequest(resolveApiUrl(config, rootRelativePath), {
credentials: "include",
headers: { accept: "application/json" },
...(signal ? { signal } : {}),
});
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
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");
}
try {
return (await response.json()) as unknown;
} catch (error) {
throw new HostProtocolError("HOST_RESPONSE_INVALID", { cause: error });
}
}
/** Who is viewing. Anonymous is a normal answer, not a failure: the shell renders a sign-in prompt
* rather than an error, and never asks for the inventory it would be refused. */
export async function loadHostPrincipal(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<ModuleUiPrincipal> {
return parseHostPrincipal(await requestPlatformJson(config, PRINCIPAL_PATH, signal));
}
/** The resolved Dashboard navigation: the same mountable modules, arranged as this installation has
* configured them (ADR 0027). */
export async function loadDashboardNavigation(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<DashboardNavigation> {
return parseDashboardNavigation(
await requestPlatformJson(config, DASHBOARD_NAVIGATION_PATH, signal, {
forbidden: "HOST_ACCESS_DENIED",
}),
);
}
/**
* 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<HostModuleDisplayResponse> {
const payload = await requestPlatformJson(config, moduleDisplayDescriptorPath(moduleId), signal, {
forbidden: "DISPLAY_ACCESS_DENIED",
notFound: "DISPLAY_NOT_AVAILABLE",
});
const response = parseHostModuleDisplay(payload);
if (response.module.id !== moduleId) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return response;
}
export type DisplayHostBootstrap = {
readonly status: "ready";
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
};
/**
* Descriptor first, deliberately — the opposite of the Dashboard's principal-first bootstrap.
*
* The Dashboard asks who you are before asking what you may mount, because it is asking about a
* person and `/api/v1/principal` answers that plainly. A display cannot: a paired device is
* anonymous to that endpoint by design (ADR 0036), so a principal-first display would conclude that
* every device in the building was signed out. The descriptor is the actual authorization boundary
* for a display, and it reports the viewer it authorized, so one request settles both questions for
* a person and a device alike.
*
* A denied descriptor throws `DISPLAY_ACCESS_DENIED`, which is what the host turns into pairing.
*/
export async function bootstrapDisplayHost(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<DisplayHostBootstrap> {
const response = await loadModuleDisplay(config, moduleId, signal);
return Object.freeze({
descriptor: response.module,
principal: response.viewer,
status: "ready" as const,
});
}
export interface HostBootstrap {
readonly principal: ModuleUiPrincipal;
readonly navigation: DashboardNavigation;
}
const EMPTY_NAVIGATION: DashboardNavigation = Object.freeze({
groups: Object.freeze([]),
ungrouped: Object.freeze([]),
});
/**
* The shell's whole startup sequence, in one explicit function: resolve who is viewing, then ask
* what they may mount. Sequential rather than parallel on purpose — an anonymous viewer is told to
* sign in and the inventory is never requested, so the shell does not depend on a `403` to discover
* something `/api/v1/principal` already said plainly.
*/
export async function bootstrapHost(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<HostBootstrap> {
const principal = await loadHostPrincipal(config, signal);
if (principal.kind === "anonymous") {
return { navigation: EMPTY_NAVIGATION, principal };
}
return { navigation: await loadDashboardNavigation(config, signal), principal };
}