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"; export type HostProtocolErrorCode = /** The caller is not signed in, or lacks `core.modules.ui.read`. */ | "HOST_UNAUTHENTICATED" /** 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"; const errorMessages: Readonly> = Object.freeze({ 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.", }); /** * 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[]; } 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 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; } 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, id, permissions: Object.freeze([...(permissions as string[])]), realtimeBaseUrl, uiUrl, version, }); } /** * 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, ): 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 || 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 (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), ); } 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 }; }