import type { ModuleUiApiClient, ModuleUiHostContext, ModuleUiPrincipal, ModuleUiRealtimeClient, ModuleUiRealtimeError, ModuleUiRealtimeErrorCode, ModuleUiRealtimeHandlers, ModuleUiRealtimeStatus, } from "@ekklesia/module-ui-sdk"; import { type EkklesiaHostConfig, type HostModuleUiDescriptor, HostProtocolError, resolveApiUrl, } from "./host-protocol.js"; const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u; const REALTIME_CHANNEL_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; const REALTIME_CHANNEL_MAX_LENGTH = 48; const realtimeErrors: Readonly> = Object.freeze({ REALTIME_CONNECTION_FAILED: Object.freeze({ code: "REALTIME_CONNECTION_FAILED", message: "The live connection could not be maintained.", }), REALTIME_MESSAGE_INVALID: Object.freeze({ code: "REALTIME_MESSAGE_INVALID", message: "The host received an invalid live update.", }), }); interface RealtimeEnvelope { readonly payload: unknown; readonly sequence: number; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Strictly parses the host transport's private envelope. The module sees neither this envelope nor * the event implementation; it receives only `payload` and `sequence`. */ function parseRealtimeEnvelope(data: unknown): RealtimeEnvelope | undefined { if (typeof data !== "string") { return undefined; } let value: unknown; try { value = JSON.parse(data) as unknown; } catch { return undefined; } if (!isRecord(value)) { return undefined; } const keys = Object.keys(value).sort(); if ( keys.length !== 2 || keys[0] !== "payload" || keys[1] !== "sequence" || !Number.isSafeInteger(value.sequence) || (value.sequence as number) <= 0 ) { return undefined; } return { payload: value.payload, sequence: value.sequence as number }; } function realtimeChannelUrl( config: EkklesiaHostConfig, descriptor: HostModuleUiDescriptor, channel: string, ): string { const root = new URL(resolveApiUrl(config, descriptor.realtimeBaseUrl)); const rootPath = root.pathname.replace(/\/+$/u, ""); return new URL(`${rootPath}/${channel}`, root).href; } function createModuleRealtimeClient( config: EkklesiaHostConfig, descriptor: HostModuleUiDescriptor, ): ModuleUiRealtimeClient { return Object.freeze({ subscribe(channel: string, handlers: ModuleUiRealtimeHandlers) { if (channel.length > REALTIME_CHANNEL_MAX_LENGTH || !REALTIME_CHANNEL_PATTERN.test(channel)) { throw new HostProtocolError("MODULE_REALTIME_CHANNEL_INVALID"); } let closed = false; let lastStatus: ModuleUiRealtimeStatus | undefined; let awaitingSnapshot = true; let lastSequence = 0; const reportStatus = (status: ModuleUiRealtimeStatus): void => { if (lastStatus !== status) { lastStatus = status; handlers.onStatus?.(status); } }; const reportError = (code: ModuleUiRealtimeErrorCode): void => { handlers.onError?.(realtimeErrors[code]); }; reportStatus("connecting"); const factory = config.eventSourceFactory ?? ((url: string, options: { readonly withCredentials: true }) => new EventSource(url, options)); let source: EventSource; try { source = factory(realtimeChannelUrl(config, descriptor, channel), { withCredentials: true, }); } catch { reportError("REALTIME_CONNECTION_FAILED"); closed = true; reportStatus("closed"); return Object.freeze({ close: (): void => undefined }); } function onOpen(): void { if (!closed) { awaitingSnapshot = true; reportStatus("connected"); } } function onConnectionError(): void { if (closed) { return; } reportError("REALTIME_CONNECTION_FAILED"); if (source.readyState === source.CLOSED) { terminate(); return; } // EventSource itself retries established transport disconnects. The host reports that native // transition and waits for the next `open`; it never creates a competing retry loop. awaitingSnapshot = true; reportStatus("reconnecting"); } function onDelivery(kind: "snapshot" | "update", event: Event): void { if (closed) { return; } const envelope = parseRealtimeEnvelope((event as MessageEvent).data); const invalidOrder = envelope !== undefined && (kind === "snapshot" ? !awaitingSnapshot : awaitingSnapshot || envelope.sequence <= lastSequence); if (envelope === undefined || invalidOrder) { reportError("REALTIME_MESSAGE_INVALID"); return; } lastSequence = envelope.sequence; awaitingSnapshot = false; if (kind === "snapshot") { handlers.onSnapshot?.(envelope.payload as State, envelope.sequence); } else { handlers.onUpdate?.(envelope.payload as State, envelope.sequence); } } function onSnapshot(event: Event): void { onDelivery("snapshot", event); } function onUpdate(event: Event): void { onDelivery("update", event); } function terminate(): void { if (closed) { return; } closed = true; // Detach first: a browser or injected implementation that synchronously reports closure // from `close()` cannot re-enter `onConnectionError` and duplicate callbacks. source.removeEventListener("open", onOpen); source.removeEventListener("error", onConnectionError); source.removeEventListener("snapshot", onSnapshot); source.removeEventListener("update", onUpdate); source.close(); reportStatus("closed"); } source.addEventListener("open", onOpen); source.addEventListener("error", onConnectionError); source.addEventListener("snapshot", onSnapshot); source.addEventListener("update", onUpdate); return Object.freeze({ close: terminate, }); }, }); } /** * Turns the relative path a module UI asks for into the real Ekklesia URL, and refuses anything * that is not the module's own API. * * `context.api.request("records")` becomes `/api/v1/modules//records`. The * `/api/v1/modules/` half is **not** written here: it arrives from the server in the * inventory descriptor (`apiBaseUrl`), so Ekklesia's module mount prefix is spelled in exactly one * place in the whole repository (`moduleApiBaseUrl`, `apps/api/src/module-http-routes.ts`). The * browser is a consumer of that URL, never a second author of it. * * Containment is enforced with the URL parser rather than string inspection, because the parser is * what a real `fetch` would use: `"../calendar/records"` normalizes to another module's API, and a * check that only looked at the input string would miss it. Anything that resolves outside this * module's own root — a traversal, a protocol-relative `//host`, an absolute URL, an encoded * separator — is refused with `MODULE_API_PATH_INVALID`. * * This is a portability boundary, not a security one. Module UI is trusted same-realm code (ADR * 0022) and could call `fetch` directly; refusing here is what keeps a module honest about * addressing only its own backend, so the same artifact still runs under a standalone host. Server * authorization is unaffected either way: every module route stays behind `requirePermission`. */ function createModuleApiClient( config: EkklesiaHostConfig, descriptor: HostModuleUiDescriptor, onAuthorizationResponse?: (status: 401 | 403) => void, ): ModuleUiApiClient { const performRequest = config.fetch ?? fetch; const moduleRoot = new URL(resolveApiUrl(config, descriptor.apiBaseUrl)); return Object.freeze({ async request(path: string, init?: RequestInit): Promise { if (ABSOLUTE_URL_PATTERN.test(path) || path.startsWith("//")) { throw new HostProtocolError("MODULE_API_PATH_INVALID"); } const relative = path.replace(/^\/+/u, ""); const target = new URL( relative === "" ? moduleRoot.pathname : `${moduleRoot.pathname}/${relative}`, moduleRoot, ); const contained = target.origin === moduleRoot.origin && (target.pathname === moduleRoot.pathname || target.pathname.startsWith(`${moduleRoot.pathname}/`)); if (!contained) { throw new HostProtocolError("MODULE_API_PATH_INVALID"); } // The module's own `init` is forwarded whole — method, headers, body, signal — and only // `credentials` is imposed, because session handling is the host's job and a module UI never // sees or supplies a token (ADR 0022). A module cannot opt out of it. const response = await performRequest(target.href, { ...init, credentials: "include" }); if (response.status === 401 || response.status === 403) { // This callback stays entirely on the host side. The module still receives its original // response, and the public ModuleUiContext gains no authentication control or event. onAuthorizationResponse?.(response.status); } return response; }, }); } export interface CreateModuleUiContextOptions { readonly config: EkklesiaHostConfig; readonly descriptor: HostModuleUiDescriptor; readonly principal: ModuleUiPrincipal; /** Host-only signal used by the Dashboard to revalidate an apparently expired session. It is not * included in the returned ModuleUiHostContext and is intentionally absent from the UI SDK. */ readonly onAuthorizationResponse?: (status: 401 | 403) => void; } /** * Builds the `ModuleUiHostContext` (ADR 0022) Ekklesia hands a module UI at mount time. * * All five members come from host-derived data: the module id, API root, and realtime root from the * inventory descriptor; the principal from `/api/v1/principal`; and permission hints from the * server's evaluation of that module's declarations. Nothing is guessed, defaulted to `true`, or * inferred from a client-side role name. * * The permission set is the module's own and only the module's own. A module UI cannot use this to * discover what the viewer may do elsewhere in the platform, and `has` returning `true` is a * statement about what the server said a moment ago — a hint for hiding a control, never a * substitute for the server check that actually decides. */ export function createModuleUiContext(options: CreateModuleUiContextOptions): ModuleUiHostContext { const granted = new Set(options.descriptor.permissions); return Object.freeze({ api: createModuleApiClient(options.config, options.descriptor, options.onAuthorizationResponse), moduleId: options.descriptor.id, permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }), principal: options.principal, realtime: createModuleRealtimeClient(options.config, options.descriptor), }); } /** The absolute URL the generic loader should import this module's artifact from — the descriptor's * server-produced path resolved against the configured API origin, and nothing more. */ export function moduleUiSourceUrl( config: EkklesiaHostConfig, descriptor: HostModuleUiDescriptor, ): string { return resolveApiUrl(config, descriptor.uiUrl); }