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 the API * origin the serving API wrote into the Dashboard page (ADR 0045, {@link resolveApiOrigin}), and from * `window.location.origin` for the display host, which is always served from the API's own origin — * 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> = 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 page for this module's display, when it publishes one. */ readonly displayPageUrl: string | undefined; /** 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 { 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 { 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 displayPageUrl = isRecord(entry) && entry.display !== null ? rootRelativePath(entry.display, "url") : undefined; const permissions = isRecord(entry) ? entry.permissions : undefined; if ( !hasExactKeys(entry, [ "api", "contributions", "display", "id", "permissions", "realtime", "ui", "version", ]) || !hasExactKeys(entry.api, ["baseUrl"]) || !hasExactKeys(entry.realtime, ["baseUrl"]) || !hasExactKeys(entry.ui, ["url"]) || (entry.display !== null && !hasExactKeys(entry.display, ["url"])) || 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), displayPageUrl, 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", "page", "permissions", "realtime", "version", ]) || !hasExactKeys(entry.api, ["baseUrl"]) || !hasExactKeys(entry.display, ["url"]) || !hasExactKeys(entry.page, ["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 displayPageUrl = rootRelativePath(entry.page, "url"); const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl"); const permissions = entry.permissions; if ( id === undefined || !isModuleId(id) || version === undefined || !isSemanticVersion(version) || apiBaseUrl === undefined || uiUrl === undefined || displayPageUrl === 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), displayPageUrl, 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 { 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 { 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 { await requestPlatformMutation( config, "/api/v1/device-pairing/approvals", { code, deviceName }, signal, ); } export async function listHostDevices( config: EkklesiaHostConfig, signal?: AbortSignal, ): Promise { 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 { 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/` 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"); } /** * The API origin a Dashboard page was served with (ADR 0045). * * `declared` is the `content` of the page's ``, which the serving API * fills in at startup. Absent or empty means the API shares this page's origin, the same-origin * topology. Anything else must be exactly a canonical `http(s)` origin — no path, no trailing slash, * no credentials, no query — or the result is `undefined` and the page must fail closed rather than * send credentialed requests to an address it cannot vouch for. */ export function resolveApiOrigin( declared: string | null | undefined, pageOrigin: string, ): string | undefined { if (declared === null || declared === undefined || declared === "") { return pageOrigin; } let url: URL; try { url = new URL(declared); } catch { return undefined; } if ((url.protocol !== "https:" && url.protocol !== "http:") || url.origin !== declared) { return undefined; } return url.origin; } /** 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 { 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 { 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 { 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 { 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 { 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 { const principal = await loadHostPrincipal(config, signal); if (principal.kind === "anonymous") { return { navigation: EMPTY_NAVIGATION, principal }; } return { navigation: await loadDashboardNavigation(config, signal), principal }; }