From fc6c8045c4c5029350832bd9df9267ac6b9b5d46 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 23 Aug 2026 18:47:18 -0500 Subject: [PATCH] feat: add native module realtime transport --- README.md | 53 +- apps/api/src/app.ts | 15 + apps/api/src/module-http-routes.ts | 16 +- apps/api/src/module-realtime-routes.ts | 252 ++++++++ apps/api/src/module-ui-inventory.ts | 5 + apps/api/src/server.ts | 9 +- apps/api/tests/api-http.integration.ts | 97 ++- apps/api/tests/app.test.ts | 13 +- apps/api/tests/dashboard-routes.test.ts | 3 + .../tests/module-configuration-routes.test.ts | 2 + apps/api/tests/module-database.integration.ts | 11 +- apps/api/tests/module-http-routes.test.ts | 7 +- .../api/tests/module-lifecycle-routes.test.ts | 2 + apps/api/tests/module-realtime-live.test.ts | 528 ++++++++++++++++ apps/api/tests/module-realtime-routes.test.ts | 270 ++++++++ apps/api/tests/module-ui-host-e2e.test.ts | 2 + apps/api/tests/module-ui-inventory.test.ts | 10 +- apps/api/tests/module-ui-routes.test.ts | 3 + core/src/modules/index.ts | 5 +- core/src/modules/realtime.ts | 578 ++++++++++++++++++ core/src/modules/runtime.ts | 123 +++- core/tests/module-realtime-runtime.test.ts | 240 ++++++++ core/tests/module-realtime.test.ts | 362 +++++++++++ .../0026-minimal-ekklesia-module-ui-host.md | 6 + .../decisions/0031-direct-object-delivery.md | 5 +- .../0032-native-module-realtime-transport.md | 204 +++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 55 +- docs/development/api.md | 119 +++- docs/development/security.md | 36 +- docs/development/testing.md | 28 + docs/module-development/README.md | 106 +++- packages/contracts/src/index.ts | 1 + packages/contracts/src/module-http.ts | 13 +- packages/contracts/src/module-realtime.ts | 147 +++++ packages/contracts/src/module-runtime.ts | 10 +- .../contracts/tests/module-realtime.test.ts | 83 +++ .../tests/define-module-runtime.test.ts | 1 + .../tests/load-module-ui.test.ts | 1 + .../tests/real-artifact.test.ts | 2 + packages/module-ui-sdk/src/host-context.ts | 4 + packages/module-ui-sdk/src/index.ts | 1 + packages/module-ui-sdk/src/realtime.ts | 57 ++ .../tests/browser-boundary/supported.tsx | 9 + .../tests/module-ui-contract.test.ts | 7 + surfaces/shell/src/host-protocol.ts | 30 +- surfaces/shell/src/index.ts | 2 + surfaces/shell/src/module-context.ts | 193 +++++- surfaces/shell/tests/host-app.test.tsx | 1 + surfaces/shell/tests/host-protocol.test.ts | 21 +- .../tests/module-realtime-context.test.ts | 312 ++++++++++ templates/module/tests/manifest.test.ts | 5 + templates/module/tests/module-ui.test.ts | 1 + 53 files changed, 3959 insertions(+), 108 deletions(-) create mode 100644 apps/api/src/module-realtime-routes.ts create mode 100644 apps/api/tests/module-realtime-live.test.ts create mode 100644 apps/api/tests/module-realtime-routes.test.ts create mode 100644 core/src/modules/realtime.ts create mode 100644 core/tests/module-realtime-runtime.test.ts create mode 100644 core/tests/module-realtime.test.ts create mode 100644 docs/architecture/decisions/0032-native-module-realtime-transport.md create mode 100644 packages/contracts/src/module-realtime.ts create mode 100644 packages/contracts/tests/module-realtime.test.ts create mode 100644 packages/module-ui-sdk/src/realtime.ts create mode 100644 surfaces/shell/tests/module-realtime-context.test.ts diff --git a/README.md b/README.md index ff0c838..d2e80ca 100644 --- a/README.md +++ b/README.md @@ -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 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 -completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the module configuration and -secrets contract), 3 (host-provided module object storage), and 4 (direct browser object delivery) -have landed.** The operational +completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the +module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct +browser object delivery), and 5 (native module realtime transport) have landed.** The operational 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 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 ↓ 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, -artifact URL, module API root, and the viewer's granted permissions **for that module** — and nothing -else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because navigation metadata is a -design that does not exist yet. It is authenticated (`core.modules.ui.read`, baseline +The inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version, +artifact URL, module API root, module realtime root, and the viewer's granted permissions **for that +module** — and nothing else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because +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 -deliberately withholds. Both URLs are produced by the server's own canonical helpers, so the browser -never rebuilds Ekklesia's module prefixes and either can change without touching host or module code. +deliberately withholds. All three URLs are produced by the server's own canonical helpers, so the +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 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 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 -request for the same object is refused, and a test asserts it. **Signed write and delete URLs, and a -realtime refresh transport, are deliberately deferred.** See +request for the same object is refused, and a test asserts it. **Signed write and delete URLs are +deliberately deferred.** See [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("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 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 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 0d66c96..d599a63 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -19,6 +19,7 @@ import type { ModuleConfigurationLookup, ModuleLifecycleStore, ModuleStorageLookup, + ModuleRealtimeRegistry, ModuleUiPublicationRegistry, } from "@ekklesia/core/modules"; 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 { createGrantsRoutes } from "./grants-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 { createModuleLifecycleRoutes } from "./module-lifecycle-routes.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 * implicit fallback. */ 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 * 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 @@ -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, { configurations: options.moduleConfiguration.snapshots, storages: options.moduleStorage.snapshots, evaluator: options.authorization.evaluator, registry: options.authorization.registry, + realtime: options.moduleRealtime, runtime, services: options.moduleServices, trustedOrigins: allowedOrigins, diff --git a/apps/api/src/module-http-routes.ts b/apps/api/src/module-http-routes.ts index 060a23a..d58253c 100644 --- a/apps/api/src/module-http-routes.ts +++ b/apps/api/src/module-http-routes.ts @@ -6,7 +6,11 @@ import { type ModuleRuntimeServices, type RegisteredModuleHttpRoute, } 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 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. */ 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 @@ -195,9 +201,10 @@ function sanitizeModuleResponse(response: Response): Response { * 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. * `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 - * `Context`, authorization store, or configuration; `services.database` is the module's only - * reachable database surface, and is unreachable to anything short of a successfully authorized + * frozen context carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`, + * `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or + * 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 * this handler at all). * @@ -239,6 +246,7 @@ export function mountModuleHttpRoutes( storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE, params: Object.freeze({ ...c.req.param() }), principal: getAuthorizedPrincipal(c), + realtime: options.realtime.publisherFor(route.moduleId), services: options.services, }); diff --git a/apps/api/src/module-realtime-routes.ts b/apps/api/src/module-realtime-routes.ts new file mode 100644 index 0000000..b5ab72d --- /dev/null +++ b/apps/api/src/module-realtime-routes.ts @@ -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 | 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 { + const encoder = new TextEncoder(); + const subscription = registry.subscribe(definition.moduleId, definition.channel); + let snapshotPending = true; + let closed = false; + let pendingRead: Promise | undefined; + let activeDelay: CancelableDelay | undefined; + let expiryTimer: ReturnType | undefined; + + const close = (): void => { + if (closed) { + return; + } + closed = true; + activeDelay?.cancel(); + activeDelay = undefined; + clearTimeout(expiryTimer); + expiryTimer = undefined; + subscription.close(); + }; + + return new ReadableStream({ + 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; + 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); + } +} diff --git a/apps/api/src/module-ui-inventory.ts b/apps/api/src/module-ui-inventory.ts index dbbc8fb..39e8514 100644 --- a/apps/api/src/module-ui-inventory.ts +++ b/apps/api/src/module-ui-inventory.ts @@ -9,6 +9,7 @@ import { requirePermission, } from "./authorization-middleware.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"; /** @@ -44,6 +45,9 @@ export interface HostModuleUiDescriptor { * `ui.url` is: `/api/v1/modules/` is Ekklesia's private deployment detail, and neither the * module nor the browser host should be the one spelling it. */ 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, * evaluated server-side by Core's one authorization evaluator. @@ -115,6 +119,7 @@ async function describeModule( api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, id: published.moduleId, permissions: await grantedPermissionIds(evaluator, principal, published.permissions), + realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) }, ui: { url: moduleUiArtifactUrl(published) }, version: published.version, }; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index f368c55..84b59d4 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -521,6 +521,7 @@ export async function startApi( store: authorizationStore, }, moduleRoutes: moduleRuntime.routes, + moduleRealtime: moduleRuntime.realtime, moduleServices: services, // 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 @@ -585,7 +586,9 @@ export async function startApi( // 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 // 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 // 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 @@ -598,6 +601,10 @@ export async function startApi( const shutdown = (): Promise => { if (!shutdownPromise) { 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); let moduleStopError: unknown; try { diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index d1a00f4..610ccf6 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -52,6 +52,26 @@ function harnessError(message: string): IntegrationHarnessError { return new IntegrationHarnessError(message); } +async function withTransportDeadline( + operation: Promise, + onTimeout: () => void, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => { + onTimeout(); + reject(harnessError("An integration transport operation timed out.")); + }, 5_000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + function baseDatabaseUrl(): string { const value = process.env[integrationDatabaseUrlEnvironment]; if (typeof value !== "string" || value.trim() === "") { @@ -313,22 +333,25 @@ async function createGatedRouteModule( `export default { id: ${JSON.stringify(id)}, version: "0.1.0", - register({ http }) { + register({ http, realtime }) { + realtime.channel({ channel: "state", permission: ${JSON.stringify(permissionId)} }); http.route({ method: "GET", path: "/gated", permission: ${JSON.stringify(permissionId)}, maxBodyBytes: 0, - async handler() { + async handler(_request, context) { ${recorder.expression}.push(${JSON.stringify(`${id}:handler-entered`)}); ${gate.expression}.enter(); 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`)}); return Response.json({ gated: true }); }, }); }, - start() {}, + start({ realtime }) { realtime.publish("state", { status: "waiting" }); }, stop() { ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); }, };`, ), @@ -355,6 +378,9 @@ async function createFixtureModule( const declaresCalendarRead = (manifest.permissions as ReadonlyArray<{ id: string }>).some( (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 Promise.all([ writeFile( @@ -372,8 +398,13 @@ async function createFixtureModule( `export default { id: ${JSON.stringify(id)}, version: "0.1.0", - register({ http }) { + register({ http, realtime }) { ${recorder.expression}.push(${JSON.stringify(`${id}:register`)}); + ${ + realtimePermission + ? `realtime.channel({ channel: "state", permission: ${JSON.stringify(realtimePermission)} });` + : "" + } ${ declaresCalendarRead ? `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`)}); }, stop() { @@ -3037,6 +3073,7 @@ describe.sequential("live API — module lifecycle governs what actually runs", await createFixtureModule(modulesDirectory, "calendar", recorder, { permissions: [ { 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, { @@ -3046,6 +3083,11 @@ describe.sequential("live API — module lifecycle governs what actually runs", description: "Read announcements.", 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, { modulesDirectoryOverride: modulesDirectory, }); + const realtimeAbort = new AbortController(); + let calendarReader: ReadableStreamDefaultReader | undefined; try { const origin = apiOrigin(running); @@ -3064,6 +3108,18 @@ describe.sequential("live API — module lifecycle governs what actually runs", expect(recorder.events).toContain("calendar:start"); const calendarRoute = await fetch(`${origin}/api/v1/modules/calendar/events/abc`); 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 // 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`); expect(disabledRoute.status).toBe(404); 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. - 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).not.toContain("announcements:stop"); } finally { + realtimeAbort.abort(); + await calendarReader?.cancel().catch(() => undefined); await running.shutdown().catch(() => undefined); 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, "announcements", recorder, { dependencies: { modules: { required: { storage: "^0.1.0" } } }, + permissions: [ + { + baseline: "public", + description: "View announcement state.", + id: "announcements.state.view", + }, + ], }); 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); expect(raw).not.toContain(modulesDirectory); 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 { await running.shutdown().catch(() => undefined); 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('"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 stopped = recorder.events.indexOf("drainorder:stop"); + expect(published).toBeGreaterThanOrEqual(0); expect(finished).toBeGreaterThanOrEqual(0); expect(stopped).toBeGreaterThanOrEqual(0); + expect(published).toBeLessThan(finished); expect(finished).toBeLessThan(stopped); } finally { gate.release(); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index 350f913..8bc7e8c 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -13,12 +13,17 @@ import { import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, + EMPTY_MODULE_REALTIME_REGISTRY, type ModuleUiPublicationRegistry, } from "@ekklesia/core/modules"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; 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 { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; @@ -58,6 +63,12 @@ function emptyModuleUiPublication(): ModuleUiPublicationRegistry { 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) { + return createApiAppWithModules({ ...options, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY }); +} + /** 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 * defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index 4dd00b1..e72df91 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -19,6 +19,7 @@ import { composePlatformPermissionRegistry, createModuleUiPublicationRegistry, discoverModules, + EMPTY_MODULE_REALTIME_REGISTRY, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; 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 }, dashboard: createInMemoryDashboardStore(options.configuration ?? {}), moduleRoutes: Object.freeze([]), + moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }) as ModuleRuntimeServices, @@ -270,6 +272,7 @@ describe("Dashboard navigation — reading", () => { api: { baseUrl: "/api/v1/modules/livestream" }, id: "livestream", permissions: [], + realtime: { baseUrl: "/api/v1/module-realtime/livestream" }, ui: { url: "/api/v1/module-ui/livestream/1.0.0" }, version: "1.0.0", }); diff --git a/apps/api/tests/module-configuration-routes.test.ts b/apps/api/tests/module-configuration-routes.test.ts index 7b8226f..3c964c1 100644 --- a/apps/api/tests/module-configuration-routes.test.ts +++ b/apps/api/tests/module-configuration-routes.test.ts @@ -25,6 +25,7 @@ import { type ModuleConfigurationRecord, type ModuleConfigurationStore, ModuleConfigurationStoreError, + EMPTY_MODULE_REALTIME_REGISTRY, resolveModuleEligibility, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; @@ -205,6 +206,7 @@ async function install(options: InstallOptions = {}): Promise { store: lifecycleStore, }, moduleRoutes: Object.freeze([]), + moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }) as ModuleRuntimeServices, diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index 653e3a4..a8e93f9 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -509,7 +509,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe 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}`); expect(response.status).toBe(200); 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() .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 - // here rather than an unnoticed one: `config` (ADR 0029) and `storage` (ADR 0030) are the two - // scoped, per-module additions; everything else a module might want — a Hono Context, the - // authorization store, raw PostgreSQL, the storage provider, an endpoint or credential — remains - // unreachable. + // here rather than an unnoticed one: `config` (ADR 0029), `storage` (ADR 0030), and `realtime` + // (ADR 0032) are scoped, per-module additions; everything else a module might want — a Hono + // Context, the authorization store, raw PostgreSQL, the storage provider, an endpoint or + // credential — remains unreachable. expect(readHandlerEvent?.contextKeys).toEqual([ "config", "moduleId", "params", "principal", + "realtime", "services", "storage", ]); diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index 8bb40c1..f892330 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -17,7 +17,10 @@ import { type PermissionRegistry, } from "@ekklesia/core/authorization"; 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp } from "../src/app.js"; @@ -110,6 +113,7 @@ function buildModuleApp(options: { const app = createApiApp({ authorization: { evaluator, registry: options.registry, store: fakeStore() }, moduleRoutes: options.routes, + moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: services, moduleUi: createModuleUiPublicationRegistry([]), moduleLifecycle: emptyModuleLifecycle(), @@ -352,6 +356,7 @@ describe("module route handler adaptation", () => { "moduleId", "params", "principal", + "realtime", "services", "storage", ]); diff --git a/apps/api/tests/module-lifecycle-routes.test.ts b/apps/api/tests/module-lifecycle-routes.test.ts index 7ea66e1..1ccb8d2 100644 --- a/apps/api/tests/module-lifecycle-routes.test.ts +++ b/apps/api/tests/module-lifecycle-routes.test.ts @@ -22,6 +22,7 @@ import { discoverModules, type ModuleLifecycleStore, ModuleLifecycleStoreError, + EMPTY_MODULE_REALTIME_REGISTRY, resolveModuleEligibility, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; @@ -181,6 +182,7 @@ async function install(options: InstallOptions = {}): Promise { store, }, moduleRoutes: Object.freeze([]), + moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }) as ModuleRuntimeServices, diff --git a/apps/api/tests/module-realtime-live.test.ts b/apps/api/tests/module-realtime-live.test.ts new file mode 100644 index 0000000..96c17c0 --- /dev/null +++ b/apps/api/tests/module-realtime-live.test.ts @@ -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( + operation: Promise, + message: string, + onTimeout: () => void = () => undefined, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => { + onTimeout(); + reject(new Error(message)); + }, TRANSPORT_DEADLINE_MS); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function waitForTransportCondition(predicate: () => boolean, message: string): Promise { + let cancelled = false; + await withinTransportDeadline( + new Promise((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; + #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 { + 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 { + const result = await this.#readWithinDeadline(); + expect(result.done).toBe(true); + } + + async close(): Promise { + 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)[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((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 { + await withinTransportDeadline( + new Promise((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)[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"]); + }); +}); diff --git a/apps/api/tests/module-realtime-routes.test.ts b/apps/api/tests/module-realtime-routes.test.ts new file mode 100644 index 0000000..1e10acd --- /dev/null +++ b/apps/api/tests/module-realtime-routes.test.ts @@ -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; + 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): Promise { + let timer: ReturnType | undefined; + try { + const result = await Promise.race([ + reader.read(), + new Promise((_, 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); + 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; + + 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; + + 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); + }); +}); diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 57eed0b..bbcf9ca 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -217,6 +217,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { const app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, moduleRoutes: moduleRuntime.routes, + moduleRealtime: moduleRuntime.realtime, moduleServices: services, moduleUi: createModuleUiPublicationRegistry(modules), moduleLifecycle: { @@ -289,6 +290,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () apiBaseUrl: "/api/v1/modules/example", id: "example", permissions: ["example.records.read"], + realtimeBaseUrl: "/api/v1/module-realtime/example", uiUrl: "/api/v1/module-ui/example/0.1.0", version: "0.1.0", }); diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index e263b72..b8743f2 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -20,12 +20,14 @@ import { createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, + EMPTY_MODULE_REALTIME_REGISTRY, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp } from "../src/app.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 { moduleUiArtifactUrl } from "../src/module-ui-routes.js"; import { @@ -173,6 +175,7 @@ async function install(options: InstallOptions): Promise { const app = createApiApp({ authorization: { evaluator, registry: permissionRegistry, store }, moduleRoutes: Object.freeze([]), + moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: services, moduleUi: createModuleUiPublicationRegistry(modules), moduleLifecycle: { @@ -219,13 +222,14 @@ describe("module UI inventory — what it lists", () => { api: { baseUrl: "/api/v1/modules/example" }, id: "example", permissions: [], + realtime: { baseUrl: "/api/v1/module-realtime/example" }, ui: { url: "/api/v1/module-ui/example/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({ fixtures: [{ id: "example", version: "2.4.1", withUi: true }], principal: signedIn(), @@ -240,6 +244,9 @@ describe("module UI inventory — what it lists", () => { url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }), }); 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 () => { @@ -310,6 +317,7 @@ describe("module UI inventory — what it lists", () => { "api", "id", "permissions", + "realtime", "ui", "version", ]); diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index 5eb4118..24dc41a 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -16,6 +16,7 @@ import { createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, + EMPTY_MODULE_REALTIME_REGISTRY, } from "@ekklesia/core/modules"; import { loadModuleUi } from "@ekklesia/module-ui-host"; import type { Hono } from "hono"; @@ -173,6 +174,7 @@ async function install(fixtures: readonly ModuleFixture[]): Promise false }, principal: { kind: "anonymous" as const }, + realtime: { subscribe: () => ({ close: () => undefined }) }, }; } diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index 8b233c0..b1dcf90 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -1,13 +1,14 @@ +export * from "./configuration.js"; export * from "./configuration-schema.js"; export * from "./configuration-store.js"; -export * from "./configuration.js"; export * from "./database.js"; export * from "./discovery.js"; +export * from "./lifecycle.js"; export * from "./lifecycle-schema.js"; export * from "./lifecycle-store.js"; -export * from "./lifecycle.js"; export * from "./migration-discovery.js"; export * from "./permissions.js"; +export * from "./realtime.js"; export * from "./runtime.js"; export * from "./schema-discovery.js"; export * from "./ui-publication.js"; diff --git a/core/src/modules/realtime.ts b/core/src/modules/realtime.ts new file mode 100644 index 0000000..75bb88f --- /dev/null +++ b/core/src/modules/realtime.ts @@ -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; + 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; + 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, + 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(), 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 { + 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(); + readonly #states = new Map(); + readonly #activeModules = new Set(); + #acceptingSubscriptions = true; + + createRegistrationStage( + moduleId: ModuleId, + ownedPermissionIds: ReadonlySet, + ): RealtimeRegistrationStage { + const local: RegisteredModuleRealtimeChannel[] = []; + const localNames = new Set(); + 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); + } + } +} diff --git a/core/src/modules/runtime.ts b/core/src/modules/runtime.ts index 8e0faea..93908ba 100644 --- a/core/src/modules/runtime.ts +++ b/core/src/modules/runtime.ts @@ -1,20 +1,22 @@ import { pathToFileURL } from "node:url"; import { type CapabilityId, - EMPTY_MODULE_CONFIGURATION, - EMPTY_MODULE_STORAGE, - type ModuleConfiguration, - type ModuleStorage, type CapabilityRegistrar, type CapabilityResolver, capabilityIdSchema, + EMPTY_MODULE_CONFIGURATION, + EMPTY_MODULE_STORAGE, + type ModuleConfiguration, type ModuleHttpRouteRegistrar, type ModuleId, + ModuleRealtimeError, + type ModuleRealtimePublisher, type ModuleRegistrationContext, type ModuleRouteDefinitionInput, type ModuleRuntimeContext, type ModuleRuntimeDefinition, type ModuleRuntimeServices, + type ModuleStorage, moduleRouteShapeKey, moduleRoutesOverlap, parseModuleRuntimeDefinition, @@ -27,6 +29,11 @@ import { discoverModules, type ModuleDiscoveryOptions, } from "./discovery.js"; +import { + ModuleRealtimeRegistrationError, + type ModuleRealtimeRegistry, + StagedModuleRealtimeRegistry, +} from "./realtime.js"; export type ModuleRuntimePhase = "load" | "register" | "rollback" | "start" | "stop"; @@ -37,6 +44,18 @@ export type ModuleRuntimeIssueCode = | "MODULE_CAPABILITY_UNDECLARED" | "MODULE_CAPABILITY_UNREGISTERED" | "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_TIMEOUT" | "MODULE_ROUTE_DEFINITION_INVALID" @@ -77,6 +96,8 @@ export interface StartedModuleRuntime { /** 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. */ readonly routes: readonly RegisteredModuleHttpRoute[]; + /** Opaque, transport-neutral state and subscriptions for the host. */ + readonly realtime: ModuleRealtimeRegistry; stop(): Promise; } @@ -193,6 +214,23 @@ function runtimeIssue( 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -280,6 +318,7 @@ class StagedCapabilityRegistry { services: ModuleRuntimeServices, config: ModuleConfiguration, storage: ModuleStorage, + realtime: ModuleRealtimePublisher, ): ModuleRuntimeContext { const capabilities: CapabilityResolver = Object.freeze({ resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => { @@ -291,6 +330,7 @@ class StagedCapabilityRegistry { capabilities, config, moduleId: module.manifest.id, + realtime, services, storage, }); @@ -528,16 +568,22 @@ async function registerModules( modules: readonly LoadedModule[], capabilityRegistry: StagedCapabilityRegistry, httpRoutes: StagedModuleHttpRoutes, + realtimeRegistry: StagedModuleRealtimeRegistry, configurations: ModuleConfigurationLookup, ): Promise { for (const module of modules) { const capabilityStage = capabilityRegistry.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({ capabilities: capabilityStage.capabilities, config: configurationFor(configurations, module.discovered.manifest.id), http: routeStage.http, moduleId: module.discovered.manifest.id, + realtime: realtimeStage.realtime, }); try { await module.definition.register?.(context); @@ -545,6 +591,16 @@ async function registerModules( if (error instanceof ModuleRuntimeSignal) { 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([ runtimeIssue( "MODULE_REGISTER_FAILED", @@ -563,6 +619,7 @@ async function registerModules( // a failed registration therefore publishes no routes for this module, matching the same // all-or-nothing guarantee capabilities already have. routeStage.commit(); + realtimeStage.commit(); } } @@ -629,12 +686,16 @@ async function stopModules( services: ModuleRuntimeServices, configurations: ModuleConfigurationLookup, storages: ModuleStorageLookup, + realtimeRegistry: StagedModuleRealtimeRegistry, options: StopModulesOptions = {}, ): Promise { const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS; const issues: ModuleRuntimeIssue[] = []; for (const module of [...modules].reverse()) { 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; if (!stop) { // No synthetic callback and no timer for a module that declares no stop hook. @@ -650,6 +711,7 @@ async function stopModules( services, configurationFor(configurations, moduleId), storageFor(storages, moduleId), + realtimeRegistry.publisherFor(moduleId), ), ), ), @@ -689,6 +751,7 @@ async function startModules( services: ModuleRuntimeServices, configurations: ModuleConfigurationLookup, storages: ModuleStorageLookup, + realtimeRegistry: StagedModuleRealtimeRegistry, options: StopModulesOptions = {}, ): Promise { // A module joins the rollback set the moment its `start` hook is *invoked*, not when it @@ -708,18 +771,38 @@ async function startModules( services, configurationFor(configurations, 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) { const startIssue = error instanceof ModuleRuntimeSignal ? error.issue - : runtimeIssue( - "MODULE_START_FAILED", - "start", - module.discovered.manifest.id, - `Module ${module.discovered.manifest.id} startup failed.`, - ); + : error instanceof ModuleRealtimeError + ? runtimeIssue( + realtimeRuntimeIssueCode(error), + "start", + 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( rollbackCandidates, registry, @@ -727,8 +810,10 @@ async function startModules( services, configurations, storages, + realtimeRegistry, options, ); + realtimeRegistry.deactivateAll(); throw new ModuleRuntimeError([startIssue, ...rollbackIssues]); } } @@ -750,13 +835,23 @@ export async function startDiscoveredModuleRuntime( const modules = await loadRuntimeDefinitions(discoveredModules); const capabilityRegistry = new StagedCapabilityRegistry(); const httpRoutes = new StagedModuleHttpRoutes(); + const realtimeRegistry = new StagedModuleRealtimeRegistry(); const configurations = timeouts.configurations ?? new Map(); const storages = timeouts.storages ?? new Map(); - await registerModules(modules, capabilityRegistry, httpRoutes, configurations); - await startModules(modules, capabilityRegistry, services, configurations, storages, timeouts); + await registerModules(modules, capabilityRegistry, httpRoutes, realtimeRegistry, configurations); + await startModules( + modules, + capabilityRegistry, + services, + configurations, + storages, + realtimeRegistry, + timeouts, + ); const capabilities = capabilityRegistry.publish(); const routes = httpRoutes.publish(); + const realtime = realtimeRegistry.publish(); const runningModules = Object.freeze( modules.map((module) => Object.freeze({ @@ -770,9 +865,11 @@ export async function startDiscoveredModuleRuntime( return Object.freeze({ capabilities, modules: runningModules, + realtime, routes, stop: (): Promise => { stopPromise ??= (async () => { + realtime.closeSubscriptions(); const issues = await stopModules( modules, capabilityRegistry, @@ -780,8 +877,10 @@ export async function startDiscoveredModuleRuntime( services, configurations, storages, + realtimeRegistry, timeouts, ); + realtimeRegistry.deactivateAll(); if (issues.length > 0) { throw new ModuleRuntimeError(issues); } diff --git a/core/tests/module-realtime-runtime.test.ts b/core/tests/module-realtime-runtime.test.ts new file mode 100644 index 0000000..510e206 --- /dev/null +++ b/core/tests/module-realtime-runtime.test.ts @@ -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; + 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[] = [], +): Record { + 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[] = [], +): Promise { + 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)[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"]); + }); +}); diff --git a/core/tests/module-realtime.test.ts b/core/tests/module-realtime.test.ts new file mode 100644 index 0000000..5c7036c --- /dev/null +++ b/core/tests/module-realtime.test.ts @@ -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>, +): 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 = {}; + 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(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."); + }); +}); diff --git a/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md index 926cc9b..b531e72 100644 --- a/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md +++ b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md @@ -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 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 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 diff --git a/docs/architecture/decisions/0031-direct-object-delivery.md b/docs/architecture/decisions/0031-direct-object-delivery.md index c3547dc..a14bab5 100644 --- a/docs/architecture/decisions/0031-direct-object-delivery.md +++ b/docs/architecture/decisions/0031-direct-object-delivery.md @@ -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 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 -that established reads. Realtime URL refresh is not built either — ordinary API reloads are -sufficient, and a future realtime pass can refresh state and URLs together when it exists. +that established reads. [ADR 0032](0032-native-module-realtime-transport.md) now supplies the native +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. diff --git a/docs/architecture/decisions/0032-native-module-realtime-transport.md b/docs/architecture/decisions/0032-native-module-realtime-transport.md new file mode 100644 index 0000000..007bcba --- /dev/null +++ b/docs/architecture/decisions/0032-native-module-realtime-transport.md @@ -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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index a2f9f1e..ece616f 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 - [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 +- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index a587bd9..b90e549 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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 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 -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 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 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 - 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 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 -declared permissions the caller actually holds. Nothing else: no filesystem paths, and no display or -navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's presentation -configuration. The module set is the publication registry, never a rescan, so the inventory and the -artifact route cannot disagree. It requires `core.modules.ui.read` (baseline `authenticated`) because -it is precisely the enumeration ADR 0025 withheld from the anonymous artifact route. +artifact URL, the server-produced roots of the module's own HTTP and realtime APIs, and the subset of +that module's declared permissions the caller actually holds. Nothing else: no filesystem paths, and +no display or navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's +presentation configuration. The module set is the publication registry, never a rescan, so the +inventory and artifact route cannot disagree. It requires `core.modules.ui.read` (baseline +`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 -`moduleApiBaseUrl` are each the single construction of their URL shape, so the browser is a consumer -of both — it never rebuilds `/api/v1/modules/`, the prefix ADR 0022 forbids a module UI from -spelling, and neither prefix is pinned by having a second author. +All three URLs being server-produced is load-bearing. `moduleUiArtifactUrl`, `moduleApiBaseUrl`, and +`moduleRealtimeBaseUrl` are each the single construction of their URL shape, so the browser consumes +them rather than rebuilding a platform module prefix, and none is pinned by having a second author. 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 @@ -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 route remains available for responses that must be computed or authorized per byte, and signed **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. diff --git a/docs/development/api.md b/docs/development/api.md index a18e478..9d0c1cc 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -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\|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//*` | 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//` | 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). | | `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//` | 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 never rediscovered for this step — passing it the services object every module's `start`/`stop` hook receives. -7. Create the Hono application, mounting the started module runtime's published routes, publishing - UI for that same eligible set, and passing the identical services object to every module HTTP - handler. +7. Create the Hono application, mounting the started module runtime's HTTP routes and realtime + channels, publishing UI for that same eligible set, and passing the identical scoped runtime + objects to module HTTP handlers. 8. Bind the HTTP server. Discovery and permission composition happen before any pool, Better Auth instance, module @@ -779,13 +780,12 @@ are mounted under `/api/v1/modules/`, in the module runtime's publish 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 authorization, the handler, or the module database. -- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and - body all behave normally, and the full query string remains available) plus a frozen context of - exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an - empty frozen object for a route with none), and `services` — the same frozen - `ModuleRuntimeServices` object the module's own `start`/`stop` hooks received (see - [Module database access](#module-database-access)) — never a Hono `Context`, the authorization - store, storage, jobs, configuration, or secrets. +- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and body + all behave normally, and the full query string remains available) plus a frozen context of exactly + `moduleId`, the authorized `EkklesiaPrincipal`, frozen Hono-decoded path parameters (an empty + frozen object for a route with none), `services`, and the module's scoped configuration, storage, + and realtime publisher. It never receives a Hono `Context`, authorization/grant stores, jobs, or + another module's scoped objects. Core does not serialize configuration or secrets into responses. - **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 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/`, in the module runtime's publish 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"}`. +## 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// +``` + +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 ```text @@ -829,6 +892,7 @@ How this installation presents its mountable modules "version": "1.0.0", "ui": { "url": "/api/v1/module-ui/livestream/1.0.0" }, "api": { "baseUrl": "/api/v1/modules/livestream" }, + "realtime": { "baseUrl": "/api/v1/module-realtime/livestream" }, "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", "ui": { "url": "/api/v1/module-ui/example/0.1.0" }, "api": { "baseUrl": "/api/v1/modules/example" }, + "realtime": { "baseUrl": "/api/v1/module-realtime/example" }, "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 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 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 - `moduleApiBaseUrl`, so each URL shape is constructed in exactly one place in the repository. A - browser never rebuilds `/api/v1/module-ui//` or `/api/v1/modules/`, which is what - lets either change without touching host or module code. +- **All three URLs are server-produced.** `ui.url` comes from `moduleUiArtifactUrl`, `api.baseUrl` + from `moduleApiBaseUrl`, and `realtime.baseUrl` from `moduleRealtimeBaseUrl`, so each URL shape is + constructed in exactly one place in the repository. A browser never rebuilds any platform module + 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 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. @@ -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 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()` -is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight -requests finish; then stops the module runtime (so no module resource stops while a route handler -might still be in flight) — a module's `stop` hook may still use `services.database` here, since -the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization, -and the module database facade all read from it — see +is called. In order: it refuses new realtime subscribers and closes every existing stream; then +stops the HTTP listener from accepting new connections and lets finite in-flight requests finish; +then stops the module runtime in reverse dependency order; then closes storage and the one shared +PostgreSQL pool (identity, authorization, and the module database facade all read from it — see [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 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 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`. diff --git a/docs/development/security.md b/docs/development/security.md index 68fbe15..72e7485 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -7,7 +7,9 @@ document's guarantees. 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) (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). ## 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 | | 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 | +| 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 | | 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 | @@ -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 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 A host may tell a module UI what permissions it believes the viewer holds diff --git a/docs/development/testing.md b/docs/development/testing.md index 87553f5..0555c36 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -247,6 +247,34 @@ cannot appear unnoticed. See [ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md) and [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 Pass 4 introduced several deadlines, and their tests deliberately differ in kind: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index ee936bc..9690e0a 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -248,7 +248,7 @@ independent. ### 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 | | --- | --- | @@ -256,6 +256,7 @@ independent. | `api` | `request(path, init)` against **your own** module API — see below | | `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` | | `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 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 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 -is deliberately limited to `moduleId`, capability access, and the HTTP route registrar (below) — -never `services`. `start` and `stop` share one context shape and both additionally receive -`services` — the same frozen `ModuleRuntimeServices` object for the lifetime of one running API, -currently exposing only `database` — see [Database access](#database-access). +contains `moduleId`, capability access, the frozen configuration snapshot, and the HTTP and realtime +declaration registrars — never live `services` or storage. `start` and `stop` share one context shape: +capability access, configuration, `services`, storage, and a module-scoped realtime publisher. The +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 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. 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 -same frozen `ModuleRuntimeServices` object the module's own `start`/`stop` hooks receive, currently -exposing only `database` (see [Database access](#database-access)) — never an authorization store, -grant/platform-owner state, storage, jobs, configuration, secrets, a framework-specific request -context, or a raw Better Auth session. The full query string remains available through -`Request.url`; there is no separate query or body abstraction. +already-authorized `EkklesiaPrincipal`, immutable decoded path parameters, `services`, the module's +frozen configuration snapshot, scoped storage, and scoped realtime publisher. It never receives an +authorization store, grant/platform-owner state, jobs, a framework-specific request context, or a +raw Better Auth session. Secret configuration exists only when the module explicitly declared that +key and the deployment supplied it; Core never serializes it. The full query string remains +available through `Request.url`; there is no separate query or body abstraction. 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 @@ -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 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("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 A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`: diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cc522b4..5b66d87 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -7,6 +7,7 @@ export * from "./module-http.js"; export * from "./module-lifecycle.js"; export * from "./module-manifest.js"; export * from "./module-migration.js"; +export * from "./module-realtime.js"; export * from "./module-runtime.js"; export * from "./module-schema.js"; export * from "./module-storage.js"; diff --git a/packages/contracts/src/module-http.ts b/packages/contracts/src/module-http.ts index ed82633..8354be1 100644 --- a/packages/contracts/src/module-http.ts +++ b/packages/contracts/src/module-http.ts @@ -1,9 +1,10 @@ import { z } from "zod"; -import type { EkklesiaPrincipal } from "./identity.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 { ModuleStorage } from "./module-storage.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 // 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 - * available to it — never an authorization-store, grant-management, storage, jobs, configuration, - * secrets, the Hono `Context`, or a raw Better Auth session. `principal` is already authorized by + * available to it — never an authorization store, grant-management state, jobs, the Hono `Context`, + * 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 * 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` @@ -226,6 +229,8 @@ export interface ModuleRouteHandlerContext { * into a `Response` without the browser ever learning that object storage exists. */ readonly storage: ModuleStorage; 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 * 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 diff --git a/packages/contracts/src/module-realtime.ts b/packages/contracts/src/module-realtime.ts new file mode 100644 index 0000000..ccea7a9 --- /dev/null +++ b/packages/contracts/src/module-realtime.ts @@ -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; + +export function parseModuleRealtimeChannel(input: unknown): ModuleRealtimeChannel { + return moduleRealtimeChannelSchema.parse(input); +} + +export function safeParseModuleRealtimeChannel( + input: unknown, +): z.ZodSafeParseResult { + 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 { + 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> = 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); + } +} diff --git a/packages/contracts/src/module-runtime.ts b/packages/contracts/src/module-runtime.ts index df011b0..8088eed 100644 --- a/packages/contracts/src/module-runtime.ts +++ b/packages/contracts/src/module-runtime.ts @@ -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 { moduleIdSchema } from "./identifiers.js"; +import type { ModuleConfiguration } from "./module-configuration.js"; import type { ModuleRuntimeServices } from "./module-database.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"; export type Awaitable = Value | PromiseLike; @@ -29,6 +30,9 @@ export interface ModuleRegistrationContext { * 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. */ 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; /** * 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. */ 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. * diff --git a/packages/contracts/tests/module-realtime.test.ts b/packages/contracts/tests/module-realtime.test.ts new file mode 100644 index 0000000..cac2ae2 --- /dev/null +++ b/packages/contracts/tests/module-realtime.test.ts @@ -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); + }); +}); diff --git a/packages/module-sdk/tests/define-module-runtime.test.ts b/packages/module-sdk/tests/define-module-runtime.test.ts index 9deaf7c..4ef2abc 100644 --- a/packages/module-sdk/tests/define-module-runtime.test.ts +++ b/packages/module-sdk/tests/define-module-runtime.test.ts @@ -65,6 +65,7 @@ describe("defineModuleRuntime", () => { config: EMPTY_MODULE_CONFIGURATION, http, moduleId: "example", + realtime: { channel: () => undefined }, }; await runtime.register?.(context); diff --git a/packages/module-ui-host/tests/load-module-ui.test.ts b/packages/module-ui-host/tests/load-module-ui.test.ts index db3d798..06c657d 100644 --- a/packages/module-ui-host/tests/load-module-ui.test.ts +++ b/packages/module-ui-host/tests/load-module-ui.test.ts @@ -44,6 +44,7 @@ function hostContext(requested: string[], moduleId = "announcements"): ModuleUiH }, principal: { kind: "anonymous" }, permissions: { has: () => true }, + realtime: { subscribe: () => ({ close: () => undefined }) }, }; } diff --git a/packages/module-ui-host/tests/real-artifact.test.ts b/packages/module-ui-host/tests/real-artifact.test.ts index 7feb617..252a666 100644 --- a/packages/module-ui-host/tests/real-artifact.test.ts +++ b/packages/module-ui-host/tests/real-artifact.test.ts @@ -28,6 +28,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext { }, principal: { kind: "authenticated", userId: "user-123" }, permissions: { has: () => true }, + realtime: { subscribe: () => ({ close: () => undefined }) }, }; } @@ -43,6 +44,7 @@ function standaloneContext(requested: string[]): ModuleUiHostContext { }, principal: { kind: "anonymous" }, permissions: { has: () => false }, + realtime: { subscribe: () => ({ close: () => undefined }) }, }; } diff --git a/packages/module-ui-sdk/src/host-context.ts b/packages/module-ui-sdk/src/host-context.ts index c082780..910cf53 100644 --- a/packages/module-ui-sdk/src/host-context.ts +++ b/packages/module-ui-sdk/src/host-context.ts @@ -1,3 +1,5 @@ +import type { ModuleUiRealtimeClient } from "./realtime.js"; + /** * Who is viewing, as a browser sees it. * @@ -53,6 +55,8 @@ export interface ModuleUiHostContext { readonly principal: ModuleUiPrincipal; /** Presentation-only permission hints. See {@link ModuleUiPermissions}. */ readonly permissions: ModuleUiPermissions; + /** Host-owned delivery of this module's own runtime-published state. */ + readonly realtime: ModuleUiRealtimeClient; } /** diff --git a/packages/module-ui-sdk/src/index.ts b/packages/module-ui-sdk/src/index.ts index b866232..3e493d0 100644 --- a/packages/module-ui-sdk/src/index.ts +++ b/packages/module-ui-sdk/src/index.ts @@ -1,3 +1,4 @@ export * from "./define-module-ui.js"; export * from "./host-context.js"; export * from "./module-ui.js"; +export * from "./realtime.js"; diff --git a/packages/module-ui-sdk/src/realtime.ts b/packages/module-ui-sdk/src/realtime.ts new file mode 100644 index 0000000..0ca635a --- /dev/null +++ b/packages/module-ui-sdk/src/realtime.ts @@ -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 { + 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( + channel: string, + handlers: ModuleUiRealtimeHandlers, + ): ModuleUiRealtimeSubscription; +} diff --git a/packages/module-ui-sdk/tests/browser-boundary/supported.tsx b/packages/module-ui-sdk/tests/browser-boundary/supported.tsx index 8efcb04..78cfd3e 100644 --- a/packages/module-ui-sdk/tests/browser-boundary/supported.tsx +++ b/packages/module-ui-sdk/tests/browser-boundary/supported.tsx @@ -17,7 +17,16 @@ export default defineModuleUi({ container.textContent = describePrincipal(context.principal); void context.api.request("records"); 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 () => { + subscription.close(); container.textContent = ""; }; }, diff --git a/packages/module-ui-sdk/tests/module-ui-contract.test.ts b/packages/module-ui-sdk/tests/module-ui-contract.test.ts index a8bf4f4..3e3d28e 100644 --- a/packages/module-ui-sdk/tests/module-ui-contract.test.ts +++ b/packages/module-ui-sdk/tests/module-ui-contract.test.ts @@ -6,6 +6,7 @@ import { type ModuleUiApiClient, type ModuleUiDefinition, type ModuleUiHostContext, + type ModuleUiRealtimeClient, parseModuleUiDefinition, parseModuleUiExport, } 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. */ const noop = (): void => undefined; +const noopRealtime: ModuleUiRealtimeClient = Object.freeze({ + subscribe: () => Object.freeze({ close: noop }), +}); + function fakeContainer(): 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, principal: { kind: "anonymous" }, permissions: { has: () => canPublish }, + realtime: noopRealtime, }, }; } @@ -154,6 +160,7 @@ describe("the same module UI artifact hosted by two different hosts", () => { api, principal: { kind: "anonymous" }, permissions: { has: () => canPublish }, + realtime: noopRealtime, }, }; } diff --git a/surfaces/shell/src/host-protocol.ts b/surfaces/shell/src/host-protocol.ts index 47ac300..14a4d9e 100644 --- a/surfaces/shell/src/host-protocol.ts +++ b/surfaces/shell/src/host-protocol.ts @@ -14,8 +14,17 @@ export interface EkklesiaHostConfig { /** Injected so tests can drive the real protocol against a real in-process app without a socket. * Production composition leaves this unset and the platform `fetch` is used. */ readonly fetch?: typeof fetch; + /** Test/composition seam for the browser's native live-event client. Production leaves this + * unset and the platform `EventSource` constructor is used. This never enters module UI. */ + readonly eventSourceFactory?: HostEventSourceFactory; } +export interface HostEventSourceOptions { + readonly withCredentials: true; +} + +export type HostEventSourceFactory = (url: string, options: HostEventSourceOptions) => EventSource; + /** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API * URL — the server produces both and hands them over in the navigation view — so these two are the * only URLs it knows, and they describe the platform itself rather than any module. @@ -35,13 +44,16 @@ export type HostProtocolErrorCode = /** The API could not be reached, or failed. */ | "HOST_REQUEST_FAILED" /** A module UI asked for something outside its own API root. */ - | "MODULE_API_PATH_INVALID"; + | "MODULE_API_PATH_INVALID" + /** A module UI asked for a live channel outside the public local-channel grammar. */ + | "MODULE_REALTIME_CHANNEL_INVALID"; const errorMessages: Readonly> = Object.freeze({ HOST_REQUEST_FAILED: "Ekklesia could not be reached.", HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.", HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.", MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.", + MODULE_REALTIME_CHANNEL_INVALID: "A module asked for an invalid live channel.", }); /** @@ -77,6 +89,8 @@ export interface HostModuleUiDescriptor { readonly uiUrl: string; /** Server-produced root of this module's own HTTP API. */ readonly apiBaseUrl: string; + /** Server-produced root for this module's own live channels. */ + readonly realtimeBaseUrl: string; /** Granted ids only, already evaluated server-side. Presentation hints, never authority. */ readonly permissions: readonly string[]; } @@ -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 * 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 { 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 candidate; @@ -122,12 +143,14 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor { const version = nestedString(entry, "version"); const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined; const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined; + const realtimeBaseUrl = isRecord(entry) ? rootRelativePath(entry.realtime, "baseUrl") : undefined; const permissions = isRecord(entry) ? entry.permissions : undefined; if ( id === undefined || version === undefined || uiUrl === undefined || apiBaseUrl === undefined || + realtimeBaseUrl === undefined || !Array.isArray(permissions) || permissions.some((permission) => typeof permission !== "string") ) { @@ -137,6 +160,7 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor { apiBaseUrl, id, permissions: Object.freeze([...(permissions as string[])]), + realtimeBaseUrl, uiUrl, version, }); diff --git a/surfaces/shell/src/index.ts b/surfaces/shell/src/index.ts index df2766f..0915667 100644 --- a/surfaces/shell/src/index.ts +++ b/surfaces/shell/src/index.ts @@ -16,6 +16,8 @@ export { type DashboardNavigationGroup, type EkklesiaHostConfig, type HostBootstrap, + type HostEventSourceFactory, + type HostEventSourceOptions, type HostModuleUiDescriptor, HostProtocolError, type HostProtocolErrorCode, diff --git a/surfaces/shell/src/module-context.ts b/surfaces/shell/src/module-context.ts index 4a908c6..4ce89fb 100644 --- a/surfaces/shell/src/module-context.ts +++ b/surfaces/shell/src/module-context.ts @@ -2,6 +2,11 @@ import type { ModuleUiApiClient, ModuleUiHostContext, ModuleUiPrincipal, + ModuleUiRealtimeClient, + ModuleUiRealtimeError, + ModuleUiRealtimeErrorCode, + ModuleUiRealtimeHandlers, + ModuleUiRealtimeStatus, } from "@ekklesia/module-ui-sdk"; import { type EkklesiaHostConfig, @@ -11,6 +16,185 @@ import { } from "./host-protocol.js"; const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u; +const REALTIME_CHANNEL_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +const REALTIME_CHANNEL_MAX_LENGTH = 48; + +const realtimeErrors: Readonly> = + Object.freeze({ + REALTIME_CONNECTION_FAILED: Object.freeze({ + code: "REALTIME_CONNECTION_FAILED", + message: "The live connection could not be maintained.", + }), + REALTIME_MESSAGE_INVALID: Object.freeze({ + code: "REALTIME_MESSAGE_INVALID", + message: "The host received an invalid live update.", + }), + }); + +interface RealtimeEnvelope { + readonly payload: unknown; + readonly sequence: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Strictly parses the host transport's private envelope. The module sees neither this envelope nor + * the event implementation; it receives only `payload` and `sequence`. */ +function parseRealtimeEnvelope(data: unknown): RealtimeEnvelope | undefined { + if (typeof data !== "string") { + return undefined; + } + let value: unknown; + try { + value = JSON.parse(data) as unknown; + } catch { + return undefined; + } + if (!isRecord(value)) { + return undefined; + } + const keys = Object.keys(value).sort(); + if ( + keys.length !== 2 || + keys[0] !== "payload" || + keys[1] !== "sequence" || + !Number.isSafeInteger(value.sequence) || + (value.sequence as number) <= 0 + ) { + return undefined; + } + return { payload: value.payload, sequence: value.sequence as number }; +} + +function realtimeChannelUrl( + config: EkklesiaHostConfig, + descriptor: HostModuleUiDescriptor, + channel: string, +): string { + const root = new URL(resolveApiUrl(config, descriptor.realtimeBaseUrl)); + const rootPath = root.pathname.replace(/\/+$/u, ""); + return new URL(`${rootPath}/${channel}`, root).href; +} + +function createModuleRealtimeClient( + config: EkklesiaHostConfig, + descriptor: HostModuleUiDescriptor, +): ModuleUiRealtimeClient { + return Object.freeze({ + subscribe(channel: string, handlers: ModuleUiRealtimeHandlers) { + if (channel.length > REALTIME_CHANNEL_MAX_LENGTH || !REALTIME_CHANNEL_PATTERN.test(channel)) { + throw new HostProtocolError("MODULE_REALTIME_CHANNEL_INVALID"); + } + + let closed = false; + let lastStatus: ModuleUiRealtimeStatus | undefined; + let awaitingSnapshot = true; + let lastSequence = 0; + + const reportStatus = (status: ModuleUiRealtimeStatus): void => { + if (lastStatus !== status) { + lastStatus = status; + handlers.onStatus?.(status); + } + }; + const reportError = (code: ModuleUiRealtimeErrorCode): void => { + handlers.onError?.(realtimeErrors[code]); + }; + + reportStatus("connecting"); + + const factory = + config.eventSourceFactory ?? + ((url: string, options: { readonly withCredentials: true }) => + new EventSource(url, options)); + let source: EventSource; + try { + source = factory(realtimeChannelUrl(config, descriptor, channel), { + withCredentials: true, + }); + } catch { + reportError("REALTIME_CONNECTION_FAILED"); + closed = true; + reportStatus("closed"); + return Object.freeze({ close: (): void => undefined }); + } + + function onOpen(): void { + if (!closed) { + awaitingSnapshot = true; + reportStatus("connected"); + } + } + function onConnectionError(): void { + if (closed) { + return; + } + reportError("REALTIME_CONNECTION_FAILED"); + if (source.readyState === source.CLOSED) { + terminate(); + return; + } + // EventSource itself retries established transport disconnects. The host reports that native + // transition and waits for the next `open`; it never creates a competing retry loop. + awaitingSnapshot = true; + reportStatus("reconnecting"); + } + function onDelivery(kind: "snapshot" | "update", event: Event): void { + if (closed) { + return; + } + const envelope = parseRealtimeEnvelope((event as MessageEvent).data); + const invalidOrder = + envelope !== undefined && + (kind === "snapshot" + ? !awaitingSnapshot + : awaitingSnapshot || envelope.sequence <= lastSequence); + if (envelope === undefined || invalidOrder) { + reportError("REALTIME_MESSAGE_INVALID"); + return; + } + lastSequence = envelope.sequence; + awaitingSnapshot = false; + if (kind === "snapshot") { + handlers.onSnapshot?.(envelope.payload as State, envelope.sequence); + } else { + handlers.onUpdate?.(envelope.payload as State, envelope.sequence); + } + } + function onSnapshot(event: Event): void { + onDelivery("snapshot", event); + } + function onUpdate(event: Event): void { + onDelivery("update", event); + } + function terminate(): void { + if (closed) { + return; + } + closed = true; + // Detach first: a browser or injected implementation that synchronously reports closure + // from `close()` cannot re-enter `onConnectionError` and duplicate callbacks. + source.removeEventListener("open", onOpen); + source.removeEventListener("error", onConnectionError); + source.removeEventListener("snapshot", onSnapshot); + source.removeEventListener("update", onUpdate); + source.close(); + reportStatus("closed"); + } + + source.addEventListener("open", onOpen); + source.addEventListener("error", onConnectionError); + source.addEventListener("snapshot", onSnapshot); + source.addEventListener("update", onUpdate); + + return Object.freeze({ + close: terminate, + }); + }, + }); +} /** * Turns the relative path a module UI asks for into the real Ekklesia URL, and refuses anything @@ -74,10 +258,10 @@ export interface CreateModuleUiContextOptions { /** * 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 - * descriptor, the principal from `/api/v1/principal`, and the permission hints from the server's own - * evaluation of that module's declared permissions against that principal. Nothing is guessed, - * defaulted to `true`, or inferred from a client-side role name. + * All five members come from host-derived data: the module id, API root, and realtime root from the + * inventory descriptor; the principal from `/api/v1/principal`; and permission hints from the + * server's evaluation of that module's declarations. Nothing is guessed, defaulted to `true`, or + * inferred from a client-side role name. * * The permission set is the module's own and only the module's own. A module UI cannot use this to * discover what the viewer may do elsewhere in the platform, and `has` returning `true` is a @@ -91,6 +275,7 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo moduleId: options.descriptor.id, permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }), principal: options.principal, + realtime: createModuleRealtimeClient(options.config, options.descriptor), }); } diff --git a/surfaces/shell/tests/host-app.test.tsx b/surfaces/shell/tests/host-app.test.tsx index 5f154bb..914f01b 100644 --- a/surfaces/shell/tests/host-app.test.tsx +++ b/surfaces/shell/tests/host-app.test.tsx @@ -42,6 +42,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl api: { baseUrl: `/api/v1/modules/${id}` }, id, permissions, + realtime: { baseUrl: `/api/v1/module-realtime/${id}` }, ui: { url: `/api/v1/module-ui/${id}/${version}` }, version, }, diff --git a/surfaces/shell/tests/host-protocol.test.ts b/surfaces/shell/tests/host-protocol.test.ts index edf75f7..73f9ea3 100644 --- a/surfaces/shell/tests/host-protocol.test.ts +++ b/surfaces/shell/tests/host-protocol.test.ts @@ -40,6 +40,7 @@ const inventoryPayload = { api: { baseUrl: "/api/v1/modules/example" }, id: "example", permissions: ["example.records.read"], + realtime: { baseUrl: "/api/v1/module-realtime/example" }, ui: { url: "/api/v1/module-ui/example/0.1.0" }, version: "0.1.0", }, @@ -50,6 +51,7 @@ const calendarDescriptor: HostModuleUiDescriptor = { apiBaseUrl: "/api/v1/modules/calendar", id: "calendar", permissions: [], + realtimeBaseUrl: "/api/v1/module-realtime/calendar", uiUrl: "/api/v1/module-ui/calendar/2.0.0", version: "2.0.0", }; @@ -69,6 +71,7 @@ const navigationPayload = { api: { baseUrl: "/api/v1/modules/calendar" }, id: "calendar", permissions: [], + realtime: { baseUrl: "/api/v1/module-realtime/calendar" }, ui: { url: "/api/v1/module-ui/calendar/2.0.0" }, version: "2.0.0", }, @@ -81,6 +84,7 @@ const exampleDescriptor: HostModuleUiDescriptor = { apiBaseUrl: "/api/v1/modules/example", id: "example", permissions: ["example.records.read"], + realtimeBaseUrl: "/api/v1/module-realtime/example", uiUrl: "/api/v1/module-ui/example/0.1.0", version: "0.1.0", }; @@ -103,14 +107,18 @@ describe("host protocol — descriptor validation", () => { { id: "example", version: "0.1.0" }, { ...base, ui: {} }, { ...base, api: { baseUrl: "" } }, + { ...base, realtime: {} }, { ...base, permissions: "example.records.read" }, { ...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. { ...base, ui: { url: "https://evil.example.test/x.js" } }, { ...base, ui: { url: "//evil.example.test/x.js" } }, { ...base, ui: { url: "relative/x.js" } }, { ...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) { expect( @@ -366,6 +374,7 @@ describe("module UI host context", () => { apiBaseUrl: "/api/v1/modules/calendar", id: "calendar", permissions: [], + realtimeBaseUrl: "/api/v1/module-realtime/calendar", uiUrl: "/api/v1/module-ui/calendar/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); }); - 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 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.principal).toEqual({ kind: "authenticated", userId: "user-1" }); }); diff --git a/surfaces/shell/tests/module-realtime-context.test.ts b/surfaces/shell/tests/module-realtime-context.test.ts new file mode 100644 index 0000000..8877dd9 --- /dev/null +++ b/surfaces/shell/tests/module-realtime-context.test.ts @@ -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>(); + closed = false; + closeCalls = 0; + readyState: number = this.CONNECTING; + + addEventListener(type: string, listener: EventListener): void { + const listeners = this.listeners.get(type) ?? new Set(); + 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>)].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>)].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"); + }); +}); diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 26e6f30..3b02764 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -51,6 +51,9 @@ async function registerAndCaptureRoutes(): Promise<{ }, }, 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 }; } @@ -138,6 +141,7 @@ describe("module template", () => { moduleId: "example", params: Object.freeze({}), principal: ANONYMOUS_PRINCIPAL, + realtime: { publish: () => undefined }, services, // 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. @@ -182,6 +186,7 @@ describe("module template", () => { moduleId: "example", params: Object.freeze({}), principal: ANONYMOUS_PRINCIPAL, + realtime: { publish: () => undefined }, services: Object.freeze({ database: fakeModuleDatabase(fakeRows) }), storage: EMPTY_MODULE_STORAGE, }); diff --git a/templates/module/tests/module-ui.test.ts b/templates/module/tests/module-ui.test.ts index 653f225..ff04468 100644 --- a/templates/module/tests/module-ui.test.ts +++ b/templates/module/tests/module-ui.test.ts @@ -51,6 +51,7 @@ function recordingHost(options: { api, principal: { kind: "anonymous" }, permissions: { has: () => options.canRead }, + realtime: { subscribe: () => ({ close: () => undefined }) }, }; }