300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
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<Record<ModuleUiRealtimeErrorCode, ModuleUiRealtimeError>> =
|
|
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<string, unknown> {
|
|
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<State>(channel: string, handlers: ModuleUiRealtimeHandlers<State>) {
|
|
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<unknown>).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 `<origin>/api/v1/modules/<module-id>/records`. The
|
|
* `/api/v1/modules/<module-id>` 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<Response> {
|
|
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);
|
|
}
|