feat: add native module realtime transport
This commit is contained in:
@@ -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 }) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user