Merge pull request 'feat: add native module realtime transport' (#31) from phase-7a/native-realtime-transport into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/31
This commit was merged in pull request #31.
This commit is contained in:
@@ -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<SlideshowState>("state", {
|
||||
onSnapshot: (state) => render(state),
|
||||
onUpdate: (state) => render(state),
|
||||
});
|
||||
|
||||
// Return this from `mount`, or call it from your unmount cleanup.
|
||||
subscription.close();
|
||||
```
|
||||
|
||||
There is no durable replay, global topic namespace, cross-module subscription, query-string token,
|
||||
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
|
||||
pass through realtime unchanged. The final unauthenticated/device principal for public displays and
|
||||
display/kiosk hosting itself remain a later pass.
|
||||
|
||||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { ModuleRealtimeError, type ModuleId, type PermissionId } from "@ekklesia/contracts";
|
||||
import type {
|
||||
ModuleRealtimePublication,
|
||||
ModuleRealtimeRegistry,
|
||||
RegisteredModuleRealtimeChannel,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
|
||||
const MODULE_REALTIME_PREFIX = "/api/v1/module-realtime";
|
||||
|
||||
/** Host-owned keepalive interval. Comments keep idle HTTP streams alive without entering module code,
|
||||
* consuming a sequence, or becoming a browser-visible state update. */
|
||||
export const MODULE_REALTIME_KEEPALIVE_MS = 15_000;
|
||||
/** Forces a fresh cookie/permission evaluation periodically rather than letting an authorization
|
||||
* decision live for an unbounded stream lifetime. EventSource reconnects after the clean EOF. */
|
||||
export const MODULE_REALTIME_MAX_CONNECTION_AGE_MS = 5 * 60_000;
|
||||
|
||||
export interface ModuleRealtimeTransportTiming {
|
||||
/** Test-only narrowing of the production keepalive. Production composition never sets this. */
|
||||
readonly keepaliveMs?: number;
|
||||
/** Test-only narrowing of the production reauthorization interval. */
|
||||
readonly maxConnectionAgeMs?: number;
|
||||
}
|
||||
|
||||
export interface ModuleRealtimeRoutesOptions
|
||||
extends AuthorizationMiddlewareOptions,
|
||||
ModuleRealtimeTransportTiming {
|
||||
readonly realtime: ModuleRealtimeRegistry;
|
||||
}
|
||||
|
||||
/** The server is the sole author of this deployment-private base. Module UI receives it through the
|
||||
* host descriptor and never reconstructs the platform prefix or supplies its own module id. */
|
||||
export function moduleRealtimeBaseUrl(moduleId: string): string {
|
||||
return `${MODULE_REALTIME_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
function moduleRealtimeChannelUrl(moduleId: ModuleId, channel: string): string {
|
||||
return `${moduleRealtimeBaseUrl(moduleId)}/${encodeURIComponent(channel)}`;
|
||||
}
|
||||
|
||||
/** A forged runtime snapshot that could not be permission-gated is a composition failure. Carries no
|
||||
* module/channel/permission value and is thrown before any route from that snapshot is usable. */
|
||||
export class ModuleRealtimeConfigurationError extends Error {
|
||||
constructor() {
|
||||
super("A published module realtime channel references a permission that is not registered.");
|
||||
this.name = "ModuleRealtimeConfigurationError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function realtimeEnvelope(publication: ModuleRealtimePublication): string {
|
||||
// `serializedPayload` was validated and serialized exactly once by Core. Concatenating it as the
|
||||
// JSON value preserves every opaque string — including signed object-delivery URLs — byte for byte.
|
||||
return `{"sequence":${publication.sequence},"payload":${publication.serializedPayload}}`;
|
||||
}
|
||||
|
||||
function realtimeEvent(
|
||||
kind: "snapshot" | "update",
|
||||
publication: ModuleRealtimePublication,
|
||||
): string {
|
||||
// Core's compact JSON serialization contains no literal CR/LF bytes inside strings, so one `data`
|
||||
// line is sufficient. Deliberately no SSE `id`: this transport does not implement Last-Event-ID
|
||||
// replay, and a process-local sequence must not be mistaken for a durable cursor.
|
||||
return `event: ${kind}\ndata: ${realtimeEnvelope(publication)}\n\n`;
|
||||
}
|
||||
|
||||
function sanitizedRealtimeFailure(c: Context, error: unknown): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const code =
|
||||
error instanceof ModuleRealtimeError && error.code === "REALTIME_SUBSCRIBER_LIMIT"
|
||||
? "REALTIME_SUBSCRIBER_LIMIT"
|
||||
: "REALTIME_UNAVAILABLE";
|
||||
return c.json({ code }, 503);
|
||||
}
|
||||
|
||||
interface CancelableDelay {
|
||||
readonly promise: Promise<"keepalive">;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
function keepaliveDelay(milliseconds: number): CancelableDelay {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const promise = new Promise<"keepalive">((resolve) => {
|
||||
timer = setTimeout(() => resolve("keepalive"), milliseconds);
|
||||
});
|
||||
return {
|
||||
cancel: () => clearTimeout(timer),
|
||||
promise,
|
||||
};
|
||||
}
|
||||
|
||||
function createRealtimeStream(
|
||||
definition: RegisteredModuleRealtimeChannel,
|
||||
registry: ModuleRealtimeRegistry,
|
||||
keepaliveMs: number,
|
||||
maxConnectionAgeMs: number,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const subscription = registry.subscribe(definition.moduleId, definition.channel);
|
||||
let snapshotPending = true;
|
||||
let closed = false;
|
||||
let pendingRead: Promise<ModuleRealtimePublication | undefined> | undefined;
|
||||
let activeDelay: CancelableDelay | undefined;
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const close = (): void => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
activeDelay?.cancel();
|
||||
activeDelay = undefined;
|
||||
clearTimeout(expiryTimer);
|
||||
expiryTimer = undefined;
|
||||
subscription.close();
|
||||
};
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
expiryTimer = setTimeout(() => {
|
||||
close();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// A simultaneous browser cancellation or completed pull already closed the controller.
|
||||
}
|
||||
}, maxConnectionAgeMs);
|
||||
},
|
||||
cancel: () => close(),
|
||||
async pull(controller) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (snapshotPending) {
|
||||
snapshotPending = false;
|
||||
controller.enqueue(encoder.encode(realtimeEvent("snapshot", subscription.snapshot)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only one Core read is ever outstanding. If a keepalive wins the race, the same read is
|
||||
// retained for the next pull instead of allocating a second listener. Core itself holds only
|
||||
// one newest pending publication, and this stream asks for work only when the browser-facing
|
||||
// ReadableStream has demand, so a slow client cannot create an array at either layer.
|
||||
pendingRead ??= subscription.read();
|
||||
activeDelay = keepaliveDelay(keepaliveMs);
|
||||
const result = await Promise.race([pendingRead, activeDelay.promise]);
|
||||
activeDelay.cancel();
|
||||
activeDelay = undefined;
|
||||
|
||||
if (result === "keepalive") {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRead = undefined;
|
||||
if (result === undefined) {
|
||||
close();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(realtimeEvent("update", result)));
|
||||
} catch {
|
||||
close();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// The browser may have canceled between the awaited read/timer and this close. Cancellation
|
||||
// already performed the same idempotent subscription cleanup.
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mountChannel(
|
||||
app: Hono,
|
||||
definition: RegisteredModuleRealtimeChannel,
|
||||
options: ModuleRealtimeRoutesOptions,
|
||||
keepaliveMs: number,
|
||||
maxConnectionAgeMs: number,
|
||||
): void {
|
||||
const requireChannelPermission = requirePermission(
|
||||
options,
|
||||
definition.permission as PermissionId,
|
||||
);
|
||||
app.get(
|
||||
moduleRealtimeChannelUrl(definition.moduleId, definition.channel),
|
||||
requireChannelPermission,
|
||||
(c) => {
|
||||
// Hono deliberately lets a registered GET handler answer HEAD. That is useful for finite
|
||||
// resources, but unsafe for an infinite stream: the adapter discards the response body without
|
||||
// necessarily canceling it, which could retain a Core subscriber forever. Refuse HEAD before
|
||||
// allocating anything. Authentication/authorization has still run through the same middleware.
|
||||
if (c.req.method !== "GET") {
|
||||
return new Response(null, {
|
||||
headers: { Allow: "GET", "Cache-Control": "no-store" },
|
||||
status: 405,
|
||||
});
|
||||
}
|
||||
let stream: ReadableStream<Uint8Array>;
|
||||
try {
|
||||
stream = createRealtimeStream(
|
||||
definition,
|
||||
options.realtime,
|
||||
keepaliveMs,
|
||||
maxConnectionAgeMs,
|
||||
);
|
||||
} catch (error) {
|
||||
return sanitizedRealtimeFailure(c, error);
|
||||
}
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
Connection: "keep-alive",
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
Vary: "Origin, Cookie",
|
||||
// Honored by nginx and harmless elsewhere. Other proxies must likewise disable buffering for
|
||||
// this path; no proxy-specific behavior enters the module-facing contract.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Mounts one exact, immutable, permission-gated GET route per successfully started module channel.
|
||||
* There is no dynamic module/channel lookup route and no request-controlled permission selection. */
|
||||
export function mountModuleRealtimeRoutes(app: Hono, options: ModuleRealtimeRoutesOptions): void {
|
||||
const keepaliveMs = options.keepaliveMs ?? MODULE_REALTIME_KEEPALIVE_MS;
|
||||
const maxConnectionAgeMs = options.maxConnectionAgeMs ?? MODULE_REALTIME_MAX_CONNECTION_AGE_MS;
|
||||
if (!Number.isSafeInteger(keepaliveMs) || keepaliveMs <= 0) {
|
||||
throw new TypeError("The module realtime keepalive interval is invalid.");
|
||||
}
|
||||
if (!Number.isSafeInteger(maxConnectionAgeMs) || maxConnectionAgeMs <= 0) {
|
||||
throw new TypeError("The module realtime maximum connection age is invalid.");
|
||||
}
|
||||
for (const definition of options.realtime.channels) {
|
||||
if (!options.registry.has(definition.permission)) {
|
||||
throw new ModuleRealtimeConfigurationError();
|
||||
}
|
||||
}
|
||||
for (const definition of options.realtime.channels) {
|
||||
mountChannel(app, definition, options, keepaliveMs, maxConnectionAgeMs);
|
||||
}
|
||||
}
|
||||
@@ -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/<id>` 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,
|
||||
};
|
||||
|
||||
@@ -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<void> => {
|
||||
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 {
|
||||
|
||||
@@ -52,6 +52,26 @@ function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
async function withTransportDeadline<Value>(
|
||||
operation: Promise<Value>,
|
||||
onTimeout: () => void,
|
||||
): Promise<Value> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
onTimeout();
|
||||
reject(harnessError("An integration transport operation timed out."));
|
||||
}, 5_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
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<Uint8Array> | 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();
|
||||
|
||||
@@ -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<CreateApiAppOptions, "moduleRealtime">) {
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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<Installation> {
|
||||
store: lifecycleStore,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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<Installation> {
|
||||
store,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRealtimePublisher,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
type AuthorizationEvaluator,
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
discoverModules,
|
||||
type ModuleRealtimeRegistry,
|
||||
type ModuleRealtimeSubscription,
|
||||
startDiscoveredModuleRuntime,
|
||||
type StartedModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { type ServerType, serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mountModuleRealtimeRoutes } from "../src/module-realtime-routes.js";
|
||||
|
||||
const allowedUserId = parseEkklesiaUserId("99999999-9999-4999-8999-999999999999");
|
||||
const deniedUserId = parseEkklesiaUserId("77777777-7777-4777-8777-777777777777");
|
||||
const stableDeliveryUrl =
|
||||
"https://objects.example.test/announcements/slides/welcome.png?X-Amz-Signature=stable";
|
||||
const TRANSPORT_DEADLINE_MS = 2_000;
|
||||
|
||||
async function withinTransportDeadline<Value>(
|
||||
operation: Promise<Value>,
|
||||
message: string,
|
||||
onTimeout: () => void = () => undefined,
|
||||
): Promise<Value> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
onTimeout();
|
||||
reject(new Error(message));
|
||||
}, TRANSPORT_DEADLINE_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTransportCondition(predicate: () => boolean, message: string): Promise<void> {
|
||||
let cancelled = false;
|
||||
await withinTransportDeadline(
|
||||
new Promise<void>((resolve) => {
|
||||
const inspect = (): void => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (predicate()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(inspect, 5);
|
||||
};
|
||||
inspect();
|
||||
}),
|
||||
message,
|
||||
() => {
|
||||
cancelled = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface FixtureControl {
|
||||
readonly events: string[];
|
||||
publisher?: ModuleRealtimePublisher;
|
||||
}
|
||||
|
||||
interface LiveEvent {
|
||||
readonly event: "snapshot" | "update";
|
||||
readonly payload: unknown;
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
class LiveSseConnection {
|
||||
readonly #abort: AbortController;
|
||||
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
#buffer = "";
|
||||
|
||||
constructor(response: Response, abort: AbortController) {
|
||||
if (!response.body) {
|
||||
throw new Error("Realtime integration response had no body.");
|
||||
}
|
||||
this.#abort = abort;
|
||||
this.#reader = response.body.getReader();
|
||||
}
|
||||
|
||||
async readEvent(): Promise<LiveEvent> {
|
||||
for (;;) {
|
||||
const boundary = this.#buffer.indexOf("\n\n");
|
||||
if (boundary >= 0) {
|
||||
const block = this.#buffer.slice(0, boundary);
|
||||
this.#buffer = this.#buffer.slice(boundary + 2);
|
||||
if (block.startsWith(":")) {
|
||||
continue;
|
||||
}
|
||||
const event = block
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("event: "))
|
||||
?.slice("event: ".length);
|
||||
const data = block
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data: "))
|
||||
?.slice("data: ".length);
|
||||
if ((event !== "snapshot" && event !== "update") || data === undefined) {
|
||||
throw new Error("Realtime integration received an invalid event.");
|
||||
}
|
||||
const envelope = JSON.parse(data) as { payload?: unknown; sequence?: unknown };
|
||||
if (!Number.isSafeInteger(envelope.sequence) || (envelope.sequence as number) <= 0) {
|
||||
throw new Error("Realtime integration received an invalid sequence.");
|
||||
}
|
||||
return { event, payload: envelope.payload, sequence: envelope.sequence as number };
|
||||
}
|
||||
|
||||
const result = await this.#readWithinDeadline();
|
||||
if (result.done) {
|
||||
throw new Error("Realtime integration stream closed before the expected event.");
|
||||
}
|
||||
this.#buffer += new TextDecoder().decode(result.value, { stream: true });
|
||||
}
|
||||
}
|
||||
|
||||
async expectClosed(): Promise<void> {
|
||||
const result = await this.#readWithinDeadline();
|
||||
expect(result.done).toBe(true);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#abort.abort();
|
||||
await withinTransportDeadline(
|
||||
// Abort and cancel race by design; a rejection means one of them already released the
|
||||
// transport. A deadline still propagates and fails the test.
|
||||
this.#reader.cancel().catch(() => undefined),
|
||||
"Realtime integration client cancellation timed out.",
|
||||
);
|
||||
}
|
||||
|
||||
async #readWithinDeadline() {
|
||||
return withinTransportDeadline(
|
||||
this.#reader.read(),
|
||||
"Realtime integration read timed out.",
|
||||
() => this.#abort.abort(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
readonly directory: string;
|
||||
readonly symbol: symbol;
|
||||
}> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-native-realtime-"));
|
||||
const moduleDirectory = path.join(directory, "announcements");
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
const symbol = Symbol.for(`ekklesia.native-realtime.${Date.now()}.${Math.random()}`);
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = control;
|
||||
const expression = `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`;
|
||||
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: "@test/module-announcements",
|
||||
type: "module",
|
||||
version: "0.1.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: "announcements",
|
||||
manifestVersion: 5,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: "announcements.slideshow.view",
|
||||
},
|
||||
],
|
||||
version: "0.1.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
${expression}.events.push("register");
|
||||
},
|
||||
start({ realtime }) {
|
||||
${expression}.publisher = realtime;
|
||||
realtime.publish("state", {
|
||||
mode: "on",
|
||||
currentSlide: 0,
|
||||
previousSlide: -1,
|
||||
slides: [${JSON.stringify(stableDeliveryUrl)}]
|
||||
});
|
||||
${expression}.events.push("start");
|
||||
},
|
||||
stop() { ${expression}.events.push("stop"); }
|
||||
};\n`,
|
||||
),
|
||||
]);
|
||||
return { directory, symbol };
|
||||
}
|
||||
|
||||
function services(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
function identityRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: async (headers) => {
|
||||
const cookie = headers.get("cookie");
|
||||
if (cookie === "ekklesia-session=allowed") {
|
||||
return createAuthenticatedPrincipal(allowedUserId);
|
||||
}
|
||||
if (cookie === "ekklesia-session=denied") {
|
||||
return createAuthenticatedPrincipal(deniedUserId);
|
||||
}
|
||||
return ANONYMOUS_PRINCIPAL;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(): AuthorizationEvaluator {
|
||||
return createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async ({ userId }) => userId === allowedUserId },
|
||||
platformOwners: { isPlatformOwner: async () => false },
|
||||
});
|
||||
}
|
||||
|
||||
async function bindLiveServer(
|
||||
runtime: StartedModuleRuntime,
|
||||
registry: PermissionRegistry,
|
||||
): Promise<{
|
||||
readonly activeSubscriptions: () => number;
|
||||
readonly origin: string;
|
||||
readonly server: ServerType;
|
||||
}> {
|
||||
const app = new Hono();
|
||||
let activeSubscriptions = 0;
|
||||
const observedRealtime: ModuleRealtimeRegistry = {
|
||||
channels: runtime.realtime.channels,
|
||||
closeSubscriptions: () => runtime.realtime.closeSubscriptions(),
|
||||
publisherFor: (moduleId) => runtime.realtime.publisherFor(moduleId),
|
||||
subscribe: (moduleId, channel): ModuleRealtimeSubscription => {
|
||||
const subscription = runtime.realtime.subscribe(moduleId, channel);
|
||||
activeSubscriptions += 1;
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
close: (): void => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
activeSubscriptions -= 1;
|
||||
}
|
||||
subscription.close();
|
||||
},
|
||||
read: () => subscription.read(),
|
||||
snapshot: subscription.snapshot,
|
||||
});
|
||||
},
|
||||
};
|
||||
mountModuleRealtimeRoutes(app, {
|
||||
evaluator: authorization(),
|
||||
realtime: observedRealtime,
|
||||
registry,
|
||||
runtime: identityRuntime(),
|
||||
});
|
||||
app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
|
||||
|
||||
let pendingServer: ServerType | undefined;
|
||||
const server = await withinTransportDeadline(
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
pendingServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, () =>
|
||||
resolve(pendingServer as ServerType),
|
||||
);
|
||||
pendingServer.once("error", reject);
|
||||
}),
|
||||
"Realtime integration server bind timed out.",
|
||||
() => pendingServer?.close(),
|
||||
);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Realtime integration server did not bind a TCP port.");
|
||||
}
|
||||
return {
|
||||
activeSubscriptions: () => activeSubscriptions,
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
server,
|
||||
};
|
||||
}
|
||||
|
||||
async function connect(
|
||||
origin: string,
|
||||
cookie: string,
|
||||
): Promise<{
|
||||
readonly connection: LiveSseConnection;
|
||||
readonly response: Response;
|
||||
}> {
|
||||
const abort = new AbortController();
|
||||
const response = await withinTransportDeadline(
|
||||
fetch(`${origin}/api/v1/module-realtime/announcements/state`, {
|
||||
headers: { cookie },
|
||||
signal: abort.signal,
|
||||
}),
|
||||
"Realtime integration connection timed out.",
|
||||
() => abort.abort(),
|
||||
);
|
||||
return { connection: new LiveSseConnection(response, abort), response };
|
||||
}
|
||||
|
||||
async function closeServer(server: ServerType): Promise<void> {
|
||||
await withinTransportDeadline(
|
||||
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
"Realtime integration server close timed out.",
|
||||
() =>
|
||||
(
|
||||
server as ServerType & {
|
||||
closeAllConnections?: () => void;
|
||||
}
|
||||
).closeAllConnections?.(),
|
||||
);
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ readonly body: unknown; readonly response: Response }> {
|
||||
const abort = new AbortController();
|
||||
const operation = (async (): Promise<{ readonly body: unknown; readonly response: Response }> => {
|
||||
const response = await fetch(url, { ...init, signal: abort.signal });
|
||||
const body = (await response.json()) as unknown;
|
||||
return { body, response };
|
||||
})();
|
||||
return withinTransportDeadline(operation, "Realtime integration finite request timed out.", () =>
|
||||
abort.abort(),
|
||||
);
|
||||
}
|
||||
|
||||
describe("native realtime over a live Node HTTP server", () => {
|
||||
it("loads a real module and proves auth, snapshot, fanout, update, reconnect, and shutdown", async () => {
|
||||
const control: FixtureControl = { events: [] };
|
||||
const fixture = await createFixtureModule(control);
|
||||
let runtime: StartedModuleRuntime | undefined;
|
||||
let server: ServerType | undefined;
|
||||
const connections: LiveSseConnection[] = [];
|
||||
const failures: unknown[] = [];
|
||||
try {
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: fixture.directory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
runtime = await startDiscoveredModuleRuntime(modules, services());
|
||||
expect(control.events).toEqual(["register", "start"]);
|
||||
const live = await bindLiveServer(runtime, composePlatformPermissionRegistry(modules));
|
||||
server = live.server;
|
||||
|
||||
const anonymous = await requestJson(
|
||||
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||
);
|
||||
expect(anonymous.response.status).toBe(403);
|
||||
expect(anonymous.body).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
const missingPermission = await requestJson(
|
||||
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||
{ headers: { cookie: "ekklesia-session=denied" } },
|
||||
);
|
||||
expect(missingPermission.response.status).toBe(403);
|
||||
|
||||
const first = await connect(live.origin, "ekklesia-session=allowed");
|
||||
const second = await connect(live.origin, "ekklesia-session=allowed");
|
||||
connections.push(first.connection, second.connection);
|
||||
expect(first.response.status).toBe(200);
|
||||
expect(first.response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
|
||||
const expectedInitial = {
|
||||
currentSlide: 0,
|
||||
mode: "on",
|
||||
previousSlide: -1,
|
||||
slides: [stableDeliveryUrl],
|
||||
};
|
||||
expect(await first.connection.readEvent()).toEqual({
|
||||
event: "snapshot",
|
||||
payload: expectedInitial,
|
||||
sequence: 1,
|
||||
});
|
||||
expect(await second.connection.readEvent()).toEqual({
|
||||
event: "snapshot",
|
||||
payload: expectedInitial,
|
||||
sequence: 1,
|
||||
});
|
||||
expect(live.activeSubscriptions()).toBe(2);
|
||||
|
||||
control.publisher?.publish("state", {
|
||||
currentSlide: 1,
|
||||
mode: "on",
|
||||
previousSlide: 0,
|
||||
slides: [stableDeliveryUrl],
|
||||
});
|
||||
const expectedUpdate = {
|
||||
event: "update",
|
||||
payload: {
|
||||
currentSlide: 1,
|
||||
mode: "on",
|
||||
previousSlide: 0,
|
||||
slides: [stableDeliveryUrl],
|
||||
},
|
||||
sequence: 2,
|
||||
};
|
||||
expect(await first.connection.readEvent()).toEqual(expectedUpdate);
|
||||
expect(await second.connection.readEvent()).toEqual(expectedUpdate);
|
||||
|
||||
await first.connection.close();
|
||||
await waitForTransportCondition(
|
||||
() => live.activeSubscriptions() === 1,
|
||||
"Realtime integration subscriber cleanup timed out.",
|
||||
);
|
||||
control.publisher?.publish("state", {
|
||||
currentSlide: 2,
|
||||
mode: "off",
|
||||
previousSlide: 1,
|
||||
slides: [stableDeliveryUrl],
|
||||
});
|
||||
expect(await second.connection.readEvent()).toMatchObject({ event: "update", sequence: 3 });
|
||||
|
||||
const reconnected = await connect(live.origin, "ekklesia-session=allowed");
|
||||
connections.push(reconnected.connection);
|
||||
expect(await reconnected.connection.readEvent()).toEqual({
|
||||
event: "snapshot",
|
||||
payload: {
|
||||
currentSlide: 2,
|
||||
mode: "off",
|
||||
previousSlide: 1,
|
||||
slides: [stableDeliveryUrl],
|
||||
},
|
||||
sequence: 3,
|
||||
});
|
||||
expect(live.activeSubscriptions()).toBe(2);
|
||||
|
||||
const secondClosed = second.connection.expectClosed();
|
||||
const reconnectedClosed = reconnected.connection.expectClosed();
|
||||
runtime.realtime.closeSubscriptions();
|
||||
await Promise.all([secondClosed, reconnectedClosed]);
|
||||
await waitForTransportCondition(
|
||||
() => live.activeSubscriptions() === 0,
|
||||
"Realtime integration shutdown cleanup timed out.",
|
||||
);
|
||||
// Stream shutdown happens before ordinary HTTP drain in production. A publisher must remain
|
||||
// usable until that drain completes so an already-running command can publish its result.
|
||||
expect(() => control.publisher?.publish("state", null)).not.toThrow();
|
||||
|
||||
const afterShutdown = await requestJson(
|
||||
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||
{ headers: { cookie: "ekklesia-session=allowed" } },
|
||||
);
|
||||
expect(afterShutdown.response.status).toBe(503);
|
||||
expect(afterShutdown.body).toEqual({ code: "REALTIME_UNAVAILABLE" });
|
||||
await runtime.stop();
|
||||
expect(() => control.publisher?.publish("state", null)).toThrowError(
|
||||
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
|
||||
);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
} finally {
|
||||
try {
|
||||
runtime?.realtime.closeSubscriptions();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
const connectionCleanup = await Promise.allSettled(
|
||||
connections.map((connection) => connection.close()),
|
||||
);
|
||||
for (const result of connectionCleanup) {
|
||||
if (result.status === "rejected") {
|
||||
failures.push(result.reason);
|
||||
}
|
||||
}
|
||||
if (server) {
|
||||
try {
|
||||
await closeServer(server);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (runtime) {
|
||||
try {
|
||||
await runtime.stop();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
delete (globalThis as unknown as Record<PropertyKey, unknown>)[fixture.symbol];
|
||||
try {
|
||||
await rm(fixture.directory, { force: true, recursive: true });
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw failures[0];
|
||||
}
|
||||
expect(control.events).toEqual(["register", "start", "stop"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
ModuleRealtimeError,
|
||||
moduleIdSchema,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { createPermissionRegistry, type PermissionRegistry } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { type ModuleRealtimeRegistry, StagedModuleRealtimeRegistry } from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MODULE_REALTIME_KEEPALIVE_MS,
|
||||
MODULE_REALTIME_MAX_CONNECTION_AGE_MS,
|
||||
ModuleRealtimeConfigurationError,
|
||||
moduleRealtimeBaseUrl,
|
||||
mountModuleRealtimeRoutes,
|
||||
} from "../src/module-realtime-routes.js";
|
||||
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const permission = parsePermissionId("announcements.slideshow.view");
|
||||
const userId = parseEkklesiaUserId("88888888-8888-4888-8888-888888888888");
|
||||
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||
|
||||
function permissionRegistry(): PermissionRegistry {
|
||||
return createPermissionRegistry([
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: permission,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function realtimeFixture(initialPayload: unknown = { mode: "on" }): {
|
||||
readonly publisher: ReturnType<StagedModuleRealtimeRegistry["publisherFor"]>;
|
||||
readonly realtime: ModuleRealtimeRegistry;
|
||||
} {
|
||||
const staged = new StagedModuleRealtimeRegistry();
|
||||
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||
registration.realtime.channel({ channel: "state", permission });
|
||||
registration.commit();
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
publisher.publish("state", initialPayload as never);
|
||||
return { publisher, realtime: staged.publish() };
|
||||
}
|
||||
|
||||
function appFor(
|
||||
realtime: ModuleRealtimeRegistry,
|
||||
options: {
|
||||
readonly allowed?: boolean;
|
||||
readonly keepaliveMs?: number;
|
||||
readonly maxConnectionAgeMs?: number;
|
||||
readonly signedIn?: boolean;
|
||||
} = {},
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
options.signedIn === false ? ANONYMOUS_PRINCIPAL : createAuthenticatedPrincipal(userId),
|
||||
),
|
||||
};
|
||||
mountModuleRealtimeRoutes(app, {
|
||||
evaluator: {
|
||||
authorize: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: options.signedIn !== false && options.allowed !== false }),
|
||||
},
|
||||
...(options.keepaliveMs === undefined ? {} : { keepaliveMs: options.keepaliveMs }),
|
||||
...(options.maxConnectionAgeMs === undefined
|
||||
? {}
|
||||
: { maxConnectionAgeMs: options.maxConnectionAgeMs }),
|
||||
realtime,
|
||||
registry: permissionRegistry(),
|
||||
runtime,
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Realtime test read timed out.")), 1_000);
|
||||
}),
|
||||
]);
|
||||
return result.done ? "" : new TextDecoder().decode(result.value);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
describe("module realtime HTTP transport", () => {
|
||||
it("builds one server-owned root and rejects an unenforceable channel snapshot", () => {
|
||||
expect(moduleRealtimeBaseUrl("announcements")).toBe("/api/v1/module-realtime/announcements");
|
||||
expect(MODULE_REALTIME_KEEPALIVE_MS).toBe(15_000);
|
||||
expect(MODULE_REALTIME_MAX_CONNECTION_AGE_MS).toBe(300_000);
|
||||
|
||||
const { realtime } = realtimeFixture();
|
||||
expect(() =>
|
||||
mountModuleRealtimeRoutes(new Hono(), {
|
||||
evaluator: { authorize: vi.fn() },
|
||||
realtime,
|
||||
registry: createPermissionRegistry([]),
|
||||
runtime: { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn() },
|
||||
}),
|
||||
).toThrow(ModuleRealtimeConfigurationError);
|
||||
});
|
||||
|
||||
it("denies anonymous and unauthorized callers before allocating a subscription", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||
const observed: ModuleRealtimeRegistry = {
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe,
|
||||
};
|
||||
|
||||
const anonymous = await appFor(observed, { signedIn: false }).request(endpoint);
|
||||
expect(anonymous.status).toBe(403);
|
||||
expect(await anonymous.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
|
||||
const unauthorized = await appFor(observed, { allowed: false }).request(endpoint);
|
||||
expect(unauthorized.status).toBe(403);
|
||||
expect(await unauthorized.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses Hono's implicit HEAD handling without allocating a stream subscription", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||
const observed: ModuleRealtimeRegistry = {
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe,
|
||||
};
|
||||
|
||||
const response = await appFor(observed).request(endpoint, { method: "HEAD" });
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens an authorized snapshot-first stream with proxy-safe headers and unchanged URLs", async () => {
|
||||
const deliveryUrl =
|
||||
"https://objects.example.test/slides/welcome.png?X-Amz-Signature=byte-identical";
|
||||
const { realtime } = realtimeFixture({
|
||||
currentSlide: 0,
|
||||
mode: "on",
|
||||
previousSlide: -1,
|
||||
slides: [deliveryUrl],
|
||||
});
|
||||
const response = await appFor(realtime).request(endpoint);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("x-accel-buffering")).toBe("no");
|
||||
expect(response.headers.get("vary")).toBe("Origin, Cookie");
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
expect(reader).toBeDefined();
|
||||
const snapshot = await readChunk(reader as ReadableStreamDefaultReader<Uint8Array>);
|
||||
expect(snapshot).toContain("event: snapshot\n");
|
||||
expect(snapshot).toContain('"sequence":1');
|
||||
expect(snapshot).toContain(deliveryUrl);
|
||||
expect(snapshot).not.toContain("\nid:");
|
||||
await reader?.cancel();
|
||||
});
|
||||
|
||||
it("streams ordered updates, emits transport-only keepalives, and closes on shutdown", async () => {
|
||||
const { publisher, realtime } = realtimeFixture({ currentSlide: 0 });
|
||||
const response = await appFor(realtime, { keepaliveMs: 5 }).request(endpoint);
|
||||
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||
publisher.publish("state", { currentSlide: 1 });
|
||||
const update = await readChunk(reader);
|
||||
expect(update).toContain("event: update");
|
||||
expect(update).toContain('"sequence":2');
|
||||
expect(update).toContain('"currentSlide":1');
|
||||
expect(await readChunk(reader)).toBe(": keepalive\n\n");
|
||||
|
||||
const closed = reader.read();
|
||||
realtime.closeSubscriptions();
|
||||
await expect(closed).resolves.toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
it("closes a stream at the host-owned maximum age so reconnect reauthorizes", async () => {
|
||||
const { realtime } = realtimeFixture({ currentSlide: 0 });
|
||||
let activeSubscriptions = 0;
|
||||
const observed: ModuleRealtimeRegistry = {
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe: (owner, channel) => {
|
||||
const subscription = realtime.subscribe(owner, channel);
|
||||
activeSubscriptions += 1;
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
close: () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
activeSubscriptions -= 1;
|
||||
}
|
||||
subscription.close();
|
||||
},
|
||||
read: () => subscription.read(),
|
||||
snapshot: subscription.snapshot,
|
||||
});
|
||||
},
|
||||
};
|
||||
const response = await appFor(observed, {
|
||||
keepaliveMs: 1_000,
|
||||
maxConnectionAgeMs: 25,
|
||||
}).request(endpoint);
|
||||
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||
expect(activeSubscriptions).toBe(1);
|
||||
expect(await readChunk(reader)).toBe("");
|
||||
expect(activeSubscriptions).toBe(0);
|
||||
const replacement = realtime.subscribe(moduleId, "state");
|
||||
replacement.close();
|
||||
});
|
||||
|
||||
it("does not expose unknown module/channel combinations", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const app = appFor(realtime);
|
||||
expect((await app.request("/api/v1/module-realtime/calendar/state")).status).toBe(404);
|
||||
expect((await app.request("/api/v1/module-realtime/announcements/other")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("maps subscriber-capacity and unexpected allocation failures to fixed non-secret responses", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const observed = (failure: unknown): ModuleRealtimeRegistry => ({
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe: () => {
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
|
||||
const atCapacity = await appFor(
|
||||
observed(new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT")),
|
||||
).request(endpoint);
|
||||
expect(atCapacity.status).toBe(503);
|
||||
expect(await atCapacity.json()).toEqual({ code: "REALTIME_SUBSCRIBER_LIMIT" });
|
||||
|
||||
const detail = "secret-token-and-machine-path";
|
||||
const unavailable = await appFor(observed(new Error(detail))).request(endpoint);
|
||||
expect(unavailable.status).toBe(503);
|
||||
const body = await unavailable.text();
|
||||
expect(body).toBe('{"code":"REALTIME_UNAVAILABLE"}');
|
||||
expect(body).not.toContain(detail);
|
||||
});
|
||||
});
|
||||
@@ -217,6 +217,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
const app = createApiApp({
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -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<Installation> {
|
||||
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",
|
||||
]);
|
||||
|
||||
@@ -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<Installation
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store },
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
@@ -708,6 +710,7 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
moduleId: "announcements",
|
||||
permissions: { has: () => false },
|
||||
principal: { kind: "anonymous" as const },
|
||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import {
|
||||
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
|
||||
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
||||
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
|
||||
type ModuleId,
|
||||
type ModuleRealtimeChannel,
|
||||
type ModuleRealtimeChannelDefinitionInput,
|
||||
type ModuleRealtimeChannelRegistrar,
|
||||
ModuleRealtimeError,
|
||||
type ModuleRealtimePublisher,
|
||||
moduleIdSchema,
|
||||
moduleRealtimeChannelSchema,
|
||||
type PermissionId,
|
||||
safeParseModuleRealtimeChannelDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
export type ModuleRealtimeRegistrationIssueCode =
|
||||
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
|
||||
| "MODULE_REALTIME_CHANNEL_INVALID"
|
||||
| "MODULE_REALTIME_PERMISSION_INVALID"
|
||||
| "MODULE_REALTIME_PERMISSION_UNDECLARED"
|
||||
| "MODULE_REALTIME_CHANNEL_DUPLICATE"
|
||||
| "MODULE_REALTIME_CHANNEL_LIMIT";
|
||||
|
||||
/** An internal fixed-code signal. The runtime converts it to its ordinary structured issue shape. */
|
||||
export class ModuleRealtimeRegistrationError extends Error {
|
||||
readonly code: ModuleRealtimeRegistrationIssueCode;
|
||||
|
||||
constructor(code: ModuleRealtimeRegistrationIssueCode) {
|
||||
super("Module realtime channel registration failed.");
|
||||
this.name = "ModuleRealtimeRegistrationError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegisteredModuleRealtimeChannel {
|
||||
readonly moduleId: ModuleId;
|
||||
readonly channel: ModuleRealtimeChannel;
|
||||
readonly permission: PermissionId;
|
||||
}
|
||||
|
||||
/** A host-facing opaque publication. `serializedPayload` is the exact JSON snapshot serialized once
|
||||
* at publish time; hosts forward it without inspecting, mutating, or re-signing values inside it. */
|
||||
export interface ModuleRealtimePublication {
|
||||
readonly sequence: number;
|
||||
readonly serializedPayload: string;
|
||||
}
|
||||
|
||||
/** `snapshot` is captured atomically with installing this subscription. `read` then yields ordered
|
||||
* later publications. A slow reader retains only the newest pending publication. */
|
||||
export interface ModuleRealtimeSubscription {
|
||||
readonly snapshot: ModuleRealtimePublication;
|
||||
read(): Promise<ModuleRealtimePublication | undefined>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface ModuleRealtimeRegistry {
|
||||
readonly channels: readonly RegisteredModuleRealtimeChannel[];
|
||||
/** Refuses new subscriptions and closes existing streams while publishers remain usable for
|
||||
* ordinary HTTP requests already draining. Publisher lifetime remains Core runtime-owned. */
|
||||
closeSubscriptions(): void;
|
||||
publisherFor(moduleId: ModuleId): ModuleRealtimePublisher;
|
||||
subscribe(moduleId: ModuleId, channel: string): ModuleRealtimeSubscription;
|
||||
}
|
||||
|
||||
const emptyRealtimePublisher: ModuleRealtimePublisher = Object.freeze({
|
||||
publish: (): never => {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
},
|
||||
});
|
||||
|
||||
/** Explicit no-channel host composition for tests and hosts that start no modules. */
|
||||
export const EMPTY_MODULE_REALTIME_REGISTRY: ModuleRealtimeRegistry = Object.freeze({
|
||||
channels: Object.freeze([]),
|
||||
closeSubscriptions: (): void => undefined,
|
||||
publisherFor: (): ModuleRealtimePublisher => emptyRealtimePublisher,
|
||||
subscribe: (): never => {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
},
|
||||
});
|
||||
|
||||
interface ChannelState {
|
||||
readonly definition: RegisteredModuleRealtimeChannel;
|
||||
readonly subscriptions: Set<BoundedModuleRealtimeSubscription>;
|
||||
publication?: ModuleRealtimePublication;
|
||||
}
|
||||
|
||||
interface RealtimeRegistrationStage {
|
||||
readonly realtime: ModuleRealtimeChannelRegistrar;
|
||||
commit(): void;
|
||||
}
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
type JsonWriteResult = "invalid" | "success" | "too-large";
|
||||
|
||||
/** Accumulates at most the public payload ceiling. Each encoded fragment is itself bounded, so an
|
||||
* oversized value cannot create an oversized temporary serialization before being refused. */
|
||||
class BoundedJsonWriter {
|
||||
readonly #chunks: string[] = [];
|
||||
#bytes = 0;
|
||||
|
||||
get remainingBytes(): number {
|
||||
return MODULE_REALTIME_MAX_PAYLOAD_BYTES - this.#bytes;
|
||||
}
|
||||
|
||||
append(fragment: string): boolean {
|
||||
const bytes = textEncoder.encode(fragment).byteLength;
|
||||
if (bytes > this.remainingBytes) {
|
||||
return false;
|
||||
}
|
||||
this.#chunks.push(fragment);
|
||||
this.#bytes += bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
finish(): string {
|
||||
return this.#chunks.join("");
|
||||
}
|
||||
}
|
||||
|
||||
function appendRawString(
|
||||
writer: BoundedJsonWriter,
|
||||
value: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): boolean {
|
||||
let offset = start;
|
||||
while (offset < end) {
|
||||
let chunkEnd = Math.min(offset + 1_024, end);
|
||||
const finalCodeUnit = value.charCodeAt(chunkEnd - 1);
|
||||
if (
|
||||
chunkEnd < end &&
|
||||
finalCodeUnit >= 0xd800 &&
|
||||
finalCodeUnit <= 0xdbff &&
|
||||
value.charCodeAt(chunkEnd) >= 0xdc00 &&
|
||||
value.charCodeAt(chunkEnd) <= 0xdfff
|
||||
) {
|
||||
chunkEnd -= 1;
|
||||
}
|
||||
if (!writer.append(value.slice(offset, chunkEnd))) {
|
||||
return false;
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapedCodeUnit(codeUnit: number): string | undefined {
|
||||
switch (codeUnit) {
|
||||
case 0x08:
|
||||
return "\\b";
|
||||
case 0x09:
|
||||
return "\\t";
|
||||
case 0x0a:
|
||||
return "\\n";
|
||||
case 0x0c:
|
||||
return "\\f";
|
||||
case 0x0d:
|
||||
return "\\r";
|
||||
case 0x22:
|
||||
return '\\"';
|
||||
case 0x5c:
|
||||
return "\\\\";
|
||||
default:
|
||||
return codeUnit <= 0x1f ? `\\u${codeUnit.toString(16).padStart(4, "0")}` : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the same JSON string representation as `JSON.stringify`, including escaping lone UTF-16
|
||||
* surrogates, but stops the moment the output budget cannot be met. */
|
||||
function writeJsonString(writer: BoundedJsonWriter, value: string): JsonWriteResult {
|
||||
// Every UTF-16 code unit contributes at least one UTF-8 byte to JSON. This rejects an enormous
|
||||
// string without first copying or stringifying it.
|
||||
if (value.length + 2 > writer.remainingBytes) {
|
||||
return "too-large";
|
||||
}
|
||||
if (!writer.append('"')) {
|
||||
return "too-large";
|
||||
}
|
||||
let rawStart = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
let escaped = escapedCodeUnit(codeUnit);
|
||||
if (escaped === undefined && codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const following = value.charCodeAt(index + 1);
|
||||
if (following >= 0xdc00 && following <= 0xdfff) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
escaped = `\\u${codeUnit.toString(16)}`;
|
||||
} else if (escaped === undefined && codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
escaped = `\\u${codeUnit.toString(16)}`;
|
||||
}
|
||||
if (escaped === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!appendRawString(writer, value, rawStart, index) || !writer.append(escaped)) {
|
||||
return "too-large";
|
||||
}
|
||||
rawStart = index + 1;
|
||||
}
|
||||
if (!appendRawString(writer, value, rawStart, value.length) || !writer.append('"')) {
|
||||
return "too-large";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
function writeJsonValue(
|
||||
writer: BoundedJsonWriter,
|
||||
value: unknown,
|
||||
ancestors: Set<object>,
|
||||
depth: number,
|
||||
): JsonWriteResult {
|
||||
if (value === null) {
|
||||
return writer.append("null") ? "success" : "too-large";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return writeJsonString(writer, value);
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return writer.append(value ? "true" : "false") ? "success" : "too-large";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "invalid";
|
||||
}
|
||||
// A finite JavaScript number's JSON spelling is tiny and cannot be an unbounded allocation.
|
||||
const serializedNumber = JSON.stringify(value);
|
||||
return writer.append(serializedNumber) ? "success" : "too-large";
|
||||
}
|
||||
if (typeof value !== "object" || depth > 64 || ancestors.has(value)) {
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
try {
|
||||
// Never execute user-defined serialization hooks. A non-enumerable `toJSON` still changes
|
||||
// `JSON.stringify` output, so silently ignoring it would make validation and wire semantics
|
||||
// disagree. Reject the hook itself without reading or invoking its value.
|
||||
if (Object.hasOwn(value, "toJSON")) {
|
||||
return "invalid";
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getPrototypeOf(value) !== Array.prototype) {
|
||||
return "invalid";
|
||||
}
|
||||
// Brackets, one single-byte value per element, and separators are the smallest possible
|
||||
// representation. If even that cannot fit, do not enumerate a potentially enormous array.
|
||||
const minimumBytes = value.length === 0 ? 2 : value.length * 2 + 1;
|
||||
if (minimumBytes > writer.remainingBytes) {
|
||||
return "too-large";
|
||||
}
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue;
|
||||
}
|
||||
if (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length) {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
if (!writer.append("[")) {
|
||||
return "too-large";
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
return "invalid";
|
||||
}
|
||||
if (index > 0 && !writer.append(",")) {
|
||||
return "too-large";
|
||||
}
|
||||
const result = writeJsonValue(writer, descriptor.value, ancestors, depth + 1);
|
||||
if (result !== "success") {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return writer.append("]") ? "success" : "too-large";
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
return "invalid";
|
||||
}
|
||||
if (!writer.append("{")) {
|
||||
return "too-large";
|
||||
}
|
||||
let entryCount = 0;
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue;
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
return "invalid";
|
||||
}
|
||||
if (entryCount > 0 && !writer.append(",")) {
|
||||
return "too-large";
|
||||
}
|
||||
const keyResult = writeJsonString(writer, key);
|
||||
if (keyResult !== "success") {
|
||||
return keyResult;
|
||||
}
|
||||
if (!writer.append(":")) {
|
||||
return "too-large";
|
||||
}
|
||||
const valueResult = writeJsonValue(writer, descriptor.value, ancestors, depth + 1);
|
||||
if (valueResult !== "success") {
|
||||
return valueResult;
|
||||
}
|
||||
entryCount += 1;
|
||||
}
|
||||
return writer.append("}") ? "success" : "too-large";
|
||||
} catch {
|
||||
return "invalid";
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
export type ModuleRealtimePayloadSerializationResult =
|
||||
| { readonly success: true; readonly serializedPayload: string }
|
||||
| { readonly success: false; readonly code: "REALTIME_PAYLOAD_INVALID" }
|
||||
| { readonly success: false; readonly code: "REALTIME_PAYLOAD_TOO_LARGE" };
|
||||
|
||||
/** Serializes once and reports only a fixed code. It never returns or embeds rejected input. */
|
||||
export function serializeModuleRealtimePayload(
|
||||
payload: unknown,
|
||||
): ModuleRealtimePayloadSerializationResult {
|
||||
const writer = new BoundedJsonWriter();
|
||||
const result = writeJsonValue(writer, payload, new Set<object>(), 0);
|
||||
if (result === "invalid") {
|
||||
return { success: false, code: "REALTIME_PAYLOAD_INVALID" };
|
||||
}
|
||||
if (result === "too-large") {
|
||||
return { success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" };
|
||||
}
|
||||
return { success: true, serializedPayload: writer.finish() };
|
||||
}
|
||||
|
||||
class BoundedModuleRealtimeSubscription implements ModuleRealtimeSubscription {
|
||||
readonly snapshot: ModuleRealtimePublication;
|
||||
#closed = false;
|
||||
#pending: ModuleRealtimePublication | undefined;
|
||||
#reader: ((publication: ModuleRealtimePublication | undefined) => void) | undefined;
|
||||
readonly #onClose: () => void;
|
||||
|
||||
constructor(snapshot: ModuleRealtimePublication, onClose: () => void) {
|
||||
this.snapshot = snapshot;
|
||||
this.#onClose = onClose;
|
||||
}
|
||||
|
||||
push(publication: ModuleRealtimePublication): void {
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
const reader = this.#reader;
|
||||
if (reader) {
|
||||
this.#reader = undefined;
|
||||
reader(publication);
|
||||
return;
|
||||
}
|
||||
// One slot, overwritten by the newest publication. A publisher never waits for a browser.
|
||||
this.#pending = publication;
|
||||
}
|
||||
|
||||
read(): Promise<ModuleRealtimePublication | undefined> {
|
||||
if (this.#closed) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
if (this.#pending) {
|
||||
const publication = this.#pending;
|
||||
this.#pending = undefined;
|
||||
return Promise.resolve(publication);
|
||||
}
|
||||
if (this.#reader) {
|
||||
return Promise.reject(new ModuleRealtimeError("REALTIME_UNAVAILABLE"));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.#reader = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
this.#closed = true;
|
||||
this.#pending = undefined;
|
||||
const reader = this.#reader;
|
||||
this.#reader = undefined;
|
||||
this.#onClose();
|
||||
reader?.(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function stateKey(moduleId: string, channel: string): string {
|
||||
return `${moduleId}\0${channel}`;
|
||||
}
|
||||
|
||||
/** Registration is staged per module, while state and subscriptions stay private and bounded. */
|
||||
export class StagedModuleRealtimeRegistry {
|
||||
readonly #channels: RegisteredModuleRealtimeChannel[] = [];
|
||||
readonly #publishers = new Map<ModuleId, ModuleRealtimePublisher>();
|
||||
readonly #states = new Map<string, ChannelState>();
|
||||
readonly #activeModules = new Set<ModuleId>();
|
||||
#acceptingSubscriptions = true;
|
||||
|
||||
createRegistrationStage(
|
||||
moduleId: ModuleId,
|
||||
ownedPermissionIds: ReadonlySet<PermissionId>,
|
||||
): RealtimeRegistrationStage {
|
||||
const local: RegisteredModuleRealtimeChannel[] = [];
|
||||
const localNames = new Set<ModuleRealtimeChannel>();
|
||||
const realtime: ModuleRealtimeChannelRegistrar = Object.freeze({
|
||||
channel: (definition: ModuleRealtimeChannelDefinitionInput): void => {
|
||||
const parsed = safeParseModuleRealtimeChannelDefinition(definition);
|
||||
if (!parsed.success) {
|
||||
throw new ModuleRealtimeRegistrationError(parsed.code);
|
||||
}
|
||||
if (!ownedPermissionIds.has(parsed.data.permission)) {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_PERMISSION_UNDECLARED");
|
||||
}
|
||||
if (localNames.has(parsed.data.channel)) {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_DUPLICATE");
|
||||
}
|
||||
if (local.length >= MODULE_REALTIME_MAX_CHANNELS_PER_MODULE) {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_LIMIT");
|
||||
}
|
||||
localNames.add(parsed.data.channel);
|
||||
local.push(
|
||||
Object.freeze({
|
||||
channel: parsed.data.channel,
|
||||
moduleId,
|
||||
permission: parsed.data.permission,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
realtime,
|
||||
commit: (): void => {
|
||||
for (const definition of local) {
|
||||
this.#channels.push(definition);
|
||||
this.#states.set(stateKey(moduleId, definition.channel), {
|
||||
definition,
|
||||
subscriptions: new Set(),
|
||||
});
|
||||
}
|
||||
this.#activeModules.add(moduleId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
publisherFor(moduleId: ModuleId): ModuleRealtimePublisher {
|
||||
let publisher = this.#publishers.get(moduleId);
|
||||
if (!publisher) {
|
||||
publisher = Object.freeze({
|
||||
publish: (channel: string, payload: unknown): void => {
|
||||
this.#publish(moduleId, channel, payload);
|
||||
},
|
||||
});
|
||||
this.#publishers.set(moduleId, publisher);
|
||||
}
|
||||
return publisher;
|
||||
}
|
||||
|
||||
missingSnapshots(moduleId: ModuleId): readonly ModuleRealtimeChannel[] {
|
||||
return Object.freeze(
|
||||
this.#channels
|
||||
.filter((definition) => definition.moduleId === moduleId)
|
||||
.filter(
|
||||
(definition) => !this.#states.get(stateKey(moduleId, definition.channel))?.publication,
|
||||
)
|
||||
.map((definition) => definition.channel),
|
||||
);
|
||||
}
|
||||
|
||||
closeSubscriptions(): void {
|
||||
this.#acceptingSubscriptions = false;
|
||||
for (const state of this.#states.values()) {
|
||||
for (const subscription of [...state.subscriptions]) {
|
||||
subscription.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes one module's captured publisher inert immediately before that module's stop hook. Other
|
||||
* dependency modules remain live until reverse-order shutdown reaches their own hook. */
|
||||
deactivateModule(moduleId: ModuleId): void {
|
||||
this.#activeModules.delete(moduleId);
|
||||
for (const state of this.#states.values()) {
|
||||
if (state.definition.moduleId !== moduleId) {
|
||||
continue;
|
||||
}
|
||||
for (const subscription of [...state.subscriptions]) {
|
||||
subscription.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deactivateAll(): void {
|
||||
this.closeSubscriptions();
|
||||
this.#activeModules.clear();
|
||||
}
|
||||
|
||||
publish(): ModuleRealtimeRegistry {
|
||||
const channels = Object.freeze([...this.#channels]);
|
||||
return Object.freeze({
|
||||
channels,
|
||||
closeSubscriptions: (): void => this.closeSubscriptions(),
|
||||
publisherFor: (moduleId: ModuleId): ModuleRealtimePublisher => this.publisherFor(moduleId),
|
||||
subscribe: (moduleId: ModuleId, channel: string): ModuleRealtimeSubscription =>
|
||||
this.subscribe(moduleId, channel),
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(moduleId: ModuleId, channel: string): ModuleRealtimeSubscription {
|
||||
if (!this.#acceptingSubscriptions) {
|
||||
throw new ModuleRealtimeError("REALTIME_UNAVAILABLE");
|
||||
}
|
||||
if (
|
||||
!moduleIdSchema.safeParse(moduleId).success ||
|
||||
!moduleRealtimeChannelSchema.safeParse(channel).success
|
||||
) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_INVALID");
|
||||
}
|
||||
const state = this.#states.get(stateKey(moduleId, channel));
|
||||
if (!state) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
}
|
||||
if (!state.publication) {
|
||||
throw new ModuleRealtimeError("REALTIME_SNAPSHOT_UNAVAILABLE");
|
||||
}
|
||||
if (state.subscriptions.size >= MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL) {
|
||||
throw new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT");
|
||||
}
|
||||
const subscription = new BoundedModuleRealtimeSubscription(state.publication, () => {
|
||||
state.subscriptions.delete(subscription);
|
||||
});
|
||||
// JavaScript runs this capture and insertion without interleaving. A later publish therefore
|
||||
// either becomes `snapshot` or is observed by `read`, never lost between those two operations.
|
||||
state.subscriptions.add(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
#publish(moduleId: ModuleId, channel: string, payload: unknown): void {
|
||||
if (!this.#activeModules.has(moduleId)) {
|
||||
throw new ModuleRealtimeError("REALTIME_PUBLISHER_STOPPED");
|
||||
}
|
||||
const parsedChannel = moduleRealtimeChannelSchema.safeParse(channel);
|
||||
if (!parsedChannel.success) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_INVALID");
|
||||
}
|
||||
const state = this.#states.get(stateKey(moduleId, parsedChannel.data));
|
||||
if (!state) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
}
|
||||
const serialized = serializeModuleRealtimePayload(payload);
|
||||
if (!serialized.success) {
|
||||
throw new ModuleRealtimeError(serialized.code);
|
||||
}
|
||||
const previousSequence = state.publication?.sequence ?? 0;
|
||||
if (previousSequence >= Number.MAX_SAFE_INTEGER) {
|
||||
throw new ModuleRealtimeError("REALTIME_UNAVAILABLE");
|
||||
}
|
||||
const publication = Object.freeze({
|
||||
sequence: previousSequence + 1,
|
||||
serializedPayload: serialized.serializedPayload,
|
||||
});
|
||||
state.publication = publication;
|
||||
for (const subscription of state.subscriptions) {
|
||||
subscription.push(publication);
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
-6
@@ -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<void>;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<void> {
|
||||
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<readonly ModuleRuntimeIssue[]> {
|
||||
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<void> {
|
||||
// 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
|
||||
: 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<string, ModuleConfiguration>();
|
||||
const storages = timeouts.storages ?? new Map<string, ModuleStorage>();
|
||||
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<void> => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ModuleDatabase,
|
||||
ModuleRealtimePublisher,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const globalSymbols: symbol[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
const globals = globalThis as unknown as Record<PropertyKey, unknown>;
|
||||
for (const symbol of globalSymbols.splice(0)) {
|
||||
delete globals[symbol];
|
||||
}
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
function fakeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
function manifest(
|
||||
id: string,
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
permissions,
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
};
|
||||
}
|
||||
|
||||
async function createRuntimeFixture(
|
||||
id: string,
|
||||
serverSource: string,
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Promise<string> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-realtime-runtime-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
name: `@test/module-${id}`,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify(manifest(id, permissions)),
|
||||
),
|
||||
writeFile(path.join(moduleDirectory, "dist/server/index.js"), serverSource),
|
||||
]);
|
||||
return modulesDirectory;
|
||||
}
|
||||
|
||||
function runtimeOptions(modulesDirectory: string) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
services: fakeServices(),
|
||||
};
|
||||
}
|
||||
|
||||
function recorder(): {
|
||||
readonly expression: string;
|
||||
readonly value: { events: string[]; publishers: ModuleRealtimePublisher[] };
|
||||
} {
|
||||
const symbol = Symbol.for(`ekklesia.realtime.${Date.now()}.${Math.random()}`);
|
||||
const value = { events: [] as string[], publishers: [] as ModuleRealtimePublisher[] };
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = value;
|
||||
globalSymbols.push(symbol);
|
||||
return { expression: `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`, value };
|
||||
}
|
||||
|
||||
describe("module runtime realtime lifecycle", () => {
|
||||
it("stages declarations, requires startup state, delivers live state, and closes before stop", async () => {
|
||||
const recorded = recorder();
|
||||
const objectUrl = "https://delivery.invalid/slides/welcome.png?signature=unchanged";
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"announcements",
|
||||
`
|
||||
export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
${recorded.expression}.events.push("register");
|
||||
},
|
||||
start({ realtime }) {
|
||||
${recorded.expression}.publishers.push(realtime);
|
||||
realtime.publish("state", {
|
||||
currentSlide: 0,
|
||||
previousSlide: -1,
|
||||
slides: [${JSON.stringify(objectUrl)}],
|
||||
slideSeconds: 7,
|
||||
mode: "on"
|
||||
});
|
||||
${recorded.expression}.events.push("start");
|
||||
},
|
||||
stop({ realtime }) {
|
||||
try { realtime.publish("state", { mode: "off" }); }
|
||||
catch (error) { ${recorded.expression}.events.push("stop:" + error.code); }
|
||||
}
|
||||
};
|
||||
`,
|
||||
[
|
||||
{
|
||||
id: "announcements.slideshow.view",
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
expect(recorded.value.events).toEqual(["register", "start"]);
|
||||
expect(runtime.realtime.channels).toEqual([
|
||||
{
|
||||
moduleId: "announcements",
|
||||
channel: "state",
|
||||
permission: "announcements.slideshow.view",
|
||||
},
|
||||
]);
|
||||
const subscription = runtime.realtime.subscribe("announcements", "state");
|
||||
expect(subscription.snapshot.sequence).toBe(1);
|
||||
expect(subscription.snapshot.serializedPayload).toContain(objectUrl);
|
||||
|
||||
const next = subscription.read();
|
||||
recorded.value.publishers[0]?.publish("state", {
|
||||
currentSlide: 1,
|
||||
previousSlide: 0,
|
||||
slides: [objectUrl],
|
||||
slideSeconds: 7,
|
||||
mode: "on",
|
||||
});
|
||||
await expect(next).resolves.toMatchObject({ sequence: 2 });
|
||||
const pendingClose = subscription.read();
|
||||
await runtime.stop();
|
||||
await expect(pendingClose).resolves.toBeUndefined();
|
||||
expect(recorded.value.events).toEqual(["register", "start", "stop:REALTIME_PUBLISHER_STOPPED"]);
|
||||
expect(() => recorded.value.publishers[0]?.publish("state", null)).toThrowError(
|
||||
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails atomically and rolls back when a declared channel has no initial snapshot", async () => {
|
||||
const recorded = recorder();
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"announcements",
|
||||
`
|
||||
export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
},
|
||||
start() { ${recorded.expression}.events.push("start"); },
|
||||
stop() { ${recorded.expression}.events.push("rollback"); }
|
||||
};
|
||||
`,
|
||||
[
|
||||
{
|
||||
id: "announcements.slideshow.view",
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await expect(startModuleRuntime(runtimeOptions(modulesDirectory))).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_REALTIME_SNAPSHOT_MISSING" })],
|
||||
});
|
||||
expect(recorded.value.events).toEqual(["start", "rollback"]);
|
||||
});
|
||||
|
||||
it("reports invalid owned-permission registration with a deterministic sanitized issue", async () => {
|
||||
const secret = "/machine/private/permission-token";
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"announcements",
|
||||
`
|
||||
export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.not-owned" });
|
||||
}
|
||||
};
|
||||
// ${secret}
|
||||
`,
|
||||
);
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(ModuleRuntimeError);
|
||||
expect(failure).toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_REALTIME_PERMISSION_UNDECLARED" })],
|
||||
});
|
||||
expect(JSON.stringify(failure)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("starts and stops modules that declare no realtime channels unchanged", async () => {
|
||||
const recorded = recorder();
|
||||
const modulesDirectory = await createRuntimeFixture(
|
||||
"calendar",
|
||||
`
|
||||
export default {
|
||||
id: "calendar",
|
||||
version: "0.1.0",
|
||||
start() { ${recorded.expression}.events.push("start"); },
|
||||
stop() { ${recorded.expression}.events.push("stop"); }
|
||||
};
|
||||
`,
|
||||
);
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
expect(runtime.realtime.channels).toEqual([]);
|
||||
await runtime.stop();
|
||||
expect(recorded.value.events).toEqual(["start", "stop"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
|
||||
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
||||
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
|
||||
ModuleRealtimeError,
|
||||
moduleIdSchema,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ModuleRealtimeRegistrationError,
|
||||
StagedModuleRealtimeRegistry,
|
||||
serializeModuleRealtimePayload,
|
||||
} from "../src/modules/realtime.js";
|
||||
|
||||
const permission = parsePermissionId("announcements.slideshow.view");
|
||||
|
||||
function registeredRegistry(
|
||||
modules: Readonly<Record<string, readonly string[]>>,
|
||||
): StagedModuleRealtimeRegistry {
|
||||
const registry = new StagedModuleRealtimeRegistry();
|
||||
for (const [rawModuleId, channels] of Object.entries(modules)) {
|
||||
const moduleId = moduleIdSchema.parse(rawModuleId);
|
||||
const stage = registry.createRegistrationStage(moduleId, new Set([permission]));
|
||||
for (const channel of channels) {
|
||||
stage.realtime.channel({ channel, permission });
|
||||
}
|
||||
stage.commit();
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
function expectRealtimeCode(callback: () => unknown, code: string): void {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleRealtimeError);
|
||||
expect(error).toMatchObject({ code });
|
||||
return;
|
||||
}
|
||||
throw new Error(`Expected realtime error ${code}.`);
|
||||
}
|
||||
|
||||
describe("module realtime registry", () => {
|
||||
it("publishes immutable module-scoped channel inventory", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"], calendar: ["schedule"] });
|
||||
const registry = staged.publish();
|
||||
|
||||
expect(registry.channels).toEqual([
|
||||
{ moduleId: "announcements", channel: "state", permission },
|
||||
{ moduleId: "calendar", channel: "schedule", permission },
|
||||
]);
|
||||
expect(Object.isFrozen(registry.channels)).toBe(true);
|
||||
expect(Object.isFrozen(registry.channels[0])).toBe(true);
|
||||
|
||||
staged.publisherFor(moduleIdSchema.parse("announcements")).publish("state", { mode: "on" });
|
||||
staged.publisherFor(moduleIdSchema.parse("calendar")).publish("schedule", { mode: "off" });
|
||||
expect(
|
||||
JSON.parse(
|
||||
registry.subscribe(moduleIdSchema.parse("announcements"), "state").snapshot
|
||||
.serializedPayload,
|
||||
),
|
||||
).toEqual({ mode: "on" });
|
||||
expect(
|
||||
JSON.parse(
|
||||
registry.subscribe(moduleIdSchema.parse("calendar"), "schedule").snapshot.serializedPayload,
|
||||
),
|
||||
).toEqual({ mode: "off" });
|
||||
expectRealtimeCode(
|
||||
() =>
|
||||
staged
|
||||
.publisherFor(moduleIdSchema.parse("announcements"))
|
||||
.publish("schedule", { mode: "off" }),
|
||||
"REALTIME_CHANNEL_UNDECLARED",
|
||||
);
|
||||
expectRealtimeCode(
|
||||
() => staged.publisherFor(moduleIdSchema.parse("calendar")).publish("state", { mode: "on" }),
|
||||
"REALTIME_CHANNEL_UNDECLARED",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures an immediate snapshot, preserves ordered live updates, and reconnects at latest", async () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
const registry = staged.publish();
|
||||
publisher.publish("state", { currentSlide: 0 });
|
||||
|
||||
const first = registry.subscribe(moduleId, "state");
|
||||
expect(first.snapshot).toEqual({ sequence: 1, serializedPayload: '{"currentSlide":0}' });
|
||||
|
||||
const secondPublication = first.read();
|
||||
publisher.publish("state", { currentSlide: 1 });
|
||||
await expect(secondPublication).resolves.toEqual({
|
||||
sequence: 2,
|
||||
serializedPayload: '{"currentSlide":1}',
|
||||
});
|
||||
const thirdPublication = first.read();
|
||||
publisher.publish("state", { currentSlide: 2 });
|
||||
await expect(thirdPublication).resolves.toEqual({
|
||||
sequence: 3,
|
||||
serializedPayload: '{"currentSlide":2}',
|
||||
});
|
||||
|
||||
const reconnected = registry.subscribe(moduleId, "state");
|
||||
expect(reconnected.snapshot).toEqual({
|
||||
sequence: 3,
|
||||
serializedPayload: '{"currentSlide":2}',
|
||||
});
|
||||
first.close();
|
||||
reconnected.close();
|
||||
});
|
||||
|
||||
it("coalesces a slow subscriber to its newest single pending update", async () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
publisher.publish("state", { value: 0 });
|
||||
const subscription = staged.publish().subscribe(moduleId, "state");
|
||||
|
||||
for (let value = 1; value <= 1_000; value += 1) {
|
||||
publisher.publish("state", { value });
|
||||
}
|
||||
await expect(subscription.read()).resolves.toEqual({
|
||||
sequence: 1_001,
|
||||
serializedPayload: '{"value":1000}',
|
||||
});
|
||||
subscription.close();
|
||||
});
|
||||
|
||||
it("bounds subscribers and releases every slot on close", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
staged.publisherFor(moduleId).publish("state", null);
|
||||
const registry = staged.publish();
|
||||
const subscriptions = Array.from({ length: MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL }, () =>
|
||||
registry.subscribe(moduleId, "state"),
|
||||
);
|
||||
expectRealtimeCode(() => registry.subscribe(moduleId, "state"), "REALTIME_SUBSCRIBER_LIMIT");
|
||||
subscriptions[0]?.close();
|
||||
expect(() => registry.subscribe(moduleId, "state")).not.toThrow();
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes pending readers and permanently deactivates publishers during shutdown", async () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
publisher.publish("state", { mode: "on" });
|
||||
const registry = staged.publish();
|
||||
const subscription = registry.subscribe(moduleId, "state");
|
||||
const pending = subscription.read();
|
||||
|
||||
staged.deactivateAll();
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expectRealtimeCode(
|
||||
() => publisher.publish("state", { mode: "off" }),
|
||||
"REALTIME_PUBLISHER_STOPPED",
|
||||
);
|
||||
expectRealtimeCode(() => registry.subscribe(moduleId, "state"), "REALTIME_UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("can close transport subscriptions while an in-flight HTTP publisher finishes", async () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
publisher.publish("state", { mode: "on" });
|
||||
const registry = staged.publish();
|
||||
const pending = registry.subscribe(moduleId, "state").read();
|
||||
|
||||
registry.closeSubscriptions();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(() => publisher.publish("state", { mode: "off" })).not.toThrow();
|
||||
expectRealtimeCode(() => registry.subscribe(moduleId, "state"), "REALTIME_UNAVAILABLE");
|
||||
|
||||
staged.deactivateAll();
|
||||
expectRealtimeCode(
|
||||
() => publisher.publish("state", { mode: "on" }),
|
||||
"REALTIME_PUBLISHER_STOPPED",
|
||||
);
|
||||
});
|
||||
|
||||
it("deactivates only the module whose reverse-order stop is beginning", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"], calendar: ["schedule"] });
|
||||
const announcementsId = moduleIdSchema.parse("announcements");
|
||||
const calendarId = moduleIdSchema.parse("calendar");
|
||||
const announcements = staged.publisherFor(announcementsId);
|
||||
const calendar = staged.publisherFor(calendarId);
|
||||
announcements.publish("state", { mode: "on" });
|
||||
calendar.publish("schedule", { date: "2026-08-23" });
|
||||
|
||||
staged.deactivateModule(announcementsId);
|
||||
|
||||
expectRealtimeCode(
|
||||
() => announcements.publish("state", { mode: "off" }),
|
||||
"REALTIME_PUBLISHER_STOPPED",
|
||||
);
|
||||
expect(() => calendar.publish("schedule", { date: "2026-08-24" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("serializes opaque state once so later source mutation cannot change it", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const slides = ["https://delivery.invalid/object?signature=stable"];
|
||||
staged.publisherFor(moduleId).publish("state", { slides });
|
||||
slides[0] = "https://delivery.invalid/object?signature=changed";
|
||||
|
||||
const snapshot = staged.publish().subscribe(moduleId, "state").snapshot;
|
||||
expect(snapshot.serializedPayload).toContain("signature=stable");
|
||||
expect(snapshot.serializedPayload).not.toContain("signature=changed");
|
||||
});
|
||||
|
||||
it("accepts a named module state interface while runtime validation remains authoritative", () => {
|
||||
interface SlideshowState {
|
||||
readonly currentSlide: number;
|
||||
readonly mode: "on" | "off";
|
||||
readonly slides: readonly string[];
|
||||
}
|
||||
const state: SlideshowState = {
|
||||
currentSlide: 4,
|
||||
mode: "on",
|
||||
slides: ["https://delivery.invalid/welcome.png"],
|
||||
};
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
|
||||
staged.publisherFor(moduleId).publish("state", state);
|
||||
|
||||
expect(
|
||||
JSON.parse(staged.publish().subscribe(moduleId, "state").snapshot.serializedPayload),
|
||||
).toEqual(state);
|
||||
});
|
||||
|
||||
it("rejects non-JSON values and payloads above the UTF-8 limit with fixed codes", () => {
|
||||
const staged = registeredRegistry({ announcements: ["state"] });
|
||||
const publisher = staged.publisherFor(moduleIdSchema.parse("announcements"));
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
const sparse = Array.from({ length: 2 }) as unknown[];
|
||||
sparse[0] = "present";
|
||||
const toJson = vi.fn(() => ({ mode: "rewritten" }));
|
||||
const hooked = { mode: "original" };
|
||||
Object.defineProperty(hooked, "toJSON", { value: toJson });
|
||||
|
||||
for (const payload of [
|
||||
undefined,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
1n,
|
||||
new Date(),
|
||||
cyclic,
|
||||
sparse,
|
||||
hooked,
|
||||
]) {
|
||||
expectRealtimeCode(() => publisher.publish("state", payload), "REALTIME_PAYLOAD_INVALID");
|
||||
}
|
||||
expect(toJson).not.toHaveBeenCalled();
|
||||
expectRealtimeCode(
|
||||
() => publisher.publish("state", "x".repeat(MODULE_REALTIME_MAX_PAYLOAD_BYTES)),
|
||||
"REALTIME_PAYLOAD_TOO_LARGE",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces the serialized UTF-8 boundary including JSON escaping", () => {
|
||||
const exactlyAtLimit = serializeModuleRealtimePayload(
|
||||
"x".repeat(MODULE_REALTIME_MAX_PAYLOAD_BYTES - 2),
|
||||
);
|
||||
expect(exactlyAtLimit).toMatchObject({ success: true });
|
||||
if (exactlyAtLimit.success) {
|
||||
expect(new TextEncoder().encode(exactlyAtLimit.serializedPayload)).toHaveLength(
|
||||
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
serializeModuleRealtimePayload("x".repeat(MODULE_REALTIME_MAX_PAYLOAD_BYTES - 1)),
|
||||
).toEqual({ success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" });
|
||||
expect(serializeModuleRealtimePayload("\0".repeat(11_000))).toEqual({
|
||||
success: false,
|
||||
code: "REALTIME_PAYLOAD_TOO_LARGE",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects enormous strings and containers before attempting an unbounded stringify", () => {
|
||||
const hugeString = "x".repeat(1_000_000);
|
||||
const hugeArray = new Array<unknown>(1_000_000).fill(0);
|
||||
const stringify = vi.spyOn(JSON, "stringify");
|
||||
const stringResult = serializeModuleRealtimePayload(hugeString);
|
||||
const arrayResult = serializeModuleRealtimePayload(hugeArray);
|
||||
const calls = [...stringify.mock.calls];
|
||||
stringify.mockRestore();
|
||||
|
||||
expect(stringResult).toEqual({ success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" });
|
||||
expect(arrayResult).toEqual({ success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches canonical JSON escaping without converting invalid values", () => {
|
||||
const payload = {
|
||||
controls: '\b\t\n\f\r\0"\\',
|
||||
loneSurrogate: "\ud800",
|
||||
unicode: "Welcome 👋 — καλημέρα",
|
||||
};
|
||||
expect(serializeModuleRealtimePayload(payload)).toEqual({
|
||||
success: true,
|
||||
serializedPayload: JSON.stringify(payload),
|
||||
});
|
||||
expect(serializeModuleRealtimePayload({ invalid: undefined })).toEqual({
|
||||
success: false,
|
||||
code: "REALTIME_PAYLOAD_INVALID",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates duplicate, undeclared-permission, and finite channel registration", () => {
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const duplicateRegistry = new StagedModuleRealtimeRegistry();
|
||||
const duplicate = duplicateRegistry.createRegistrationStage(moduleId, new Set([permission]));
|
||||
duplicate.realtime.channel({ channel: "state", permission });
|
||||
expect(() => duplicate.realtime.channel({ channel: "state", permission })).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_REALTIME_CHANNEL_DUPLICATE" }),
|
||||
);
|
||||
|
||||
const foreign = new StagedModuleRealtimeRegistry().createRegistrationStage(moduleId, new Set());
|
||||
expect(() => foreign.realtime.channel({ channel: "state", permission })).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_REALTIME_PERMISSION_UNDECLARED" }),
|
||||
);
|
||||
|
||||
const limited = new StagedModuleRealtimeRegistry().createRegistrationStage(
|
||||
moduleId,
|
||||
new Set([permission]),
|
||||
);
|
||||
for (let index = 0; index < MODULE_REALTIME_MAX_CHANNELS_PER_MODULE; index += 1) {
|
||||
limited.realtime.channel({ channel: `channel-${index}`, permission });
|
||||
}
|
||||
expect(() => limited.realtime.channel({ channel: "one-too-many", permission })).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_REALTIME_CHANNEL_LIMIT" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps modules without realtime declarations fully compatible", () => {
|
||||
const staged = registeredRegistry({ calendar: [] });
|
||||
const moduleId = moduleIdSchema.parse("calendar");
|
||||
expect(staged.publish().channels).toEqual([]);
|
||||
expectRealtimeCode(
|
||||
() => staged.publisherFor(moduleId).publish("state", null),
|
||||
"REALTIME_CHANNEL_UNDECLARED",
|
||||
);
|
||||
expect(staged.missingSnapshots(moduleId)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports payload validation without reflecting contents", () => {
|
||||
const secret = "/machine/private/secret?token=do-not-leak";
|
||||
const result = serializeModuleRealtimePayload({ secret, invalid: undefined });
|
||||
expect(result).toEqual({ success: false, code: "REALTIME_PAYLOAD_INVALID" });
|
||||
expect(JSON.stringify(result)).not.toContain(secret);
|
||||
expect(() => {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_INVALID");
|
||||
}).toThrow("Module realtime channel registration failed.");
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,12 @@ administrator owns rather than something a module declares. A display field adde
|
||||
become module-owned navigation before that design exists, so the temporary selector renders module
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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/<id>`, 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.
|
||||
|
||||
+97
-22
@@ -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/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
|
||||
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's API lives, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
|
||||
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
|
||||
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's HTTP and realtime roots live, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
|
||||
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
||||
@@ -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/<module-id>`, 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/<module-id>`, 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/<module-id>/<channel>
|
||||
```
|
||||
|
||||
The path is host-private. Module UI receives the module's server-produced realtime base in its mount
|
||||
descriptor and calls `context.realtime.subscribe("state", handlers)`; it never builds this URL,
|
||||
chooses a module id, supplies a permission, or sees an EventSource. Browser commands remain ordinary
|
||||
module HTTP requests, retaining their body, origin, authorization, and response semantics.
|
||||
|
||||
The response is a Server-Sent Events stream. It begins with exactly one authoritative `snapshot`
|
||||
event and then carries `update` events:
|
||||
|
||||
```text
|
||||
event: snapshot
|
||||
data: {"sequence":1,"payload":{"mode":"on","currentSlide":0}}
|
||||
|
||||
event: update
|
||||
data: {"sequence":2,"payload":{"mode":"on","currentSlide":1}}
|
||||
```
|
||||
|
||||
Core atomically captures the latest state while installing the subscriber, so there is no separate
|
||||
GET-then-subscribe gap. Sequence is monotonically increasing per channel for this process only; there
|
||||
is no `id` field, `Last-Event-ID` replay, durable history, or exactly-once promise. A reconnect is a
|
||||
new subscription and begins with the latest snapshot. Slow clients retain only the newest pending
|
||||
update, so sequence gaps are expected and intermediate states may be coalesced.
|
||||
|
||||
- **Authentication and authorization.** The SSE GET uses the existing session cookie and the same
|
||||
principal resolver, permission registry, and evaluator as module HTTP. The channel's declaration
|
||||
fixes the permission before any request exists. Permission evaluation completes before a Core
|
||||
subscriber is allocated. There is no query-string token or second realtime auth system.
|
||||
- **Reauthorization.** A stream has a host-owned maximum age of five minutes. The host closes it at
|
||||
that boundary; native EventSource reconnects and the ordinary cookie and permission checks run
|
||||
again. Grant revocation, grant expiry, and session changes therefore do not leave an accepted
|
||||
stream authorized forever.
|
||||
- **Bounds.** Core accepts at most 16 channels per module, 100 subscribers per channel, and 64 KiB
|
||||
of serialized JSON per publication. Publishing never awaits a browser. One latest snapshot and
|
||||
one pending update per subscriber are the only state retained.
|
||||
- **Transport behavior.** Responses use `text/event-stream; charset=utf-8`, `Cache-Control:
|
||||
no-store`, `Vary: Origin, Cookie`, `X-Accel-Buffering: no`, and a 15-second comment keepalive.
|
||||
Keepalives consume no module sequence and never reach module UI. A reverse proxy must forward
|
||||
streaming bodies incrementally and permit that idle interval. No proxy product is part of the
|
||||
module contract.
|
||||
- **Methods and errors.** Hono normally lets `HEAD` reuse GET handlers; this route explicitly returns
|
||||
`405` before allocating a stream because discarding an infinite body could leak a subscriber.
|
||||
Subscriber exhaustion is the fixed `503 {"code":"REALTIME_SUBSCRIBER_LIMIT"}`; other stopped or
|
||||
unavailable runtime states are `503 {"code":"REALTIME_UNAVAILABLE"}`. Raw errors, paths, URLs,
|
||||
payloads, permissions, secrets, and stacks are never reflected.
|
||||
- **Lifecycle.** Disabled and blocked modules have no route because their entrypoint never runs and
|
||||
their channel is never mounted. Shutdown first refuses and closes realtime subscriptions so they
|
||||
cannot hold HTTP drain open. Ordinary in-flight module HTTP handlers may finish and publish during
|
||||
that drain. Core then stops modules in reverse dependency order, deactivating each module's scoped
|
||||
publisher immediately before its own stop hook; a captured publisher fails predictably afterward.
|
||||
|
||||
Realtime carries opaque module state. If a module includes a direct object-delivery URL, the exact
|
||||
serialized string passes through unchanged: Core neither re-signs it per subscriber nor extends its
|
||||
expiry. Image, video, and audio bytes remain object-storage traffic, never realtime payloads. See
|
||||
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md).
|
||||
|
||||
## Dashboard navigation
|
||||
|
||||
```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/<id>/<version>` or `/api/v1/modules/<id>`, 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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<SlideshowState>("state", {
|
||||
onSnapshot(state, sequence) {
|
||||
replaceSlideshow(state, sequence);
|
||||
},
|
||||
onUpdate(state, sequence) {
|
||||
replaceSlideshow(state, sequence);
|
||||
},
|
||||
onStatus(status) {
|
||||
showConnectionStatus(status);
|
||||
},
|
||||
onError(error) {
|
||||
showSafeRealtimeError(error.code);
|
||||
},
|
||||
});
|
||||
|
||||
return () => subscription.close();
|
||||
```
|
||||
|
||||
The public UI vocabulary contains no SSE, WebSocket, Socket.IO, URL, cookie, token, origin, or retry
|
||||
setting. Ekklesia currently implements it with credentialed Server-Sent Events and native browser
|
||||
reconnect; a standalone host can implement the same interface differently. The host-generated
|
||||
endpoint is always scoped to this module, and the server performs the channel's normal permission
|
||||
check before subscribing. Ekklesia closes each stream after five minutes so reconnect rechecks the
|
||||
session and permission. Client permission hints remain presentation-only.
|
||||
|
||||
On shutdown, Core refuses new subscribers and closes existing subscriptions before HTTP drain, while
|
||||
letting requests already in flight finish publishing. Reverse-order stop then disables each module's
|
||||
publisher immediately before that module's own stop hook. Call `close()` during UI unmount as well;
|
||||
it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are
|
||||
forwarded unchanged.
|
||||
Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Public
|
||||
display/device identity, durable replay, multi-node fanout, client-to-server commands, and
|
||||
display/kiosk hosting remain future work. See
|
||||
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md).
|
||||
|
||||
## Drizzle schema definition
|
||||
|
||||
A module with tables keeps the generator-facing declarations in `src/schema/tables.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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { z } from "zod";
|
||||
import { type PermissionId, permissionIdSchema } from "./identifiers.js";
|
||||
|
||||
/** A module-local channel name. Core supplies the owning module namespace; modules cannot publish
|
||||
* into another module's channel. The deliberately flat grammar has no hierarchy or wildcards. */
|
||||
export const MODULE_REALTIME_CHANNEL_MAX_LENGTH = 48;
|
||||
export const moduleRealtimeChannelSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MODULE_REALTIME_CHANNEL_MAX_LENGTH)
|
||||
.regex(
|
||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u,
|
||||
"Must be a lowercase kebab-case module realtime channel.",
|
||||
)
|
||||
.brand<"ModuleRealtimeChannel">();
|
||||
|
||||
export type ModuleRealtimeChannel = z.infer<typeof moduleRealtimeChannelSchema>;
|
||||
|
||||
export function parseModuleRealtimeChannel(input: unknown): ModuleRealtimeChannel {
|
||||
return moduleRealtimeChannelSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParseModuleRealtimeChannel(
|
||||
input: unknown,
|
||||
): z.ZodSafeParseResult<ModuleRealtimeChannel> {
|
||||
return moduleRealtimeChannelSchema.safeParse(input);
|
||||
}
|
||||
|
||||
/** Registration and fan-out ceilings are platform policy, identical for every module. */
|
||||
export const MODULE_REALTIME_MAX_CHANNELS_PER_MODULE = 16;
|
||||
export const MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL = 100;
|
||||
export const MODULE_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024;
|
||||
|
||||
export type ModuleRealtimeJsonPrimitive = boolean | number | string | null;
|
||||
export type ModuleRealtimeJsonValue =
|
||||
| ModuleRealtimeJsonPrimitive
|
||||
| readonly ModuleRealtimeJsonValue[]
|
||||
| { readonly [key: string]: ModuleRealtimeJsonValue };
|
||||
|
||||
export interface ModuleRealtimeChannelDefinitionInput {
|
||||
readonly channel: string;
|
||||
readonly permission: string;
|
||||
}
|
||||
|
||||
export interface ModuleRealtimeChannelDefinition {
|
||||
readonly channel: ModuleRealtimeChannel;
|
||||
readonly permission: PermissionId;
|
||||
}
|
||||
|
||||
export interface ModuleRealtimeChannelRegistrar {
|
||||
channel(definition: ModuleRealtimeChannelDefinitionInput): void;
|
||||
}
|
||||
|
||||
/** The runtime publisher is already scoped to its module. No module id, origin, transport, token,
|
||||
* room, or topic can be supplied by module code. */
|
||||
export interface ModuleRealtimePublisher {
|
||||
/** `unknown` is deliberate: an ordinary named interface is valid input without having to add a
|
||||
* meaningless string index signature. TypeScript cannot prove recursive JSON compatibility for
|
||||
* every structural interface, so the host validates the exact runtime value authoritatively. */
|
||||
publish(channel: string, payload: unknown): void;
|
||||
}
|
||||
|
||||
export type ModuleRealtimeChannelDefinitionIssueCode =
|
||||
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
|
||||
| "MODULE_REALTIME_CHANNEL_INVALID"
|
||||
| "MODULE_REALTIME_PERMISSION_INVALID";
|
||||
|
||||
export type ModuleRealtimeChannelDefinitionParseResult =
|
||||
| { readonly success: true; readonly data: ModuleRealtimeChannelDefinition }
|
||||
| { readonly success: false; readonly code: ModuleRealtimeChannelDefinitionIssueCode };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Validates only the definition's transport-neutral shape. Core separately proves that the
|
||||
* permission belongs to the registering module. Rejected input is never reflected in the result. */
|
||||
export function safeParseModuleRealtimeChannelDefinition(
|
||||
value: unknown,
|
||||
): ModuleRealtimeChannelDefinitionParseResult {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" };
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 2 || !keys.includes("channel") || !keys.includes("permission")) {
|
||||
return { success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" };
|
||||
}
|
||||
const channel = moduleRealtimeChannelSchema.safeParse(value.channel);
|
||||
if (!channel.success) {
|
||||
return { success: false, code: "MODULE_REALTIME_CHANNEL_INVALID" };
|
||||
}
|
||||
const permission = permissionIdSchema.safeParse(value.permission);
|
||||
if (!permission.success) {
|
||||
return { success: false, code: "MODULE_REALTIME_PERMISSION_INVALID" };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: Object.freeze({ channel: channel.data, permission: permission.data }),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseModuleRealtimeChannelDefinition(
|
||||
value: unknown,
|
||||
): ModuleRealtimeChannelDefinition {
|
||||
const result = safeParseModuleRealtimeChannelDefinition(value);
|
||||
if (!result.success) {
|
||||
throw new TypeError("Invalid Ekklesia module realtime channel definition.");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const MODULE_REALTIME_ERROR_CODES = [
|
||||
"REALTIME_CHANNEL_INVALID",
|
||||
"REALTIME_CHANNEL_UNDECLARED",
|
||||
"REALTIME_PAYLOAD_INVALID",
|
||||
"REALTIME_PAYLOAD_TOO_LARGE",
|
||||
"REALTIME_PUBLISHER_STOPPED",
|
||||
"REALTIME_SNAPSHOT_UNAVAILABLE",
|
||||
"REALTIME_SUBSCRIBER_LIMIT",
|
||||
"REALTIME_UNAVAILABLE",
|
||||
] as const;
|
||||
|
||||
export type ModuleRealtimeErrorCode = (typeof MODULE_REALTIME_ERROR_CODES)[number];
|
||||
|
||||
const realtimeErrorMessages: Readonly<Record<ModuleRealtimeErrorCode, string>> = Object.freeze({
|
||||
REALTIME_CHANNEL_INVALID: "The realtime channel is invalid.",
|
||||
REALTIME_CHANNEL_UNDECLARED: "This module did not declare the realtime channel.",
|
||||
REALTIME_PAYLOAD_INVALID: "The realtime payload is not JSON-compatible.",
|
||||
REALTIME_PAYLOAD_TOO_LARGE: "The realtime payload exceeds the platform limit.",
|
||||
REALTIME_PUBLISHER_STOPPED: "The realtime publisher is no longer running.",
|
||||
REALTIME_SNAPSHOT_UNAVAILABLE: "The realtime channel has no current snapshot.",
|
||||
REALTIME_SUBSCRIBER_LIMIT: "The realtime channel subscriber limit has been reached.",
|
||||
REALTIME_UNAVAILABLE: "Realtime delivery is not available.",
|
||||
});
|
||||
|
||||
/** A fixed-code, fixed-message failure with no cause, payload, URL, path, or transport detail. */
|
||||
export class ModuleRealtimeError extends Error {
|
||||
readonly code: ModuleRealtimeErrorCode;
|
||||
|
||||
constructor(code: ModuleRealtimeErrorCode) {
|
||||
super(realtimeErrorMessages[code]);
|
||||
this.name = "ModuleRealtimeError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
@@ -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> = Value | PromiseLike<Value>;
|
||||
@@ -29,6 +30,9 @@ export interface ModuleRegistrationContext {
|
||||
* be declared while Core is still building the immutable startup snapshot, never added or removed
|
||||
* 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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODULE_REALTIME_CHANNEL_MAX_LENGTH,
|
||||
MODULE_REALTIME_ERROR_CODES,
|
||||
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
|
||||
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
||||
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
|
||||
ModuleRealtimeError,
|
||||
type ModuleRealtimePublisher,
|
||||
parseModuleRealtimeChannel,
|
||||
parseModuleRealtimeChannelDefinition,
|
||||
safeParseModuleRealtimeChannel,
|
||||
safeParseModuleRealtimeChannelDefinition,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("module realtime contracts", () => {
|
||||
it("accepts an ordinary named state interface without an index signature or cast", () => {
|
||||
interface SlideshowState {
|
||||
readonly currentSlide: number;
|
||||
readonly mode: "on" | "off";
|
||||
readonly slides: readonly string[];
|
||||
}
|
||||
const state: SlideshowState = { currentSlide: 1, mode: "on", slides: ["/welcome.png"] };
|
||||
const received: unknown[] = [];
|
||||
const publisher: ModuleRealtimePublisher = {
|
||||
publish: (_channel, payload) => received.push(payload),
|
||||
};
|
||||
|
||||
publisher.publish("state", state);
|
||||
expect(received).toEqual([state]);
|
||||
});
|
||||
|
||||
it("accepts flat local kebab-case channels and rejects hierarchy and wildcards", () => {
|
||||
expect(parseModuleRealtimeChannel("state")).toBe("state");
|
||||
expect(parseModuleRealtimeChannel("slide-state")).toBe("slide-state");
|
||||
for (const invalid of ["", "State", "slide_state", "slide.state", "slide/state", "*", "a.*"]) {
|
||||
expect(safeParseModuleRealtimeChannel(invalid).success).toBe(false);
|
||||
}
|
||||
expect(
|
||||
safeParseModuleRealtimeChannel("a".repeat(MODULE_REALTIME_CHANNEL_MAX_LENGTH)).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
safeParseModuleRealtimeChannel("a".repeat(MODULE_REALTIME_CHANNEL_MAX_LENGTH + 1)).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("parses only the exact channel and permission definition shape", () => {
|
||||
expect(
|
||||
parseModuleRealtimeChannelDefinition({
|
||||
channel: "state",
|
||||
permission: "announcements.slideshow.view",
|
||||
}),
|
||||
).toEqual({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
expect(
|
||||
safeParseModuleRealtimeChannelDefinition({
|
||||
channel: "state",
|
||||
permission: "announcements.slideshow.view",
|
||||
topic: "legacy",
|
||||
}),
|
||||
).toEqual({ success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" });
|
||||
expect(safeParseModuleRealtimeChannelDefinition({ channel: "state", permission: "*" })).toEqual(
|
||||
{ success: false, code: "MODULE_REALTIME_PERMISSION_INVALID" },
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes finite platform limits as explicit contract constants", () => {
|
||||
expect(MODULE_REALTIME_MAX_CHANNELS_PER_MODULE).toBe(16);
|
||||
expect(MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL).toBe(100);
|
||||
expect(MODULE_REALTIME_MAX_PAYLOAD_BYTES).toBe(65_536);
|
||||
});
|
||||
|
||||
it("uses a closed error-code set with fixed sanitized messages and stacks", () => {
|
||||
expect(MODULE_REALTIME_ERROR_CODES).toContain("REALTIME_PAYLOAD_INVALID");
|
||||
const error = new ModuleRealtimeError("REALTIME_PAYLOAD_INVALID");
|
||||
expect(error).toMatchObject({
|
||||
code: "REALTIME_PAYLOAD_INVALID",
|
||||
message: "The realtime payload is not JSON-compatible.",
|
||||
name: "ModuleRealtimeError",
|
||||
});
|
||||
expect(error.stack).toBe("ModuleRealtimeError: The realtime payload is not JSON-compatible.");
|
||||
expect(Object.isFrozen(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ describe("defineModuleRuntime", () => {
|
||||
config: EMPTY_MODULE_CONFIGURATION,
|
||||
http,
|
||||
moduleId: "example",
|
||||
realtime: { channel: () => undefined },
|
||||
};
|
||||
await runtime.register?.(context);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ function hostContext(requested: string[], moduleId = "announcements"): ModuleUiH
|
||||
},
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => true },
|
||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./define-module-ui.js";
|
||||
export * from "./host-context.js";
|
||||
export * from "./module-ui.js";
|
||||
export * from "./realtime.js";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* A JSON value carried from a module's server runtime to its browser UI.
|
||||
*
|
||||
* Realtime state is deliberately data, not transport vocabulary. A module does not open a socket,
|
||||
* choose an origin, attach credentials, or know how its host keeps a subscription alive. The host
|
||||
* owns all of that and gives the module only typed snapshots and updates.
|
||||
*/
|
||||
export type ModuleUiRealtimeJsonValue =
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| null
|
||||
| readonly ModuleUiRealtimeJsonValue[]
|
||||
| { readonly [key: string]: ModuleUiRealtimeJsonValue };
|
||||
|
||||
/** Connection state useful for rendering a small, honest status indicator. */
|
||||
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
|
||||
|
||||
/** Stable, browser-safe failures. A host never exposes a caught platform or network error. */
|
||||
export type ModuleUiRealtimeErrorCode = "REALTIME_CONNECTION_FAILED" | "REALTIME_MESSAGE_INVALID";
|
||||
|
||||
export interface ModuleUiRealtimeError {
|
||||
readonly code: ModuleUiRealtimeErrorCode;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Callbacks for one module-local channel. Snapshot and update are distinct on purpose: a UI can
|
||||
* replace all derived state when it (re)connects, then apply later authoritative states normally. */
|
||||
export interface ModuleUiRealtimeHandlers<State = ModuleUiRealtimeJsonValue> {
|
||||
readonly onSnapshot?: (state: State, sequence: number) => void;
|
||||
readonly onUpdate?: (state: State, sequence: number) => void;
|
||||
readonly onStatus?: (status: ModuleUiRealtimeStatus) => void;
|
||||
readonly onError?: (error: ModuleUiRealtimeError) => void;
|
||||
}
|
||||
|
||||
/** One live subscription. Closing is synchronous and idempotent. */
|
||||
export interface ModuleUiRealtimeSubscription {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A module UI's access to its own runtime-published state.
|
||||
*
|
||||
* `channel` is local to the module and uses lowercase kebab-case. The host supplies the module id,
|
||||
* endpoint, origin, credentials, reconnection policy, and concrete transport. Modules can neither
|
||||
* subscribe to another module nor supply a bearer token through this API.
|
||||
*
|
||||
* State must be JSON-compatible. The generic is intentionally unconstrained so a normal named
|
||||
* interface can describe a payload without needing an index signature; the runtime wire boundary
|
||||
* still validates that every delivered payload is JSON.
|
||||
*/
|
||||
export interface ModuleUiRealtimeClient {
|
||||
subscribe<State = ModuleUiRealtimeJsonValue>(
|
||||
channel: string,
|
||||
handlers: ModuleUiRealtimeHandlers<State>,
|
||||
): ModuleUiRealtimeSubscription;
|
||||
}
|
||||
@@ -17,7 +17,16 @@ export default defineModuleUi({
|
||||
container.textContent = describePrincipal(context.principal);
|
||||
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 = "";
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Record<HostProtocolErrorCode, string>> = 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,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export {
|
||||
type DashboardNavigationGroup,
|
||||
type EkklesiaHostConfig,
|
||||
type HostBootstrap,
|
||||
type HostEventSourceFactory,
|
||||
type HostEventSourceOptions,
|
||||
type HostModuleUiDescriptor,
|
||||
HostProtocolError,
|
||||
type HostProtocolErrorCode,
|
||||
|
||||
@@ -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<Record<ModuleUiRealtimeErrorCode, ModuleUiRealtimeError>> =
|
||||
Object.freeze({
|
||||
REALTIME_CONNECTION_FAILED: Object.freeze({
|
||||
code: "REALTIME_CONNECTION_FAILED",
|
||||
message: "The live connection could not be maintained.",
|
||||
}),
|
||||
REALTIME_MESSAGE_INVALID: Object.freeze({
|
||||
code: "REALTIME_MESSAGE_INVALID",
|
||||
message: "The host received an invalid live update.",
|
||||
}),
|
||||
});
|
||||
|
||||
interface RealtimeEnvelope {
|
||||
readonly payload: unknown;
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Strictly parses the host transport's private envelope. The module sees neither this envelope nor
|
||||
* the event implementation; it receives only `payload` and `sequence`. */
|
||||
function parseRealtimeEnvelope(data: unknown): RealtimeEnvelope | undefined {
|
||||
if (typeof data !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
if (
|
||||
keys.length !== 2 ||
|
||||
keys[0] !== "payload" ||
|
||||
keys[1] !== "sequence" ||
|
||||
!Number.isSafeInteger(value.sequence) ||
|
||||
(value.sequence as number) <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { payload: value.payload, sequence: value.sequence as number };
|
||||
}
|
||||
|
||||
function realtimeChannelUrl(
|
||||
config: EkklesiaHostConfig,
|
||||
descriptor: HostModuleUiDescriptor,
|
||||
channel: string,
|
||||
): string {
|
||||
const root = new URL(resolveApiUrl(config, descriptor.realtimeBaseUrl));
|
||||
const rootPath = root.pathname.replace(/\/+$/u, "");
|
||||
return new URL(`${rootPath}/${channel}`, root).href;
|
||||
}
|
||||
|
||||
function createModuleRealtimeClient(
|
||||
config: EkklesiaHostConfig,
|
||||
descriptor: HostModuleUiDescriptor,
|
||||
): ModuleUiRealtimeClient {
|
||||
return Object.freeze({
|
||||
subscribe<State>(channel: string, handlers: ModuleUiRealtimeHandlers<State>) {
|
||||
if (channel.length > REALTIME_CHANNEL_MAX_LENGTH || !REALTIME_CHANNEL_PATTERN.test(channel)) {
|
||||
throw new HostProtocolError("MODULE_REALTIME_CHANNEL_INVALID");
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
let lastStatus: ModuleUiRealtimeStatus | undefined;
|
||||
let awaitingSnapshot = true;
|
||||
let lastSequence = 0;
|
||||
|
||||
const reportStatus = (status: ModuleUiRealtimeStatus): void => {
|
||||
if (lastStatus !== status) {
|
||||
lastStatus = status;
|
||||
handlers.onStatus?.(status);
|
||||
}
|
||||
};
|
||||
const reportError = (code: ModuleUiRealtimeErrorCode): void => {
|
||||
handlers.onError?.(realtimeErrors[code]);
|
||||
};
|
||||
|
||||
reportStatus("connecting");
|
||||
|
||||
const factory =
|
||||
config.eventSourceFactory ??
|
||||
((url: string, options: { readonly withCredentials: true }) =>
|
||||
new EventSource(url, options));
|
||||
let source: EventSource;
|
||||
try {
|
||||
source = factory(realtimeChannelUrl(config, descriptor, channel), {
|
||||
withCredentials: true,
|
||||
});
|
||||
} catch {
|
||||
reportError("REALTIME_CONNECTION_FAILED");
|
||||
closed = true;
|
||||
reportStatus("closed");
|
||||
return Object.freeze({ close: (): void => undefined });
|
||||
}
|
||||
|
||||
function onOpen(): void {
|
||||
if (!closed) {
|
||||
awaitingSnapshot = true;
|
||||
reportStatus("connected");
|
||||
}
|
||||
}
|
||||
function onConnectionError(): void {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
reportError("REALTIME_CONNECTION_FAILED");
|
||||
if (source.readyState === source.CLOSED) {
|
||||
terminate();
|
||||
return;
|
||||
}
|
||||
// EventSource itself retries established transport disconnects. The host reports that native
|
||||
// transition and waits for the next `open`; it never creates a competing retry loop.
|
||||
awaitingSnapshot = true;
|
||||
reportStatus("reconnecting");
|
||||
}
|
||||
function onDelivery(kind: "snapshot" | "update", event: Event): void {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
const envelope = parseRealtimeEnvelope((event as MessageEvent<unknown>).data);
|
||||
const invalidOrder =
|
||||
envelope !== undefined &&
|
||||
(kind === "snapshot"
|
||||
? !awaitingSnapshot
|
||||
: awaitingSnapshot || envelope.sequence <= lastSequence);
|
||||
if (envelope === undefined || invalidOrder) {
|
||||
reportError("REALTIME_MESSAGE_INVALID");
|
||||
return;
|
||||
}
|
||||
lastSequence = envelope.sequence;
|
||||
awaitingSnapshot = false;
|
||||
if (kind === "snapshot") {
|
||||
handlers.onSnapshot?.(envelope.payload as State, envelope.sequence);
|
||||
} else {
|
||||
handlers.onUpdate?.(envelope.payload as State, envelope.sequence);
|
||||
}
|
||||
}
|
||||
function onSnapshot(event: Event): void {
|
||||
onDelivery("snapshot", event);
|
||||
}
|
||||
function onUpdate(event: Event): void {
|
||||
onDelivery("update", event);
|
||||
}
|
||||
function terminate(): void {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
// Detach first: a browser or injected implementation that synchronously reports closure
|
||||
// from `close()` cannot re-enter `onConnectionError` and duplicate callbacks.
|
||||
source.removeEventListener("open", onOpen);
|
||||
source.removeEventListener("error", onConnectionError);
|
||||
source.removeEventListener("snapshot", onSnapshot);
|
||||
source.removeEventListener("update", onUpdate);
|
||||
source.close();
|
||||
reportStatus("closed");
|
||||
}
|
||||
|
||||
source.addEventListener("open", onOpen);
|
||||
source.addEventListener("error", onConnectionError);
|
||||
source.addEventListener("snapshot", onSnapshot);
|
||||
source.addEventListener("update", onUpdate);
|
||||
|
||||
return Object.freeze({
|
||||
close: terminate,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the relative path a module UI asks for into the real Ekklesia URL, and refuses anything
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { ModuleUiRealtimeError, ModuleUiRealtimeStatus } from "@ekklesia/module-ui-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
HostEventSourceFactory,
|
||||
HostEventSourceOptions,
|
||||
HostModuleUiDescriptor,
|
||||
} from "../src/host-protocol.js";
|
||||
import { createModuleUiContext } from "../src/module-context.js";
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
id: "example",
|
||||
permissions: ["example.state.read"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
uiUrl: "/api/v1/module-ui/example/0.1.0",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
class FakeEventSource {
|
||||
readonly CLOSED = 2 as const;
|
||||
readonly CONNECTING = 0 as const;
|
||||
readonly OPEN = 1 as const;
|
||||
readonly listeners = new Map<string, Set<EventListener>>();
|
||||
closed = false;
|
||||
closeCalls = 0;
|
||||
readyState: number = this.CONNECTING;
|
||||
|
||||
addEventListener(type: string, listener: EventListener): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: EventListener): void {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.closeCalls += 1;
|
||||
this.readyState = this.CLOSED;
|
||||
}
|
||||
|
||||
emit(type: string, data?: string): void {
|
||||
if (type === "open") {
|
||||
this.readyState = this.OPEN;
|
||||
}
|
||||
const event = Object.freeze({ data, type }) as unknown as Event;
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface EventSourceHarness {
|
||||
readonly calls: Array<{
|
||||
readonly options: HostEventSourceOptions;
|
||||
readonly source: FakeEventSource;
|
||||
readonly url: string;
|
||||
}>;
|
||||
readonly factory: HostEventSourceFactory;
|
||||
}
|
||||
|
||||
function eventSourceHarness(): EventSourceHarness {
|
||||
const calls: EventSourceHarness["calls"] = [];
|
||||
const factory = ((url: string, options: HostEventSourceOptions): EventSource => {
|
||||
const source = new FakeEventSource();
|
||||
calls.push({ options, source, url });
|
||||
return source as unknown as EventSource;
|
||||
}) as HostEventSourceFactory;
|
||||
return { calls, factory };
|
||||
}
|
||||
|
||||
function context(factory: HostEventSourceFactory, descriptor = exampleDescriptor) {
|
||||
return createModuleUiContext({
|
||||
config: { apiBaseUrl, eventSourceFactory: factory },
|
||||
descriptor,
|
||||
principal: { kind: "authenticated", userId: "user-1" },
|
||||
});
|
||||
}
|
||||
|
||||
function envelope(sequence: number, payload: unknown): string {
|
||||
return JSON.stringify({ payload, sequence });
|
||||
}
|
||||
|
||||
describe("module UI realtime host adapter", () => {
|
||||
it("opens the server-provided module channel URL with host-owned credentials", () => {
|
||||
const harness = eventSourceHarness();
|
||||
|
||||
context(harness.factory).realtime.subscribe("slideshow-state", {});
|
||||
|
||||
expect(harness.calls).toHaveLength(1);
|
||||
expect(harness.calls[0]).toMatchObject({
|
||||
options: { withCredentials: true },
|
||||
url: `${apiBaseUrl}/api/v1/module-realtime/example/slideshow-state`,
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes the same local channel to each descriptor without accepting a module id", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const calendar: HostModuleUiDescriptor = {
|
||||
...exampleDescriptor,
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||
uiUrl: "/api/v1/module-ui/calendar/1.0.0",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
context(harness.factory).realtime.subscribe("state", {});
|
||||
context(harness.factory, calendar).realtime.subscribe("state", {});
|
||||
|
||||
expect(harness.calls.map((call) => call.url)).toEqual([
|
||||
`${apiBaseUrl}/api/v1/module-realtime/example/state`,
|
||||
`${apiBaseUrl}/api/v1/module-realtime/calendar/state`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("delivers snapshot first, ordered updates, and fresh state after automatic reconnect", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const statuses: ModuleUiRealtimeStatus[] = [];
|
||||
const snapshots: unknown[] = [];
|
||||
const updates: unknown[] = [];
|
||||
const errors: ModuleUiRealtimeError[] = [];
|
||||
|
||||
context(harness.factory).realtime.subscribe<{ readonly mode: string }>("state", {
|
||||
onError: (error) => errors.push(error),
|
||||
onSnapshot: (state, sequence) => snapshots.push({ sequence, state }),
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onUpdate: (state, sequence) => updates.push({ sequence, state }),
|
||||
});
|
||||
const source = harness.calls[0]?.source;
|
||||
expect(source).toBeDefined();
|
||||
|
||||
source?.emit("open");
|
||||
source?.emit("snapshot", envelope(1, { mode: "on" }));
|
||||
source?.emit("update", envelope(2, { mode: "off" }));
|
||||
// Native EventSource owns reconnect. The host reports it, then treats the next snapshot as a
|
||||
// fresh authoritative base even when its sequence is the same as the last state already seen.
|
||||
if (source) {
|
||||
source.readyState = source.CONNECTING;
|
||||
source.emit("error");
|
||||
}
|
||||
source?.emit("open");
|
||||
source?.emit("snapshot", envelope(2, { mode: "off" }));
|
||||
source?.emit("update", envelope(3, { mode: "on" }));
|
||||
|
||||
expect(statuses).toEqual(["connecting", "connected", "reconnecting", "connected"]);
|
||||
expect(snapshots).toEqual([
|
||||
{ sequence: 1, state: { mode: "on" } },
|
||||
{ sequence: 2, state: { mode: "off" } },
|
||||
]);
|
||||
expect(updates).toEqual([
|
||||
{ sequence: 2, state: { mode: "off" } },
|
||||
{ sequence: 3, state: { mode: "on" } },
|
||||
]);
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
code: "REALTIME_CONNECTION_FAILED",
|
||||
message: "The live connection could not be maintained.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed envelopes and out-of-order deliveries with one fixed safe error", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const updates: unknown[] = [];
|
||||
const errors: ModuleUiRealtimeError[] = [];
|
||||
context(harness.factory).realtime.subscribe("state", {
|
||||
onError: (error) => errors.push(error),
|
||||
onUpdate: (state, sequence) => updates.push({ sequence, state }),
|
||||
});
|
||||
const source = harness.calls[0]?.source;
|
||||
source?.emit("open");
|
||||
|
||||
source?.emit("update", envelope(1, { secret: "not delivered" }));
|
||||
source?.emit("snapshot", "not json: /private/server/path");
|
||||
source?.emit("snapshot", JSON.stringify({ extra: true, payload: {}, sequence: 1 }));
|
||||
source?.emit("snapshot", envelope(0, {}));
|
||||
source?.emit("snapshot", envelope(2, { mode: "on" }));
|
||||
source?.emit("update", envelope(2, { mode: "stale" }));
|
||||
|
||||
expect(updates).toEqual([]);
|
||||
expect(errors).toHaveLength(5);
|
||||
expect(new Set(errors.map((error) => JSON.stringify(error)))).toEqual(
|
||||
new Set([
|
||||
JSON.stringify({
|
||||
code: "REALTIME_MESSAGE_INVALID",
|
||||
message: "The host received an invalid live update.",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(errors)).not.toContain("private");
|
||||
expect(JSON.stringify(errors)).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("refuses invalid channel names before opening a connection", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const realtime = context(harness.factory).realtime;
|
||||
|
||||
for (const channel of [
|
||||
"",
|
||||
"State",
|
||||
"slide_state",
|
||||
"slide/state",
|
||||
"state.*",
|
||||
"-state",
|
||||
"state-",
|
||||
"a".repeat(49),
|
||||
]) {
|
||||
expect(() => realtime.subscribe(channel, {}), channel).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_REALTIME_CHANNEL_INVALID" }),
|
||||
);
|
||||
}
|
||||
expect(harness.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("closes idempotently, removes every listener, and delivers nothing afterward", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const statuses: ModuleUiRealtimeStatus[] = [];
|
||||
const snapshots = vi.fn();
|
||||
const subscription = context(harness.factory).realtime.subscribe("state", {
|
||||
onSnapshot: snapshots,
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
const source = harness.calls[0]?.source;
|
||||
source?.emit("open");
|
||||
|
||||
subscription.close();
|
||||
subscription.close();
|
||||
source?.emit("snapshot", envelope(1, { mode: "on" }));
|
||||
source?.emit("open");
|
||||
|
||||
expect(source?.closed).toBe(true);
|
||||
expect(source?.closeCalls).toBe(1);
|
||||
expect(
|
||||
[...((source?.listeners.values() ?? []) as Iterable<Set<EventListener>>)].every(
|
||||
(listeners) => listeners.size === 0,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(snapshots).not.toHaveBeenCalled();
|
||||
expect(statuses).toEqual(["connecting", "connected", "closed"]);
|
||||
});
|
||||
|
||||
it("treats a terminal native CLOSED error as closed and eagerly removes all listeners", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const statuses: ModuleUiRealtimeStatus[] = [];
|
||||
const errors: ModuleUiRealtimeError[] = [];
|
||||
const snapshots = vi.fn();
|
||||
const subscription = context(harness.factory).realtime.subscribe("state", {
|
||||
onError: (error) => errors.push(error),
|
||||
onSnapshot: snapshots,
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
const source = harness.calls[0]?.source;
|
||||
source?.emit("open");
|
||||
source?.emit("snapshot", envelope(1, { mode: "on" }));
|
||||
|
||||
if (source) {
|
||||
// Fatal HTTP/protocol failures leave native EventSource CLOSED; unlike CONNECTING, the browser
|
||||
// will not retry them, so the host must not promise `reconnecting`.
|
||||
source.readyState = source.CLOSED;
|
||||
source.emit("error");
|
||||
source.emit("error");
|
||||
source.emit("open");
|
||||
source.emit("snapshot", envelope(2, { mode: "off" }));
|
||||
}
|
||||
subscription.close();
|
||||
|
||||
expect(source?.closed).toBe(true);
|
||||
expect(source?.closeCalls).toBe(1);
|
||||
expect(
|
||||
[...((source?.listeners.values() ?? []) as Iterable<Set<EventListener>>)].every(
|
||||
(listeners) => listeners.size === 0,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(statuses).toEqual(["connecting", "connected", "closed"]);
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
code: "REALTIME_CONNECTION_FAILED",
|
||||
message: "The live connection could not be maintained.",
|
||||
},
|
||||
]);
|
||||
expect(snapshots).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("sanitizes constructor failures and closes without exposing the caught value", () => {
|
||||
const errors: ModuleUiRealtimeError[] = [];
|
||||
const statuses: ModuleUiRealtimeStatus[] = [];
|
||||
const factory = vi.fn(() => {
|
||||
throw new Error("token=secret at /home/server/realtime");
|
||||
}) as unknown as HostEventSourceFactory;
|
||||
|
||||
const subscription = context(factory).realtime.subscribe("state", {
|
||||
onError: (error) => errors.push(error),
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
subscription.close();
|
||||
|
||||
expect(statuses).toEqual(["connecting", "closed"]);
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
code: "REALTIME_CONNECTION_FAILED",
|
||||
message: "The live connection could not be maintained.",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(errors)).not.toContain("secret");
|
||||
expect(JSON.stringify(errors)).not.toContain("home");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ function recordingHost(options: {
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => options.canRead },
|
||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user