feat: add native module realtime transport
CI / verify (pull_request) Successful in 37s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m7s

This commit is contained in:
ekklesia
2026-08-23 18:47:18 -05:00
parent fcc531a39c
commit fc6c8045c4
53 changed files with 3959 additions and 108 deletions
+42 -11
View File
@@ -12,9 +12,9 @@ transport), and Phase 5C (module database access) have landed. **Phase 5D (secur
against an independent security audit) is complete: all five passes have landed and the final against an independent security audit) is complete: all five passes have landed and the final
independent whole-repository re-audit has closed, under the current trusted-contributor, independent whole-repository re-audit has closed, under the current trusted-contributor,
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the module configuration and completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the
secrets contract), 3 (host-provided module object storage), and 4 (direct browser object delivery) module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
have landed.** The operational browser object delivery), and 5 (native module realtime transport) have landed.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
pass 5 closure below and pass 5 closure below and
@@ -340,15 +340,17 @@ GET /api/v1/module-ui → which module UIs may be mounted, and where
@ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container @ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container
↓ ↓
context.api.request("records") → /api/v1/modules/example/records context.api.request("records") → /api/v1/modules/example/records
context.realtime.subscribe("state") → host-owned live state transport
``` ```
The new inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version, The inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
artifact URL, module API root, and the viewer's granted permissions **for that module** — and nothing artifact URL, module API root, module realtime root, and the viewer's granted permissions **for that
else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because navigation metadata is a module** — and nothing else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because
design that does not exist yet. It is authenticated (`core.modules.ui.read`, baseline navigation metadata is a separate design. It is authenticated (`core.modules.ui.read`, baseline
`authenticated`) precisely because it is the module enumeration the anonymous artifact route `authenticated`) precisely because it is the module enumeration the anonymous artifact route
deliberately withholds. Both URLs are produced by the server's own canonical helpers, so the browser deliberately withholds. All three URLs are produced by the server's own canonical helpers, so the
never rebuilds Ekklesia's module prefixes and either can change without touching host or module code. browser never rebuilds Ekklesia's module prefixes and any can change without touching host or module
code.
**The shell statically imports no module.** Every module it can mount arrives at run time from the **The shell statically imports no module.** Every module it can mount arrives at run time from the
inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no
@@ -594,10 +596,39 @@ and rewritten to another. The origin a browser uses is therefore a signing input
(`S3_DELIVERY_ENDPOINT`), defaulting to `S3_ENDPOINT` — so the standard deployment configures nothing (`S3_DELIVERY_ENDPOINT`), defaulting to `S3_ENDPOINT` — so the standard deployment configures nothing
and delivery simply works. A reverse proxy in front of storage must pass the original `Host` through and delivery simply works. A reverse proxy in front of storage must pass the original `Host` through
unchanged; it may terminate TLS, because the scheme is not signed. Buckets stay private: an unsigned unchanged; it may terminate TLS, because the scheme is not signed. Buckets stay private: an unsigned
request for the same object is refused, and a test asserts it. **Signed write and delete URLs, and a request for the same object is refused, and a test asserts it. **Signed write and delete URLs are
realtime refresh transport, are deliberately deferred.** See deliberately deferred.** See
[ADR 0031](docs/architecture/decisions/0031-direct-object-delivery.md). [ADR 0031](docs/architecture/decisions/0031-direct-object-delivery.md).
Phase 7A pass 5 adds native, host-neutral module realtime
([ADR 0032](docs/architecture/decisions/0032-native-module-realtime-transport.md)). During
`register`, a module declares a small, module-local channel and the permission that protects it;
during `start`, running work, and HTTP handlers, it publishes bounded JSON state through its own
scoped publisher. The publisher is inert by the time that module's stop hook begins. Core keeps only
the latest state and one pending update per subscriber. A module
must publish an initial state before startup completes, so every accepted subscriber receives one
atomic authoritative snapshot and then monotonically sequenced updates without a GET-then-subscribe
race. Slow clients never block publishers and intermediate states may be coalesced.
Ekklesia carries that contract over permission-gated Server-Sent Events, with cookie authentication,
15-second transport-owned keepalives, a five-minute reauthorization cycle, browser-native reconnect,
and a fresh snapshot on every reconnection. The module UI API remains transport-neutral:
```ts
const subscription = context.realtime.subscribe<SlideshowState>("state", {
onSnapshot: (state) => render(state),
onUpdate: (state) => render(state),
});
// Return this from `mount`, or call it from your unmount cleanup.
subscription.close();
```
There is no durable replay, global topic namespace, cross-module subscription, query-string token,
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
pass through realtime unchanged. The final unauthenticated/device principal for public displays and
display/kiosk hosting itself remain a later pass.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested **Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing, folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
and no module-management, settings, or storage UI — module enablement and configuration are an API and and no module-management, settings, or storage UI — module enablement and configuration are an API and
+15
View File
@@ -19,6 +19,7 @@ import type {
ModuleConfigurationLookup, ModuleConfigurationLookup,
ModuleLifecycleStore, ModuleLifecycleStore,
ModuleStorageLookup, ModuleStorageLookup,
ModuleRealtimeRegistry,
ModuleUiPublicationRegistry, ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { Hono } from "hono"; import { Hono } from "hono";
@@ -28,6 +29,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
import { createDashboardRoutes } from "./dashboard-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js"; import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js"; import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js"; import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
@@ -50,6 +52,9 @@ export interface CreateApiAppOptions {
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an * caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
* implicit fallback. */ * implicit fallback. */
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[]; readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
* empty, for the same reason as module routes: application composition never guesses what ran. */
readonly moduleRealtime: ModuleRealtimeRegistry;
/** The same frozen services object the caller's started module runtime already passed to every /** The same frozen services object the caller's started module runtime already passed to every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh * module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one * object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
@@ -310,11 +315,21 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}), }),
); );
// A sibling platform-owned prefix, never nested under `/api/v1/modules/` where installed modules
// own every path. One exact route per runtime-published channel, authorized before subscription.
mountModuleRealtimeRoutes(app, {
evaluator: options.authorization.evaluator,
realtime: options.moduleRealtime,
registry: options.authorization.registry,
runtime,
});
mountModuleHttpRoutes(app, options.moduleRoutes, { mountModuleHttpRoutes(app, options.moduleRoutes, {
configurations: options.moduleConfiguration.snapshots, configurations: options.moduleConfiguration.snapshots,
storages: options.moduleStorage.snapshots, storages: options.moduleStorage.snapshots,
evaluator: options.authorization.evaluator, evaluator: options.authorization.evaluator,
registry: options.authorization.registry, registry: options.authorization.registry,
realtime: options.moduleRealtime,
runtime, runtime,
services: options.moduleServices, services: options.moduleServices,
trustedOrigins: allowedOrigins, trustedOrigins: allowedOrigins,
+12 -4
View File
@@ -6,7 +6,11 @@ import {
type ModuleRuntimeServices, type ModuleRuntimeServices,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import type { ModuleConfigurationLookup, ModuleStorageLookup } from "@ekklesia/core/modules"; import type {
ModuleConfigurationLookup,
ModuleRealtimeRegistry,
ModuleStorageLookup,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { import {
type AuthorizationMiddlewareOptions, type AuthorizationMiddlewareOptions,
@@ -53,6 +57,8 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* its own module's storage, because that is the only entry looked up for it. * its own module's storage, because that is the only entry looked up for it.
*/ */
readonly storages: ModuleStorageLookup; readonly storages: ModuleStorageLookup;
/** The same started registry whose scoped publishers lifecycle hooks receive. */
readonly realtime: ModuleRealtimeRegistry;
} }
/** Never carries the offending permission id, route path, module id, or any other configuration /** Never carries the offending permission id, route path, module id, or any other configuration
@@ -195,9 +201,10 @@ function sanitizeModuleResponse(response: Response): Response {
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the * implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
* response body afterward — there is no separate `HEAD` registration or permission path. * response body afterward — there is no separate `HEAD` registration or permission path.
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a * `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
* frozen context carrying exactly `moduleId`, `principal`, `params`, and `services` — never a Hono * frozen context carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`,
* `Context`, authorization store, or configuration; `services.database` is the module's only * `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or
* reachable database surface, and is unreachable to anything short of a successfully authorized * deployment-wide configuration. `services.database` is the module's only reachable database
* surface, and the whole context is unreachable to anything short of a successfully authorized
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach * handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
* this handler at all). * this handler at all).
* *
@@ -239,6 +246,7 @@ export function mountModuleHttpRoutes(
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE, storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
params: Object.freeze({ ...c.req.param() }), params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c), principal: getAuthorizedPrincipal(c),
realtime: options.realtime.publisherFor(route.moduleId),
services: options.services, services: options.services,
}); });
+252
View File
@@ -0,0 +1,252 @@
import { ModuleRealtimeError, type ModuleId, type PermissionId } from "@ekklesia/contracts";
import type {
ModuleRealtimePublication,
ModuleRealtimeRegistry,
RegisteredModuleRealtimeChannel,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
} from "./authorization-middleware.js";
const MODULE_REALTIME_PREFIX = "/api/v1/module-realtime";
/** Host-owned keepalive interval. Comments keep idle HTTP streams alive without entering module code,
* consuming a sequence, or becoming a browser-visible state update. */
export const MODULE_REALTIME_KEEPALIVE_MS = 15_000;
/** Forces a fresh cookie/permission evaluation periodically rather than letting an authorization
* decision live for an unbounded stream lifetime. EventSource reconnects after the clean EOF. */
export const MODULE_REALTIME_MAX_CONNECTION_AGE_MS = 5 * 60_000;
export interface ModuleRealtimeTransportTiming {
/** Test-only narrowing of the production keepalive. Production composition never sets this. */
readonly keepaliveMs?: number;
/** Test-only narrowing of the production reauthorization interval. */
readonly maxConnectionAgeMs?: number;
}
export interface ModuleRealtimeRoutesOptions
extends AuthorizationMiddlewareOptions,
ModuleRealtimeTransportTiming {
readonly realtime: ModuleRealtimeRegistry;
}
/** The server is the sole author of this deployment-private base. Module UI receives it through the
* host descriptor and never reconstructs the platform prefix or supplies its own module id. */
export function moduleRealtimeBaseUrl(moduleId: string): string {
return `${MODULE_REALTIME_PREFIX}/${encodeURIComponent(moduleId)}`;
}
function moduleRealtimeChannelUrl(moduleId: ModuleId, channel: string): string {
return `${moduleRealtimeBaseUrl(moduleId)}/${encodeURIComponent(channel)}`;
}
/** A forged runtime snapshot that could not be permission-gated is a composition failure. Carries no
* module/channel/permission value and is thrown before any route from that snapshot is usable. */
export class ModuleRealtimeConfigurationError extends Error {
constructor() {
super("A published module realtime channel references a permission that is not registered.");
this.name = "ModuleRealtimeConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function realtimeEnvelope(publication: ModuleRealtimePublication): string {
// `serializedPayload` was validated and serialized exactly once by Core. Concatenating it as the
// JSON value preserves every opaque string — including signed object-delivery URLs — byte for byte.
return `{"sequence":${publication.sequence},"payload":${publication.serializedPayload}}`;
}
function realtimeEvent(
kind: "snapshot" | "update",
publication: ModuleRealtimePublication,
): string {
// Core's compact JSON serialization contains no literal CR/LF bytes inside strings, so one `data`
// line is sufficient. Deliberately no SSE `id`: this transport does not implement Last-Event-ID
// replay, and a process-local sequence must not be mistaken for a durable cursor.
return `event: ${kind}\ndata: ${realtimeEnvelope(publication)}\n\n`;
}
function sanitizedRealtimeFailure(c: Context, error: unknown): Response {
c.header("Cache-Control", "no-store");
const code =
error instanceof ModuleRealtimeError && error.code === "REALTIME_SUBSCRIBER_LIMIT"
? "REALTIME_SUBSCRIBER_LIMIT"
: "REALTIME_UNAVAILABLE";
return c.json({ code }, 503);
}
interface CancelableDelay {
readonly promise: Promise<"keepalive">;
cancel(): void;
}
function keepaliveDelay(milliseconds: number): CancelableDelay {
let timer: ReturnType<typeof setTimeout> | undefined;
const promise = new Promise<"keepalive">((resolve) => {
timer = setTimeout(() => resolve("keepalive"), milliseconds);
});
return {
cancel: () => clearTimeout(timer),
promise,
};
}
function createRealtimeStream(
definition: RegisteredModuleRealtimeChannel,
registry: ModuleRealtimeRegistry,
keepaliveMs: number,
maxConnectionAgeMs: number,
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const subscription = registry.subscribe(definition.moduleId, definition.channel);
let snapshotPending = true;
let closed = false;
let pendingRead: Promise<ModuleRealtimePublication | undefined> | undefined;
let activeDelay: CancelableDelay | undefined;
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
const close = (): void => {
if (closed) {
return;
}
closed = true;
activeDelay?.cancel();
activeDelay = undefined;
clearTimeout(expiryTimer);
expiryTimer = undefined;
subscription.close();
};
return new ReadableStream<Uint8Array>({
start(controller) {
expiryTimer = setTimeout(() => {
close();
try {
controller.close();
} catch {
// A simultaneous browser cancellation or completed pull already closed the controller.
}
}, maxConnectionAgeMs);
},
cancel: () => close(),
async pull(controller) {
if (closed) {
return;
}
try {
if (snapshotPending) {
snapshotPending = false;
controller.enqueue(encoder.encode(realtimeEvent("snapshot", subscription.snapshot)));
return;
}
// Only one Core read is ever outstanding. If a keepalive wins the race, the same read is
// retained for the next pull instead of allocating a second listener. Core itself holds only
// one newest pending publication, and this stream asks for work only when the browser-facing
// ReadableStream has demand, so a slow client cannot create an array at either layer.
pendingRead ??= subscription.read();
activeDelay = keepaliveDelay(keepaliveMs);
const result = await Promise.race([pendingRead, activeDelay.promise]);
activeDelay.cancel();
activeDelay = undefined;
if (result === "keepalive") {
controller.enqueue(encoder.encode(": keepalive\n\n"));
return;
}
pendingRead = undefined;
if (result === undefined) {
close();
controller.close();
return;
}
controller.enqueue(encoder.encode(realtimeEvent("update", result)));
} catch {
close();
try {
controller.close();
} catch {
// The browser may have canceled between the awaited read/timer and this close. Cancellation
// already performed the same idempotent subscription cleanup.
}
}
},
});
}
function mountChannel(
app: Hono,
definition: RegisteredModuleRealtimeChannel,
options: ModuleRealtimeRoutesOptions,
keepaliveMs: number,
maxConnectionAgeMs: number,
): void {
const requireChannelPermission = requirePermission(
options,
definition.permission as PermissionId,
);
app.get(
moduleRealtimeChannelUrl(definition.moduleId, definition.channel),
requireChannelPermission,
(c) => {
// Hono deliberately lets a registered GET handler answer HEAD. That is useful for finite
// resources, but unsafe for an infinite stream: the adapter discards the response body without
// necessarily canceling it, which could retain a Core subscriber forever. Refuse HEAD before
// allocating anything. Authentication/authorization has still run through the same middleware.
if (c.req.method !== "GET") {
return new Response(null, {
headers: { Allow: "GET", "Cache-Control": "no-store" },
status: 405,
});
}
let stream: ReadableStream<Uint8Array>;
try {
stream = createRealtimeStream(
definition,
options.realtime,
keepaliveMs,
maxConnectionAgeMs,
);
} catch (error) {
return sanitizedRealtimeFailure(c, error);
}
return new Response(stream, {
headers: {
"Cache-Control": "no-store",
Connection: "keep-alive",
"Content-Type": "text/event-stream; charset=utf-8",
Vary: "Origin, Cookie",
// Honored by nginx and harmless elsewhere. Other proxies must likewise disable buffering for
// this path; no proxy-specific behavior enters the module-facing contract.
"X-Accel-Buffering": "no",
},
status: 200,
});
},
);
}
/** Mounts one exact, immutable, permission-gated GET route per successfully started module channel.
* There is no dynamic module/channel lookup route and no request-controlled permission selection. */
export function mountModuleRealtimeRoutes(app: Hono, options: ModuleRealtimeRoutesOptions): void {
const keepaliveMs = options.keepaliveMs ?? MODULE_REALTIME_KEEPALIVE_MS;
const maxConnectionAgeMs = options.maxConnectionAgeMs ?? MODULE_REALTIME_MAX_CONNECTION_AGE_MS;
if (!Number.isSafeInteger(keepaliveMs) || keepaliveMs <= 0) {
throw new TypeError("The module realtime keepalive interval is invalid.");
}
if (!Number.isSafeInteger(maxConnectionAgeMs) || maxConnectionAgeMs <= 0) {
throw new TypeError("The module realtime maximum connection age is invalid.");
}
for (const definition of options.realtime.channels) {
if (!options.registry.has(definition.permission)) {
throw new ModuleRealtimeConfigurationError();
}
}
for (const definition of options.realtime.channels) {
mountChannel(app, definition, options, keepaliveMs, maxConnectionAgeMs);
}
}
+5
View File
@@ -9,6 +9,7 @@ import {
requirePermission, requirePermission,
} from "./authorization-middleware.js"; } from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js"; import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js";
/** /**
@@ -44,6 +45,9 @@ export interface HostModuleUiDescriptor {
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the * `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
* module nor the browser host should be the one spelling it. */ * module nor the browser host should be the one spelling it. */
readonly api: { readonly baseUrl: string }; readonly api: { readonly baseUrl: string };
/** Server-produced root for this module's host-neutral browser realtime facade. The browser host,
* not module UI, resolves and appends a validated local channel. */
readonly realtime: { readonly baseUrl: string };
/** /**
* The subset of **this module's own declared permissions** that the current principal holds, * The subset of **this module's own declared permissions** that the current principal holds,
* evaluated server-side by Core's one authorization evaluator. * evaluated server-side by Core's one authorization evaluator.
@@ -115,6 +119,7 @@ async function describeModule(
api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
id: published.moduleId, id: published.moduleId,
permissions: await grantedPermissionIds(evaluator, principal, published.permissions), permissions: await grantedPermissionIds(evaluator, principal, published.permissions),
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
ui: { url: moduleUiArtifactUrl(published) }, ui: { url: moduleUiArtifactUrl(published) },
version: published.version, version: published.version,
}; };
+8 -1
View File
@@ -521,6 +521,7 @@ export async function startApi(
store: authorizationStore, store: authorizationStore,
}, },
moduleRoutes: moduleRuntime.routes, moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime,
moduleServices: services, moduleServices: services,
// Built from the exact **eligible** set the runtime just started, never the raw discovered // Built from the exact **eligible** set the runtime just started, never the raw discovered
// inventory, and only once startup has succeeded — so the published set is the running set. A // inventory, and only once startup has succeeded — so the published set is the running set. A
@@ -585,7 +586,9 @@ export async function startApi(
// would previously get an already-resolved `undefined` immediately, before the listener had // would previously get an already-resolved `undefined` immediately, before the listener had
// actually drained or the pool had actually closed. Storing the promise itself means every // actually drained or the pool had actually closed. Storing the promise itself means every
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and // caller — concurrent or sequential — awaits the exact same drain-then-close operation and
// observes it complete (or fail) exactly once. Order matters, and is now additionally // observes it complete (or fail) exactly once. Realtime subscriptions are closed *before* HTTP
// drain begins: they are intentionally long-lived HTTP responses and would otherwise keep every
// clean shutdown open until the forced-drain deadline. Order matters, and is now additionally
// load-bearing for the module database facade: the HTTP listener is drained and closed first so // load-bearing for the module database facade: the HTTP listener is drained and closed first so
// no new or in-flight request can reach a module handler while the module runtime is stopping; // no new or in-flight request can reach a module handler while the module runtime is stopping;
// then the module runtime stops — its stop hooks may still use `services.database`, which is // then the module runtime stops — its stop hooks may still use `services.database`, which is
@@ -598,6 +601,10 @@ export async function startApi(
const shutdown = (): Promise<void> => { const shutdown = (): Promise<void> => {
if (!shutdownPromise) { if (!shutdownPromise) {
shutdownPromise = (async () => { shutdownPromise = (async () => {
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
// completes: an in-flight module command may still commit and publish its resulting state.
// `moduleRuntime.stop()` deactivates publishers immediately before stop hooks run.
moduleRuntime.realtime.closeSubscriptions();
const drainOutcome = await drainHttpServer(server, drainTimeoutMs); const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
let moduleStopError: unknown; let moduleStopError: unknown;
try { try {
+90 -7
View File
@@ -52,6 +52,26 @@ function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message); return new IntegrationHarnessError(message);
} }
async function withTransportDeadline<Value>(
operation: Promise<Value>,
onTimeout: () => void,
): Promise<Value> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
onTimeout();
reject(harnessError("An integration transport operation timed out."));
}, 5_000);
}),
]);
} finally {
clearTimeout(timer);
}
}
function baseDatabaseUrl(): string { function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment]; const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") { if (typeof value !== "string" || value.trim() === "") {
@@ -313,22 +333,25 @@ async function createGatedRouteModule(
`export default { `export default {
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
register({ http }) { register({ http, realtime }) {
realtime.channel({ channel: "state", permission: ${JSON.stringify(permissionId)} });
http.route({ http.route({
method: "GET", method: "GET",
path: "/gated", path: "/gated",
permission: ${JSON.stringify(permissionId)}, permission: ${JSON.stringify(permissionId)},
maxBodyBytes: 0, maxBodyBytes: 0,
async handler() { async handler(_request, context) {
${recorder.expression}.push(${JSON.stringify(`${id}:handler-entered`)}); ${recorder.expression}.push(${JSON.stringify(`${id}:handler-entered`)});
${gate.expression}.enter(); ${gate.expression}.enter();
await ${gate.expression}.released; await ${gate.expression}.released;
context.realtime.publish("state", { status: "finished" });
${recorder.expression}.push(${JSON.stringify(`${id}:handler-published`)});
${recorder.expression}.push(${JSON.stringify(`${id}:handler-finished`)}); ${recorder.expression}.push(${JSON.stringify(`${id}:handler-finished`)});
return Response.json({ gated: true }); return Response.json({ gated: true });
}, },
}); });
}, },
start() {}, start({ realtime }) { realtime.publish("state", { status: "waiting" }); },
stop() { ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); }, stop() { ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); },
};`, };`,
), ),
@@ -355,6 +378,9 @@ async function createFixtureModule(
const declaresCalendarRead = (manifest.permissions as ReadonlyArray<{ id: string }>).some( const declaresCalendarRead = (manifest.permissions as ReadonlyArray<{ id: string }>).some(
(declaration) => declaration.id === "calendar.events.read", (declaration) => declaration.id === "calendar.events.read",
); );
const realtimePermission = (manifest.permissions as ReadonlyArray<{ id: string }>).find(
(declaration) => declaration.id === `${id}.state.view`,
)?.id;
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([ await Promise.all([
writeFile( writeFile(
@@ -372,8 +398,13 @@ async function createFixtureModule(
`export default { `export default {
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
register({ http }) { register({ http, realtime }) {
${recorder.expression}.push(${JSON.stringify(`${id}:register`)}); ${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
${
realtimePermission
? `realtime.channel({ channel: "state", permission: ${JSON.stringify(realtimePermission)} });`
: ""
}
${ ${
declaresCalendarRead declaresCalendarRead
? `http.route({ ? `http.route({
@@ -397,7 +428,12 @@ async function createFixtureModule(
: "" : ""
} }
}, },
start() { start({ realtime }) {
${
realtimePermission
? `realtime.publish("state", { module: ${JSON.stringify(id)}, status: "running" });`
: ""
}
${recorder.expression}.push(${JSON.stringify(`${id}:start`)}); ${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
}, },
stop() { stop() {
@@ -3037,6 +3073,7 @@ describe.sequential("live API — module lifecycle governs what actually runs",
await createFixtureModule(modulesDirectory, "calendar", recorder, { await createFixtureModule(modulesDirectory, "calendar", recorder, {
permissions: [ permissions: [
{ baseline: "public", description: "Read calendar events.", id: "calendar.events.read" }, { baseline: "public", description: "Read calendar events.", id: "calendar.events.read" },
{ baseline: "public", description: "View calendar state.", id: "calendar.state.view" },
], ],
}); });
await createFixtureModule(modulesDirectory, "announcements", recorder, { await createFixtureModule(modulesDirectory, "announcements", recorder, {
@@ -3046,6 +3083,11 @@ describe.sequential("live API — module lifecycle governs what actually runs",
description: "Read announcements.", description: "Read announcements.",
id: "announcements.events.read", id: "announcements.events.read",
}, },
{
baseline: "public",
description: "View announcement state.",
id: "announcements.state.view",
},
], ],
}); });
@@ -3056,6 +3098,8 @@ describe.sequential("live API — module lifecycle governs what actually runs",
const running = await startConfiguredApi(database.url, { const running = await startConfiguredApi(database.url, {
modulesDirectoryOverride: modulesDirectory, modulesDirectoryOverride: modulesDirectory,
}); });
const realtimeAbort = new AbortController();
let calendarReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try { try {
const origin = apiOrigin(running); const origin = apiOrigin(running);
@@ -3064,6 +3108,18 @@ describe.sequential("live API — module lifecycle governs what actually runs",
expect(recorder.events).toContain("calendar:start"); expect(recorder.events).toContain("calendar:start");
const calendarRoute = await fetch(`${origin}/api/v1/modules/calendar/events/abc`); const calendarRoute = await fetch(`${origin}/api/v1/modules/calendar/events/abc`);
expect(calendarRoute.status).toBe(200); expect(calendarRoute.status).toBe(200);
await withTransportDeadline(
(async () => {
const calendarRealtime = await fetch(`${origin}/api/v1/module-realtime/calendar/state`, {
signal: realtimeAbort.signal,
});
expect(calendarRealtime.status).toBe(200);
calendarReader = calendarRealtime.body?.getReader();
const calendarSnapshot = await calendarReader?.read();
expect(new TextDecoder().decode(calendarSnapshot?.value)).toContain("event: snapshot");
})(),
() => realtimeAbort.abort(),
);
// The disabled module ran nothing at all. Not "registered but skipped", not "started and // The disabled module ran nothing at all. Not "registered but skipped", not "started and
// gated per request" — its compiled entrypoint was never imported, so no hook could run. // gated per request" — its compiled entrypoint was never imported, so no hook could run.
@@ -3076,12 +3132,25 @@ describe.sequential("live API — module lifecycle governs what actually runs",
const disabledRoute = await fetch(`${origin}/api/v1/modules/announcements/events/abc`); const disabledRoute = await fetch(`${origin}/api/v1/modules/announcements/events/abc`);
expect(disabledRoute.status).toBe(404); expect(disabledRoute.status).toBe(404);
expect(await disabledRoute.json()).toEqual({ code: "NOT_FOUND" }); expect(await disabledRoute.json()).toEqual({ code: "NOT_FOUND" });
const disabledRealtime = await fetch(`${origin}/api/v1/module-realtime/announcements/state`);
expect(disabledRealtime.status).toBe(404);
expect(await disabledRealtime.json()).toEqual({ code: "NOT_FOUND" });
// Shutdown never invokes a stop hook for a module that never started. // Shutdown never invokes a stop hook for a module that never started.
await running.shutdown(); const streamClosed = calendarReader?.read();
const shutdown = running.shutdown();
await expect(
withTransportDeadline(
streamClosed ?? Promise.reject(harnessError("The realtime stream was unavailable.")),
() => realtimeAbort.abort(),
),
).resolves.toEqual({ done: true, value: undefined });
await shutdown;
expect(recorder.events).toContain("calendar:stop"); expect(recorder.events).toContain("calendar:stop");
expect(recorder.events).not.toContain("announcements:stop"); expect(recorder.events).not.toContain("announcements:stop");
} finally { } finally {
realtimeAbort.abort();
await calendarReader?.cancel().catch(() => undefined);
await running.shutdown().catch(() => undefined); await running.shutdown().catch(() => undefined);
await dropDatabase(database.name); await dropDatabase(database.name);
} }
@@ -3093,6 +3162,13 @@ describe.sequential("live API — module lifecycle governs what actually runs",
await createFixtureModule(modulesDirectory, "storage", recorder); await createFixtureModule(modulesDirectory, "storage", recorder);
await createFixtureModule(modulesDirectory, "announcements", recorder, { await createFixtureModule(modulesDirectory, "announcements", recorder, {
dependencies: { modules: { required: { storage: "^0.1.0" } } }, dependencies: { modules: { required: { storage: "^0.1.0" } } },
permissions: [
{
baseline: "public",
description: "View announcement state.",
id: "announcements.state.view",
},
],
}); });
const database = await createDisposableDatabase("lifecycle_block"); const database = await createDisposableDatabase("lifecycle_block");
@@ -3151,6 +3227,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
const raw = JSON.stringify(body); const raw = JSON.stringify(body);
expect(raw).not.toContain(modulesDirectory); expect(raw).not.toContain(modulesDirectory);
expect(raw).not.toContain("dist/"); expect(raw).not.toContain("dist/");
const blockedRealtime = await fetch(`${origin}/api/v1/module-realtime/announcements/state`);
expect(blockedRealtime.status).toBe(404);
expect(await blockedRealtime.json()).toEqual({ code: "NOT_FOUND" });
} finally { } finally {
await running.shutdown().catch(() => undefined); await running.shutdown().catch(() => undefined);
await dropDatabase(database.name); await dropDatabase(database.name);
@@ -3384,11 +3463,15 @@ describe.sequential("live API — bounded HTTP drain and shutdown", () => {
expect(raw).toContain("HTTP/1.1 200"); expect(raw).toContain("HTTP/1.1 200");
expect(raw).toContain('"gated":true'); expect(raw).toContain('"gated":true');
// Explicit ordering: the handler finished and the module stopped, in that order. // Explicit ordering: the already-running command can publish its committed state while HTTP
// drains, then the handler finishes and only afterward does its module stop.
const published = recorder.events.indexOf("drainorder:handler-published");
const finished = recorder.events.indexOf("drainorder:handler-finished"); const finished = recorder.events.indexOf("drainorder:handler-finished");
const stopped = recorder.events.indexOf("drainorder:stop"); const stopped = recorder.events.indexOf("drainorder:stop");
expect(published).toBeGreaterThanOrEqual(0);
expect(finished).toBeGreaterThanOrEqual(0); expect(finished).toBeGreaterThanOrEqual(0);
expect(stopped).toBeGreaterThanOrEqual(0); expect(stopped).toBeGreaterThanOrEqual(0);
expect(published).toBeLessThan(finished);
expect(finished).toBeLessThan(stopped); expect(finished).toBeLessThan(stopped);
} finally { } finally {
gate.release(); gate.release();
+12 -1
View File
@@ -13,12 +13,17 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleUiPublicationRegistry, type ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js"; import {
createApiApp as createApiAppWithModules,
type CreateApiAppAuthorizationOptions,
type CreateApiAppOptions,
} from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
@@ -58,6 +63,12 @@ function emptyModuleUiPublication(): ModuleUiPublicationRegistry {
return createModuleUiPublicationRegistry([]); return createModuleUiPublicationRegistry([]);
} }
/** Most application-wiring cases exercise no modules. Keep that fact explicit in one test helper
* while production composition still requires the realtime registry with no implicit fallback. */
function createApiApp(options: Omit<CreateApiAppOptions, "moduleRealtime">) {
return createApiAppWithModules({ ...options, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY });
}
/** A fully stubbed authorization dependency, denying everything by default — tests that need a /** A fully stubbed authorization dependency, denying everything by default — tests that need a
* specific decision or store behavior override the relevant method explicitly. The registry * specific decision or store behavior override the relevant method explicitly. The registry
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the * defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
+3
View File
@@ -19,6 +19,7 @@ import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
discoverModules, discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
@@ -139,6 +140,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
dashboard: createInMemoryDashboardStore(options.configuration ?? {}), dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({ moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase, database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices, }) as ModuleRuntimeServices,
@@ -270,6 +272,7 @@ describe("Dashboard navigation — reading", () => {
api: { baseUrl: "/api/v1/modules/livestream" }, api: { baseUrl: "/api/v1/modules/livestream" },
id: "livestream", id: "livestream",
permissions: [], permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
ui: { url: "/api/v1/module-ui/livestream/1.0.0" }, ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
version: "1.0.0", version: "1.0.0",
}); });
@@ -25,6 +25,7 @@ import {
type ModuleConfigurationRecord, type ModuleConfigurationRecord,
type ModuleConfigurationStore, type ModuleConfigurationStore,
ModuleConfigurationStoreError, ModuleConfigurationStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
resolveModuleEligibility, resolveModuleEligibility,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
@@ -205,6 +206,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store: lifecycleStore, store: lifecycleStore,
}, },
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({ moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase, database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices, }) as ModuleRuntimeServices,
@@ -509,7 +509,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
expect(signedUp.userId).toBeDefined(); expect(signedUp.userId).toBeDefined();
}); });
it("authorized read reflects real PostgreSQL state, exposes exactly moduleId/principal/params/services, and the handler's database is reference-equal to start's", async () => { it("authorized read reflects real PostgreSQL state, exposes the exact scoped handler context, and the handler's database is reference-equal to start's", async () => {
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`); const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" }); expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
@@ -520,15 +520,16 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
.reverse() .reverse()
.find((event) => event.type === "handler" && event.route === "GET /records/:id"); .find((event) => event.type === "handler" && event.route === "GET /records/:id");
// The complete handler-context surface, asserted exactly so a new field is a deliberate change // The complete handler-context surface, asserted exactly so a new field is a deliberate change
// here rather than an unnoticed one: `config` (ADR 0029) and `storage` (ADR 0030) are the two // here rather than an unnoticed one: `config` (ADR 0029), `storage` (ADR 0030), and `realtime`
// scoped, per-module additions; everything else a module might want — a Hono Context, the // (ADR 0032) are scoped, per-module additions; everything else a module might want — a Hono
// authorization store, raw PostgreSQL, the storage provider, an endpoint or credential — remains // Context, the authorization store, raw PostgreSQL, the storage provider, an endpoint or
// unreachable. // credential — remains unreachable.
expect(readHandlerEvent?.contextKeys).toEqual([ expect(readHandlerEvent?.contextKeys).toEqual([
"config", "config",
"moduleId", "moduleId",
"params", "params",
"principal", "principal",
"realtime",
"services", "services",
"storage", "storage",
]); ]);
+6 -1
View File
@@ -17,7 +17,10 @@ import {
type PermissionRegistry, type PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { createModuleUiPublicationRegistry } from "@ekklesia/core/modules"; import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
@@ -110,6 +113,7 @@ function buildModuleApp(options: {
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: options.registry, store: fakeStore() }, authorization: { evaluator, registry: options.registry, store: fakeStore() },
moduleRoutes: options.routes, moduleRoutes: options.routes,
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services, moduleServices: services,
moduleUi: createModuleUiPublicationRegistry([]), moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
@@ -352,6 +356,7 @@ describe("module route handler adaptation", () => {
"moduleId", "moduleId",
"params", "params",
"principal", "principal",
"realtime",
"services", "services",
"storage", "storage",
]); ]);
@@ -22,6 +22,7 @@ import {
discoverModules, discoverModules,
type ModuleLifecycleStore, type ModuleLifecycleStore,
ModuleLifecycleStoreError, ModuleLifecycleStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
resolveModuleEligibility, resolveModuleEligibility,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
@@ -181,6 +182,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store, store,
}, },
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({ moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase, database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices, }) as ModuleRuntimeServices,
+528
View File
@@ -0,0 +1,528 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRealtimePublisher,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
createAuthorizationEvaluator,
type AuthorizationEvaluator,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
discoverModules,
type ModuleRealtimeRegistry,
type ModuleRealtimeSubscription,
startDiscoveredModuleRuntime,
type StartedModuleRuntime,
} from "@ekklesia/core/modules";
import { type ServerType, serve } from "@hono/node-server";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { mountModuleRealtimeRoutes } from "../src/module-realtime-routes.js";
const allowedUserId = parseEkklesiaUserId("99999999-9999-4999-8999-999999999999");
const deniedUserId = parseEkklesiaUserId("77777777-7777-4777-8777-777777777777");
const stableDeliveryUrl =
"https://objects.example.test/announcements/slides/welcome.png?X-Amz-Signature=stable";
const TRANSPORT_DEADLINE_MS = 2_000;
async function withinTransportDeadline<Value>(
operation: Promise<Value>,
message: string,
onTimeout: () => void = () => undefined,
): Promise<Value> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
onTimeout();
reject(new Error(message));
}, TRANSPORT_DEADLINE_MS);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function waitForTransportCondition(predicate: () => boolean, message: string): Promise<void> {
let cancelled = false;
await withinTransportDeadline(
new Promise<void>((resolve) => {
const inspect = (): void => {
if (cancelled) {
return;
}
if (predicate()) {
resolve();
return;
}
setTimeout(inspect, 5);
};
inspect();
}),
message,
() => {
cancelled = true;
},
);
}
interface FixtureControl {
readonly events: string[];
publisher?: ModuleRealtimePublisher;
}
interface LiveEvent {
readonly event: "snapshot" | "update";
readonly payload: unknown;
readonly sequence: number;
}
class LiveSseConnection {
readonly #abort: AbortController;
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
#buffer = "";
constructor(response: Response, abort: AbortController) {
if (!response.body) {
throw new Error("Realtime integration response had no body.");
}
this.#abort = abort;
this.#reader = response.body.getReader();
}
async readEvent(): Promise<LiveEvent> {
for (;;) {
const boundary = this.#buffer.indexOf("\n\n");
if (boundary >= 0) {
const block = this.#buffer.slice(0, boundary);
this.#buffer = this.#buffer.slice(boundary + 2);
if (block.startsWith(":")) {
continue;
}
const event = block
.split("\n")
.find((line) => line.startsWith("event: "))
?.slice("event: ".length);
const data = block
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice("data: ".length);
if ((event !== "snapshot" && event !== "update") || data === undefined) {
throw new Error("Realtime integration received an invalid event.");
}
const envelope = JSON.parse(data) as { payload?: unknown; sequence?: unknown };
if (!Number.isSafeInteger(envelope.sequence) || (envelope.sequence as number) <= 0) {
throw new Error("Realtime integration received an invalid sequence.");
}
return { event, payload: envelope.payload, sequence: envelope.sequence as number };
}
const result = await this.#readWithinDeadline();
if (result.done) {
throw new Error("Realtime integration stream closed before the expected event.");
}
this.#buffer += new TextDecoder().decode(result.value, { stream: true });
}
}
async expectClosed(): Promise<void> {
const result = await this.#readWithinDeadline();
expect(result.done).toBe(true);
}
async close(): Promise<void> {
this.#abort.abort();
await withinTransportDeadline(
// Abort and cancel race by design; a rejection means one of them already released the
// transport. A deadline still propagates and fails the test.
this.#reader.cancel().catch(() => undefined),
"Realtime integration client cancellation timed out.",
);
}
async #readWithinDeadline() {
return withinTransportDeadline(
this.#reader.read(),
"Realtime integration read timed out.",
() => this.#abort.abort(),
);
}
}
async function createFixtureModule(control: FixtureControl): Promise<{
readonly directory: string;
readonly symbol: symbol;
}> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-native-realtime-"));
const moduleDirectory = path.join(directory, "announcements");
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
const symbol = Symbol.for(`ekklesia.native-realtime.${Date.now()}.${Math.random()}`);
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = control;
const expression = `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`;
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: "@test/module-announcements",
type: "module",
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id: "announcements",
manifestVersion: 5,
name: "Announcements fixture",
permissions: [
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: "announcements.slideshow.view",
},
],
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: "announcements",
version: "0.1.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
${expression}.events.push("register");
},
start({ realtime }) {
${expression}.publisher = realtime;
realtime.publish("state", {
mode: "on",
currentSlide: 0,
previousSlide: -1,
slides: [${JSON.stringify(stableDeliveryUrl)}]
});
${expression}.events.push("start");
},
stop() { ${expression}.events.push("stop"); }
};\n`,
),
]);
return { directory, symbol };
}
function services(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
}
function identityRuntime(): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: async (headers) => {
const cookie = headers.get("cookie");
if (cookie === "ekklesia-session=allowed") {
return createAuthenticatedPrincipal(allowedUserId);
}
if (cookie === "ekklesia-session=denied") {
return createAuthenticatedPrincipal(deniedUserId);
}
return ANONYMOUS_PRINCIPAL;
},
};
}
function authorization(): AuthorizationEvaluator {
return createAuthorizationEvaluator({
grants: { hasActiveGrant: async ({ userId }) => userId === allowedUserId },
platformOwners: { isPlatformOwner: async () => false },
});
}
async function bindLiveServer(
runtime: StartedModuleRuntime,
registry: PermissionRegistry,
): Promise<{
readonly activeSubscriptions: () => number;
readonly origin: string;
readonly server: ServerType;
}> {
const app = new Hono();
let activeSubscriptions = 0;
const observedRealtime: ModuleRealtimeRegistry = {
channels: runtime.realtime.channels,
closeSubscriptions: () => runtime.realtime.closeSubscriptions(),
publisherFor: (moduleId) => runtime.realtime.publisherFor(moduleId),
subscribe: (moduleId, channel): ModuleRealtimeSubscription => {
const subscription = runtime.realtime.subscribe(moduleId, channel);
activeSubscriptions += 1;
let closed = false;
return Object.freeze({
close: (): void => {
if (!closed) {
closed = true;
activeSubscriptions -= 1;
}
subscription.close();
},
read: () => subscription.read(),
snapshot: subscription.snapshot,
});
},
};
mountModuleRealtimeRoutes(app, {
evaluator: authorization(),
realtime: observedRealtime,
registry,
runtime: identityRuntime(),
});
app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
let pendingServer: ServerType | undefined;
const server = await withinTransportDeadline(
new Promise<ServerType>((resolve, reject) => {
pendingServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, () =>
resolve(pendingServer as ServerType),
);
pendingServer.once("error", reject);
}),
"Realtime integration server bind timed out.",
() => pendingServer?.close(),
);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Realtime integration server did not bind a TCP port.");
}
return {
activeSubscriptions: () => activeSubscriptions,
origin: `http://127.0.0.1:${address.port}`,
server,
};
}
async function connect(
origin: string,
cookie: string,
): Promise<{
readonly connection: LiveSseConnection;
readonly response: Response;
}> {
const abort = new AbortController();
const response = await withinTransportDeadline(
fetch(`${origin}/api/v1/module-realtime/announcements/state`, {
headers: { cookie },
signal: abort.signal,
}),
"Realtime integration connection timed out.",
() => abort.abort(),
);
return { connection: new LiveSseConnection(response, abort), response };
}
async function closeServer(server: ServerType): Promise<void> {
await withinTransportDeadline(
new Promise<void>((resolve) => server.close(() => resolve())),
"Realtime integration server close timed out.",
() =>
(
server as ServerType & {
closeAllConnections?: () => void;
}
).closeAllConnections?.(),
);
}
async function requestJson(
url: string,
init?: RequestInit,
): Promise<{ readonly body: unknown; readonly response: Response }> {
const abort = new AbortController();
const operation = (async (): Promise<{ readonly body: unknown; readonly response: Response }> => {
const response = await fetch(url, { ...init, signal: abort.signal });
const body = (await response.json()) as unknown;
return { body, response };
})();
return withinTransportDeadline(operation, "Realtime integration finite request timed out.", () =>
abort.abort(),
);
}
describe("native realtime over a live Node HTTP server", () => {
it("loads a real module and proves auth, snapshot, fanout, update, reconnect, and shutdown", async () => {
const control: FixtureControl = { events: [] };
const fixture = await createFixtureModule(control);
let runtime: StartedModuleRuntime | undefined;
let server: ServerType | undefined;
const connections: LiveSseConnection[] = [];
const failures: unknown[] = [];
try {
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory: fixture.directory,
platformVersion: "0.1.0",
});
runtime = await startDiscoveredModuleRuntime(modules, services());
expect(control.events).toEqual(["register", "start"]);
const live = await bindLiveServer(runtime, composePlatformPermissionRegistry(modules));
server = live.server;
const anonymous = await requestJson(
`${live.origin}/api/v1/module-realtime/announcements/state`,
);
expect(anonymous.response.status).toBe(403);
expect(anonymous.body).toEqual({ code: "AUTHORIZATION_DENIED" });
const missingPermission = await requestJson(
`${live.origin}/api/v1/module-realtime/announcements/state`,
{ headers: { cookie: "ekklesia-session=denied" } },
);
expect(missingPermission.response.status).toBe(403);
const first = await connect(live.origin, "ekklesia-session=allowed");
const second = await connect(live.origin, "ekklesia-session=allowed");
connections.push(first.connection, second.connection);
expect(first.response.status).toBe(200);
expect(first.response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
const expectedInitial = {
currentSlide: 0,
mode: "on",
previousSlide: -1,
slides: [stableDeliveryUrl],
};
expect(await first.connection.readEvent()).toEqual({
event: "snapshot",
payload: expectedInitial,
sequence: 1,
});
expect(await second.connection.readEvent()).toEqual({
event: "snapshot",
payload: expectedInitial,
sequence: 1,
});
expect(live.activeSubscriptions()).toBe(2);
control.publisher?.publish("state", {
currentSlide: 1,
mode: "on",
previousSlide: 0,
slides: [stableDeliveryUrl],
});
const expectedUpdate = {
event: "update",
payload: {
currentSlide: 1,
mode: "on",
previousSlide: 0,
slides: [stableDeliveryUrl],
},
sequence: 2,
};
expect(await first.connection.readEvent()).toEqual(expectedUpdate);
expect(await second.connection.readEvent()).toEqual(expectedUpdate);
await first.connection.close();
await waitForTransportCondition(
() => live.activeSubscriptions() === 1,
"Realtime integration subscriber cleanup timed out.",
);
control.publisher?.publish("state", {
currentSlide: 2,
mode: "off",
previousSlide: 1,
slides: [stableDeliveryUrl],
});
expect(await second.connection.readEvent()).toMatchObject({ event: "update", sequence: 3 });
const reconnected = await connect(live.origin, "ekklesia-session=allowed");
connections.push(reconnected.connection);
expect(await reconnected.connection.readEvent()).toEqual({
event: "snapshot",
payload: {
currentSlide: 2,
mode: "off",
previousSlide: 1,
slides: [stableDeliveryUrl],
},
sequence: 3,
});
expect(live.activeSubscriptions()).toBe(2);
const secondClosed = second.connection.expectClosed();
const reconnectedClosed = reconnected.connection.expectClosed();
runtime.realtime.closeSubscriptions();
await Promise.all([secondClosed, reconnectedClosed]);
await waitForTransportCondition(
() => live.activeSubscriptions() === 0,
"Realtime integration shutdown cleanup timed out.",
);
// Stream shutdown happens before ordinary HTTP drain in production. A publisher must remain
// usable until that drain completes so an already-running command can publish its result.
expect(() => control.publisher?.publish("state", null)).not.toThrow();
const afterShutdown = await requestJson(
`${live.origin}/api/v1/module-realtime/announcements/state`,
{ headers: { cookie: "ekklesia-session=allowed" } },
);
expect(afterShutdown.response.status).toBe(503);
expect(afterShutdown.body).toEqual({ code: "REALTIME_UNAVAILABLE" });
await runtime.stop();
expect(() => control.publisher?.publish("state", null)).toThrowError(
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
);
} catch (error) {
failures.push(error);
} finally {
try {
runtime?.realtime.closeSubscriptions();
} catch (error) {
failures.push(error);
}
const connectionCleanup = await Promise.allSettled(
connections.map((connection) => connection.close()),
);
for (const result of connectionCleanup) {
if (result.status === "rejected") {
failures.push(result.reason);
}
}
if (server) {
try {
await closeServer(server);
} catch (error) {
failures.push(error);
}
}
if (runtime) {
try {
await runtime.stop();
} catch (error) {
failures.push(error);
}
}
delete (globalThis as unknown as Record<PropertyKey, unknown>)[fixture.symbol];
try {
await rm(fixture.directory, { force: true, recursive: true });
} catch (error) {
failures.push(error);
}
}
if (failures.length > 0) {
throw failures[0];
}
expect(control.events).toEqual(["register", "start", "stop"]);
});
});
@@ -0,0 +1,270 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
ModuleRealtimeError,
moduleIdSchema,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts";
import { createPermissionRegistry, type PermissionRegistry } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { type ModuleRealtimeRegistry, StagedModuleRealtimeRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import {
MODULE_REALTIME_KEEPALIVE_MS,
MODULE_REALTIME_MAX_CONNECTION_AGE_MS,
ModuleRealtimeConfigurationError,
moduleRealtimeBaseUrl,
mountModuleRealtimeRoutes,
} from "../src/module-realtime-routes.js";
const moduleId = moduleIdSchema.parse("announcements");
const permission = parsePermissionId("announcements.slideshow.view");
const userId = parseEkklesiaUserId("88888888-8888-4888-8888-888888888888");
const endpoint = "/api/v1/module-realtime/announcements/state";
function permissionRegistry(): PermissionRegistry {
return createPermissionRegistry([
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: permission,
},
]);
}
function realtimeFixture(initialPayload: unknown = { mode: "on" }): {
readonly publisher: ReturnType<StagedModuleRealtimeRegistry["publisherFor"]>;
readonly realtime: ModuleRealtimeRegistry;
} {
const staged = new StagedModuleRealtimeRegistry();
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
registration.realtime.channel({ channel: "state", permission });
registration.commit();
const publisher = staged.publisherFor(moduleId);
publisher.publish("state", initialPayload as never);
return { publisher, realtime: staged.publish() };
}
function appFor(
realtime: ModuleRealtimeRegistry,
options: {
readonly allowed?: boolean;
readonly keepaliveMs?: number;
readonly maxConnectionAgeMs?: number;
readonly signedIn?: boolean;
} = {},
): Hono {
const app = new Hono();
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi
.fn()
.mockResolvedValue(
options.signedIn === false ? ANONYMOUS_PRINCIPAL : createAuthenticatedPrincipal(userId),
),
};
mountModuleRealtimeRoutes(app, {
evaluator: {
authorize: vi
.fn()
.mockResolvedValue({ allowed: options.signedIn !== false && options.allowed !== false }),
},
...(options.keepaliveMs === undefined ? {} : { keepaliveMs: options.keepaliveMs }),
...(options.maxConnectionAgeMs === undefined
? {}
: { maxConnectionAgeMs: options.maxConnectionAgeMs }),
realtime,
registry: permissionRegistry(),
runtime,
});
return app;
}
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
reader.read(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Realtime test read timed out.")), 1_000);
}),
]);
return result.done ? "" : new TextDecoder().decode(result.value);
} finally {
clearTimeout(timer);
}
}
describe("module realtime HTTP transport", () => {
it("builds one server-owned root and rejects an unenforceable channel snapshot", () => {
expect(moduleRealtimeBaseUrl("announcements")).toBe("/api/v1/module-realtime/announcements");
expect(MODULE_REALTIME_KEEPALIVE_MS).toBe(15_000);
expect(MODULE_REALTIME_MAX_CONNECTION_AGE_MS).toBe(300_000);
const { realtime } = realtimeFixture();
expect(() =>
mountModuleRealtimeRoutes(new Hono(), {
evaluator: { authorize: vi.fn() },
realtime,
registry: createPermissionRegistry([]),
runtime: { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn() },
}),
).toThrow(ModuleRealtimeConfigurationError);
});
it("denies anonymous and unauthorized callers before allocating a subscription", async () => {
const { realtime } = realtimeFixture();
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
const observed: ModuleRealtimeRegistry = {
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe,
};
const anonymous = await appFor(observed, { signedIn: false }).request(endpoint);
expect(anonymous.status).toBe(403);
expect(await anonymous.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(subscribe).not.toHaveBeenCalled();
const unauthorized = await appFor(observed, { allowed: false }).request(endpoint);
expect(unauthorized.status).toBe(403);
expect(await unauthorized.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(subscribe).not.toHaveBeenCalled();
});
it("refuses Hono's implicit HEAD handling without allocating a stream subscription", async () => {
const { realtime } = realtimeFixture();
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
const observed: ModuleRealtimeRegistry = {
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe,
};
const response = await appFor(observed).request(endpoint, { method: "HEAD" });
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(subscribe).not.toHaveBeenCalled();
});
it("opens an authorized snapshot-first stream with proxy-safe headers and unchanged URLs", async () => {
const deliveryUrl =
"https://objects.example.test/slides/welcome.png?X-Amz-Signature=byte-identical";
const { realtime } = realtimeFixture({
currentSlide: 0,
mode: "on",
previousSlide: -1,
slides: [deliveryUrl],
});
const response = await appFor(realtime).request(endpoint);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("x-accel-buffering")).toBe("no");
expect(response.headers.get("vary")).toBe("Origin, Cookie");
const reader = response.body?.getReader();
expect(reader).toBeDefined();
const snapshot = await readChunk(reader as ReadableStreamDefaultReader<Uint8Array>);
expect(snapshot).toContain("event: snapshot\n");
expect(snapshot).toContain('"sequence":1');
expect(snapshot).toContain(deliveryUrl);
expect(snapshot).not.toContain("\nid:");
await reader?.cancel();
});
it("streams ordered updates, emits transport-only keepalives, and closes on shutdown", async () => {
const { publisher, realtime } = realtimeFixture({ currentSlide: 0 });
const response = await appFor(realtime, { keepaliveMs: 5 }).request(endpoint);
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
expect(await readChunk(reader)).toContain("event: snapshot");
publisher.publish("state", { currentSlide: 1 });
const update = await readChunk(reader);
expect(update).toContain("event: update");
expect(update).toContain('"sequence":2');
expect(update).toContain('"currentSlide":1');
expect(await readChunk(reader)).toBe(": keepalive\n\n");
const closed = reader.read();
realtime.closeSubscriptions();
await expect(closed).resolves.toEqual({ done: true, value: undefined });
});
it("closes a stream at the host-owned maximum age so reconnect reauthorizes", async () => {
const { realtime } = realtimeFixture({ currentSlide: 0 });
let activeSubscriptions = 0;
const observed: ModuleRealtimeRegistry = {
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe: (owner, channel) => {
const subscription = realtime.subscribe(owner, channel);
activeSubscriptions += 1;
let closed = false;
return Object.freeze({
close: () => {
if (!closed) {
closed = true;
activeSubscriptions -= 1;
}
subscription.close();
},
read: () => subscription.read(),
snapshot: subscription.snapshot,
});
},
};
const response = await appFor(observed, {
keepaliveMs: 1_000,
maxConnectionAgeMs: 25,
}).request(endpoint);
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
expect(await readChunk(reader)).toContain("event: snapshot");
expect(activeSubscriptions).toBe(1);
expect(await readChunk(reader)).toBe("");
expect(activeSubscriptions).toBe(0);
const replacement = realtime.subscribe(moduleId, "state");
replacement.close();
});
it("does not expose unknown module/channel combinations", async () => {
const { realtime } = realtimeFixture();
const app = appFor(realtime);
expect((await app.request("/api/v1/module-realtime/calendar/state")).status).toBe(404);
expect((await app.request("/api/v1/module-realtime/announcements/other")).status).toBe(404);
});
it("maps subscriber-capacity and unexpected allocation failures to fixed non-secret responses", async () => {
const { realtime } = realtimeFixture();
const observed = (failure: unknown): ModuleRealtimeRegistry => ({
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe: () => {
throw failure;
},
});
const atCapacity = await appFor(
observed(new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT")),
).request(endpoint);
expect(atCapacity.status).toBe(503);
expect(await atCapacity.json()).toEqual({ code: "REALTIME_SUBSCRIBER_LIMIT" });
const detail = "secret-token-and-machine-path";
const unavailable = await appFor(observed(new Error(detail))).request(endpoint);
expect(unavailable.status).toBe(503);
const body = await unavailable.text();
expect(body).toBe('{"code":"REALTIME_UNAVAILABLE"}');
expect(body).not.toContain(detail);
});
});
@@ -217,6 +217,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
moduleRoutes: moduleRuntime.routes, moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime,
moduleServices: services, moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules), moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: { moduleLifecycle: {
@@ -289,6 +290,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
apiBaseUrl: "/api/v1/modules/example", apiBaseUrl: "/api/v1/modules/example",
id: "example", id: "example",
permissions: ["example.records.read"], permissions: ["example.records.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
uiUrl: "/api/v1/module-ui/example/0.1.0", uiUrl: "/api/v1/module-ui/example/0.1.0",
version: "0.1.0", version: "0.1.0",
}); });
+9 -1
View File
@@ -20,12 +20,14 @@ import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModules, discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js"; import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js"; import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
import { import {
@@ -173,6 +175,7 @@ async function install(options: InstallOptions): Promise<Installation> {
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: permissionRegistry, store }, authorization: { evaluator, registry: permissionRegistry, store },
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services, moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules), moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: { moduleLifecycle: {
@@ -219,13 +222,14 @@ describe("module UI inventory — what it lists", () => {
api: { baseUrl: "/api/v1/modules/example" }, api: { baseUrl: "/api/v1/modules/example" },
id: "example", id: "example",
permissions: [], permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/example" },
ui: { url: "/api/v1/module-ui/example/0.1.0" }, ui: { url: "/api/v1/module-ui/example/0.1.0" },
version: "0.1.0", version: "0.1.0",
}, },
]); ]);
}); });
it("builds both URLs with the server's own canonical helpers", async () => { it("builds all host URLs with the server's own canonical helpers", async () => {
const { app, modules: discovered } = await install({ const { app, modules: discovered } = await install({
fixtures: [{ id: "example", version: "2.4.1", withUi: true }], fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
principal: signedIn(), principal: signedIn(),
@@ -240,6 +244,9 @@ describe("module UI inventory — what it lists", () => {
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }), url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
}); });
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) }); expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
expect(modules[0]?.realtime).toEqual({
baseUrl: moduleRealtimeBaseUrl(module.manifest.id),
});
}); });
it("omits a module that declares no UI entrypoint", async () => { it("omits a module that declares no UI entrypoint", async () => {
@@ -310,6 +317,7 @@ describe("module UI inventory — what it lists", () => {
"api", "api",
"id", "id",
"permissions", "permissions",
"realtime",
"ui", "ui",
"version", "version",
]); ]);
+3
View File
@@ -16,6 +16,7 @@ import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModules, discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; import { loadModuleUi } from "@ekklesia/module-ui-host";
import type { Hono } from "hono"; import type { Hono } from "hono";
@@ -173,6 +174,7 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store }, authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store },
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services, moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules), moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: { moduleLifecycle: {
@@ -708,6 +710,7 @@ describe("module UI artifact publication — the generic loader consumes what is
moduleId: "announcements", moduleId: "announcements",
permissions: { has: () => false }, permissions: { has: () => false },
principal: { kind: "anonymous" as const }, principal: { kind: "anonymous" as const },
realtime: { subscribe: () => ({ close: () => undefined }) },
}; };
} }
+3 -2
View File
@@ -1,13 +1,14 @@
export * from "./configuration.js";
export * from "./configuration-schema.js"; export * from "./configuration-schema.js";
export * from "./configuration-store.js"; export * from "./configuration-store.js";
export * from "./configuration.js";
export * from "./database.js"; export * from "./database.js";
export * from "./discovery.js"; export * from "./discovery.js";
export * from "./lifecycle.js";
export * from "./lifecycle-schema.js"; export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js"; export * from "./lifecycle-store.js";
export * from "./lifecycle.js";
export * from "./migration-discovery.js"; export * from "./migration-discovery.js";
export * from "./permissions.js"; export * from "./permissions.js";
export * from "./realtime.js";
export * from "./runtime.js"; export * from "./runtime.js";
export * from "./schema-discovery.js"; export * from "./schema-discovery.js";
export * from "./ui-publication.js"; export * from "./ui-publication.js";
+578
View File
@@ -0,0 +1,578 @@
import {
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
type ModuleId,
type ModuleRealtimeChannel,
type ModuleRealtimeChannelDefinitionInput,
type ModuleRealtimeChannelRegistrar,
ModuleRealtimeError,
type ModuleRealtimePublisher,
moduleIdSchema,
moduleRealtimeChannelSchema,
type PermissionId,
safeParseModuleRealtimeChannelDefinition,
} from "@ekklesia/contracts";
export type ModuleRealtimeRegistrationIssueCode =
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
| "MODULE_REALTIME_CHANNEL_INVALID"
| "MODULE_REALTIME_PERMISSION_INVALID"
| "MODULE_REALTIME_PERMISSION_UNDECLARED"
| "MODULE_REALTIME_CHANNEL_DUPLICATE"
| "MODULE_REALTIME_CHANNEL_LIMIT";
/** An internal fixed-code signal. The runtime converts it to its ordinary structured issue shape. */
export class ModuleRealtimeRegistrationError extends Error {
readonly code: ModuleRealtimeRegistrationIssueCode;
constructor(code: ModuleRealtimeRegistrationIssueCode) {
super("Module realtime channel registration failed.");
this.name = "ModuleRealtimeRegistrationError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
export interface RegisteredModuleRealtimeChannel {
readonly moduleId: ModuleId;
readonly channel: ModuleRealtimeChannel;
readonly permission: PermissionId;
}
/** A host-facing opaque publication. `serializedPayload` is the exact JSON snapshot serialized once
* at publish time; hosts forward it without inspecting, mutating, or re-signing values inside it. */
export interface ModuleRealtimePublication {
readonly sequence: number;
readonly serializedPayload: string;
}
/** `snapshot` is captured atomically with installing this subscription. `read` then yields ordered
* later publications. A slow reader retains only the newest pending publication. */
export interface ModuleRealtimeSubscription {
readonly snapshot: ModuleRealtimePublication;
read(): Promise<ModuleRealtimePublication | undefined>;
close(): void;
}
export interface ModuleRealtimeRegistry {
readonly channels: readonly RegisteredModuleRealtimeChannel[];
/** Refuses new subscriptions and closes existing streams while publishers remain usable for
* ordinary HTTP requests already draining. Publisher lifetime remains Core runtime-owned. */
closeSubscriptions(): void;
publisherFor(moduleId: ModuleId): ModuleRealtimePublisher;
subscribe(moduleId: ModuleId, channel: string): ModuleRealtimeSubscription;
}
const emptyRealtimePublisher: ModuleRealtimePublisher = Object.freeze({
publish: (): never => {
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
},
});
/** Explicit no-channel host composition for tests and hosts that start no modules. */
export const EMPTY_MODULE_REALTIME_REGISTRY: ModuleRealtimeRegistry = Object.freeze({
channels: Object.freeze([]),
closeSubscriptions: (): void => undefined,
publisherFor: (): ModuleRealtimePublisher => emptyRealtimePublisher,
subscribe: (): never => {
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
},
});
interface ChannelState {
readonly definition: RegisteredModuleRealtimeChannel;
readonly subscriptions: Set<BoundedModuleRealtimeSubscription>;
publication?: ModuleRealtimePublication;
}
interface RealtimeRegistrationStage {
readonly realtime: ModuleRealtimeChannelRegistrar;
commit(): void;
}
const textEncoder = new TextEncoder();
type JsonWriteResult = "invalid" | "success" | "too-large";
/** Accumulates at most the public payload ceiling. Each encoded fragment is itself bounded, so an
* oversized value cannot create an oversized temporary serialization before being refused. */
class BoundedJsonWriter {
readonly #chunks: string[] = [];
#bytes = 0;
get remainingBytes(): number {
return MODULE_REALTIME_MAX_PAYLOAD_BYTES - this.#bytes;
}
append(fragment: string): boolean {
const bytes = textEncoder.encode(fragment).byteLength;
if (bytes > this.remainingBytes) {
return false;
}
this.#chunks.push(fragment);
this.#bytes += bytes;
return true;
}
finish(): string {
return this.#chunks.join("");
}
}
function appendRawString(
writer: BoundedJsonWriter,
value: string,
start: number,
end: number,
): boolean {
let offset = start;
while (offset < end) {
let chunkEnd = Math.min(offset + 1_024, end);
const finalCodeUnit = value.charCodeAt(chunkEnd - 1);
if (
chunkEnd < end &&
finalCodeUnit >= 0xd800 &&
finalCodeUnit <= 0xdbff &&
value.charCodeAt(chunkEnd) >= 0xdc00 &&
value.charCodeAt(chunkEnd) <= 0xdfff
) {
chunkEnd -= 1;
}
if (!writer.append(value.slice(offset, chunkEnd))) {
return false;
}
offset = chunkEnd;
}
return true;
}
function escapedCodeUnit(codeUnit: number): string | undefined {
switch (codeUnit) {
case 0x08:
return "\\b";
case 0x09:
return "\\t";
case 0x0a:
return "\\n";
case 0x0c:
return "\\f";
case 0x0d:
return "\\r";
case 0x22:
return '\\"';
case 0x5c:
return "\\\\";
default:
return codeUnit <= 0x1f ? `\\u${codeUnit.toString(16).padStart(4, "0")}` : undefined;
}
}
/** Writes the same JSON string representation as `JSON.stringify`, including escaping lone UTF-16
* surrogates, but stops the moment the output budget cannot be met. */
function writeJsonString(writer: BoundedJsonWriter, value: string): JsonWriteResult {
// Every UTF-16 code unit contributes at least one UTF-8 byte to JSON. This rejects an enormous
// string without first copying or stringifying it.
if (value.length + 2 > writer.remainingBytes) {
return "too-large";
}
if (!writer.append('"')) {
return "too-large";
}
let rawStart = 0;
for (let index = 0; index < value.length; index += 1) {
const codeUnit = value.charCodeAt(index);
let escaped = escapedCodeUnit(codeUnit);
if (escaped === undefined && codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
const following = value.charCodeAt(index + 1);
if (following >= 0xdc00 && following <= 0xdfff) {
index += 1;
continue;
}
escaped = `\\u${codeUnit.toString(16)}`;
} else if (escaped === undefined && codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
escaped = `\\u${codeUnit.toString(16)}`;
}
if (escaped === undefined) {
continue;
}
if (!appendRawString(writer, value, rawStart, index) || !writer.append(escaped)) {
return "too-large";
}
rawStart = index + 1;
}
if (!appendRawString(writer, value, rawStart, value.length) || !writer.append('"')) {
return "too-large";
}
return "success";
}
function writeJsonValue(
writer: BoundedJsonWriter,
value: unknown,
ancestors: Set<object>,
depth: number,
): JsonWriteResult {
if (value === null) {
return writer.append("null") ? "success" : "too-large";
}
if (typeof value === "string") {
return writeJsonString(writer, value);
}
if (typeof value === "boolean") {
return writer.append(value ? "true" : "false") ? "success" : "too-large";
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
return "invalid";
}
// A finite JavaScript number's JSON spelling is tiny and cannot be an unbounded allocation.
const serializedNumber = JSON.stringify(value);
return writer.append(serializedNumber) ? "success" : "too-large";
}
if (typeof value !== "object" || depth > 64 || ancestors.has(value)) {
return "invalid";
}
ancestors.add(value);
try {
// Never execute user-defined serialization hooks. A non-enumerable `toJSON` still changes
// `JSON.stringify` output, so silently ignoring it would make validation and wire semantics
// disagree. Reject the hook itself without reading or invoking its value.
if (Object.hasOwn(value, "toJSON")) {
return "invalid";
}
if (Array.isArray(value)) {
if (Object.getPrototypeOf(value) !== Array.prototype) {
return "invalid";
}
// Brackets, one single-byte value per element, and separators are the smallest possible
// representation. If even that cannot fit, do not enumerate a potentially enormous array.
const minimumBytes = value.length === 0 ? 2 : value.length * 2 + 1;
if (minimumBytes > writer.remainingBytes) {
return "too-large";
}
for (const key in value) {
if (!Object.hasOwn(value, key)) {
continue;
}
if (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length) {
return "invalid";
}
}
if (!writer.append("[")) {
return "too-large";
}
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
return "invalid";
}
if (index > 0 && !writer.append(",")) {
return "too-large";
}
const result = writeJsonValue(writer, descriptor.value, ancestors, depth + 1);
if (result !== "success") {
return result;
}
}
return writer.append("]") ? "success" : "too-large";
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
return "invalid";
}
if (!writer.append("{")) {
return "too-large";
}
let entryCount = 0;
for (const key in value) {
if (!Object.hasOwn(value, key)) {
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
return "invalid";
}
if (entryCount > 0 && !writer.append(",")) {
return "too-large";
}
const keyResult = writeJsonString(writer, key);
if (keyResult !== "success") {
return keyResult;
}
if (!writer.append(":")) {
return "too-large";
}
const valueResult = writeJsonValue(writer, descriptor.value, ancestors, depth + 1);
if (valueResult !== "success") {
return valueResult;
}
entryCount += 1;
}
return writer.append("}") ? "success" : "too-large";
} catch {
return "invalid";
} finally {
ancestors.delete(value);
}
}
export type ModuleRealtimePayloadSerializationResult =
| { readonly success: true; readonly serializedPayload: string }
| { readonly success: false; readonly code: "REALTIME_PAYLOAD_INVALID" }
| { readonly success: false; readonly code: "REALTIME_PAYLOAD_TOO_LARGE" };
/** Serializes once and reports only a fixed code. It never returns or embeds rejected input. */
export function serializeModuleRealtimePayload(
payload: unknown,
): ModuleRealtimePayloadSerializationResult {
const writer = new BoundedJsonWriter();
const result = writeJsonValue(writer, payload, new Set<object>(), 0);
if (result === "invalid") {
return { success: false, code: "REALTIME_PAYLOAD_INVALID" };
}
if (result === "too-large") {
return { success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" };
}
return { success: true, serializedPayload: writer.finish() };
}
class BoundedModuleRealtimeSubscription implements ModuleRealtimeSubscription {
readonly snapshot: ModuleRealtimePublication;
#closed = false;
#pending: ModuleRealtimePublication | undefined;
#reader: ((publication: ModuleRealtimePublication | undefined) => void) | undefined;
readonly #onClose: () => void;
constructor(snapshot: ModuleRealtimePublication, onClose: () => void) {
this.snapshot = snapshot;
this.#onClose = onClose;
}
push(publication: ModuleRealtimePublication): void {
if (this.#closed) {
return;
}
const reader = this.#reader;
if (reader) {
this.#reader = undefined;
reader(publication);
return;
}
// One slot, overwritten by the newest publication. A publisher never waits for a browser.
this.#pending = publication;
}
read(): Promise<ModuleRealtimePublication | undefined> {
if (this.#closed) {
return Promise.resolve(undefined);
}
if (this.#pending) {
const publication = this.#pending;
this.#pending = undefined;
return Promise.resolve(publication);
}
if (this.#reader) {
return Promise.reject(new ModuleRealtimeError("REALTIME_UNAVAILABLE"));
}
return new Promise((resolve) => {
this.#reader = resolve;
});
}
close(): void {
if (this.#closed) {
return;
}
this.#closed = true;
this.#pending = undefined;
const reader = this.#reader;
this.#reader = undefined;
this.#onClose();
reader?.(undefined);
}
}
function stateKey(moduleId: string, channel: string): string {
return `${moduleId}\0${channel}`;
}
/** Registration is staged per module, while state and subscriptions stay private and bounded. */
export class StagedModuleRealtimeRegistry {
readonly #channels: RegisteredModuleRealtimeChannel[] = [];
readonly #publishers = new Map<ModuleId, ModuleRealtimePublisher>();
readonly #states = new Map<string, ChannelState>();
readonly #activeModules = new Set<ModuleId>();
#acceptingSubscriptions = true;
createRegistrationStage(
moduleId: ModuleId,
ownedPermissionIds: ReadonlySet<PermissionId>,
): RealtimeRegistrationStage {
const local: RegisteredModuleRealtimeChannel[] = [];
const localNames = new Set<ModuleRealtimeChannel>();
const realtime: ModuleRealtimeChannelRegistrar = Object.freeze({
channel: (definition: ModuleRealtimeChannelDefinitionInput): void => {
const parsed = safeParseModuleRealtimeChannelDefinition(definition);
if (!parsed.success) {
throw new ModuleRealtimeRegistrationError(parsed.code);
}
if (!ownedPermissionIds.has(parsed.data.permission)) {
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_PERMISSION_UNDECLARED");
}
if (localNames.has(parsed.data.channel)) {
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_DUPLICATE");
}
if (local.length >= MODULE_REALTIME_MAX_CHANNELS_PER_MODULE) {
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_LIMIT");
}
localNames.add(parsed.data.channel);
local.push(
Object.freeze({
channel: parsed.data.channel,
moduleId,
permission: parsed.data.permission,
}),
);
},
});
return {
realtime,
commit: (): void => {
for (const definition of local) {
this.#channels.push(definition);
this.#states.set(stateKey(moduleId, definition.channel), {
definition,
subscriptions: new Set(),
});
}
this.#activeModules.add(moduleId);
},
};
}
publisherFor(moduleId: ModuleId): ModuleRealtimePublisher {
let publisher = this.#publishers.get(moduleId);
if (!publisher) {
publisher = Object.freeze({
publish: (channel: string, payload: unknown): void => {
this.#publish(moduleId, channel, payload);
},
});
this.#publishers.set(moduleId, publisher);
}
return publisher;
}
missingSnapshots(moduleId: ModuleId): readonly ModuleRealtimeChannel[] {
return Object.freeze(
this.#channels
.filter((definition) => definition.moduleId === moduleId)
.filter(
(definition) => !this.#states.get(stateKey(moduleId, definition.channel))?.publication,
)
.map((definition) => definition.channel),
);
}
closeSubscriptions(): void {
this.#acceptingSubscriptions = false;
for (const state of this.#states.values()) {
for (const subscription of [...state.subscriptions]) {
subscription.close();
}
}
}
/** Makes one module's captured publisher inert immediately before that module's stop hook. Other
* dependency modules remain live until reverse-order shutdown reaches their own hook. */
deactivateModule(moduleId: ModuleId): void {
this.#activeModules.delete(moduleId);
for (const state of this.#states.values()) {
if (state.definition.moduleId !== moduleId) {
continue;
}
for (const subscription of [...state.subscriptions]) {
subscription.close();
}
}
}
deactivateAll(): void {
this.closeSubscriptions();
this.#activeModules.clear();
}
publish(): ModuleRealtimeRegistry {
const channels = Object.freeze([...this.#channels]);
return Object.freeze({
channels,
closeSubscriptions: (): void => this.closeSubscriptions(),
publisherFor: (moduleId: ModuleId): ModuleRealtimePublisher => this.publisherFor(moduleId),
subscribe: (moduleId: ModuleId, channel: string): ModuleRealtimeSubscription =>
this.subscribe(moduleId, channel),
});
}
subscribe(moduleId: ModuleId, channel: string): ModuleRealtimeSubscription {
if (!this.#acceptingSubscriptions) {
throw new ModuleRealtimeError("REALTIME_UNAVAILABLE");
}
if (
!moduleIdSchema.safeParse(moduleId).success ||
!moduleRealtimeChannelSchema.safeParse(channel).success
) {
throw new ModuleRealtimeError("REALTIME_CHANNEL_INVALID");
}
const state = this.#states.get(stateKey(moduleId, channel));
if (!state) {
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
}
if (!state.publication) {
throw new ModuleRealtimeError("REALTIME_SNAPSHOT_UNAVAILABLE");
}
if (state.subscriptions.size >= MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL) {
throw new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT");
}
const subscription = new BoundedModuleRealtimeSubscription(state.publication, () => {
state.subscriptions.delete(subscription);
});
// JavaScript runs this capture and insertion without interleaving. A later publish therefore
// either becomes `snapshot` or is observed by `read`, never lost between those two operations.
state.subscriptions.add(subscription);
return subscription;
}
#publish(moduleId: ModuleId, channel: string, payload: unknown): void {
if (!this.#activeModules.has(moduleId)) {
throw new ModuleRealtimeError("REALTIME_PUBLISHER_STOPPED");
}
const parsedChannel = moduleRealtimeChannelSchema.safeParse(channel);
if (!parsedChannel.success) {
throw new ModuleRealtimeError("REALTIME_CHANNEL_INVALID");
}
const state = this.#states.get(stateKey(moduleId, parsedChannel.data));
if (!state) {
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
}
const serialized = serializeModuleRealtimePayload(payload);
if (!serialized.success) {
throw new ModuleRealtimeError(serialized.code);
}
const previousSequence = state.publication?.sequence ?? 0;
if (previousSequence >= Number.MAX_SAFE_INTEGER) {
throw new ModuleRealtimeError("REALTIME_UNAVAILABLE");
}
const publication = Object.freeze({
sequence: previousSequence + 1,
serializedPayload: serialized.serializedPayload,
});
state.publication = publication;
for (const subscription of state.subscriptions) {
subscription.push(publication);
}
}
}
+111 -12
View File
@@ -1,20 +1,22 @@
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { import {
type CapabilityId, type CapabilityId,
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleStorage,
type CapabilityRegistrar, type CapabilityRegistrar,
type CapabilityResolver, type CapabilityResolver,
capabilityIdSchema, capabilityIdSchema,
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleHttpRouteRegistrar, type ModuleHttpRouteRegistrar,
type ModuleId, type ModuleId,
ModuleRealtimeError,
type ModuleRealtimePublisher,
type ModuleRegistrationContext, type ModuleRegistrationContext,
type ModuleRouteDefinitionInput, type ModuleRouteDefinitionInput,
type ModuleRuntimeContext, type ModuleRuntimeContext,
type ModuleRuntimeDefinition, type ModuleRuntimeDefinition,
type ModuleRuntimeServices, type ModuleRuntimeServices,
type ModuleStorage,
moduleRouteShapeKey, moduleRouteShapeKey,
moduleRoutesOverlap, moduleRoutesOverlap,
parseModuleRuntimeDefinition, parseModuleRuntimeDefinition,
@@ -27,6 +29,11 @@ import {
discoverModules, discoverModules,
type ModuleDiscoveryOptions, type ModuleDiscoveryOptions,
} from "./discovery.js"; } from "./discovery.js";
import {
ModuleRealtimeRegistrationError,
type ModuleRealtimeRegistry,
StagedModuleRealtimeRegistry,
} from "./realtime.js";
export type ModuleRuntimePhase = "load" | "register" | "rollback" | "start" | "stop"; export type ModuleRuntimePhase = "load" | "register" | "rollback" | "start" | "stop";
@@ -37,6 +44,18 @@ export type ModuleRuntimeIssueCode =
| "MODULE_CAPABILITY_UNDECLARED" | "MODULE_CAPABILITY_UNDECLARED"
| "MODULE_CAPABILITY_UNREGISTERED" | "MODULE_CAPABILITY_UNREGISTERED"
| "MODULE_REGISTER_FAILED" | "MODULE_REGISTER_FAILED"
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
| "MODULE_REALTIME_CHANNEL_DUPLICATE"
| "MODULE_REALTIME_CHANNEL_INVALID"
| "MODULE_REALTIME_CHANNEL_LIMIT"
| "MODULE_REALTIME_CHANNEL_UNDECLARED"
| "MODULE_REALTIME_PAYLOAD_INVALID"
| "MODULE_REALTIME_PAYLOAD_TOO_LARGE"
| "MODULE_REALTIME_PERMISSION_INVALID"
| "MODULE_REALTIME_PERMISSION_UNDECLARED"
| "MODULE_REALTIME_PUBLISH_FAILED"
| "MODULE_REALTIME_PUBLISHER_STOPPED"
| "MODULE_REALTIME_SNAPSHOT_MISSING"
| "MODULE_ROLLBACK_FAILED" | "MODULE_ROLLBACK_FAILED"
| "MODULE_ROLLBACK_TIMEOUT" | "MODULE_ROLLBACK_TIMEOUT"
| "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DEFINITION_INVALID"
@@ -77,6 +96,8 @@ export interface StartedModuleRuntime {
/** One immutable startup snapshot: discovered-module order, then registration order within each /** One immutable startup snapshot: discovered-module order, then registration order within each
* module. Never mutated after startup — there is no dynamic route addition or removal. */ * module. Never mutated after startup — there is no dynamic route addition or removal. */
readonly routes: readonly RegisteredModuleHttpRoute[]; readonly routes: readonly RegisteredModuleHttpRoute[];
/** Opaque, transport-neutral state and subscriptions for the host. */
readonly realtime: ModuleRealtimeRegistry;
stop(): Promise<void>; stop(): Promise<void>;
} }
@@ -193,6 +214,23 @@ function runtimeIssue(
return { code, message, moduleId, phase }; return { code, message, moduleId, phase };
} }
function realtimeRuntimeIssueCode(error: ModuleRealtimeError): ModuleRuntimeIssueCode {
switch (error.code) {
case "REALTIME_CHANNEL_INVALID":
return "MODULE_REALTIME_CHANNEL_INVALID";
case "REALTIME_CHANNEL_UNDECLARED":
return "MODULE_REALTIME_CHANNEL_UNDECLARED";
case "REALTIME_PAYLOAD_INVALID":
return "MODULE_REALTIME_PAYLOAD_INVALID";
case "REALTIME_PAYLOAD_TOO_LARGE":
return "MODULE_REALTIME_PAYLOAD_TOO_LARGE";
case "REALTIME_PUBLISHER_STOPPED":
return "MODULE_REALTIME_PUBLISHER_STOPPED";
default:
return "MODULE_REALTIME_PUBLISH_FAILED";
}
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
@@ -280,6 +318,7 @@ class StagedCapabilityRegistry {
services: ModuleRuntimeServices, services: ModuleRuntimeServices,
config: ModuleConfiguration, config: ModuleConfiguration,
storage: ModuleStorage, storage: ModuleStorage,
realtime: ModuleRealtimePublisher,
): ModuleRuntimeContext { ): ModuleRuntimeContext {
const capabilities: CapabilityResolver = Object.freeze({ const capabilities: CapabilityResolver = Object.freeze({
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => { resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
@@ -291,6 +330,7 @@ class StagedCapabilityRegistry {
capabilities, capabilities,
config, config,
moduleId: module.manifest.id, moduleId: module.manifest.id,
realtime,
services, services,
storage, storage,
}); });
@@ -528,16 +568,22 @@ async function registerModules(
modules: readonly LoadedModule[], modules: readonly LoadedModule[],
capabilityRegistry: StagedCapabilityRegistry, capabilityRegistry: StagedCapabilityRegistry,
httpRoutes: StagedModuleHttpRoutes, httpRoutes: StagedModuleHttpRoutes,
realtimeRegistry: StagedModuleRealtimeRegistry,
configurations: ModuleConfigurationLookup, configurations: ModuleConfigurationLookup,
): Promise<void> { ): Promise<void> {
for (const module of modules) { for (const module of modules) {
const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered); const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
const routeStage = httpRoutes.createRegistrationStage(module.discovered); const routeStage = httpRoutes.createRegistrationStage(module.discovered);
const realtimeStage = realtimeRegistry.createRegistrationStage(
module.discovered.manifest.id,
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
);
const context: ModuleRegistrationContext = Object.freeze({ const context: ModuleRegistrationContext = Object.freeze({
capabilities: capabilityStage.capabilities, capabilities: capabilityStage.capabilities,
config: configurationFor(configurations, module.discovered.manifest.id), config: configurationFor(configurations, module.discovered.manifest.id),
http: routeStage.http, http: routeStage.http,
moduleId: module.discovered.manifest.id, moduleId: module.discovered.manifest.id,
realtime: realtimeStage.realtime,
}); });
try { try {
await module.definition.register?.(context); await module.definition.register?.(context);
@@ -545,6 +591,16 @@ async function registerModules(
if (error instanceof ModuleRuntimeSignal) { if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]); throw new ModuleRuntimeError([error.issue]);
} }
if (error instanceof ModuleRealtimeRegistrationError) {
throw new ModuleRuntimeError([
runtimeIssue(
error.code,
"register",
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} registered an invalid realtime channel.`,
),
]);
}
throw new ModuleRuntimeError([ throw new ModuleRuntimeError([
runtimeIssue( runtimeIssue(
"MODULE_REGISTER_FAILED", "MODULE_REGISTER_FAILED",
@@ -563,6 +619,7 @@ async function registerModules(
// a failed registration therefore publishes no routes for this module, matching the same // a failed registration therefore publishes no routes for this module, matching the same
// all-or-nothing guarantee capabilities already have. // all-or-nothing guarantee capabilities already have.
routeStage.commit(); routeStage.commit();
realtimeStage.commit();
} }
} }
@@ -629,12 +686,16 @@ async function stopModules(
services: ModuleRuntimeServices, services: ModuleRuntimeServices,
configurations: ModuleConfigurationLookup, configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup, storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
options: StopModulesOptions = {}, options: StopModulesOptions = {},
): Promise<readonly ModuleRuntimeIssue[]> { ): Promise<readonly ModuleRuntimeIssue[]> {
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS; const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
const issues: ModuleRuntimeIssue[] = []; const issues: ModuleRuntimeIssue[] = [];
for (const module of [...modules].reverse()) { for (const module of [...modules].reverse()) {
const moduleId = module.discovered.manifest.id; const moduleId = module.discovered.manifest.id;
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
realtimeRegistry.deactivateModule(moduleId);
const stop = module.definition.stop; const stop = module.definition.stop;
if (!stop) { if (!stop) {
// No synthetic callback and no timer for a module that declares no stop hook. // No synthetic callback and no timer for a module that declares no stop hook.
@@ -650,6 +711,7 @@ async function stopModules(
services, services,
configurationFor(configurations, moduleId), configurationFor(configurations, moduleId),
storageFor(storages, moduleId), storageFor(storages, moduleId),
realtimeRegistry.publisherFor(moduleId),
), ),
), ),
), ),
@@ -689,6 +751,7 @@ async function startModules(
services: ModuleRuntimeServices, services: ModuleRuntimeServices,
configurations: ModuleConfigurationLookup, configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup, storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
options: StopModulesOptions = {}, options: StopModulesOptions = {},
): Promise<void> { ): Promise<void> {
// A module joins the rollback set the moment its `start` hook is *invoked*, not when it // A module joins the rollback set the moment its `start` hook is *invoked*, not when it
@@ -708,18 +771,38 @@ async function startModules(
services, services,
configurationFor(configurations, module.discovered.manifest.id), configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id), storageFor(storages, module.discovered.manifest.id),
realtimeRegistry.publisherFor(module.discovered.manifest.id),
), ),
); );
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
if (missingSnapshots.length > 0) {
throw new ModuleRuntimeSignal(
runtimeIssue(
"MODULE_REALTIME_SNAPSHOT_MISSING",
"start",
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} did not publish an initial snapshot for every declared realtime channel.`,
),
);
}
} catch (error) { } catch (error) {
const startIssue = const startIssue =
error instanceof ModuleRuntimeSignal error instanceof ModuleRuntimeSignal
? error.issue ? error.issue
: runtimeIssue( : error instanceof ModuleRealtimeError
"MODULE_START_FAILED", ? runtimeIssue(
"start", realtimeRuntimeIssueCode(error),
module.discovered.manifest.id, "start",
`Module ${module.discovered.manifest.id} startup failed.`, module.discovered.manifest.id,
); `Module ${module.discovered.manifest.id} realtime publication failed during startup.`,
)
: runtimeIssue(
"MODULE_START_FAILED",
"start",
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} startup failed.`,
);
realtimeRegistry.closeSubscriptions();
const rollbackIssues = await stopModules( const rollbackIssues = await stopModules(
rollbackCandidates, rollbackCandidates,
registry, registry,
@@ -727,8 +810,10 @@ async function startModules(
services, services,
configurations, configurations,
storages, storages,
realtimeRegistry,
options, options,
); );
realtimeRegistry.deactivateAll();
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]); throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
} }
} }
@@ -750,13 +835,23 @@ export async function startDiscoveredModuleRuntime(
const modules = await loadRuntimeDefinitions(discoveredModules); const modules = await loadRuntimeDefinitions(discoveredModules);
const capabilityRegistry = new StagedCapabilityRegistry(); const capabilityRegistry = new StagedCapabilityRegistry();
const httpRoutes = new StagedModuleHttpRoutes(); const httpRoutes = new StagedModuleHttpRoutes();
const realtimeRegistry = new StagedModuleRealtimeRegistry();
const configurations = timeouts.configurations ?? new Map<string, ModuleConfiguration>(); const configurations = timeouts.configurations ?? new Map<string, ModuleConfiguration>();
const storages = timeouts.storages ?? new Map<string, ModuleStorage>(); const storages = timeouts.storages ?? new Map<string, ModuleStorage>();
await registerModules(modules, capabilityRegistry, httpRoutes, configurations); await registerModules(modules, capabilityRegistry, httpRoutes, realtimeRegistry, configurations);
await startModules(modules, capabilityRegistry, services, configurations, storages, timeouts); await startModules(
modules,
capabilityRegistry,
services,
configurations,
storages,
realtimeRegistry,
timeouts,
);
const capabilities = capabilityRegistry.publish(); const capabilities = capabilityRegistry.publish();
const routes = httpRoutes.publish(); const routes = httpRoutes.publish();
const realtime = realtimeRegistry.publish();
const runningModules = Object.freeze( const runningModules = Object.freeze(
modules.map((module) => modules.map((module) =>
Object.freeze({ Object.freeze({
@@ -770,9 +865,11 @@ export async function startDiscoveredModuleRuntime(
return Object.freeze({ return Object.freeze({
capabilities, capabilities,
modules: runningModules, modules: runningModules,
realtime,
routes, routes,
stop: (): Promise<void> => { stop: (): Promise<void> => {
stopPromise ??= (async () => { stopPromise ??= (async () => {
realtime.closeSubscriptions();
const issues = await stopModules( const issues = await stopModules(
modules, modules,
capabilityRegistry, capabilityRegistry,
@@ -780,8 +877,10 @@ export async function startDiscoveredModuleRuntime(
services, services,
configurations, configurations,
storages, storages,
realtimeRegistry,
timeouts, timeouts,
); );
realtimeRegistry.deactivateAll();
if (issues.length > 0) { if (issues.length > 0) {
throw new ModuleRuntimeError(issues); throw new ModuleRuntimeError(issues);
} }
+240
View File
@@ -0,0 +1,240 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type {
ModuleDatabase,
ModuleRealtimePublisher,
ModuleRuntimeServices,
} from "@ekklesia/contracts";
import { afterEach, describe, expect, it } from "vitest";
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
const temporaryDirectories: string[] = [];
const globalSymbols: symbol[] = [];
afterEach(async () => {
const globals = globalThis as unknown as Record<PropertyKey, unknown>;
for (const symbol of globalSymbols.splice(0)) {
delete globals[symbol];
}
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
function fakeServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
}
function manifest(
id: string,
permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> {
return {
manifestVersion: 5,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
permissions,
entrypoints: { server: "./dist/server/index.js" },
};
}
async function createRuntimeFixture(
id: string,
serverSource: string,
permissions: readonly Record<string, string>[] = [],
): Promise<string> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-realtime-runtime-"));
temporaryDirectories.push(modulesDirectory);
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
name: `@test/module-${id}`,
version: "0.1.0",
type: "module",
ekklesia: { layer: "module" },
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify(manifest(id, permissions)),
),
writeFile(path.join(moduleDirectory, "dist/server/index.js"), serverSource),
]);
return modulesDirectory;
}
function runtimeOptions(modulesDirectory: string) {
return {
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeServices(),
};
}
function recorder(): {
readonly expression: string;
readonly value: { events: string[]; publishers: ModuleRealtimePublisher[] };
} {
const symbol = Symbol.for(`ekklesia.realtime.${Date.now()}.${Math.random()}`);
const value = { events: [] as string[], publishers: [] as ModuleRealtimePublisher[] };
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = value;
globalSymbols.push(symbol);
return { expression: `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`, value };
}
describe("module runtime realtime lifecycle", () => {
it("stages declarations, requires startup state, delivers live state, and closes before stop", async () => {
const recorded = recorder();
const objectUrl = "https://delivery.invalid/slides/welcome.png?signature=unchanged";
const modulesDirectory = await createRuntimeFixture(
"announcements",
`
export default {
id: "announcements",
version: "0.1.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
${recorded.expression}.events.push("register");
},
start({ realtime }) {
${recorded.expression}.publishers.push(realtime);
realtime.publish("state", {
currentSlide: 0,
previousSlide: -1,
slides: [${JSON.stringify(objectUrl)}],
slideSeconds: 7,
mode: "on"
});
${recorded.expression}.events.push("start");
},
stop({ realtime }) {
try { realtime.publish("state", { mode: "off" }); }
catch (error) { ${recorded.expression}.events.push("stop:" + error.code); }
}
};
`,
[
{
id: "announcements.slideshow.view",
baseline: "restricted",
description: "View the announcement slideshow.",
},
],
);
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(recorded.value.events).toEqual(["register", "start"]);
expect(runtime.realtime.channels).toEqual([
{
moduleId: "announcements",
channel: "state",
permission: "announcements.slideshow.view",
},
]);
const subscription = runtime.realtime.subscribe("announcements", "state");
expect(subscription.snapshot.sequence).toBe(1);
expect(subscription.snapshot.serializedPayload).toContain(objectUrl);
const next = subscription.read();
recorded.value.publishers[0]?.publish("state", {
currentSlide: 1,
previousSlide: 0,
slides: [objectUrl],
slideSeconds: 7,
mode: "on",
});
await expect(next).resolves.toMatchObject({ sequence: 2 });
const pendingClose = subscription.read();
await runtime.stop();
await expect(pendingClose).resolves.toBeUndefined();
expect(recorded.value.events).toEqual(["register", "start", "stop:REALTIME_PUBLISHER_STOPPED"]);
expect(() => recorded.value.publishers[0]?.publish("state", null)).toThrowError(
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
);
});
it("fails atomically and rolls back when a declared channel has no initial snapshot", async () => {
const recorded = recorder();
const modulesDirectory = await createRuntimeFixture(
"announcements",
`
export default {
id: "announcements",
version: "0.1.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
},
start() { ${recorded.expression}.events.push("start"); },
stop() { ${recorded.expression}.events.push("rollback"); }
};
`,
[
{
id: "announcements.slideshow.view",
baseline: "restricted",
description: "View the announcement slideshow.",
},
],
);
await expect(startModuleRuntime(runtimeOptions(modulesDirectory))).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_REALTIME_SNAPSHOT_MISSING" })],
});
expect(recorded.value.events).toEqual(["start", "rollback"]);
});
it("reports invalid owned-permission registration with a deterministic sanitized issue", async () => {
const secret = "/machine/private/permission-token";
const modulesDirectory = await createRuntimeFixture(
"announcements",
`
export default {
id: "announcements",
version: "0.1.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: "announcements.not-owned" });
}
};
// ${secret}
`,
);
let failure: unknown;
try {
await startModuleRuntime(runtimeOptions(modulesDirectory));
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(ModuleRuntimeError);
expect(failure).toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_REALTIME_PERMISSION_UNDECLARED" })],
});
expect(JSON.stringify(failure)).not.toContain(secret);
});
it("starts and stops modules that declare no realtime channels unchanged", async () => {
const recorded = recorder();
const modulesDirectory = await createRuntimeFixture(
"calendar",
`
export default {
id: "calendar",
version: "0.1.0",
start() { ${recorded.expression}.events.push("start"); },
stop() { ${recorded.expression}.events.push("stop"); }
};
`,
);
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(runtime.realtime.channels).toEqual([]);
await runtime.stop();
expect(recorded.value.events).toEqual(["start", "stop"]);
});
});
+362
View File
@@ -0,0 +1,362 @@
import {
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
ModuleRealtimeError,
moduleIdSchema,
parsePermissionId,
} from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest";
import {
ModuleRealtimeRegistrationError,
StagedModuleRealtimeRegistry,
serializeModuleRealtimePayload,
} from "../src/modules/realtime.js";
const permission = parsePermissionId("announcements.slideshow.view");
function registeredRegistry(
modules: Readonly<Record<string, readonly string[]>>,
): StagedModuleRealtimeRegistry {
const registry = new StagedModuleRealtimeRegistry();
for (const [rawModuleId, channels] of Object.entries(modules)) {
const moduleId = moduleIdSchema.parse(rawModuleId);
const stage = registry.createRegistrationStage(moduleId, new Set([permission]));
for (const channel of channels) {
stage.realtime.channel({ channel, permission });
}
stage.commit();
}
return registry;
}
function expectRealtimeCode(callback: () => unknown, code: string): void {
try {
callback();
} catch (error) {
expect(error).toBeInstanceOf(ModuleRealtimeError);
expect(error).toMatchObject({ code });
return;
}
throw new Error(`Expected realtime error ${code}.`);
}
describe("module realtime registry", () => {
it("publishes immutable module-scoped channel inventory", () => {
const staged = registeredRegistry({ announcements: ["state"], calendar: ["schedule"] });
const registry = staged.publish();
expect(registry.channels).toEqual([
{ moduleId: "announcements", channel: "state", permission },
{ moduleId: "calendar", channel: "schedule", permission },
]);
expect(Object.isFrozen(registry.channels)).toBe(true);
expect(Object.isFrozen(registry.channels[0])).toBe(true);
staged.publisherFor(moduleIdSchema.parse("announcements")).publish("state", { mode: "on" });
staged.publisherFor(moduleIdSchema.parse("calendar")).publish("schedule", { mode: "off" });
expect(
JSON.parse(
registry.subscribe(moduleIdSchema.parse("announcements"), "state").snapshot
.serializedPayload,
),
).toEqual({ mode: "on" });
expect(
JSON.parse(
registry.subscribe(moduleIdSchema.parse("calendar"), "schedule").snapshot.serializedPayload,
),
).toEqual({ mode: "off" });
expectRealtimeCode(
() =>
staged
.publisherFor(moduleIdSchema.parse("announcements"))
.publish("schedule", { mode: "off" }),
"REALTIME_CHANNEL_UNDECLARED",
);
expectRealtimeCode(
() => staged.publisherFor(moduleIdSchema.parse("calendar")).publish("state", { mode: "on" }),
"REALTIME_CHANNEL_UNDECLARED",
);
});
it("captures an immediate snapshot, preserves ordered live updates, and reconnects at latest", async () => {
const staged = registeredRegistry({ announcements: ["state"] });
const moduleId = moduleIdSchema.parse("announcements");
const publisher = staged.publisherFor(moduleId);
const registry = staged.publish();
publisher.publish("state", { currentSlide: 0 });
const first = registry.subscribe(moduleId, "state");
expect(first.snapshot).toEqual({ sequence: 1, serializedPayload: '{"currentSlide":0}' });
const secondPublication = first.read();
publisher.publish("state", { currentSlide: 1 });
await expect(secondPublication).resolves.toEqual({
sequence: 2,
serializedPayload: '{"currentSlide":1}',
});
const thirdPublication = first.read();
publisher.publish("state", { currentSlide: 2 });
await expect(thirdPublication).resolves.toEqual({
sequence: 3,
serializedPayload: '{"currentSlide":2}',
});
const reconnected = registry.subscribe(moduleId, "state");
expect(reconnected.snapshot).toEqual({
sequence: 3,
serializedPayload: '{"currentSlide":2}',
});
first.close();
reconnected.close();
});
it("coalesces a slow subscriber to its newest single pending update", async () => {
const staged = registeredRegistry({ announcements: ["state"] });
const moduleId = moduleIdSchema.parse("announcements");
const publisher = staged.publisherFor(moduleId);
publisher.publish("state", { value: 0 });
const subscription = staged.publish().subscribe(moduleId, "state");
for (let value = 1; value <= 1_000; value += 1) {
publisher.publish("state", { value });
}
await expect(subscription.read()).resolves.toEqual({
sequence: 1_001,
serializedPayload: '{"value":1000}',
});
subscription.close();
});
it("bounds subscribers and releases every slot on close", () => {
const staged = registeredRegistry({ announcements: ["state"] });
const moduleId = moduleIdSchema.parse("announcements");
staged.publisherFor(moduleId).publish("state", null);
const registry = staged.publish();
const subscriptions = Array.from({ length: MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL }, () =>
registry.subscribe(moduleId, "state"),
);
expectRealtimeCode(() => registry.subscribe(moduleId, "state"), "REALTIME_SUBSCRIBER_LIMIT");
subscriptions[0]?.close();
expect(() => registry.subscribe(moduleId, "state")).not.toThrow();
for (const subscription of subscriptions) {
subscription.close();
}
});
it("closes pending readers and permanently deactivates publishers during shutdown", async () => {
const staged = registeredRegistry({ announcements: ["state"] });
const moduleId = moduleIdSchema.parse("announcements");
const publisher = staged.publisherFor(moduleId);
publisher.publish("state", { mode: "on" });
const registry = staged.publish();
const subscription = registry.subscribe(moduleId, "state");
const pending = subscription.read();
staged.deactivateAll();
await expect(pending).resolves.toBeUndefined();
expectRealtimeCode(
() => publisher.publish("state", { mode: "off" }),
"REALTIME_PUBLISHER_STOPPED",
);
expectRealtimeCode(() => registry.subscribe(moduleId, "state"), "REALTIME_UNAVAILABLE");
});
it("can close transport subscriptions while an in-flight HTTP publisher finishes", async () => {
const staged = registeredRegistry({ announcements: ["state"] });
const moduleId = moduleIdSchema.parse("announcements");
const publisher = staged.publisherFor(moduleId);
publisher.publish("state", { mode: "on" });
const registry = staged.publish();
const pending = registry.subscribe(moduleId, "state").read();
registry.closeSubscriptions();
await expect(pending).resolves.toBeUndefined();
expect(() => publisher.publish("state", { mode: "off" })).not.toThrow();
expectRealtimeCode(() => registry.subscribe(moduleId, "state"), "REALTIME_UNAVAILABLE");
staged.deactivateAll();
expectRealtimeCode(
() => publisher.publish("state", { mode: "on" }),
"REALTIME_PUBLISHER_STOPPED",
);
});
it("deactivates only the module whose reverse-order stop is beginning", () => {
const staged = registeredRegistry({ announcements: ["state"], calendar: ["schedule"] });
const announcementsId = moduleIdSchema.parse("announcements");
const calendarId = moduleIdSchema.parse("calendar");
const announcements = staged.publisherFor(announcementsId);
const calendar = staged.publisherFor(calendarId);
announcements.publish("state", { mode: "on" });
calendar.publish("schedule", { date: "2026-08-23" });
staged.deactivateModule(announcementsId);
expectRealtimeCode(
() => announcements.publish("state", { mode: "off" }),
"REALTIME_PUBLISHER_STOPPED",
);
expect(() => calendar.publish("schedule", { date: "2026-08-24" })).not.toThrow();
});
it("serializes opaque state once so later source mutation cannot change it", () => {
const staged = registeredRegistry({ announcements: ["state"] });
const moduleId = moduleIdSchema.parse("announcements");
const slides = ["https://delivery.invalid/object?signature=stable"];
staged.publisherFor(moduleId).publish("state", { slides });
slides[0] = "https://delivery.invalid/object?signature=changed";
const snapshot = staged.publish().subscribe(moduleId, "state").snapshot;
expect(snapshot.serializedPayload).toContain("signature=stable");
expect(snapshot.serializedPayload).not.toContain("signature=changed");
});
it("accepts a named module state interface while runtime validation remains authoritative", () => {
interface SlideshowState {
readonly currentSlide: number;
readonly mode: "on" | "off";
readonly slides: readonly string[];
}
const state: SlideshowState = {
currentSlide: 4,
mode: "on",
slides: ["https://delivery.invalid/welcome.png"],
};
const staged = registeredRegistry({ announcements: ["state"] });
const moduleId = moduleIdSchema.parse("announcements");
staged.publisherFor(moduleId).publish("state", state);
expect(
JSON.parse(staged.publish().subscribe(moduleId, "state").snapshot.serializedPayload),
).toEqual(state);
});
it("rejects non-JSON values and payloads above the UTF-8 limit with fixed codes", () => {
const staged = registeredRegistry({ announcements: ["state"] });
const publisher = staged.publisherFor(moduleIdSchema.parse("announcements"));
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const sparse = Array.from({ length: 2 }) as unknown[];
sparse[0] = "present";
const toJson = vi.fn(() => ({ mode: "rewritten" }));
const hooked = { mode: "original" };
Object.defineProperty(hooked, "toJSON", { value: toJson });
for (const payload of [
undefined,
Number.NaN,
Number.POSITIVE_INFINITY,
1n,
new Date(),
cyclic,
sparse,
hooked,
]) {
expectRealtimeCode(() => publisher.publish("state", payload), "REALTIME_PAYLOAD_INVALID");
}
expect(toJson).not.toHaveBeenCalled();
expectRealtimeCode(
() => publisher.publish("state", "x".repeat(MODULE_REALTIME_MAX_PAYLOAD_BYTES)),
"REALTIME_PAYLOAD_TOO_LARGE",
);
});
it("enforces the serialized UTF-8 boundary including JSON escaping", () => {
const exactlyAtLimit = serializeModuleRealtimePayload(
"x".repeat(MODULE_REALTIME_MAX_PAYLOAD_BYTES - 2),
);
expect(exactlyAtLimit).toMatchObject({ success: true });
if (exactlyAtLimit.success) {
expect(new TextEncoder().encode(exactlyAtLimit.serializedPayload)).toHaveLength(
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
);
}
expect(
serializeModuleRealtimePayload("x".repeat(MODULE_REALTIME_MAX_PAYLOAD_BYTES - 1)),
).toEqual({ success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" });
expect(serializeModuleRealtimePayload("\0".repeat(11_000))).toEqual({
success: false,
code: "REALTIME_PAYLOAD_TOO_LARGE",
});
});
it("rejects enormous strings and containers before attempting an unbounded stringify", () => {
const hugeString = "x".repeat(1_000_000);
const hugeArray = new Array<unknown>(1_000_000).fill(0);
const stringify = vi.spyOn(JSON, "stringify");
const stringResult = serializeModuleRealtimePayload(hugeString);
const arrayResult = serializeModuleRealtimePayload(hugeArray);
const calls = [...stringify.mock.calls];
stringify.mockRestore();
expect(stringResult).toEqual({ success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" });
expect(arrayResult).toEqual({ success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" });
expect(calls).toEqual([]);
});
it("matches canonical JSON escaping without converting invalid values", () => {
const payload = {
controls: '\b\t\n\f\r\0"\\',
loneSurrogate: "\ud800",
unicode: "Welcome 👋 — καλημέρα",
};
expect(serializeModuleRealtimePayload(payload)).toEqual({
success: true,
serializedPayload: JSON.stringify(payload),
});
expect(serializeModuleRealtimePayload({ invalid: undefined })).toEqual({
success: false,
code: "REALTIME_PAYLOAD_INVALID",
});
});
it("validates duplicate, undeclared-permission, and finite channel registration", () => {
const moduleId = moduleIdSchema.parse("announcements");
const duplicateRegistry = new StagedModuleRealtimeRegistry();
const duplicate = duplicateRegistry.createRegistrationStage(moduleId, new Set([permission]));
duplicate.realtime.channel({ channel: "state", permission });
expect(() => duplicate.realtime.channel({ channel: "state", permission })).toThrowError(
expect.objectContaining({ code: "MODULE_REALTIME_CHANNEL_DUPLICATE" }),
);
const foreign = new StagedModuleRealtimeRegistry().createRegistrationStage(moduleId, new Set());
expect(() => foreign.realtime.channel({ channel: "state", permission })).toThrowError(
expect.objectContaining({ code: "MODULE_REALTIME_PERMISSION_UNDECLARED" }),
);
const limited = new StagedModuleRealtimeRegistry().createRegistrationStage(
moduleId,
new Set([permission]),
);
for (let index = 0; index < MODULE_REALTIME_MAX_CHANNELS_PER_MODULE; index += 1) {
limited.realtime.channel({ channel: `channel-${index}`, permission });
}
expect(() => limited.realtime.channel({ channel: "one-too-many", permission })).toThrowError(
expect.objectContaining({ code: "MODULE_REALTIME_CHANNEL_LIMIT" }),
);
});
it("keeps modules without realtime declarations fully compatible", () => {
const staged = registeredRegistry({ calendar: [] });
const moduleId = moduleIdSchema.parse("calendar");
expect(staged.publish().channels).toEqual([]);
expectRealtimeCode(
() => staged.publisherFor(moduleId).publish("state", null),
"REALTIME_CHANNEL_UNDECLARED",
);
expect(staged.missingSnapshots(moduleId)).toEqual([]);
});
it("reports payload validation without reflecting contents", () => {
const secret = "/machine/private/secret?token=do-not-leak";
const result = serializeModuleRealtimePayload({ secret, invalid: undefined });
expect(result).toEqual({ success: false, code: "REALTIME_PAYLOAD_INVALID" });
expect(JSON.stringify(result)).not.toContain(secret);
expect(() => {
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_INVALID");
}).toThrow("Module realtime channel registration failed.");
});
});
@@ -74,6 +74,12 @@ administrator owns rather than something a module declares. A display field adde
become module-owned navigation before that design exists, so the temporary selector renders module become module-owned navigation before that design exists, so the temporary selector renders module
ids instead. ids instead.
> **Cross-reference — see [ADR 0032](0032-native-module-realtime-transport.md).**
> Phase 7A pass 5 extends this original descriptor with a sixth,
> `realtime: { baseUrl: "/api/v1/module-realtime/example" }`, so the browser host can construct the
> transport-neutral `context.realtime` facade without inventing a platform URL. The five-field shape
> above remains the historical decision this ADR recorded; ADR 0032 owns the extension.
It is not a module management API. There is no create, update, enable, disable, install, or It is not a module management API. There is no create, update, enable, disable, install, or
configure, and no module that declares no UI. Ekklesia has no enable/disable state to report — configure, and no module that declares no UI. Ekklesia has no enable/disable state to report —
`ModuleLifecycleStage` remains vocabulary with no runtime state machine behind it — and reporting one `ModuleLifecycleStage` remains vocabulary with no runtime state machine behind it — and reporting one
@@ -210,5 +210,6 @@ value it could branch on.
Signed **write** and **delete** URLs are not built. Uploads still go through a module route, where Signed **write** and **delete** URLs are not built. Uploads still go through a module route, where
the module can validate what it is accepting; handing a browser a capability to write into a the module can validate what it is accepting; handing a browser a capability to write into a
module's bucket needs its own decision about size, type, and quota, and does not belong in the pass module's bucket needs its own decision about size, type, and quota, and does not belong in the pass
that established reads. Realtime URL refresh is not built either — ordinary API reloads are that established reads. [ADR 0032](0032-native-module-realtime-transport.md) now supplies the native
sufficient, and a future realtime pass can refresh state and URLs together when it exists. state channel on which a module can publish a replacement delivery URL. Core still does not inspect,
renew, or automatically publish expiring URLs; that refresh policy remains explicitly module-owned.
@@ -0,0 +1,204 @@
# 0032: Native module realtime is scoped snapshot state over SSE
- Status: Accepted
- Date: 2026-08-23
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[0013](0013-module-permission-contract.md),
[0015](0015-module-http-transport.md),
[0021](0021-modules-as-hostable-products.md),
[0022](0022-module-ui-authoring-and-host-contract.md),
[0028](0028-module-operational-lifecycle.md),
[0031](0031-direct-object-delivery.md)
## Context
Ekklesia has a host-neutral module HTTP contract, but no way for a running module to deliver a state
change to its browser UI without polling or owning a second server. The first real consumer is
`The_River/Announcements` at commit `9aab486`: its server sends one complete `slideshow:state`
snapshot on connection and broadcasts the same complete shape after a slide tick, slide-list reload,
or mode change. The browser sends no application messages back, uses no acknowledgements, and on
reconnection needs current state rather than a history of missed transitions.
That application currently uses Socket.IO, hard-coded LAN origins, and MQTT mode control. Those are
properties of the old deployment, not requirements for a module contract. Browser commands already
have the authenticated, CSRF-protected module HTTP path from ADR 0015. MQTT is a legacy external
control integration that will be removed; it is not an Ekklesia internal transport.
The existing Node adapter could be extended with a WebSocket server and upgrade handling, but the API
does not have any of that wiring today. Its public module vocabulary is Fetch `Request`/`Response`,
and it already preserves streaming response bodies. Server-Sent Events fit that boundary without a
new bidirectional protocol, dependency, upgrade/authentication path, reconnect implementation, or
shutdown stack.
## Decision
### The public contract names state, not a network protocol
A module declares a small number of local realtime channels during `register`, each protected by one
permission its own manifest declares:
```ts
context.realtime.channel({
channel: "state",
permission: "announcements.slideshow.view",
});
```
Its `start` hook, running work, and HTTP handlers receive the same module-scoped publisher:
```ts
context.realtime.publish("state", slideshowState);
```
The module does not pass a module id, origin, URL, transport, credential, or permission when it
publishes. It cannot name another module's channel through this facade. `channel` is one validated
lowercase kebab-case identifier, not a hierarchical topic: there are no slashes, wildcards, global
namespace, arbitrary cross-module subscription, or broker semantics.
Registration remains declarative and I/O-free. Publication is a live operation and is therefore
absent from registration, exactly like database and storage access. HTTP handlers receive the
publisher because an ordinary authenticated command frequently changes the state that should be
shown immediately; modules must not capture a late-bound lifecycle context to achieve that.
`stop` receives the same context shape for lifecycle consistency, but its publisher has already been
made inert: stopped code cannot emit new live state.
The first Ekklesia host implementation is an authenticated `GET` SSE stream. SSE is private host
machinery: neither the server module contract nor `ModuleUiHostContext` names SSE, EventSource,
WebSocket, or Socket.IO. A standalone host may implement the exact contracts using another transport
without changing a module.
WebSocket is rejected for this slice. The real consumer has no persistent client-to-server messages,
while commands already fit normal module HTTP. Choosing WebSocket would add an upgrade-specific auth
path, ping/pong, reconnect wrapper, dependency, server wiring, and shutdown behavior without buying a
required capability. It can be reconsidered if a real module demonstrates genuine persistent
bidirectional traffic; that would be a new transport decision, not an invisible change here.
### Snapshot first, then latest-state updates
Every declared channel must publish one authoritative state value before its module's `start` hook
completes. If it does not, startup fails and the existing reverse-order rollback runs. The API is not
bound until every eligible module starts, so every reachable channel necessarily has a snapshot.
Core validates and serializes a publication once, assigns the channel's next monotonically increasing
process-local sequence, and stores that exact serialized value as its latest snapshot. Subscription
atomically registers the consumer and captures that snapshot, eliminating a separate `GET state` then
`subscribe` race. A new connection always receives `snapshot` first and then later `update` values.
The sequence detects ordering, duplicates, and gaps; it is not a durable cursor. It may reset when the
process restarts. The transport deliberately does not promise replay, history, exactly-once delivery,
or recovery through `Last-Event-ID`. A reconnect is a new authorized subscription and receives the
fresh latest snapshot, followed by future updates. The module remains the semantic authority and
reconstructs its state from its own persistence/runtime after restart; Core's in-memory copy is only
ephemeral transport state.
### JSON and resource bounds
Payloads are JSON-compatible data only. Core rejects functions, symbols, bigints, cycles, non-finite
numbers, unsupported object values, and anything whose UTF-8 serialized representation exceeds the
fixed platform ceiling. The byte budget is consumed while traversing strings, keys, containers, and
values, before building or stringifying an arbitrarily large clone. The first ceiling is 64 KiB per
publication: far beyond an Announcements state document, while making it unambiguous that media bytes
belong in object storage.
The channel count, subscribers per channel, and per-client pending work are all finite. Publishing is
synchronous and never awaits a browser. A consumer receives publications in increasing sequence while
it keeps up; if it is slow, it retains at most the newest pending state and intermediate updates are
coalesced. Sequence gaps make that behavior visible. This is correct for authoritative state and
prevents one display from blocking a module or creating an unbounded queue.
### Browser host contract
`ModuleUiHostContext` exposes a transport-neutral, module-scoped subscription method. Module UI names
only its local channel and receives snapshot/update/status callbacks plus an idempotent close handle.
The browser does not supply a module id, permission, origin, URL, cookie, or bearer token.
Ekklesia's server adds a root-relative realtime base to the existing module UI mount descriptor, just
as it already supplies the module HTTP base. The shell strictly validates that root-relative value,
resolves it against the configured API origin, appends only the validated local channel, and opens
native `EventSource` with credentials enabled. A standalone host supplies its own root and transport
adapter behind the same browser contract.
The browser reports only stable transport-neutral states (`connecting`, `connected`, `reconnecting`,
`closed`) and fixed safe errors. A recoverable EventSource transport error reports `reconnecting` and
uses the browser's native retry; a terminal `CLOSED` state removes every listener and reports
`closed` instead of promising a retry that will never occur. It does not expose EventSource objects,
raw server responses, socket exceptions, or URLs to module UI. This is a portability and correctness
boundary, not a sandbox: installed UI is trusted same-realm JavaScript and could use browser globals
directly. Server-side permission enforcement remains the security authority.
### Authentication and authorization
The SSE request uses the existing session cookie. Ekklesia evaluates the normal principal and the
channel's registered permission before allocating a subscriber, using the same permission registry
and evaluator as module HTTP. There is no query-string token and no second realtime auth system.
Unknown, disabled, blocked, stopped, or undeclared channels have no mounted route.
Authorization is checked when a stream is established. Ekklesia closes every stream after five
minutes so EventSource reconnects and authorization is re-evaluated; changing a grant does not
otherwise reach backward into an already-established connection. A deliberate public
display/device principal remains deferred. This transport is proven only with existing authenticated
principals and permission checks, and no channel becomes public as a shortcut.
### Lifecycle and cleanup
Only the exact eligible module set that successfully starts publishes channels. Disablement and
dependency blocking therefore remove realtime the same structural way they remove HTTP and UI: the
module is never imported or mounted. There is no hot unload; lifecycle changes remain
restart-required.
Before normal stop or rollback invokes a module's stop hook, Core deactivates that module's publisher.
Deactivation happens per module in reverse dependency order: a dependency remains operational while
its dependents finish stopping, but a stale captured publisher fails with a fixed runtime error once
its own stop begins. Browser cancellation, transport failure, module stop, startup rollback, server
shutdown, and connection-age expiry all remove subscriptions idempotently.
Server shutdown closes realtime streams before draining the HTTP listener. Otherwise a healthy
long-lived stream would keep the drain open until its deadline and turn ordinary shutdown into a
forced failure. Publishers remain active for finite requests already in flight; module stop hooks run
only after the listener has drained, and Core then deactivates each publisher immediately before its
own hook. This preserves both a command's publish-after-commit path and the guarantee that no ordinary
request handler runs concurrently with stop.
### Proxy and keepalive behavior
Ekklesia sends `text/event-stream`, `Cache-Control: no-store`, disables common reverse-proxy response
buffering where supported, and emits host-owned comment keepalives while a stream is idle. Keepalives
do not enter module code, consume a module sequence, or mutate state. Deployments and reverse proxies
must pass streaming responses incrementally and allow the configured idle interval; there is no
Caddy- or Cloudflare-specific API in the contract.
### Object delivery is unchanged
Realtime carries small state, logical identifiers, metadata, and — when a module intentionally
includes one — an opaque delivery URL. It never carries image, video, or audio bytes. Core transports
the module's serialized payload unchanged; it does not inspect object keys or call `createReadUrl` for
each publication, subscriber, reconnect, or keepalive.
That preserves ADR 0031's byte-stable/quantized URLs and browser cache keys. A module deliberately
refreshes an expiring URL by creating it once and publishing new state. Realtime never re-signs an
unchanged URL behind the module's back.
### Realtime is not Companion's global bus
A future Companion module may observe deliberately exported module capabilities and invoke explicit
module actions, and may bridge supported external protocols. It does not gain privileged access to
every module's browser stream. Browser realtime and external automation/control are separate
concerns; this contract has no cross-module subscription seam to repurpose.
## Consequences
The first real state consumer gets immediate useful state, ordered live updates, automatic browser
reconnect, permission enforcement, bounded memory, and deterministic cleanup without owning a port or
transport server. Modules with no realtime declarations behave exactly as before.
The contract deliberately loses intermediate states for a slow or disconnected client. Modules must
publish authoritative state, not use this channel as an audit log, work queue, payment ledger, or sole
record of a domain transition. A future event-oriented or durable facility would be a separate design.
SSE is one-way. Browser commands remain ordinary authenticated module HTTP, retaining its existing
body limits, trusted-origin checks, and request/response semantics. A demonstrated bidirectional need
may justify WebSocket later, without renaming the module-facing API.
This slice does not add the Announcements module, MQTT, Companion, a kiosk/display host, a public or
device principal, a standalone host, hot lifecycle changes, multi-node fanout, durable history, or a
message broker.
+1
View File
@@ -40,3 +40,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0029: Module configuration is manifest-declared, host-resolved, and secret-free at rest](0029-module-configuration-and-secrets.md) — Accepted - [0029: Module configuration is manifest-declared, host-resolved, and secret-free at rest](0029-module-configuration-and-secrets.md) — Accepted
- [0030: Modules declare logical buckets; hosts provide object storage](0030-host-provided-module-object-storage.md) — Accepted - [0030: Modules declare logical buckets; hosts provide object storage](0030-host-provided-module-object-storage.md) — Accepted
- [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted - [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
+44 -11
View File
@@ -70,7 +70,7 @@ Every module-facing contract in this platform is written so that **full Ekklesia
thing that can satisfy it**. The intended second implementation is a reusable single-module thing that can satisfy it**. The intended second implementation is a reusable single-module
standalone host — conceptually `@ekklesia/standalone-host` — which loads exactly one module and standalone host — conceptually `@ekklesia/standalone-host` — which loads exactly one module and
supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module
lifecycle, and later realtime and display/UI hosting. lifecycle, and realtime, with display/kiosk hosting still later.
**No such package exists yet, and this pass does not create one.** It is recorded here because it is **No such package exists yet, and this pass does not create one.** It is recorded here because it is
what several contract decisions are *for*, and because a change that quietly makes it unimplementable what several contract decisions are *for*, and because a change that quietly makes it unimplementable
@@ -84,6 +84,12 @@ the scoped facade, binding resolution, delivery URLs and their bounds, lifecycle
host-neutral, and a standalone host implements one interface to get all of it, including direct host-neutral, and a standalone host implements one interface to get all of it, including direct
browser delivery ([ADR 0031](decisions/0031-direct-object-delivery.md)). browser delivery ([ADR 0031](decisions/0031-direct-object-delivery.md)).
Realtime is the second worked example. A module declares local state channels and publishes opaque
JSON through scoped contracts; its browser UI subscribes through another scoped contract. Neither
side names SSE, EventSource, a platform URL, an origin, a cookie, or a module namespace. Ekklesia's
host adapter uses permission-gated SSE, while a standalone host may choose another concrete
transport without changing the module. See [ADR 0032](decisions/0032-native-module-realtime-transport.md).
### Storage servers are shared infrastructure, never per-module ### Storage servers are shared infrastructure, never per-module
- A module hosted under Ekklesia uses **Ekklesia's** storage service, through its own bindings. - A module hosted under Ekklesia uses **Ekklesia's** storage service, through its own bindings.
@@ -280,17 +286,17 @@ by its own esbuild script, that discovers installed module UIs and mounts them.
The missing piece was discovery. `GET /api/v1/module-ui` — the collection root of the same prefix the The missing piece was discovery. `GET /api/v1/module-ui` — the collection root of the same prefix the
artifact route already owns — returns, per publishable module, its id and version, the server-produced artifact route already owns — returns, per publishable module, its id and version, the server-produced
artifact URL, the server-produced root of the module's own API, and the subset of that module's artifact URL, the server-produced roots of the module's own HTTP and realtime APIs, and the subset of
declared permissions the caller actually holds. Nothing else: no filesystem paths, and no display or that module's declared permissions the caller actually holds. Nothing else: no filesystem paths, and
navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's presentation no display or navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's
configuration. The module set is the publication registry, never a rescan, so the inventory and the presentation configuration. The module set is the publication registry, never a rescan, so the
artifact route cannot disagree. It requires `core.modules.ui.read` (baseline `authenticated`) because inventory and artifact route cannot disagree. It requires `core.modules.ui.read` (baseline
it is precisely the enumeration ADR 0025 withheld from the anonymous artifact route. `authenticated`) because it is precisely the enumeration ADR 0025 withheld from the anonymous
artifact route. ADR 0032 later extended ADR 0026's original descriptor with the realtime root.
Both URLs being server-produced is the load-bearing part. `moduleUiArtifactUrl` and the newly exported All three URLs being server-produced is load-bearing. `moduleUiArtifactUrl`, `moduleApiBaseUrl`, and
`moduleApiBaseUrl` are each the single construction of their URL shape, so the browser is a consumer `moduleRealtimeBaseUrl` are each the single construction of their URL shape, so the browser consumes
of both — it never rebuilds `/api/v1/modules/<id>`, the prefix ADR 0022 forbids a module UI from them rather than rebuilding a platform module prefix, and none is pinned by having a second author.
spelling, and neither prefix is pinned by having a second author.
The shell builds a `ModuleUiHostContext` entirely from server-derived data, resolves a module's The shell builds a `ModuleUiHostContext` entirely from server-derived data, resolves a module's
relative API calls onto its own API root (refusing anything that escapes it), and hands the artifact relative API calls onto its own API root (refusing anything that escapes it), and hands the artifact
@@ -438,3 +444,30 @@ configures nothing. A reverse proxy in front of storage must pass the original `
unchanged; it may terminate TLS, because the scheme is not signed. Streaming through a module's own unchanged; it may terminate TLS, because the scheme is not signed. Streaming through a module's own
route remains available for responses that must be computed or authorized per byte, and signed route remains available for responses that must be computed or authorized per byte, and signed
**write** URLs are deliberately not offered. **write** URLs are deliberately not offered.
Phase 7A pass 5 adds native module realtime
([ADR 0032](decisions/0032-native-module-realtime-transport.md)). Registration declares a bounded,
module-local channel and one permission owned by that module; live runtime and HTTP contexts receive
only that module's publisher. Every channel must publish an authoritative initial state before
startup completes. Core serializes opaque JSON once, assigns a process-local per-channel sequence,
and atomically installs each subscriber with the latest snapshot, eliminating a GET-then-subscribe
race. It retains only the latest channel state and one coalescing pending update per client, so a
slow browser never blocks module work or creates an unbounded queue. There is no history, replay,
broker namespace, or durable state.
Ekklesia's concrete host transport is one-way Server-Sent Events because the demonstrated
Announcements consumer sends complete state server-to-browser while browser commands already fit
authenticated module HTTP. The channel permission is evaluated through the existing cookie
principal and authorization system before allocation; the host closes streams after five minutes so
reconnect re-evaluates that authority. Native EventSource owns recoverable reconnects, each of which
starts with a fresh snapshot. The wire sends 15-second comment keepalives, disables common proxy
buffering, carries no query token or replay id, and never exposes transport objects through the
module UI SDK.
Shutdown closes long-lived subscriptions before finite HTTP drain, while leaving publishers usable
for requests already in flight. Reverse-order stop then deactivates each module's publisher
immediately before that module's own stop hook, so dependencies remain operational while dependents
clean up and captured publishers cannot outlive their module. Direct object-delivery URLs remain
opaque payload strings and are never re-signed per publication or subscriber. MQTT remains an
external-control concern for a future Companion boundary, not an internal module bus. Display/kiosk
hosting and its public/device principal are explicitly not part of this pass.
+97 -22
View File
@@ -76,7 +76,8 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). | | `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's API lives, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). | | `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's HTTP and realtime roots live, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). | | `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). | | `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). | | `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
@@ -400,9 +401,9 @@ account.
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are 6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
never rediscovered for this step — passing it the services object every module's `start`/`stop` never rediscovered for this step — passing it the services object every module's `start`/`stop`
hook receives. hook receives.
7. Create the Hono application, mounting the started module runtime's published routes, publishing 7. Create the Hono application, mounting the started module runtime's HTTP routes and realtime
UI for that same eligible set, and passing the identical services object to every module HTTP channels, publishing UI for that same eligible set, and passing the identical scoped runtime
handler. objects to module HTTP handlers.
8. Bind the HTTP server. 8. Bind the HTTP server.
Discovery and permission composition happen before any pool, Better Auth instance, module Discovery and permission composition happen before any pool, Better Auth instance, module
@@ -779,13 +780,12 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
check and before permission evaluation — `0` means no body is accepted at all. An oversized check and before permission evaluation — `0` means no body is accepted at all. An oversized
request receives the platform's stable `413 {"code":"REQUEST_BODY_TOO_LARGE"}` and never reaches request receives the platform's stable `413 {"code":"REQUEST_BODY_TOO_LARGE"}` and never reaches
authorization, the handler, or the module database. authorization, the handler, or the module database.
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and - **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and body
body all behave normally, and the full query string remains available) plus a frozen context of all behave normally, and the full query string remains available) plus a frozen context of exactly
exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an `moduleId`, the authorized `EkklesiaPrincipal`, frozen Hono-decoded path parameters (an empty
empty frozen object for a route with none), and `services` — the same frozen frozen object for a route with none), `services`, and the module's scoped configuration, storage,
`ModuleRuntimeServices` object the module's own `start`/`stop` hooks received (see and realtime publisher. It never receives a Hono `Context`, authorization/grant stores, jobs, or
[Module database access](#module-database-access)) — never a Hono `Context`, the authorization another module's scoped objects. Core does not serialize configuration or secrets into responses.
store, storage, jobs, configuration, or secrets.
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response` - **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
caught value, a stack, the route's path or permission id, or the module id. A `Response` a caught value, a stack, the route's path or permission id, or the module id. A `Response` a
@@ -802,6 +802,69 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
registry: installing, removing, or editing a module's routes takes effect only on the next API registry: installing, removing, or editing a module's routes takes effect only on the next API
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`. restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
## Module realtime
An eligible module may declare a module-local state channel during `register`. Ekklesia mounts one
exact route for each successfully started declaration:
```text
GET /api/v1/module-realtime/<module-id>/<channel>
```
The path is host-private. Module UI receives the module's server-produced realtime base in its mount
descriptor and calls `context.realtime.subscribe("state", handlers)`; it never builds this URL,
chooses a module id, supplies a permission, or sees an EventSource. Browser commands remain ordinary
module HTTP requests, retaining their body, origin, authorization, and response semantics.
The response is a Server-Sent Events stream. It begins with exactly one authoritative `snapshot`
event and then carries `update` events:
```text
event: snapshot
data: {"sequence":1,"payload":{"mode":"on","currentSlide":0}}
event: update
data: {"sequence":2,"payload":{"mode":"on","currentSlide":1}}
```
Core atomically captures the latest state while installing the subscriber, so there is no separate
GET-then-subscribe gap. Sequence is monotonically increasing per channel for this process only; there
is no `id` field, `Last-Event-ID` replay, durable history, or exactly-once promise. A reconnect is a
new subscription and begins with the latest snapshot. Slow clients retain only the newest pending
update, so sequence gaps are expected and intermediate states may be coalesced.
- **Authentication and authorization.** The SSE GET uses the existing session cookie and the same
principal resolver, permission registry, and evaluator as module HTTP. The channel's declaration
fixes the permission before any request exists. Permission evaluation completes before a Core
subscriber is allocated. There is no query-string token or second realtime auth system.
- **Reauthorization.** A stream has a host-owned maximum age of five minutes. The host closes it at
that boundary; native EventSource reconnects and the ordinary cookie and permission checks run
again. Grant revocation, grant expiry, and session changes therefore do not leave an accepted
stream authorized forever.
- **Bounds.** Core accepts at most 16 channels per module, 100 subscribers per channel, and 64 KiB
of serialized JSON per publication. Publishing never awaits a browser. One latest snapshot and
one pending update per subscriber are the only state retained.
- **Transport behavior.** Responses use `text/event-stream; charset=utf-8`, `Cache-Control:
no-store`, `Vary: Origin, Cookie`, `X-Accel-Buffering: no`, and a 15-second comment keepalive.
Keepalives consume no module sequence and never reach module UI. A reverse proxy must forward
streaming bodies incrementally and permit that idle interval. No proxy product is part of the
module contract.
- **Methods and errors.** Hono normally lets `HEAD` reuse GET handlers; this route explicitly returns
`405` before allocating a stream because discarding an infinite body could leak a subscriber.
Subscriber exhaustion is the fixed `503 {"code":"REALTIME_SUBSCRIBER_LIMIT"}`; other stopped or
unavailable runtime states are `503 {"code":"REALTIME_UNAVAILABLE"}`. Raw errors, paths, URLs,
payloads, permissions, secrets, and stacks are never reflected.
- **Lifecycle.** Disabled and blocked modules have no route because their entrypoint never runs and
their channel is never mounted. Shutdown first refuses and closes realtime subscriptions so they
cannot hold HTTP drain open. Ordinary in-flight module HTTP handlers may finish and publish during
that drain. Core then stops modules in reverse dependency order, deactivating each module's scoped
publisher immediately before its own stop hook; a captured publisher fails predictably afterward.
Realtime carries opaque module state. If a module includes a direct object-delivery URL, the exact
serialized string passes through unchanged: Core neither re-signs it per subscriber nor extends its
expiry. Image, video, and audio bytes remain object-storage traffic, never realtime payloads. See
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md).
## Dashboard navigation ## Dashboard navigation
```text ```text
@@ -829,6 +892,7 @@ How this installation presents its mountable modules
"version": "1.0.0", "version": "1.0.0",
"ui": { "url": "/api/v1/module-ui/livestream/1.0.0" }, "ui": { "url": "/api/v1/module-ui/livestream/1.0.0" },
"api": { "baseUrl": "/api/v1/modules/livestream" }, "api": { "baseUrl": "/api/v1/modules/livestream" },
"realtime": { "baseUrl": "/api/v1/module-realtime/livestream" },
"permissions": ["livestream.streams.read"] "permissions": ["livestream.streams.read"]
} }
} }
@@ -908,21 +972,22 @@ root of the same resource the artifact route below serves: one prefix, two repre
"version": "0.1.0", "version": "0.1.0",
"ui": { "url": "/api/v1/module-ui/example/0.1.0" }, "ui": { "url": "/api/v1/module-ui/example/0.1.0" },
"api": { "baseUrl": "/api/v1/modules/example" }, "api": { "baseUrl": "/api/v1/modules/example" },
"realtime": { "baseUrl": "/api/v1/module-realtime/example" },
"permissions": ["example.records.read"] "permissions": ["example.records.read"]
} }
] ]
} }
``` ```
- **Five fields, each because a `ModuleUiHostContext` needs it.** No filesystem path, package root, - **Six fields, each because a `ModuleUiHostContext` needs it.** No filesystem path, package root,
package name, server/schema/migrations entrypoint, capability, dependency, or storage bucket. And package name, server/schema/migrations entrypoint, capability, dependency, or storage bucket. And
no `name`, `icon`, `description`, `category`, `folder`, or `order`: those are navigation metadata, no `name`, `icon`, `description`, `category`, `folder`, or `order`: those are navigation metadata,
and [ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) keeps Dashboard and [ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) keeps Dashboard
grouping an administrator's presentation configuration rather than a module declaration. grouping an administrator's presentation configuration rather than a module declaration.
- **Both URLs are server-produced.** `ui.url` comes from `moduleUiArtifactUrl` and `api.baseUrl` from - **All three URLs are server-produced.** `ui.url` comes from `moduleUiArtifactUrl`, `api.baseUrl`
`moduleApiBaseUrl`, so each URL shape is constructed in exactly one place in the repository. A from `moduleApiBaseUrl`, and `realtime.baseUrl` from `moduleRealtimeBaseUrl`, so each URL shape is
browser never rebuilds `/api/v1/module-ui/<id>/<version>` or `/api/v1/modules/<id>`, which is what constructed in exactly one place in the repository. A browser never rebuilds any platform module
lets either change without touching host or module code. prefix, which lets one change without touching host or module code.
- **The module set is the publication registry**, not a rescan: modules that survived discovery and - **The module set is the publication registry**, not a rescan: modules that survived discovery and
declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent. declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent.
Order is the registry's own deterministic discovery order. Order is the registry's own deterministic discovery order.
@@ -1114,15 +1179,25 @@ safe, and the network boundary remains the deployment's own responsibility. See
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one
shared promise the first time it is called: every caller, concurrent or sequential, awaits the shared promise the first time it is called: every caller, concurrent or sequential, awaits the
exact same operation, and that operation runs exactly once no matter how many times `shutdown()` exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight is called. In order: it refuses new realtime subscribers and closes every existing stream; then
requests finish; then stops the module runtime (so no module resource stops while a route handler stops the HTTP listener from accepting new connections and lets finite in-flight requests finish;
might still be in flight) — a module's `stop` hook may still use `services.database` here, since then stops the module runtime in reverse dependency order; then closes storage and the one shared
the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization, PostgreSQL pool (identity, authorization, and the module database facade all read from it — see
and the module database facade all read from it — see
[Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup [Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup
path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should
observe — but pool cleanup always still runs first, so a stop failure never leaves the pool open, observe — but pool cleanup always still runs first, so a stop failure never leaves the pool open,
and no module code can run against it afterward. The executable entry point's `SIGINT`/`SIGTERM` and no module code can run against it afterward.
Closing streams and disabling publishers are deliberately separate. Streams are long-lived HTTP
responses and would otherwise force every healthy shutdown to its drain deadline. Publishers remain
active during the finite HTTP drain so an already-running module command can commit and publish its
result without turning a successful request into a false `500`. As reverse-order module stop reaches
each module, Core disables that module's publisher immediately before its stop hook; dependencies
remain live while their dependents stop, and a stale publisher fails predictably once its own module
begins stopping. A module's `stop` hook may still use `services.database` and storage here, since
those providers have not yet closed.
The executable entry point's `SIGINT`/`SIGTERM`
wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
signal arrived" without sending a real process signal or invoking the real `process.exit`. signal arrived" without sending a real process signal or invoking the real `process.exit`.
+35 -1
View File
@@ -7,7 +7,9 @@ document's guarantees.
Related: [ADR 0006](../architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md) Related: [ADR 0006](../architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md)
(compiled module runtime), [ADR 0016](../architecture/decisions/0016-module-database-access.md) (compiled module runtime), [ADR 0016](../architecture/decisions/0016-module-database-access.md)
(module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md) (module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md)
(module routing/lifecycle and migration execution), and (module routing/lifecycle and migration execution),
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md)
(native module realtime), and
[Module development](../module-development/README.md). [Module development](../module-development/README.md).
## Installed modules are trusted compiled backend code ## Installed modules are trusted compiled backend code
@@ -44,6 +46,7 @@ cooperative. None of them constrains a module that is not:
| `ekklesia.module.json` manifest and `manifestVersion` | Declared compatibility and dependency ordering | | `ekklesia.module.json` manifest and `manifestVersion` | Declared compatibility and dependency ordering |
| Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them | | Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them |
| Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds | | Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds |
| Scoped realtime channels, publishers, and subscriptions | Correct module-local state wiring, permission enforcement, and finite fanout — not process isolation |
| `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects | | `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects |
| The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL | | The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL |
| Capability registry | Explicit inter-module wiring, not access control | | Capability registry | Explicit inter-module wiring, not access control |
@@ -316,6 +319,37 @@ see the URL it is fetching.
an unsigned request for the same object is refused, and an integration test asserts it so that a an unsigned request for the same object is refused, and an integration test asserts it so that a
future change cannot "fix" delivery by opening a bucket up. future change cannot "fix" delivery by opening a bucket up.
### Realtime is permission-gated state delivery, not a trust boundary
A module may declare a local realtime channel protected by one of its own manifest permissions. Core
closes both the server publisher and browser subscription over that module id: neither API accepts a
caller-supplied module namespace, global topic, wildcard, broker address, origin, URL, permission, or
credential. This prevents accidental cross-module wiring by a cooperating module. It does not
sandbox hostile installed code; trusted same-process backend code could bypass the facade, and
trusted same-realm UI could call browser globals directly.
Ekklesia's SSE endpoint resolves the ordinary session cookie and evaluates the channel's fixed
permission before allocating a subscriber. There is no query-string token and no second realtime
authentication path. A stream is closed after five minutes so native EventSource reconnects and
session, grant-expiry, revocation, and baseline policy are evaluated again; authorization is not
continuously queried between those boundaries. Disabled and dependency-blocked modules have no
stream route at all. Public or device/display identity remains deliberately unsolved, so no channel
is made anonymous merely to support a future kiosk.
Fanout is finite: 16 channels per module, 100 subscribers per channel, 64 KiB of validated serialized
JSON per publication, one latest snapshot per channel, and one coalescing pending slot per client.
A slow browser cannot block a publisher or create an unbounded queue. The endpoint rejects implicit
`HEAD` before allocation, disconnect/cancel closes its Core subscription, and server shutdown closes
streams before finite HTTP drain. Each scoped publisher becomes inert immediately before its own
module's stop hook.
Core never adds configuration, secret values, database values, storage credentials, or delivery URLs
to a realtime document. It transports only what trusted module code explicitly publishes. A module
must therefore treat realtime like any other authorized response: include only data every principal
holding the channel permission may receive. If it intentionally includes a signed delivery URL, that
URL remains a bearer capability and is forwarded byte-for-byte; fixed transport errors never reflect
payloads, URLs, permissions, runtime exceptions, paths, or stacks.
### Client-side permission checks are not a security boundary ### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds A host may tell a module UI what permissions it believes the viewer holds
+28
View File
@@ -247,6 +247,34 @@ cannot appear unnoticed. See
[ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md) and [ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md) and
[ADR 0031](../architecture/decisions/0031-direct-object-delivery.md). [ADR 0031](../architecture/decisions/0031-direct-object-delivery.md).
Phase 7A pass 5 adds realtime evidence at every boundary
([ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md)). Contract and Core
unit suites prove channel grammar, module/permission scoping, staged registration, mandatory initial
snapshots, atomic snapshot-plus-subscribe, sequence ordering, payload/container limits, latest-state
coalescing, subscriber limits, exact shutdown behavior, per-module publisher deactivation, sanitized
errors, and that modules declaring no realtime continue unchanged. Browser SDK/shell suites prove
that the public contract has no server dependency or transport vocabulary, descriptors and local
channels are strictly contained, credentials are host-owned, reconnect requires a fresh snapshot,
terminal EventSource failure cleans up listeners, and explicit unmount is idempotent.
`apps/api/tests/module-realtime-routes.test.ts` exercises permission-before-allocation, the
snapshot/update wire envelope, lack of replay ids, stream headers, comment keepalives, forced
five-minute reauthorization through a short test-only override, shutdown closure, sanitized
subscriber failures, and the explicit `HEAD` refusal that prevents an infinite discarded body from
retaining a subscriber. `apps/api/tests/module-realtime-live.test.ts` binds an actual Node
HTTP server on an OS-assigned loopback port and drives an Announcements-shaped module through real
discovery, registration, startup, two live SSE clients, fanout, disconnect cleanup, reconnect with a
fresh latest snapshot, and shutdown. Every bind, fetch, stream read, cancellation, cleanup poll, and
listener close is bounded; the test also pins an opaque signed delivery URL byte-for-byte. It uses no
PostgreSQL or object-store double because neither belongs in realtime transport.
The live PostgreSQL API suite adds lifecycle composition evidence: an enabled module's realtime route
is live while disabled and dependency-blocked modules have no route, just like their HTTP/UI
surfaces. Its real SSE fetch/read/cancel path is bounded and migrations remain independent of runtime
enablement. During realtime development, run the focused live Node suite repeatedly as well as
`pnpm verify` and `pnpm test:migrations:integration`; consecutive runs are specifically intended to
catch retained timers, listeners, subscribers, or loopback servers.
### Which kind of timing proof lives where ### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind: Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
+95 -11
View File
@@ -248,7 +248,7 @@ independent.
### What the host gives you ### What the host gives you
`mount` receives exactly four members, and nothing else is available: `mount` receives exactly five members, and nothing else is available:
| Member | What it is | | Member | What it is |
| --- | --- | | --- | --- |
@@ -256,6 +256,7 @@ independent.
| `api` | `request(path, init)` against **your own** module API — see below | | `api` | `request(path, init)` against **your own** module API — see below |
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` | | `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
| `permissions` | `has(permissionId)` — a presentation hint, never a security check | | `permissions` | `has(permissionId)` — a presentation hint, never a security check |
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
Navigation, host theme and design tokens, module configuration, organization context, and Navigation, host theme and design tokens, module configuration, organization context, and
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a
@@ -579,10 +580,12 @@ export default defineModuleRuntime({
ID and version are intentional build-artifact identity markers and must match the manifest. Core ID and version are intentional build-artifact identity markers and must match the manifest. Core
rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its
own capabilities and the capability dependencies declared in its manifest. The `register` context own capabilities and the capability dependencies declared in its manifest. The `register` context
is deliberately limited to `moduleId`, capability access, and the HTTP route registrar (below) — contains `moduleId`, capability access, the frozen configuration snapshot, and the HTTP and realtime
never `services`. `start` and `stop` share one context shape and both additionally receive declaration registrars — never live `services` or storage. `start` and `stop` share one context shape:
`services` — the same frozen `ModuleRuntimeServices` object for the lifetime of one running API, capability access, configuration, `services`, storage, and a module-scoped realtime publisher. The
currently exposing only `database` — see [Database access](#database-access). same scoped objects are also supplied to the module's HTTP handlers; see
[Database access](#database-access), [Object storage](#object-storage), and
[Realtime state](#realtime-state).
Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
@@ -734,12 +737,12 @@ method and path (or two paths that are identical once every parameter is treated
placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route. placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route.
The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's
already-authorized `EkklesiaPrincipal`, the immutable decoded path parameters, and `services` — the already-authorized `EkklesiaPrincipal`, immutable decoded path parameters, `services`, the module's
same frozen `ModuleRuntimeServices` object the module's own `start`/`stop` hooks receive, currently frozen configuration snapshot, scoped storage, and scoped realtime publisher. It never receives an
exposing only `database` (see [Database access](#database-access)) — never an authorization store, authorization store, grant/platform-owner state, jobs, a framework-specific request context, or a
grant/platform-owner state, storage, jobs, configuration, secrets, a framework-specific request raw Better Auth session. Secret configuration exists only when the module explicitly declared that
context, or a raw Better Auth session. The full query string remains available through key and the deployment supplied it; Core never serializes it. The full query string remains
`Request.url`; there is no separate query or body abstraction. available through `Request.url`; there is no separate query or body abstraction.
Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production
@@ -756,6 +759,87 @@ deliberately conservative, since even a public-baseline handler still receives a
principal and may personalize its response. See [API](../development/api.md#module-http-routes) for principal and may personalize its response. See [API](../development/api.md#module-http-routes) for
the full production mounting, enforcement, and error-response contract. the full production mounting, enforcement, and error-response contract.
## Realtime state
Realtime is a small state-delivery contract, not a broker. Declare each module-local channel during
`register`, using one permission owned by the same module:
```ts
export default defineModuleRuntime({
id: "announcements",
version: "0.1.0",
register({ realtime }) {
realtime.channel({
channel: "state",
permission: "announcements.slideshow.view",
});
},
start({ realtime }) {
realtime.publish("state", {
mode: "on",
currentSlide: 0,
previousSlide: -1,
slides: [],
});
},
});
```
Channel names are flat lowercase kebab case, at most 48 characters. There are no global topics,
wildcards, rooms, broker addresses, or module-id arguments: Core closes the registrar and publisher
over the module identity, so a cooperating module cannot accidentally publish into another module's
channel. A module may declare at most 16 channels. Each payload must be plain, finite JSON no larger
than 64 KiB after UTF-8 serialization; functions, class instances, sparse arrays, accessors,
non-finite numbers, cycles, and `toJSON` conversion are rejected with fixed errors.
Every declared channel must receive an initial publication before that module's `start` completes.
Core captures that latest state atomically when a subscriber is installed, then supplies later
publications with a monotonically increasing per-channel sequence. This avoids a separate
GET-then-subscribe race. State is ephemeral and best effort: Core keeps no history, restart resets
the sequence, and reconnect receives a fresh authoritative snapshot instead of replay. A slow client
never blocks `publish`; each subscription retains only the newest pending publication, so
intermediate updates may be coalesced. There are at most 100 subscribers per channel.
In browser UI, subscribe through the host context rather than opening a transport yourself:
```ts
const subscription = context.realtime.subscribe<SlideshowState>("state", {
onSnapshot(state, sequence) {
replaceSlideshow(state, sequence);
},
onUpdate(state, sequence) {
replaceSlideshow(state, sequence);
},
onStatus(status) {
showConnectionStatus(status);
},
onError(error) {
showSafeRealtimeError(error.code);
},
});
return () => subscription.close();
```
The public UI vocabulary contains no SSE, WebSocket, Socket.IO, URL, cookie, token, origin, or retry
setting. Ekklesia currently implements it with credentialed Server-Sent Events and native browser
reconnect; a standalone host can implement the same interface differently. The host-generated
endpoint is always scoped to this module, and the server performs the channel's normal permission
check before subscribing. Ekklesia closes each stream after five minutes so reconnect rechecks the
session and permission. Client permission hints remain presentation-only.
On shutdown, Core refuses new subscribers and closes existing subscriptions before HTTP drain, while
letting requests already in flight finish publishing. Reverse-order stop then disables each module's
publisher immediately before that module's own stop hook. Call `close()` during UI unmount as well;
it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are
forwarded unchanged.
Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Public
display/device identity, durable replay, multi-node fanout, client-to-server commands, and
display/kiosk hosting remain future work. See
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md).
## Drizzle schema definition ## Drizzle schema definition
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`: A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`:
+1
View File
@@ -7,6 +7,7 @@ export * from "./module-http.js";
export * from "./module-lifecycle.js"; export * from "./module-lifecycle.js";
export * from "./module-manifest.js"; export * from "./module-manifest.js";
export * from "./module-migration.js"; export * from "./module-migration.js";
export * from "./module-realtime.js";
export * from "./module-runtime.js"; export * from "./module-runtime.js";
export * from "./module-schema.js"; export * from "./module-schema.js";
export * from "./module-storage.js"; export * from "./module-storage.js";
+9 -4
View File
@@ -1,9 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import type { EkklesiaPrincipal } from "./identity.js";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js"; import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
import type { EkklesiaPrincipal } from "./identity.js";
import type { ModuleConfiguration } from "./module-configuration.js"; import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleStorage } from "./module-storage.js";
import type { ModuleRuntimeServices } from "./module-database.js"; import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleRealtimePublisher } from "./module-realtime.js";
import type { ModuleStorage } from "./module-storage.js";
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing // Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
// it would create a source dependency cycle with that module, which also imports from this one for // it would create a source dependency cycle with that module, which also imports from this one for
@@ -188,8 +189,10 @@ export function moduleRoutesOverlap(
/** /**
* What a module's route handler receives about the caller, the match, and the platform services * What a module's route handler receives about the caller, the match, and the platform services
* available to it — never an authorization-store, grant-management, storage, jobs, configuration, * available to it — never an authorization store, grant-management state, jobs, the Hono `Context`,
* secrets, the Hono `Context`, or a raw Better Auth session. `principal` is already authorized by * or a raw Better Auth session. Its storage, configuration, and realtime publisher are all scoped
* to the owning module; secret values appear only when explicitly declared and supplied through
* that module's configuration contract. `principal` is already authorized by
* the time a handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route * the time a handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route
* before ever invoking its handler; this contracts-layer module never invokes a handler itself. * before ever invoking its handler; this contracts-layer module never invokes a handler itself.
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop` * `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
@@ -226,6 +229,8 @@ export interface ModuleRouteHandlerContext {
* into a `Response` without the browser ever learning that object storage exists. */ * into a `Response` without the browser ever learning that object storage exists. */
readonly storage: ModuleStorage; readonly storage: ModuleStorage;
readonly services: ModuleRuntimeServices; readonly services: ModuleRuntimeServices;
/** The identical module-scoped realtime publisher used by lifecycle hooks. */
readonly realtime: ModuleRealtimePublisher;
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the /** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
* same object, not a re-read — so a handler and a lifecycle hook can never disagree about a value * same object, not a re-read — so a handler and a lifecycle hook can never disagree about a value
* (ADR 0029). Never serialized into a response by Core; a module that chooses to expose part of * (ADR 0029). Never serialized into a response by Core; a module that chooses to expose part of
+147
View File
@@ -0,0 +1,147 @@
import { z } from "zod";
import { type PermissionId, permissionIdSchema } from "./identifiers.js";
/** A module-local channel name. Core supplies the owning module namespace; modules cannot publish
* into another module's channel. The deliberately flat grammar has no hierarchy or wildcards. */
export const MODULE_REALTIME_CHANNEL_MAX_LENGTH = 48;
export const moduleRealtimeChannelSchema = z
.string()
.min(1)
.max(MODULE_REALTIME_CHANNEL_MAX_LENGTH)
.regex(
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u,
"Must be a lowercase kebab-case module realtime channel.",
)
.brand<"ModuleRealtimeChannel">();
export type ModuleRealtimeChannel = z.infer<typeof moduleRealtimeChannelSchema>;
export function parseModuleRealtimeChannel(input: unknown): ModuleRealtimeChannel {
return moduleRealtimeChannelSchema.parse(input);
}
export function safeParseModuleRealtimeChannel(
input: unknown,
): z.ZodSafeParseResult<ModuleRealtimeChannel> {
return moduleRealtimeChannelSchema.safeParse(input);
}
/** Registration and fan-out ceilings are platform policy, identical for every module. */
export const MODULE_REALTIME_MAX_CHANNELS_PER_MODULE = 16;
export const MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL = 100;
export const MODULE_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024;
export type ModuleRealtimeJsonPrimitive = boolean | number | string | null;
export type ModuleRealtimeJsonValue =
| ModuleRealtimeJsonPrimitive
| readonly ModuleRealtimeJsonValue[]
| { readonly [key: string]: ModuleRealtimeJsonValue };
export interface ModuleRealtimeChannelDefinitionInput {
readonly channel: string;
readonly permission: string;
}
export interface ModuleRealtimeChannelDefinition {
readonly channel: ModuleRealtimeChannel;
readonly permission: PermissionId;
}
export interface ModuleRealtimeChannelRegistrar {
channel(definition: ModuleRealtimeChannelDefinitionInput): void;
}
/** The runtime publisher is already scoped to its module. No module id, origin, transport, token,
* room, or topic can be supplied by module code. */
export interface ModuleRealtimePublisher {
/** `unknown` is deliberate: an ordinary named interface is valid input without having to add a
* meaningless string index signature. TypeScript cannot prove recursive JSON compatibility for
* every structural interface, so the host validates the exact runtime value authoritatively. */
publish(channel: string, payload: unknown): void;
}
export type ModuleRealtimeChannelDefinitionIssueCode =
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
| "MODULE_REALTIME_CHANNEL_INVALID"
| "MODULE_REALTIME_PERMISSION_INVALID";
export type ModuleRealtimeChannelDefinitionParseResult =
| { readonly success: true; readonly data: ModuleRealtimeChannelDefinition }
| { readonly success: false; readonly code: ModuleRealtimeChannelDefinitionIssueCode };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Validates only the definition's transport-neutral shape. Core separately proves that the
* permission belongs to the registering module. Rejected input is never reflected in the result. */
export function safeParseModuleRealtimeChannelDefinition(
value: unknown,
): ModuleRealtimeChannelDefinitionParseResult {
if (!isRecord(value)) {
return { success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" };
}
const keys = Object.keys(value);
if (keys.length !== 2 || !keys.includes("channel") || !keys.includes("permission")) {
return { success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" };
}
const channel = moduleRealtimeChannelSchema.safeParse(value.channel);
if (!channel.success) {
return { success: false, code: "MODULE_REALTIME_CHANNEL_INVALID" };
}
const permission = permissionIdSchema.safeParse(value.permission);
if (!permission.success) {
return { success: false, code: "MODULE_REALTIME_PERMISSION_INVALID" };
}
return {
success: true,
data: Object.freeze({ channel: channel.data, permission: permission.data }),
};
}
export function parseModuleRealtimeChannelDefinition(
value: unknown,
): ModuleRealtimeChannelDefinition {
const result = safeParseModuleRealtimeChannelDefinition(value);
if (!result.success) {
throw new TypeError("Invalid Ekklesia module realtime channel definition.");
}
return result.data;
}
export const MODULE_REALTIME_ERROR_CODES = [
"REALTIME_CHANNEL_INVALID",
"REALTIME_CHANNEL_UNDECLARED",
"REALTIME_PAYLOAD_INVALID",
"REALTIME_PAYLOAD_TOO_LARGE",
"REALTIME_PUBLISHER_STOPPED",
"REALTIME_SNAPSHOT_UNAVAILABLE",
"REALTIME_SUBSCRIBER_LIMIT",
"REALTIME_UNAVAILABLE",
] as const;
export type ModuleRealtimeErrorCode = (typeof MODULE_REALTIME_ERROR_CODES)[number];
const realtimeErrorMessages: Readonly<Record<ModuleRealtimeErrorCode, string>> = Object.freeze({
REALTIME_CHANNEL_INVALID: "The realtime channel is invalid.",
REALTIME_CHANNEL_UNDECLARED: "This module did not declare the realtime channel.",
REALTIME_PAYLOAD_INVALID: "The realtime payload is not JSON-compatible.",
REALTIME_PAYLOAD_TOO_LARGE: "The realtime payload exceeds the platform limit.",
REALTIME_PUBLISHER_STOPPED: "The realtime publisher is no longer running.",
REALTIME_SNAPSHOT_UNAVAILABLE: "The realtime channel has no current snapshot.",
REALTIME_SUBSCRIBER_LIMIT: "The realtime channel subscriber limit has been reached.",
REALTIME_UNAVAILABLE: "Realtime delivery is not available.",
});
/** A fixed-code, fixed-message failure with no cause, payload, URL, path, or transport detail. */
export class ModuleRealtimeError extends Error {
readonly code: ModuleRealtimeErrorCode;
constructor(code: ModuleRealtimeErrorCode) {
super(realtimeErrorMessages[code]);
this.name = "ModuleRealtimeError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
+8 -2
View File
@@ -1,9 +1,10 @@
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleStorage } from "./module-storage.js";
import type { CapabilityId, ModuleId } from "./identifiers.js"; import type { CapabilityId, ModuleId } from "./identifiers.js";
import { moduleIdSchema } from "./identifiers.js"; import { moduleIdSchema } from "./identifiers.js";
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleRuntimeServices } from "./module-database.js"; import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleHttpRouteRegistrar } from "./module-http.js"; import type { ModuleHttpRouteRegistrar } from "./module-http.js";
import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js";
import type { ModuleStorage } from "./module-storage.js";
import { semanticVersionSchema } from "./versions.js"; import { semanticVersionSchema } from "./versions.js";
export type Awaitable<Value> = Value | PromiseLike<Value>; export type Awaitable<Value> = Value | PromiseLike<Value>;
@@ -29,6 +30,9 @@ export interface ModuleRegistrationContext {
* be declared while Core is still building the immutable startup snapshot, never added or removed * be declared while Core is still building the immutable startup snapshot, never added or removed
* afterward from `start`, `stop`, or any other post-registration code path. */ * afterward from `start`, `stop`, or any other post-registration code path. */
readonly http: ModuleHttpRouteRegistrar; readonly http: ModuleHttpRouteRegistrar;
/** Declarative, registration-only channel publication. Live publishing is available only from
* runtime and request-handler contexts. */
readonly realtime: ModuleRealtimeChannelRegistrar;
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
/** /**
* This module's own validated configuration, available here as well as at runtime (ADR 0029). * This module's own validated configuration, available here as well as at runtime (ADR 0029).
@@ -61,6 +65,8 @@ export interface ModuleRuntimeContext {
* manifest declared, and carries no endpoint, credential, or physical bucket name. * manifest declared, and carries no endpoint, credential, or physical bucket name.
*/ */
readonly storage: ModuleStorage; readonly storage: ModuleStorage;
/** This module's own publisher. Channel names are always resolved inside this module. */
readonly realtime: ModuleRealtimePublisher;
/** /**
* This module's own validated configuration, frozen for the lifetime of the process. * This module's own validated configuration, frozen for the lifetime of the process.
* *
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
MODULE_REALTIME_CHANNEL_MAX_LENGTH,
MODULE_REALTIME_ERROR_CODES,
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
ModuleRealtimeError,
type ModuleRealtimePublisher,
parseModuleRealtimeChannel,
parseModuleRealtimeChannelDefinition,
safeParseModuleRealtimeChannel,
safeParseModuleRealtimeChannelDefinition,
} from "../src/index.js";
describe("module realtime contracts", () => {
it("accepts an ordinary named state interface without an index signature or cast", () => {
interface SlideshowState {
readonly currentSlide: number;
readonly mode: "on" | "off";
readonly slides: readonly string[];
}
const state: SlideshowState = { currentSlide: 1, mode: "on", slides: ["/welcome.png"] };
const received: unknown[] = [];
const publisher: ModuleRealtimePublisher = {
publish: (_channel, payload) => received.push(payload),
};
publisher.publish("state", state);
expect(received).toEqual([state]);
});
it("accepts flat local kebab-case channels and rejects hierarchy and wildcards", () => {
expect(parseModuleRealtimeChannel("state")).toBe("state");
expect(parseModuleRealtimeChannel("slide-state")).toBe("slide-state");
for (const invalid of ["", "State", "slide_state", "slide.state", "slide/state", "*", "a.*"]) {
expect(safeParseModuleRealtimeChannel(invalid).success).toBe(false);
}
expect(
safeParseModuleRealtimeChannel("a".repeat(MODULE_REALTIME_CHANNEL_MAX_LENGTH)).success,
).toBe(true);
expect(
safeParseModuleRealtimeChannel("a".repeat(MODULE_REALTIME_CHANNEL_MAX_LENGTH + 1)).success,
).toBe(false);
});
it("parses only the exact channel and permission definition shape", () => {
expect(
parseModuleRealtimeChannelDefinition({
channel: "state",
permission: "announcements.slideshow.view",
}),
).toEqual({ channel: "state", permission: "announcements.slideshow.view" });
expect(
safeParseModuleRealtimeChannelDefinition({
channel: "state",
permission: "announcements.slideshow.view",
topic: "legacy",
}),
).toEqual({ success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" });
expect(safeParseModuleRealtimeChannelDefinition({ channel: "state", permission: "*" })).toEqual(
{ success: false, code: "MODULE_REALTIME_PERMISSION_INVALID" },
);
});
it("publishes finite platform limits as explicit contract constants", () => {
expect(MODULE_REALTIME_MAX_CHANNELS_PER_MODULE).toBe(16);
expect(MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL).toBe(100);
expect(MODULE_REALTIME_MAX_PAYLOAD_BYTES).toBe(65_536);
});
it("uses a closed error-code set with fixed sanitized messages and stacks", () => {
expect(MODULE_REALTIME_ERROR_CODES).toContain("REALTIME_PAYLOAD_INVALID");
const error = new ModuleRealtimeError("REALTIME_PAYLOAD_INVALID");
expect(error).toMatchObject({
code: "REALTIME_PAYLOAD_INVALID",
message: "The realtime payload is not JSON-compatible.",
name: "ModuleRealtimeError",
});
expect(error.stack).toBe("ModuleRealtimeError: The realtime payload is not JSON-compatible.");
expect(Object.isFrozen(error)).toBe(true);
});
});
@@ -65,6 +65,7 @@ describe("defineModuleRuntime", () => {
config: EMPTY_MODULE_CONFIGURATION, config: EMPTY_MODULE_CONFIGURATION,
http, http,
moduleId: "example", moduleId: "example",
realtime: { channel: () => undefined },
}; };
await runtime.register?.(context); await runtime.register?.(context);
@@ -44,6 +44,7 @@ function hostContext(requested: string[], moduleId = "announcements"): ModuleUiH
}, },
principal: { kind: "anonymous" }, principal: { kind: "anonymous" },
permissions: { has: () => true }, permissions: { has: () => true },
realtime: { subscribe: () => ({ close: () => undefined }) },
}; };
} }
@@ -28,6 +28,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
}, },
principal: { kind: "authenticated", userId: "user-123" }, principal: { kind: "authenticated", userId: "user-123" },
permissions: { has: () => true }, permissions: { has: () => true },
realtime: { subscribe: () => ({ close: () => undefined }) },
}; };
} }
@@ -43,6 +44,7 @@ function standaloneContext(requested: string[]): ModuleUiHostContext {
}, },
principal: { kind: "anonymous" }, principal: { kind: "anonymous" },
permissions: { has: () => false }, permissions: { has: () => false },
realtime: { subscribe: () => ({ close: () => undefined }) },
}; };
} }
@@ -1,3 +1,5 @@
import type { ModuleUiRealtimeClient } from "./realtime.js";
/** /**
* Who is viewing, as a browser sees it. * Who is viewing, as a browser sees it.
* *
@@ -53,6 +55,8 @@ export interface ModuleUiHostContext {
readonly principal: ModuleUiPrincipal; readonly principal: ModuleUiPrincipal;
/** Presentation-only permission hints. See {@link ModuleUiPermissions}. */ /** Presentation-only permission hints. See {@link ModuleUiPermissions}. */
readonly permissions: ModuleUiPermissions; readonly permissions: ModuleUiPermissions;
/** Host-owned delivery of this module's own runtime-published state. */
readonly realtime: ModuleUiRealtimeClient;
} }
/** /**
+1
View File
@@ -1,3 +1,4 @@
export * from "./define-module-ui.js"; export * from "./define-module-ui.js";
export * from "./host-context.js"; export * from "./host-context.js";
export * from "./module-ui.js"; export * from "./module-ui.js";
export * from "./realtime.js";
+57
View File
@@ -0,0 +1,57 @@
/**
* A JSON value carried from a module's server runtime to its browser UI.
*
* Realtime state is deliberately data, not transport vocabulary. A module does not open a socket,
* choose an origin, attach credentials, or know how its host keeps a subscription alive. The host
* owns all of that and gives the module only typed snapshots and updates.
*/
export type ModuleUiRealtimeJsonValue =
| boolean
| number
| string
| null
| readonly ModuleUiRealtimeJsonValue[]
| { readonly [key: string]: ModuleUiRealtimeJsonValue };
/** Connection state useful for rendering a small, honest status indicator. */
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
/** Stable, browser-safe failures. A host never exposes a caught platform or network error. */
export type ModuleUiRealtimeErrorCode = "REALTIME_CONNECTION_FAILED" | "REALTIME_MESSAGE_INVALID";
export interface ModuleUiRealtimeError {
readonly code: ModuleUiRealtimeErrorCode;
readonly message: string;
}
/** Callbacks for one module-local channel. Snapshot and update are distinct on purpose: a UI can
* replace all derived state when it (re)connects, then apply later authoritative states normally. */
export interface ModuleUiRealtimeHandlers<State = ModuleUiRealtimeJsonValue> {
readonly onSnapshot?: (state: State, sequence: number) => void;
readonly onUpdate?: (state: State, sequence: number) => void;
readonly onStatus?: (status: ModuleUiRealtimeStatus) => void;
readonly onError?: (error: ModuleUiRealtimeError) => void;
}
/** One live subscription. Closing is synchronous and idempotent. */
export interface ModuleUiRealtimeSubscription {
close(): void;
}
/**
* A module UI's access to its own runtime-published state.
*
* `channel` is local to the module and uses lowercase kebab-case. The host supplies the module id,
* endpoint, origin, credentials, reconnection policy, and concrete transport. Modules can neither
* subscribe to another module nor supply a bearer token through this API.
*
* State must be JSON-compatible. The generic is intentionally unconstrained so a normal named
* interface can describe a payload without needing an index signature; the runtime wire boundary
* still validates that every delivered payload is JSON.
*/
export interface ModuleUiRealtimeClient {
subscribe<State = ModuleUiRealtimeJsonValue>(
channel: string,
handlers: ModuleUiRealtimeHandlers<State>,
): ModuleUiRealtimeSubscription;
}
@@ -17,7 +17,16 @@ export default defineModuleUi({
container.textContent = describePrincipal(context.principal); container.textContent = describePrincipal(context.principal);
void context.api.request("records"); void context.api.request("records");
void context.permissions.has("example.records.read"); void context.permissions.has("example.records.read");
const subscription = context.realtime.subscribe<{ readonly count: number }>("record-count", {
onSnapshot(state, sequence) {
container.textContent = `${state.count}:${sequence}`;
},
onStatus(status) {
container.setAttribute("data-realtime", status);
},
});
return () => { return () => {
subscription.close();
container.textContent = ""; container.textContent = "";
}; };
}, },
@@ -6,6 +6,7 @@ import {
type ModuleUiApiClient, type ModuleUiApiClient,
type ModuleUiDefinition, type ModuleUiDefinition,
type ModuleUiHostContext, type ModuleUiHostContext,
type ModuleUiRealtimeClient,
parseModuleUiDefinition, parseModuleUiDefinition,
parseModuleUiExport, parseModuleUiExport,
} from "../src/index.js"; } from "../src/index.js";
@@ -19,6 +20,10 @@ function validDefinition(): ModuleUiDefinition {
* contract, and this package deliberately does not take a DOM dependency to obtain one. */ * contract, and this package deliberately does not take a DOM dependency to obtain one. */
const noop = (): void => undefined; const noop = (): void => undefined;
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
subscribe: () => Object.freeze({ close: noop }),
});
function fakeContainer(): Element & { textContent: string } { function fakeContainer(): Element & { textContent: string } {
return { textContent: "" } as unknown as Element & { textContent: string }; return { textContent: "" } as unknown as Element & { textContent: string };
} }
@@ -131,6 +136,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
api, api,
principal: { kind: "anonymous" }, principal: { kind: "anonymous" },
permissions: { has: () => canPublish }, permissions: { has: () => canPublish },
realtime: noopRealtime,
}, },
}; };
} }
@@ -154,6 +160,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
api, api,
principal: { kind: "anonymous" }, principal: { kind: "anonymous" },
permissions: { has: () => canPublish }, permissions: { has: () => canPublish },
realtime: noopRealtime,
}, },
}; };
} }
+27 -3
View File
@@ -14,8 +14,17 @@ export interface EkklesiaHostConfig {
/** Injected so tests can drive the real protocol against a real in-process app without a socket. /** 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. */ * Production composition leaves this unset and the platform `fetch` is used. */
readonly fetch?: typeof fetch; 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 /** 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 * 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. * only URLs it knows, and they describe the platform itself rather than any module.
@@ -35,13 +44,16 @@ export type HostProtocolErrorCode =
/** The API could not be reached, or failed. */ /** The API could not be reached, or failed. */
| "HOST_REQUEST_FAILED" | "HOST_REQUEST_FAILED"
/** A module UI asked for something outside its own API root. */ /** A module UI asked for something outside its own API root. */
| "MODULE_API_PATH_INVALID"; | "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<Record<HostProtocolErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
HOST_REQUEST_FAILED: "Ekklesia could not be reached.", HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.", 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.", 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_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.",
}); });
/** /**
@@ -77,6 +89,8 @@ export interface HostModuleUiDescriptor {
readonly uiUrl: string; readonly uiUrl: string;
/** Server-produced root of this module's own HTTP API. */ /** Server-produced root of this module's own HTTP API. */
readonly apiBaseUrl: string; 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. */ /** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
readonly permissions: readonly string[]; readonly permissions: readonly string[];
} }
@@ -94,7 +108,7 @@ function nestedString(value: unknown, key: string): string | undefined {
} }
/** /**
* Both URLs in a descriptor must be **root-relative** paths, exactly as the server produces them. * 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 * 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 * absolute or protocol-relative value arriving here would silently redirect a module's script import
@@ -105,7 +119,14 @@ function nestedString(value: unknown, key: string): string | undefined {
*/ */
function rootRelativePath(value: unknown, key: string): string | undefined { function rootRelativePath(value: unknown, key: string): string | undefined {
const candidate = nestedString(value, key); const candidate = nestedString(value, key);
if (candidate === undefined || !candidate.startsWith("/") || candidate.startsWith("//")) { if (
candidate === undefined ||
!candidate.startsWith("/") ||
candidate.startsWith("//") ||
candidate.includes("\\") ||
candidate.includes("?") ||
candidate.includes("#")
) {
return undefined; return undefined;
} }
return candidate; return candidate;
@@ -122,12 +143,14 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
const version = nestedString(entry, "version"); const version = nestedString(entry, "version");
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined; const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : 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; const permissions = isRecord(entry) ? entry.permissions : undefined;
if ( if (
id === undefined || id === undefined ||
version === undefined || version === undefined ||
uiUrl === undefined || uiUrl === undefined ||
apiBaseUrl === undefined || apiBaseUrl === undefined ||
realtimeBaseUrl === undefined ||
!Array.isArray(permissions) || !Array.isArray(permissions) ||
permissions.some((permission) => typeof permission !== "string") permissions.some((permission) => typeof permission !== "string")
) { ) {
@@ -137,6 +160,7 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
apiBaseUrl, apiBaseUrl,
id, id,
permissions: Object.freeze([...(permissions as string[])]), permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
uiUrl, uiUrl,
version, version,
}); });
+2
View File
@@ -16,6 +16,8 @@ export {
type DashboardNavigationGroup, type DashboardNavigationGroup,
type EkklesiaHostConfig, type EkklesiaHostConfig,
type HostBootstrap, type HostBootstrap,
type HostEventSourceFactory,
type HostEventSourceOptions,
type HostModuleUiDescriptor, type HostModuleUiDescriptor,
HostProtocolError, HostProtocolError,
type HostProtocolErrorCode, type HostProtocolErrorCode,
+189 -4
View File
@@ -2,6 +2,11 @@ import type {
ModuleUiApiClient, ModuleUiApiClient,
ModuleUiHostContext, ModuleUiHostContext,
ModuleUiPrincipal, ModuleUiPrincipal,
ModuleUiRealtimeClient,
ModuleUiRealtimeError,
ModuleUiRealtimeErrorCode,
ModuleUiRealtimeHandlers,
ModuleUiRealtimeStatus,
} from "@ekklesia/module-ui-sdk"; } from "@ekklesia/module-ui-sdk";
import { import {
type EkklesiaHostConfig, type EkklesiaHostConfig,
@@ -11,6 +16,185 @@ import {
} from "./host-protocol.js"; } from "./host-protocol.js";
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u; 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 * Turns the relative path a module UI asks for into the real Ekklesia URL, and refuses anything
@@ -74,10 +258,10 @@ export interface CreateModuleUiContextOptions {
/** /**
* Builds the `ModuleUiHostContext` (ADR 0022) Ekklesia hands a module UI at mount time. * Builds the `ModuleUiHostContext` (ADR 0022) Ekklesia hands a module UI at mount time.
* *
* All four members come from server-derived data: the module id and API root from the inventory * All five members come from host-derived data: the module id, API root, and realtime root from the
* descriptor, the principal from `/api/v1/principal`, and the permission hints from the server's own * inventory descriptor; the principal from `/api/v1/principal`; and permission hints from the
* evaluation of that module's declared permissions against that principal. Nothing is guessed, * server's evaluation of that module's declarations. Nothing is guessed, defaulted to `true`, or
* defaulted to `true`, or inferred from a client-side role name. * 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 * 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 * discover what the viewer may do elsewhere in the platform, and `has` returning `true` is a
@@ -91,6 +275,7 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
moduleId: options.descriptor.id, moduleId: options.descriptor.id,
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }), permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
principal: options.principal, principal: options.principal,
realtime: createModuleRealtimeClient(options.config, options.descriptor),
}); });
} }
+1
View File
@@ -42,6 +42,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl
api: { baseUrl: `/api/v1/modules/${id}` }, api: { baseUrl: `/api/v1/modules/${id}` },
id, id,
permissions, permissions,
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/${version}` }, ui: { url: `/api/v1/module-ui/${id}/${version}` },
version, version,
}, },
+18 -3
View File
@@ -40,6 +40,7 @@ const inventoryPayload = {
api: { baseUrl: "/api/v1/modules/example" }, api: { baseUrl: "/api/v1/modules/example" },
id: "example", id: "example",
permissions: ["example.records.read"], permissions: ["example.records.read"],
realtime: { baseUrl: "/api/v1/module-realtime/example" },
ui: { url: "/api/v1/module-ui/example/0.1.0" }, ui: { url: "/api/v1/module-ui/example/0.1.0" },
version: "0.1.0", version: "0.1.0",
}, },
@@ -50,6 +51,7 @@ const calendarDescriptor: HostModuleUiDescriptor = {
apiBaseUrl: "/api/v1/modules/calendar", apiBaseUrl: "/api/v1/modules/calendar",
id: "calendar", id: "calendar",
permissions: [], permissions: [],
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
uiUrl: "/api/v1/module-ui/calendar/2.0.0", uiUrl: "/api/v1/module-ui/calendar/2.0.0",
version: "2.0.0", version: "2.0.0",
}; };
@@ -69,6 +71,7 @@ const navigationPayload = {
api: { baseUrl: "/api/v1/modules/calendar" }, api: { baseUrl: "/api/v1/modules/calendar" },
id: "calendar", id: "calendar",
permissions: [], permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/calendar" },
ui: { url: "/api/v1/module-ui/calendar/2.0.0" }, ui: { url: "/api/v1/module-ui/calendar/2.0.0" },
version: "2.0.0", version: "2.0.0",
}, },
@@ -81,6 +84,7 @@ const exampleDescriptor: HostModuleUiDescriptor = {
apiBaseUrl: "/api/v1/modules/example", apiBaseUrl: "/api/v1/modules/example",
id: "example", id: "example",
permissions: ["example.records.read"], permissions: ["example.records.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
uiUrl: "/api/v1/module-ui/example/0.1.0", uiUrl: "/api/v1/module-ui/example/0.1.0",
version: "0.1.0", version: "0.1.0",
}; };
@@ -103,14 +107,18 @@ describe("host protocol — descriptor validation", () => {
{ id: "example", version: "0.1.0" }, { id: "example", version: "0.1.0" },
{ ...base, ui: {} }, { ...base, ui: {} },
{ ...base, api: { baseUrl: "" } }, { ...base, api: { baseUrl: "" } },
{ ...base, realtime: {} },
{ ...base, permissions: "example.records.read" }, { ...base, permissions: "example.records.read" },
{ ...base, permissions: [42] }, { ...base, permissions: [42] },
// Both URLs must be root-relative, so a value that would resolve against another origin is // Every URL must be root-relative, so a value that would resolve against another origin is
// refused rather than silently used to fetch browser code. // refused rather than silently used to fetch browser code.
{ ...base, ui: { url: "https://evil.example.test/x.js" } }, { ...base, ui: { url: "https://evil.example.test/x.js" } },
{ ...base, ui: { url: "//evil.example.test/x.js" } }, { ...base, ui: { url: "//evil.example.test/x.js" } },
{ ...base, ui: { url: "relative/x.js" } }, { ...base, ui: { url: "relative/x.js" } },
{ ...base, api: { baseUrl: "https://evil.example.test/api" } }, { ...base, api: { baseUrl: "https://evil.example.test/api" } },
{ ...base, realtime: { baseUrl: "https://evil.example.test/live" } },
{ ...base, realtime: { baseUrl: "//evil.example.test/live" } },
{ ...base, realtime: { baseUrl: "/\\evil.example.test/live" } },
]; ];
for (const module of malformed) { for (const module of malformed) {
expect( expect(
@@ -366,6 +374,7 @@ describe("module UI host context", () => {
apiBaseUrl: "/api/v1/modules/calendar", apiBaseUrl: "/api/v1/modules/calendar",
id: "calendar", id: "calendar",
permissions: [], permissions: [],
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
uiUrl: "/api/v1/module-ui/calendar/2.0.0", uiUrl: "/api/v1/module-ui/calendar/2.0.0",
version: "2.0.0", version: "2.0.0",
}; };
@@ -392,12 +401,18 @@ describe("module UI host context", () => {
expect(none.has("core.authorization.grants.manage")).toBe(false); expect(none.has("core.authorization.grants.manage")).toBe(false);
}); });
it("exposes exactly the four contract members and the browser principal", () => { it("exposes exactly the five contract members and the browser principal", () => {
const { implementation } = recordingFetch(); const { implementation } = recordingFetch();
const context = contextFor(implementation); const context = contextFor(implementation);
expect(Object.keys(context).sort()).toEqual(["api", "moduleId", "permissions", "principal"]); expect(Object.keys(context).sort()).toEqual([
"api",
"moduleId",
"permissions",
"principal",
"realtime",
]);
expect(context.moduleId).toBe("example"); expect(context.moduleId).toBe("example");
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" }); expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
}); });
@@ -0,0 +1,312 @@
import type { ModuleUiRealtimeError, ModuleUiRealtimeStatus } from "@ekklesia/module-ui-sdk";
import { describe, expect, it, vi } from "vitest";
import type {
HostEventSourceFactory,
HostEventSourceOptions,
HostModuleUiDescriptor,
} from "../src/host-protocol.js";
import { createModuleUiContext } from "../src/module-context.js";
const apiBaseUrl = "https://ekklesia.example.test";
const exampleDescriptor: HostModuleUiDescriptor = {
apiBaseUrl: "/api/v1/modules/example",
id: "example",
permissions: ["example.state.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
uiUrl: "/api/v1/module-ui/example/0.1.0",
version: "0.1.0",
};
class FakeEventSource {
readonly CLOSED = 2 as const;
readonly CONNECTING = 0 as const;
readonly OPEN = 1 as const;
readonly listeners = new Map<string, Set<EventListener>>();
closed = false;
closeCalls = 0;
readyState: number = this.CONNECTING;
addEventListener(type: string, listener: EventListener): void {
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: EventListener): void {
this.listeners.get(type)?.delete(listener);
}
close(): void {
this.closed = true;
this.closeCalls += 1;
this.readyState = this.CLOSED;
}
emit(type: string, data?: string): void {
if (type === "open") {
this.readyState = this.OPEN;
}
const event = Object.freeze({ data, type }) as unknown as Event;
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
interface EventSourceHarness {
readonly calls: Array<{
readonly options: HostEventSourceOptions;
readonly source: FakeEventSource;
readonly url: string;
}>;
readonly factory: HostEventSourceFactory;
}
function eventSourceHarness(): EventSourceHarness {
const calls: EventSourceHarness["calls"] = [];
const factory = ((url: string, options: HostEventSourceOptions): EventSource => {
const source = new FakeEventSource();
calls.push({ options, source, url });
return source as unknown as EventSource;
}) as HostEventSourceFactory;
return { calls, factory };
}
function context(factory: HostEventSourceFactory, descriptor = exampleDescriptor) {
return createModuleUiContext({
config: { apiBaseUrl, eventSourceFactory: factory },
descriptor,
principal: { kind: "authenticated", userId: "user-1" },
});
}
function envelope(sequence: number, payload: unknown): string {
return JSON.stringify({ payload, sequence });
}
describe("module UI realtime host adapter", () => {
it("opens the server-provided module channel URL with host-owned credentials", () => {
const harness = eventSourceHarness();
context(harness.factory).realtime.subscribe("slideshow-state", {});
expect(harness.calls).toHaveLength(1);
expect(harness.calls[0]).toMatchObject({
options: { withCredentials: true },
url: `${apiBaseUrl}/api/v1/module-realtime/example/slideshow-state`,
});
});
it("scopes the same local channel to each descriptor without accepting a module id", () => {
const harness = eventSourceHarness();
const calendar: HostModuleUiDescriptor = {
...exampleDescriptor,
apiBaseUrl: "/api/v1/modules/calendar",
id: "calendar",
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
uiUrl: "/api/v1/module-ui/calendar/1.0.0",
version: "1.0.0",
};
context(harness.factory).realtime.subscribe("state", {});
context(harness.factory, calendar).realtime.subscribe("state", {});
expect(harness.calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/module-realtime/example/state`,
`${apiBaseUrl}/api/v1/module-realtime/calendar/state`,
]);
});
it("delivers snapshot first, ordered updates, and fresh state after automatic reconnect", () => {
const harness = eventSourceHarness();
const statuses: ModuleUiRealtimeStatus[] = [];
const snapshots: unknown[] = [];
const updates: unknown[] = [];
const errors: ModuleUiRealtimeError[] = [];
context(harness.factory).realtime.subscribe<{ readonly mode: string }>("state", {
onError: (error) => errors.push(error),
onSnapshot: (state, sequence) => snapshots.push({ sequence, state }),
onStatus: (status) => statuses.push(status),
onUpdate: (state, sequence) => updates.push({ sequence, state }),
});
const source = harness.calls[0]?.source;
expect(source).toBeDefined();
source?.emit("open");
source?.emit("snapshot", envelope(1, { mode: "on" }));
source?.emit("update", envelope(2, { mode: "off" }));
// Native EventSource owns reconnect. The host reports it, then treats the next snapshot as a
// fresh authoritative base even when its sequence is the same as the last state already seen.
if (source) {
source.readyState = source.CONNECTING;
source.emit("error");
}
source?.emit("open");
source?.emit("snapshot", envelope(2, { mode: "off" }));
source?.emit("update", envelope(3, { mode: "on" }));
expect(statuses).toEqual(["connecting", "connected", "reconnecting", "connected"]);
expect(snapshots).toEqual([
{ sequence: 1, state: { mode: "on" } },
{ sequence: 2, state: { mode: "off" } },
]);
expect(updates).toEqual([
{ sequence: 2, state: { mode: "off" } },
{ sequence: 3, state: { mode: "on" } },
]);
expect(errors).toEqual([
{
code: "REALTIME_CONNECTION_FAILED",
message: "The live connection could not be maintained.",
},
]);
});
it("rejects malformed envelopes and out-of-order deliveries with one fixed safe error", () => {
const harness = eventSourceHarness();
const updates: unknown[] = [];
const errors: ModuleUiRealtimeError[] = [];
context(harness.factory).realtime.subscribe("state", {
onError: (error) => errors.push(error),
onUpdate: (state, sequence) => updates.push({ sequence, state }),
});
const source = harness.calls[0]?.source;
source?.emit("open");
source?.emit("update", envelope(1, { secret: "not delivered" }));
source?.emit("snapshot", "not json: /private/server/path");
source?.emit("snapshot", JSON.stringify({ extra: true, payload: {}, sequence: 1 }));
source?.emit("snapshot", envelope(0, {}));
source?.emit("snapshot", envelope(2, { mode: "on" }));
source?.emit("update", envelope(2, { mode: "stale" }));
expect(updates).toEqual([]);
expect(errors).toHaveLength(5);
expect(new Set(errors.map((error) => JSON.stringify(error)))).toEqual(
new Set([
JSON.stringify({
code: "REALTIME_MESSAGE_INVALID",
message: "The host received an invalid live update.",
}),
]),
);
expect(JSON.stringify(errors)).not.toContain("private");
expect(JSON.stringify(errors)).not.toContain("secret");
});
it("refuses invalid channel names before opening a connection", () => {
const harness = eventSourceHarness();
const realtime = context(harness.factory).realtime;
for (const channel of [
"",
"State",
"slide_state",
"slide/state",
"state.*",
"-state",
"state-",
"a".repeat(49),
]) {
expect(() => realtime.subscribe(channel, {}), channel).toThrowError(
expect.objectContaining({ code: "MODULE_REALTIME_CHANNEL_INVALID" }),
);
}
expect(harness.calls).toEqual([]);
});
it("closes idempotently, removes every listener, and delivers nothing afterward", () => {
const harness = eventSourceHarness();
const statuses: ModuleUiRealtimeStatus[] = [];
const snapshots = vi.fn();
const subscription = context(harness.factory).realtime.subscribe("state", {
onSnapshot: snapshots,
onStatus: (status) => statuses.push(status),
});
const source = harness.calls[0]?.source;
source?.emit("open");
subscription.close();
subscription.close();
source?.emit("snapshot", envelope(1, { mode: "on" }));
source?.emit("open");
expect(source?.closed).toBe(true);
expect(source?.closeCalls).toBe(1);
expect(
[...((source?.listeners.values() ?? []) as Iterable<Set<EventListener>>)].every(
(listeners) => listeners.size === 0,
),
).toBe(true);
expect(snapshots).not.toHaveBeenCalled();
expect(statuses).toEqual(["connecting", "connected", "closed"]);
});
it("treats a terminal native CLOSED error as closed and eagerly removes all listeners", () => {
const harness = eventSourceHarness();
const statuses: ModuleUiRealtimeStatus[] = [];
const errors: ModuleUiRealtimeError[] = [];
const snapshots = vi.fn();
const subscription = context(harness.factory).realtime.subscribe("state", {
onError: (error) => errors.push(error),
onSnapshot: snapshots,
onStatus: (status) => statuses.push(status),
});
const source = harness.calls[0]?.source;
source?.emit("open");
source?.emit("snapshot", envelope(1, { mode: "on" }));
if (source) {
// Fatal HTTP/protocol failures leave native EventSource CLOSED; unlike CONNECTING, the browser
// will not retry them, so the host must not promise `reconnecting`.
source.readyState = source.CLOSED;
source.emit("error");
source.emit("error");
source.emit("open");
source.emit("snapshot", envelope(2, { mode: "off" }));
}
subscription.close();
expect(source?.closed).toBe(true);
expect(source?.closeCalls).toBe(1);
expect(
[...((source?.listeners.values() ?? []) as Iterable<Set<EventListener>>)].every(
(listeners) => listeners.size === 0,
),
).toBe(true);
expect(statuses).toEqual(["connecting", "connected", "closed"]);
expect(errors).toEqual([
{
code: "REALTIME_CONNECTION_FAILED",
message: "The live connection could not be maintained.",
},
]);
expect(snapshots).toHaveBeenCalledOnce();
});
it("sanitizes constructor failures and closes without exposing the caught value", () => {
const errors: ModuleUiRealtimeError[] = [];
const statuses: ModuleUiRealtimeStatus[] = [];
const factory = vi.fn(() => {
throw new Error("token=secret at /home/server/realtime");
}) as unknown as HostEventSourceFactory;
const subscription = context(factory).realtime.subscribe("state", {
onError: (error) => errors.push(error),
onStatus: (status) => statuses.push(status),
});
subscription.close();
expect(statuses).toEqual(["connecting", "closed"]);
expect(errors).toEqual([
{
code: "REALTIME_CONNECTION_FAILED",
message: "The live connection could not be maintained.",
},
]);
expect(JSON.stringify(errors)).not.toContain("secret");
expect(JSON.stringify(errors)).not.toContain("home");
});
});
+5
View File
@@ -51,6 +51,9 @@ async function registerAndCaptureRoutes(): Promise<{
}, },
}, },
moduleId: "example", moduleId: "example",
// The template deliberately declares no realtime channel, proving modules opt in rather than
// receiving a mandatory feature-specific scaffold.
realtime: { channel: () => undefined },
}); });
return { provided, routes }; return { provided, routes };
} }
@@ -138,6 +141,7 @@ describe("module template", () => {
moduleId: "example", moduleId: "example",
params: Object.freeze({}), params: Object.freeze({}),
principal: ANONYMOUS_PRINCIPAL, principal: ANONYMOUS_PRINCIPAL,
realtime: { publish: () => undefined },
services, services,
// The template declares no logical bucket, so it receives storage that refuses every one — // The template declares no logical bucket, so it receives storage that refuses every one —
// present rather than `undefined`, so module code never branches on whether the host has it. // present rather than `undefined`, so module code never branches on whether the host has it.
@@ -182,6 +186,7 @@ describe("module template", () => {
moduleId: "example", moduleId: "example",
params: Object.freeze({}), params: Object.freeze({}),
principal: ANONYMOUS_PRINCIPAL, principal: ANONYMOUS_PRINCIPAL,
realtime: { publish: () => undefined },
services: Object.freeze({ database: fakeModuleDatabase(fakeRows) }), services: Object.freeze({ database: fakeModuleDatabase(fakeRows) }),
storage: EMPTY_MODULE_STORAGE, storage: EMPTY_MODULE_STORAGE,
}); });
+1
View File
@@ -51,6 +51,7 @@ function recordingHost(options: {
api, api,
principal: { kind: "anonymous" }, principal: { kind: "anonymous" },
permissions: { has: () => options.canRead }, permissions: { has: () => options.canRead },
realtime: { subscribe: () => ({ close: () => undefined }) },
}; };
} }