Merge pull request 'Phase 8/module platform prerequisites' (#40) from phase-8/module-platform-prerequisites into main
CI / verify (push) Successful in 46s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m45s

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/40
This commit was merged in pull request #40.
This commit is contained in:
The_River
2026-09-06 00:41:22 +00:00
181 changed files with 11670 additions and 528 deletions
+22 -7
View File
@@ -35,13 +35,21 @@ pnpm infrastructure:config
private source paths. private source paths.
- Use `workspace:` ranges for internal package dependencies. - Use `workspace:` ranges for internal package dependencies.
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may - Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role `src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
bundler configuration, and styling is React inline style objects only for now. Both roles use the browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
same UI SDK context with no presentation-mode flag. bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
presentation-mode flag, and the same versioned mount ABI.
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
stays rejected inside a module. Reference registered semantic tokens; never define an
`--ekklesia-*` custom property of your own.
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never - Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own `@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
@@ -51,10 +59,17 @@ pnpm infrastructure:config
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
through `GET /api/v1/module-displays/<module-id>`, mounts both with through `GET /api/v1/module-displays/<module-id>`, mounts both with
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033). `@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
- A module may contribute a browser renderer to a capability another module provides
(`manifest.contributions`, ADR 0041). The consumer discovers and mounts it through
`ModuleUiHostContext.contributions`, never by building a publication URL, and owns the input
contract entirely: the platform carries opaque JSON and defines no payload schema, slot, or
ordering. A contributed renderer gets its own id and that input, and nothing else.
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at - Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the `/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
anonymous trusted code. Public/device identity, display inventory, and variants are deferred. anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A
display-scoped service worker caches the shell and declared artifacts only, network-first, and
never a descriptor, an API response, or anything else authorization-bearing (ADR 0043).
- Dashboard groups are presentation configuration, never module structure. Do not add a parent - Dashboard groups are presentation configuration, never module structure. Do not add a parent
module, child module, owning module, or group-derived dependency: real module relationships are module, child module, owning module, or group-derived dependency: real module relationships are
the dependency contract's job alone, and grouping must never influence discovery, dependency the dependency contract's job alone, and grouping must never influence discovery, dependency
+72 -2
View File
@@ -289,7 +289,8 @@ artifacts cannot drift.
externalizing it would make every host supply a compatible React — defeating standalone hosting. The externalizing it would make every host supply a compatible React — defeating standalone hosting. The
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template); accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
sharing one runtime later is a build optimization that does not change the contract. Styling is React sharing one runtime later is a build optimization that does not change the contract. Styling is React
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds, `.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
and installing a module or starting the API never triggers one. and installing a module or starting the API never triggers one.
@@ -488,7 +489,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
manifest is version 6 after ADR 0033 added the optional display declaration. manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
browser contributions.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and **Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment no key management, so a `secret` key's value is supplied by the deployment
@@ -792,6 +794,74 @@ unproven launch-blockers rather than existing guarantees. Read
[Security model and deployment trust boundaries](docs/development/security.md) before installing a [Security model and deployment trust boundaries](docs/development/security.md) before installing a
module or exposing the API. module or exposing the API.
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
None of them is a feature module, and none of them names one.
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
badge, toast, and empty/error/loading states), every value a token reference, with real
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
exactly two shared roles to modules by name and pins both design packages beneath every other role.
The Dashboard and display install the defaults at their document root; custom properties inherit into
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
theme editor, brand builder, second theme, or component catalogue exists.**
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
means. Publication requires that another eligible module provides the capability inside the declared
range; a contribution that fails is absent rather than broken. A consumer discovers through
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
database, storage, or credential — and loading one grants its module nothing.
Phase 8 pass 4 closes the object-storage write path
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
oversize is refused before authorization, the buffered path now counts what arrived rather than
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
every contributed renderer that display mounted, network-first so a screen can never be trapped on
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
stated rather than papered over — a display cannot render module content while offline, because
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
## Quick start ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
+2
View File
@@ -28,11 +28,13 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13", "@types/smtp-server": "3.5.13",
"better-auth": "1.6.23", "better-auth": "1.6.23",
"happy-dom": "20.0.11",
"pg": "8.22.0", "pg": "8.22.0",
"smtp-server": "3.19.3", "smtp-server": "3.19.3",
"typescript": "7.0.2", "typescript": "7.0.2",
+187 -17
View File
@@ -1,6 +1,10 @@
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts"; import {
import type { Context } from "hono"; MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
import { bodyLimit } from "hono/body-limit"; MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
type ModuleRouteBodyMode,
} from "@ekklesia/contracts";
import type { Context, MiddlewareHandler } from "hono";
/** /**
* The platform's one request-body policy, centralized here rather than scattered as magic numbers * The platform's one request-body policy, centralized here rather than scattered as magic numbers
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting * field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
* abuse. * abuse.
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same * - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
* constant a module's own route declaration is validated against at registration) is the hard * constant a module's own **buffered** route declaration is validated against at registration) is
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling: * the ceiling for every body the platform materializes before a handler runs. It has not moved.
* nothing in this platform ever declares, or enforces, a larger single request body than this. * - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to * explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
* a future dedicated storage design, not to widening this ceiling. * `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
* single request body, and no unbounded option exists anywhere in the contract.
*/ */
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024; export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024; export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING }; export {
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
};
/** The one stable, sanitized public contract for every request-body-size rejection across the /** The one stable, sanitized public contract for every request-body-size rejection across the
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending * platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413); return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
} }
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413 /**
* contract rather than redefining `onError` per route. Covers both a declared oversized * A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
* `Content-Length` and an oversized chunked/streamed body with no declared length — see *
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before * A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as * the byte counting below bounds the request either way, so refusing here could only ever turn a
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */ * legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
export function platformBodyLimit(maxSize: number) { * parser rejects genuinely malformed framing before this code sees the request at all.
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse }); */
function declaredContentLength(request: Request): number | undefined {
const header = request.headers.get("content-length");
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
return undefined;
}
return Number.parseInt(header.trim(), 10);
}
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
function releaseUnreadBody(request: Request): void {
const body = request.body;
if (!body || body.locked) {
return;
}
void body.cancel().catch(() => undefined);
}
/**
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
* chunk past the limit rather than the whole upload.
*/
async function readBounded(
body: ReadableStream<Uint8Array>,
maxSize: number,
): Promise<readonly Uint8Array[] | "over-limit"> {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
return chunks;
}
size += value.byteLength;
if (size > maxSize) {
await reader.cancel().catch(() => undefined);
return "over-limit";
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
}
/**
* The platform's one request-body limiter, for every route in the application.
*
* ## Both modes reject a declared oversize before anything else happens
*
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
* too-large upload takes.
*
* ## `buffered` — the body is materialized, bounded, before the handler runs
*
* The exact behaviour every route had before streaming existed, now with the counting done here
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
* what actually arrived rather than by what was claimed. The handler then receives a request whose
* body is fully available and provably within its declared allowance — which is what makes
* `await request.json()` safe in a module handler.
*
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
*
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
*
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
* is not a limit.
*
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
* handler has started, so a handler that persists as it reads has to be prepared for a partially
* consumed upload. Writing to a temporary key and committing only after the read completes is the
* pattern; the platform cannot do it for you, because only the module knows what a partial write
* means to its own data.
*
* ## Ordering
*
* This runs before permission evaluation for both modes, so a request that is *declared* too large
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
* push a large upload through the platform at all.
*
* ## Timeouts
*
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
* platform already has one answer for that rather than a second per-route one.
*/
export function platformBodyLimit(
maxSize: number,
mode: ModuleRouteBodyMode = "buffered",
): MiddlewareHandler {
return async function enforceBodyLimit(c, next) {
const request = c.req.raw;
const declared = declaredContentLength(request);
if (declared !== undefined && declared > maxSize) {
return requestBodyTooLargeResponse(c);
}
if (!request.body) {
return next();
}
if (mode === "buffered") {
const chunks = await readBounded(request.body, maxSize);
if (chunks === "over-limit") {
return requestBodyTooLargeResponse(c);
}
c.req.raw = new Request(request, {
body: streamOf(chunks),
// Required by undici whenever a `Request` is constructed from a stream.
duplex: "half",
} as RequestInit);
return next();
}
let overLimit = false;
let total = 0;
const counted = request.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
total += chunk.byteLength;
if (total > maxSize) {
overLimit = true;
// Erroring the transform propagates back to the source and cancels it, so the socket is
// released rather than left draining an upload nobody will read.
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
return;
}
controller.enqueue(chunk);
},
}),
);
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
await next();
if (overLimit) {
c.res = requestBodyTooLargeResponse(c);
}
releaseUnreadBody(c.req.raw);
return;
};
} }
+16 -5
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets"; import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
/** /**
* The two immutable, platform-owned files that make up Ekklesia's display browser host. * The three immutable, platform-owned files that make up Ekklesia's display browser host.
* *
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the * They are read once, before the HTTP listener is bound, from the exact URLs exported by the
* surface package. Request input is never involved in this read and the package is never exposed as * surface package. Request input is never involved in this read and the package is never exposed as
@@ -14,6 +14,10 @@ export interface DisplayHostAssets {
readonly html: string; readonly html: string;
readonly script: string; readonly script: string;
readonly scriptEtag: string; readonly scriptEtag: string;
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
* at a path whose directory is its scope so it controls display pages and nothing else. */
readonly serviceWorker: string;
readonly serviceWorkerEtag: string;
} }
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package /** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
@@ -36,18 +40,24 @@ export class DisplayHostAssetError extends Error {
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a * cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
* failed host build rather than a valid page that can never mount anything. * failed host build rather than a valid page that can never mount anything.
*/ */
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets { export function createDisplayHostAssets(
if (html.trim().length === 0 || script.trim().length === 0) { html: string,
script: string,
serviceWorker: string,
): DisplayHostAssets {
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
throw new DisplayHostAssetError(); throw new DisplayHostAssetError();
} }
return Object.freeze({ return Object.freeze({
html, html,
script, script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
}); });
} }
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather /** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
* than leaving a display route that can authorize successfully and then return a broken shell. */ * than leaving a display route that can authorize successfully and then return a broken shell. */
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> { export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
try { try {
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
// rejected sibling promise is left running after this function has already failed. // rejected sibling promise is left running after this function has already failed.
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8"); const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8"); const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
return createDisplayHostAssets(html, script); const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
return createDisplayHostAssets(html, script, serviceWorker);
} catch { } catch {
throw new DisplayHostAssetError(); throw new DisplayHostAssetError();
} }
+53 -3
View File
@@ -9,7 +9,11 @@ import {
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import { grantedModulePermissionIds } from "./module-ui-inventory.js"; import {
describeModuleContributions,
grantedModulePermissionIds,
type HostModuleContributionDescriptor,
} from "./module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js"; import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under /** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
@@ -17,6 +21,18 @@ import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays"; export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
export const MODULE_DISPLAY_PAGE_PREFIX = "/display"; export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js"; export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
/**
* Where the display's offline service worker is served (ADR 0043).
*
* The path is chosen for its **directory**: a service worker's default scope is the directory it was
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
* it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
*
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
*/
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
export function moduleDisplayPageUrl(moduleId: string): string { export function moduleDisplayPageUrl(moduleId: string): string {
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`; return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
@@ -37,6 +53,9 @@ export interface HostModuleDisplayDescriptor {
readonly api: { readonly baseUrl: string }; readonly api: { readonly baseUrl: string };
readonly realtime: { readonly baseUrl: string }; readonly realtime: { readonly baseUrl: string };
readonly permissions: readonly string[]; readonly permissions: readonly string[];
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
* mechanism a signage-style display uses to show another module's content without importing it. */
readonly contributions: readonly HostModuleContributionDescriptor[];
} }
/** /**
@@ -100,8 +119,11 @@ function etagMatches(headerValue: string | undefined, etag: string): boolean {
* bytes with a stale validator. */ * bytes with a stale validator. */
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets { function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
try { try {
const snapshot = createDisplayHostAssets(input.html, input.script); const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
if (snapshot.scriptEtag !== input.scriptEtag) { if (
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
) {
throw new ModuleDisplayConfigurationError(); throw new ModuleDisplayConfigurationError();
} }
return snapshot; return snapshot;
@@ -132,6 +154,7 @@ async function describeDisplay(
viewer, viewer,
module: { module: {
api: { baseUrl: moduleApiBaseUrl(display.moduleId) }, api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
display: { url: moduleDisplayUiArtifactUrl(display) }, display: { url: moduleDisplayUiArtifactUrl(display) },
id: display.moduleId, id: display.moduleId,
permissions: await grantedModulePermissionIds( permissions: await grantedModulePermissionIds(
@@ -244,6 +267,33 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes
}); });
}); });
/**
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
* how a screen avoids being trapped on an obsolete worker.
*
* Like the host script and module artifact bytes, fetching this code grants no authority. It
* caches only public code and never a permission decision, so serving it anonymously withholds
* nothing the platform was protecting.
*/
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
return new Response(null, {
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
status: 304,
});
}
return new Response(assets.serviceWorker, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/javascript; charset=utf-8",
ETag: assets.serviceWorkerEtag,
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
for (const display of displays) { for (const display of displays) {
mountDisplay(app, { ...options, assets }, display); mountDisplay(app, { ...options, assets }, display);
} }
+6 -3
View File
@@ -235,9 +235,12 @@ export function mountModuleHttpRoutes(
const requireRoutePermission = requirePermission(middlewareOptions, route.permission); const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at // The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
// all) is enforced before permission evaluation ever runs, so an oversized request never // all) is enforced before permission evaluation ever runs, so a request *declared* oversized
// reaches authorization work, let alone the handler or the module database. // never reaches authorization work, let alone the handler or the module database. In
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes); // `streamed` mode no body byte is read until the handler reads it, and the handler runs only
// after authorization succeeds, so an unauthorized caller can never push an upload through
// (ADR 0042).
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path); const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => { const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({ const context: ModuleRouteHandlerContext = Object.freeze({
+57 -3
View File
@@ -1,6 +1,10 @@
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts"; import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization"; import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules"; import type {
ModuleUiPublicationRegistry,
PublishableModuleContribution,
PublishableModuleUi,
} from "@ekklesia/core/modules";
import { Hono } from "hono"; import { Hono } from "hono";
import type { Context } from "hono"; import type { Context } from "hono";
import { import {
@@ -10,7 +14,11 @@ import {
} from "./authorization-middleware.js"; } from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js"; import {
MODULE_UI_ASSET_PREFIX,
moduleContributionArtifactUrl,
moduleUiArtifactUrl,
} from "./module-ui-routes.js";
/** /**
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may * The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
@@ -64,12 +72,56 @@ export interface HostModuleUiDescriptor {
* nor trusts anything in this array. * nor trusts anything in this array.
*/ */
readonly permissions: readonly string[]; readonly permissions: readonly string[];
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
* the overwhelming majority of modules, which provide no extension point. */
readonly contributions: readonly HostModuleContributionDescriptor[];
}
/**
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
*
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
* consuming module names a contribution and the host resolves it, so no module ever builds
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
*
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
* contributed renderer draws and what shape its input takes are the consuming module's contract,
* carried in the browser between two modules without passing through the platform.
*/
export interface HostModuleContributionDescriptor {
readonly id: string;
readonly version: string;
/** The capability it was contributed to — one this module declares. */
readonly capability: string;
readonly url: string;
} }
export interface HostModuleUiInventory { export interface HostModuleUiInventory {
readonly modules: readonly HostModuleUiDescriptor[]; readonly modules: readonly HostModuleUiDescriptor[];
} }
/**
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
* inventory and the display descriptor so the two can never disagree about what a consuming module
* may mount.
*/
export function describeModuleContributions(
registry: ModuleUiPublicationRegistry,
moduleId: string,
): readonly HostModuleContributionDescriptor[] {
return registry
.contributionsForModule(moduleId)
.map((contribution: PublishableModuleContribution) => ({
capability: contribution.capability,
id: contribution.contributionId,
url: moduleContributionArtifactUrl(
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
contribution.contributionId,
),
version: contribution.version,
}));
}
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions { export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same publication registry the artifact route serves from — the single accepted/publishable /** The same publication registry the artifact route serves from — the single accepted/publishable
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs * module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
@@ -116,9 +168,11 @@ async function describeModule(
evaluator: AuthorizationMiddlewareOptions["evaluator"], evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal, principal: EkklesiaPrincipal,
published: PublishableModuleUi, published: PublishableModuleUi,
registry: ModuleUiPublicationRegistry,
): Promise<HostModuleUiDescriptor> { ): Promise<HostModuleUiDescriptor> {
return { return {
api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
contributions: describeModuleContributions(registry, published.moduleId),
id: published.moduleId, id: published.moduleId,
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions), permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) }, realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
@@ -153,7 +207,7 @@ export async function describeMountableModules(
): Promise<readonly MountableModuleDescription[]> { ): Promise<readonly MountableModuleDescription[]> {
return Promise.all( return Promise.all(
registry.list().map(async (published) => ({ registry.list().map(async (published) => ({
descriptor: await describeModule(evaluator, principal, published), descriptor: await describeModule(evaluator, principal, published, registry),
name: published.name, name: published.name,
})), })),
); );
+41 -3
View File
@@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${moduleUiArtifactUrl(identity)}/display`; return `${moduleUiArtifactUrl(identity)}/display`;
} }
/**
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
*
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
* module id and version are: a value that somehow did not come from a validated manifest can only
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
* fragment — and a value that needed encoding simply fails to resolve against the registry.
*
* Like every other publication helper, this takes **identity only**. There is no parameter for a
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
* module package.
*/
export function moduleContributionArtifactUrl(
identity: ModuleUiIdentity,
contributionId: string,
): string {
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
}
/** /**
* One 404 for every "there is nothing to publish here" case: the module is not installed, the * One 404 for every "there is nothing to publish here" case: the module is not installed, the
* installed module declares no artifact for the requested UI role, or the requested version is not * installed module declares no artifact for the requested UI role, or the requested version is not
@@ -191,9 +211,9 @@ export interface CreateModuleUiRoutesOptions {
* *
* ## The request never touches the filesystem * ## The request never touches the filesystem
* *
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to * `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's * components**. They are handed to the registry and, on an exact match, the already-canonical
* discovery resolved and validated is read directly. No request value is ever joined, resolved, * `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so * normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file * traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
* are all unreachable by construction rather than by filtering. An installed module directory never * are all unreachable by construction rather than by filtering. An installed module directory never
@@ -228,6 +248,24 @@ export interface CreateModuleUiRoutesOptions {
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono { export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
const routes = new Hono(); const routes = new Hono();
routes.get(
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
async (c) => {
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
// UI artifact and on no others.
const published = options.registry.resolveContribution(
c.req.param("moduleId"),
c.req.param("version"),
c.req.param("contributionId"),
);
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
},
);
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => { routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
const published = options.registry.resolveDisplay( const published = options.registry.resolveDisplay(
c.req.param("moduleId"), c.req.param("moduleId"),
+103 -3
View File
@@ -330,7 +330,7 @@ async function createGatedRouteModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0", version: "0.1.0",
@@ -374,7 +374,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -432,6 +432,39 @@ async function createFixtureModule(
async handler(request, context) { async handler(request, context) {
return Response.json({ created: true, principal: context.principal }, { status: 201 }); return Response.json({ created: true, principal: context.principal }, { status: 201 });
}, },
});
// An image-sized streamed upload (ADR 0042). The handler counts bytes as they
// arrive and never materializes the body, which is exactly what a real module
// storing an upload would do.
http.route({
method: "POST",
path: "/uploads",
permission: "calendar.events.read",
maxBodyBytes: 33554432,
bodyMode: "streamed",
async handler(request) {
let bytes = 0;
if (request.body) {
const reader = request.body.getReader();
for (;;) {
const chunk = await reader.read();
if (chunk.done) { break; }
bytes += chunk.value.byteLength;
}
}
return Response.json({ bytes }, { status: 201 });
},
});
http.route({
method: "POST",
path: "/small-uploads",
permission: "calendar.events.read",
maxBodyBytes: 64,
bodyMode: "streamed",
async handler(request) {
await request.arrayBuffer().catch(() => undefined);
return Response.json({ swallowed: true }, { status: 201 });
},
});` });`
: "" : ""
} }
@@ -479,7 +512,7 @@ async function createUiModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
version, version,
}), }),
@@ -2685,6 +2718,73 @@ describe.sequential("live API — installed module permission end-to-end", () =>
expect(await trustedResponse.json()).toMatchObject({ created: true }); expect(await trustedResponse.json()).toMatchObject({ created: true });
}); });
it("streams one image-sized upload end to end over real HTTP", async () => {
// 25 MiB — an ordinary photograph, and the size the streamed ceiling exists to accommodate. It
// travels through a real socket, a real Node server, and the platform's byte counter, and the
// platform holds none of it (ADR 0042).
const image = new Uint8Array(25 * 1024 * 1024).fill(0x41);
const response = await fetch(`${origin}/api/v1/modules/calendar/uploads`, {
body: image,
headers: {
"content-type": "application/octet-stream",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ bytes: image.byteLength });
}, 60_000);
it("refuses a declared oversize on a streamed route with a sanitized 413", async () => {
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
body: "a".repeat(65),
headers: {
"content-type": "text/plain",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("terminates a chunked streamed body that exceeds the limit, even when the handler swallowed it", async () => {
// No declared `Content-Length`, so only the running byte count can bound this — and the
// handler deliberately ignores the stream error and answers `201`. The platform's `413` still
// wins: a limit a handler can swallow is not a limit.
const oversize = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array(512).fill(0x41));
},
});
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
body: oversize,
duplex: "half",
headers: { cookie: ownerCookie, origin: dashboardOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
}, 30_000);
it("keeps the server healthy after an oversized upload", async () => {
// The socket was released rather than left draining, so the very next request is served
// normally.
const response = await fetch(`${origin}/api/v1/modules/calendar/events/evt-43`, {
headers: { cookie: ownerCookie },
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ eventId: "evt-43" });
});
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => { it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url); const admin = await openAdministrativeClient(database.url);
try { try {
+2 -1
View File
@@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
}, },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -269,6 +269,7 @@ describe("Dashboard navigation — reading", () => {
// Presentation wraps the host-neutral descriptor; it never rewrites it. // Presentation wraps the host-neutral descriptor; it never rewrites it.
expect(livestream?.module).toEqual({ expect(livestream?.module).toEqual({
contributions: [],
api: { baseUrl: "/api/v1/modules/livestream" }, api: { baseUrl: "/api/v1/modules/livestream" },
id: "livestream", id: "livestream",
permissions: [], permissions: [],
+1 -1
View File
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
server: "./dist/server/index.js", server: "./dist/server/index.js",
}, },
id: "announcements", id: "announcements",
manifestVersion: 6, manifestVersion: 7,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
+18 -7
View File
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({
displayHostAssetUrls: Object.freeze({ displayHostAssetUrls: Object.freeze({
html: "file:///surface/browser/display/index.html", html: "file:///surface/browser/display/index.html",
script: "file:///surface/browser/display/app.js", script: "file:///surface/browser/display/app.js",
serviceWorker: "file:///surface/browser/display/sw.js",
}), }),
})); }));
@@ -20,21 +21,28 @@ import {
describe("display host asset loading", () => { describe("display host asset loading", () => {
beforeEach(() => readFileMock.mockReset()); beforeEach(() => readFileMock.mockReset());
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => { it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
const html = '<!doctype html><div id="ekklesia-display"></div>'; const html = '<!doctype html><div id="ekklesia-display"></div>';
const script = "export const displayHost = true;\n"; const script = "export const displayHost = true;\n";
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script); const serviceWorker = "export const displayWorker = true;\n";
readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce(script)
.mockResolvedValueOnce(serviceWorker);
const assets = await loadDisplayHostAssets(); const assets = await loadDisplayHostAssets();
expect(readFileMock.mock.calls).toEqual([ expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/display/index.html"), "utf8"], [new URL("file:///surface/browser/display/index.html"), "utf8"],
[new URL("file:///surface/browser/display/app.js"), "utf8"], [new URL("file:///surface/browser/display/app.js"), "utf8"],
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
]); ]);
expect(assets).toEqual({ expect(assets).toEqual({
html, html,
script, script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
}); });
expect(Object.isFrozen(assets)).toBe(true); expect(Object.isFrozen(assets)).toBe(true);
}); });
@@ -62,12 +70,15 @@ describe("display host asset loading", () => {
} }
}); });
it("rejects an empty page or script as an unavailable host build", () => { it("rejects an empty page, script, or service worker as an unavailable host build", () => {
for (const [html, script] of [ for (const [html, script, serviceWorker] of [
["", "export {};"], ["", "export {};", "export {};"],
["<html></html>", " \n"], ["<html></html>", " \n", "export {};"],
["<html></html>", "export {};", " \n"],
] as const) { ] as const) {
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError); expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
DisplayHostAssetError,
);
} }
}); });
}); });
+3 -2
View File
@@ -88,7 +88,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -254,12 +254,13 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions, permissions,
version: "0.1.0", version: "0.1.0",
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
configuration: fixture.configuration ?? [], configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
+60 -2
View File
@@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
import { import {
DISPLAY_HOST_SCRIPT_PATH, DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
ModuleDisplayConfigurationError, ModuleDisplayConfigurationError,
moduleDisplayDescriptorUrl, moduleDisplayDescriptorUrl,
moduleDisplayPageUrl, moduleDisplayPageUrl,
@@ -51,6 +52,7 @@ const displayPermission = "announcements.display.view";
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets( const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`, `<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
"globalThis.__ekklesiaDisplayHostLoaded = true;\n", "globalThis.__ekklesiaDisplayHostLoaded = true;\n",
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
); );
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
@@ -101,7 +103,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
: {}), : {}),
}, },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: fixture.display permissions: fixture.display
? [ ? [
@@ -266,6 +268,7 @@ describe("module display routes — authorization and descriptor", () => {
expect(await descriptor.json()).toEqual({ expect(await descriptor.json()).toEqual({
viewer: { kind: "authenticated", userId: viewerId }, viewer: { kind: "authenticated", userId: viewerId },
module: { module: {
contributions: [],
api: { baseUrl: moduleApiBaseUrl("announcements") }, api: { baseUrl: moduleApiBaseUrl("announcements") },
display: { display: {
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }), url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
@@ -476,7 +479,11 @@ describe("module display host asset publication", () => {
}); });
it("snapshots injected assets and rejects a validator that does not describe its script", async () => { it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
const source = createDisplayHostAssets("<html>display host</html>", "export const host = 1;\n"); const source = createDisplayHostAssets(
"<html>display host</html>",
"export const host = 1;\n",
"export const worker = 1;\n",
);
const mutable = { ...source }; const mutable = { ...source };
const { app } = await install({ assets: mutable, display: true }); const { app } = await install({ assets: mutable, display: true });
mutable.html = "<html>mutated secret</html>"; mutable.html = "<html>mutated secret</html>";
@@ -497,4 +504,55 @@ describe("module display host asset publication", () => {
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404); expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
}); });
it("serves the offline service worker from the path whose directory is its scope", async () => {
const { app } = await install({ display: true });
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
// else. The Dashboard is never controlled by it (ADR 0043).
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
expect(response.status).toBe(200);
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("revalidates the service worker with its own validator, never the host script's", async () => {
const { app } = await install({ display: true });
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
});
expect(notModified.status).toBe(304);
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.scriptEtag },
});
expect(wrongValidator.status).toBe(200);
});
it("does not publish the service worker when no eligible display uses it", async () => {
const { app } = await install({ ui: true, withAssets: false });
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
});
it("cannot be reached through a neighbouring or request-selected path", async () => {
const { app } = await install({ display: true });
for (const candidate of [
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
"/display/sw.js/",
"/sw.js",
"/assets/ekklesia-display/sw.js",
]) {
expect((await app.request(candidate)).status, candidate).toBe(404);
}
});
}); });
+379 -4
View File
@@ -58,14 +58,17 @@ function fakeStore(): AuthorizationStore {
}; };
} }
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the /** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not * body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
* each need to restate an unrelated body-size choice; the dedicated "module route body limits" * each need to restate an unrelated body-size choice; the dedicated "module route body limits"
* suite further down overrides it explicitly to prove the contract itself. */ * suite further down overrides them explicitly to prove the contract itself. */
function moduleRoute( function moduleRoute(
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number }, fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
maxBodyBytes?: number;
},
): RegisteredModuleHttpRoute { ): RegisteredModuleHttpRoute {
return Object.freeze({ maxBodyBytes: 1024, ...fields }); return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
} }
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting, /** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
@@ -1634,3 +1637,375 @@ describe("module route overlap can never reach mounting", () => {
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true); expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
}); });
}); });
/**
* Explicit, bounded streaming uploads (ADR 0042).
*
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
*/
describe("module route streamed body limits", () => {
const oneMebibyte = 1024 * 1024;
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
let sent = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= size) {
controller.close();
return;
}
const next = Math.min(chunkSize, size - sent);
sent += next;
controller.enqueue(new Uint8Array(next).fill(97));
},
});
}
function streamingApp(options: {
readonly handler: ModuleRouteHandler;
readonly maxBodyBytes: number;
readonly evaluator?: AuthorizationEvaluator;
}) {
return buildModuleApp({
...(options.evaluator ? { evaluator: options.evaluator } : {}),
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler: options.handler,
maxBodyBytes: options.maxBodyBytes,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
}
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
* "buffered then handed over". */
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
return async (request) => {
const body = request.body;
if (!body) {
return Response.json({ bytes: 0 });
}
const reader = body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
received.bytes += value.byteLength;
}
return Response.json({ bytes: received.bytes });
};
}
it("accepts a body of exactly the declared limit", async () => {
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(oneMebibyte),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ bytes: oneMebibyte });
});
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
const received = { bytes: 0 };
const counting = await countingHandler(received);
const handler = vi.fn<ModuleRouteHandler>(counting);
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(2048, 16),
duplex: "half",
headers: { "content-length": String(1025), origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
// byte of the upload reached module code.
expect(handler).not.toHaveBeenCalled();
expect(received.bytes).toBe(0);
});
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { "content-length": "8", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// It stopped promptly rather than draining the whole upload first.
expect(received.bytes).toBeLessThanOrEqual(96);
});
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
// A limit a handler can swallow is not a limit.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
try {
await request.arrayBuffer();
} catch {
// Deliberately ignored, exactly as a careless module might.
}
return Response.json({ ok: "handler won" });
});
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(handler).toHaveBeenCalledTimes(1);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
let released: (() => void) | undefined;
const finished = new Promise<void>((resolve) => {
released = resolve;
});
let firstChunkSeen = false;
let secondChunkQueued = false;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
if (!secondChunkQueued) {
secondChunkQueued = true;
controller.enqueue(new Uint8Array(16).fill(97));
return;
}
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
// buffering platform would deadlock here rather than reach the assertion below.
await finished;
controller.close();
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
const first = await reader.read();
firstChunkSeen = !first.done;
released?.();
for (;;) {
const next = await reader.read();
if (next.done) {
break;
}
}
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(firstChunkSeen).toBe(true);
expect(response.status).toBe(200);
});
it("releases an aborted upload without taking the platform down", async () => {
const aborted = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
controller.error(new Error("client went away"));
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
await request.arrayBuffer();
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: aborted,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
});
it("releases a body the handler never read", async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
cancel() {
cancelled = true;
},
pull(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
},
});
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(cancelled).toBe(true);
});
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
};
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler,
maxBodyBytes: 64,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
body: "a".repeat(65),
headers: { "content-length": "65", origin: trustedOrigin },
method: "POST",
});
expect(declaredOversize.status).toBe(413);
expect(evaluator.authorize).not.toHaveBeenCalled();
// A within-limit body from a denied caller is refused by authorization, and the handler — the
// only thing that ever reads a byte in streamed mode — never runs.
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(32, 8),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(handler).not.toHaveBeenCalled();
});
it("still rejects an untrusted Origin before any body handling", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096),
duplex: "half",
headers: { origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(handler).not.toHaveBeenCalled();
});
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: "secret-payload-".repeat(64),
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
method: "POST",
});
const body = await response.text();
expect(response.status).toBe(413);
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
expect(body).not.toContain("secret-payload");
expect(body).not.toContain("64");
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("still materializes a buffered body before the handler runs", async () => {
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
// body is already there and already within its declared allowance.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const text = await request.text();
return Response.json({ length: text.length });
});
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "buffered",
handler,
maxBodyBytes: 128,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(128, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(withinLimit.status).toBe(200);
expect(await withinLimit.json()).toEqual({ length: 128 });
const overLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(129, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(overLimit.status).toBe(413);
// A buffered route's handler never sees an over-limit request at all.
expect(handler).toHaveBeenCalledTimes(1);
});
});
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
+1 -1
View File
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: "announcements", id: "announcements",
manifestVersion: 6, manifestVersion: 7,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
+50 -13
View File
@@ -20,7 +20,13 @@ import {
discoverModules, discoverModules,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
} from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import { import {
bootstrapDisplayHost, bootstrapDisplayHost,
bootstrapHost, bootstrapHost,
@@ -121,7 +127,7 @@ async function createFixtureModule(
: {}), : {}),
}, },
id, id,
manifestVersion: 6, manifestVersion: 7,
name, name,
permissions: [ permissions: [
{ {
@@ -165,6 +171,7 @@ async function createFixtureModule(
await writeFile( await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"), path.join(moduleDirectory, "dist/ui/index.js"),
`export default { `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
async mount(container, context) { async mount(container, context) {
@@ -184,6 +191,7 @@ async function createFixtureModule(
await writeFile( await writeFile(
path.join(moduleDirectory, "dist/display/index.js"), path.join(moduleDirectory, "dist/display/index.js"),
`export default { `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
async mount(container, context) { async mount(container, context) {
@@ -270,6 +278,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
displayHostAssets: createDisplayHostAssets( displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>', '<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
"export const displayHost = true;\n", "export const displayHost = true;\n",
"export const displayWorker = true;\n",
), ),
}), }),
moduleLifecycle: { moduleLifecycle: {
@@ -310,10 +319,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
return `data:text/javascript;base64,${bytes.toString("base64")}`; return `data:text/javascript;base64,${bytes.toString("base64")}`;
} }
/** The loader hands the container straight to the module and never inspects it, so a plain sink is /**
* enough and no DOM implementation is needed in this Node-environment suite. */ * A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
function containerSink(): { textContent: string } { * test environment.
return { textContent: "" }; *
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
* dynamically imports real module server entrypoints from temporary directories, which then fail to
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
* this server-side suite depends on exactly as Node provides it.
*/
function containerSink(): HostContainer {
return new Window().document.createElement("div") as unknown as HostContainer;
}
interface HostContainer {
readonly children: { readonly length: number };
querySelector(selectors: string): HostContainer | null;
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
}
/** What the mounted artifact rendered, read through its open isolated root. */
function mountedText(container: HostContainer): string {
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return (
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
""
);
} }
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"]; type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
@@ -340,6 +374,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
// 2. The URLs came from the server, not from anything the browser composed. // 2. The URLs came from the server, not from anything the browser composed.
expect(descriptor).toEqual({ expect(descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example", apiBaseUrl: "/api/v1/modules/example",
contributions: [],
id: "example", id: "example",
permissions: ["example.records.read"], permissions: ["example.records.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example", realtimeBaseUrl: "/api/v1/module-realtime/example",
@@ -365,13 +400,13 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}); });
// 5. The module rendered, having reached its own backend through the host. // 5. The module rendered, having reached its own backend through the host.
expect(container.textContent).toBe("example:200:2"); expect(mountedText(container)).toBe("example:200:2");
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix. // 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
// 7. And it tears down cleanly. // 7. And it tears down cleanly.
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
} finally { } finally {
await harness.stop(); await harness.stop();
@@ -403,6 +438,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
} }
expect(bootstrap.descriptor).toEqual({ expect(bootstrap.descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example", apiBaseUrl: "/api/v1/modules/example",
contributions: [],
id: "example", id: "example",
permissions: ["example.records.read", "example.display.view"], permissions: ["example.records.read", "example.display.view"],
realtimeBaseUrl: "/api/v1/module-realtime/example", realtimeBaseUrl: "/api/v1/module-realtime/example",
@@ -427,6 +463,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}); });
expect(Object.keys(context).sort()).toEqual([ expect(Object.keys(context).sort()).toEqual([
"api", "api",
"contributions",
"moduleId", "moduleId",
"permissions", "permissions",
"principal", "principal",
@@ -443,10 +480,10 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}, },
}); });
expect(container.textContent).toBe("example:display:200:2"); expect(mountedText(container)).toBe("example:display:200:2");
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
} finally { } finally {
await harness.stop(); await harness.stop();
@@ -514,7 +551,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}); });
// Hiding a control is a usability improvement: the module simply did not ask. // Hiding a control is a usability improvement: the module simply did not ask.
expect(container.textContent).toBe("example:denied"); expect(mountedText(container)).toBe("example:denied");
expect(harness.moduleApiCalls).toEqual([]); expect(harness.moduleApiCalls).toEqual([]);
} finally { } finally {
await harness.stop(); await harness.stop();
@@ -593,7 +630,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
version: groupedEntry.descriptor.version, version: groupedEntry.descriptor.version,
}, },
}); });
expect(container.textContent).toBe("example:200:2"); expect(mountedText(container)).toBe("example:200:2");
await mounted.unmount(); await mounted.unmount();
// Now move it somewhere else entirely. // Now move it somewhere else entirely.
@@ -645,7 +682,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
version: movedEntry.descriptor.version, version: movedEntry.descriptor.version,
}, },
}); });
expect(movedContainer.textContent).toBe("example:200:2"); expect(mountedText(movedContainer)).toBe("example:200:2");
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([ expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
"GET /api/v1/modules/example/records", "GET /api/v1/modules/example/records",
]); ]);
+107 -1
View File
@@ -59,6 +59,12 @@ interface ModuleFixture {
readonly id: string; readonly id: string;
readonly baseline: "authenticated" | "public" | "restricted"; readonly baseline: "authenticated" | "public" | "restricted";
}>; }>;
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
readonly contributions?: readonly {
readonly localName: string;
readonly capability: string;
readonly capabilityRange: string;
}[];
} }
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> { async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
@@ -82,8 +88,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
server: "./dist/server/index.js", server: "./dist/server/index.js",
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}), ...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
}, },
capabilities: fixture.capabilities ?? [],
contributions: (fixture.contributions ?? []).map((contribution) => ({
artifact: `./dist/contributions/${contribution.localName}/index.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${fixture.id}.${contribution.localName}`,
version: "1.0.0",
})),
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({ permissions: (fixture.permissions ?? []).map((permission) => ({
...permission, ...permission,
@@ -103,6 +117,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`, `export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
); );
} }
for (const contribution of fixture.contributions ?? []) {
const contributionDirectory = path.join(
moduleDirectory,
"dist/contributions",
contribution.localName,
);
await mkdir(contributionDirectory, { recursive: true });
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};\n");
}
} }
interface InstallOptions { interface InstallOptions {
@@ -219,6 +242,7 @@ describe("module UI inventory — what it lists", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(modules).toEqual([ expect(modules).toEqual([
{ {
contributions: [],
api: { baseUrl: "/api/v1/modules/example" }, api: { baseUrl: "/api/v1/modules/example" },
id: "example", id: "example",
permissions: [], permissions: [],
@@ -315,6 +339,7 @@ describe("module UI inventory — what it lists", () => {
// The exact key set, so a future field cannot be added without a deliberate decision here. // The exact key set, so a future field cannot be added without a deliberate decision here.
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([ expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
"api", "api",
"contributions",
"id", "id",
"permissions", "permissions",
"realtime", "realtime",
@@ -520,3 +545,84 @@ describe("module UI inventory — it is not a module management API", () => {
} }
}); });
}); });
/**
* The inventory is how a consuming module learns what was contributed to its own capabilities
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
*/
describe("module UI inventory — contributions", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
permissions: [],
withUi: true,
};
const announcements = (capabilityRange = "^1.0.0") => ({
contributions: [
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
],
id: "announcements",
permissions: [],
withUi: true,
});
it("gives the providing module the contribution and its server-produced URL", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
expect(consumer?.contributions).toEqual([
{
capability: "signage.slide-renderer",
id: "announcements.signage-slide",
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
version: "1.0.0",
},
]);
});
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
});
it("withholds a contribution whose provider is outside its declared range", async () => {
const { app } = await install({
fixtures: [signage, announcements("^2.0.0")],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
});
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
const { app, modulesDirectory } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { body, modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
expect(body).not.toContain(modulesDirectory);
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
"capability",
"id",
"url",
"version",
]);
});
});
+168 -10
View File
@@ -16,6 +16,8 @@ import {
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; import { loadModuleUi } from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
@@ -25,6 +27,7 @@ import {
MODULE_UI_ASSET_PREFIX, MODULE_UI_ASSET_PREFIX,
MODULE_UI_CACHE_CONTROL, MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE, MODULE_UI_CONTENT_TYPE,
moduleContributionArtifactUrl,
moduleDisplayUiArtifactUrl, moduleDisplayUiArtifactUrl,
moduleUiArtifactUrl, moduleUiArtifactUrl,
} from "../src/module-ui-routes.js"; } from "../src/module-ui-routes.js";
@@ -59,6 +62,7 @@ async function createModulesDirectory(): Promise<string> {
* sequence whose exact publication and loader-compatibility can be asserted. */ * sequence whose exact publication and loader-compatibility can be asserted. */
function artifactSource(id: string, version: string): string { function artifactSource(id: string, version: string): string {
return `export default { return `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: ${JSON.stringify(version)}, version: ${JSON.stringify(version)},
mount(container, context) { mount(container, context) {
@@ -83,6 +87,15 @@ interface ModuleFixture {
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against * chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
* files that genuinely exist on disk rather than against hypothetical ones. */ * files that genuinely exist on disk rather than against hypothetical ones. */
readonly extraFiles?: Readonly<Record<string, string>>; readonly extraFiles?: Readonly<Record<string, string>>;
/** Capabilities this module provides — what another module's contributions target. */
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
/** Browser renderers this module contributes to another module's capability. */
readonly contributions?: readonly {
readonly localName: string;
readonly capability: string;
readonly capabilityRange: string;
readonly source: string;
}[];
} }
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> { async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
@@ -115,8 +128,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
}, },
}), }),
}, },
capabilities: fixture.capabilities ?? [],
contributions: (fixture.contributions ?? []).map((contribution) => ({
artifact: `./dist/contributions/${contribution.localName}/index.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${fixture.id}.${contribution.localName}`,
version: "1.0.0",
})),
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: permissions:
fixture.display === undefined fixture.display === undefined
@@ -139,6 +160,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui); await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
} }
for (const contribution of fixture.contributions ?? []) {
const contributionDirectory = path.join(
moduleDirectory,
"dist/contributions",
contribution.localName,
);
await mkdir(contributionDirectory, { recursive: true });
await writeFile(path.join(contributionDirectory, "index.js"), contribution.source);
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
}
if (fixture.display !== undefined) { if (fixture.display !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display); await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
@@ -209,6 +240,7 @@ async function install(
displayHostAssets: createDisplayHostAssets( displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="display-root"></div>', '<!doctype html><div id="display-root"></div>',
"export {};\n", "export {};\n",
"export {};\n",
), ),
} }
: {}), : {}),
@@ -862,6 +894,12 @@ describe("module UI artifact URL generation", () => {
}); });
}); });
interface HostContainer {
readonly children: { readonly length: number };
querySelector(selectors: string): HostContainer | null;
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
}
describe("module UI artifact publication — the generic loader consumes what is published", () => { describe("module UI artifact publication — the generic loader consumes what is published", () => {
/** /**
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and * Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
@@ -880,21 +918,35 @@ describe("module UI artifact publication — the generic loader consumes what is
} }
/** /**
* The loader forwards the container untouched to the module's own `mount` and never inspects it, * A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
* so a plain sink is enough and no DOM implementation is required. The container's type is read * root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles * rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
* against Node libs only — naming a DOM type here would be the same browser/server confusion this * DOM type here would be the same browser/server confusion this route exists to keep apart.
* route exists to keep apart.
*/ */
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"]; type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
function containerSink(): { textContent: string } { /**
return { textContent: "" }; * `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
* real isolated root inside the container, and installing a DOM environment here would replace
* the module runner's resolution for a server-side suite that dynamically imports real module
* entrypoints.
*/
function containerSink(): HostContainer {
return new Window().document.createElement("div") as unknown as HostContainer;
}
/** What the mounted artifact rendered, read through its open isolated root. */
function mountedText(container: HostContainer): string {
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
return (
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
);
} }
function hostContext() { function hostContext() {
return { return {
api: { request: async () => new Response(null, { status: 200 }) }, api: { request: async () => new Response(null, { status: 200 }) },
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId: "announcements", moduleId: "announcements",
permissions: { has: () => false }, permissions: { has: () => false },
principal: { kind: "anonymous" as const }, principal: { kind: "anonymous" as const },
@@ -915,9 +967,9 @@ describe("module UI artifact publication — the generic loader consumes what is
source: { moduleId: "announcements", url, version: "1.4.2" }, source: { moduleId: "announcements", url, version: "1.4.2" },
}); });
expect(container.textContent).toBe("announcements:mounted"); expect(mountedText(container)).toBe("announcements:mounted");
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(container.children).toHaveLength(0);
}); });
it("still rejects the published artifact when the host expects a different identity", async () => { it("still rejects the published artifact when the host expects a different identity", async () => {
@@ -935,3 +987,109 @@ describe("module UI artifact publication — the generic loader consumes what is
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" }); ).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
}); });
}); });
/**
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
*/
describe("module contribution artifact publication", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
};
const contributionSource = "export default { contributed: true };\n";
const announcements = (capabilityRange = "^1.0.0") => ({
contributions: [
{
capability: "signage.slide-renderer",
capabilityRange,
localName: "signage-slide",
source: contributionSource,
},
],
id: "announcements",
});
const contributionUrl = moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
"announcements.signage-slide",
);
it("serves the declared contribution artifact byte for byte", async () => {
const { app } = await install([signage, announcements()]);
const response = await app.request(contributionUrl);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(await response.text()).toBe(contributionSource);
});
it("builds a URL that cannot select a neighbouring file", () => {
expect(contributionUrl).toBe(
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
);
// The contribution id is one segment's worth of text, always — a value that needed encoding
// simply fails to resolve.
expect(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
"../../../etc/passwd",
),
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
});
it.each([
["an unknown contribution id", "announcements.nope"],
["another module's contribution id", "signage.slide-renderer"],
["a path traversal", "../../../etc/passwd"],
["the artifact's source map", "announcements.signage-slide.map"],
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
const { app } = await install([signage, announcements()]);
const response = await app.request(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
contributionId,
),
);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
});
it("refuses a version the platform does not have installed", async () => {
const { app } = await install([signage, announcements()]);
const response = await app.request(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "9.9.9" },
"announcements.signage-slide",
),
);
expect(response.status).toBe(404);
});
it("publishes nothing when no installed module provides the target capability", async () => {
const { app } = await install([announcements()]);
expect((await app.request(contributionUrl)).status).toBe(404);
});
it("publishes nothing when the provider is outside the declared compatible range", async () => {
const { app } = await install([signage, announcements("^2.0.0")]);
expect((await app.request(contributionUrl)).status).toBe(404);
});
it("keeps the sibling source map and the module's own directory unreachable", async () => {
const { app } = await install([signage, announcements()]);
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
const response = await app.request(`${contributionUrl}${suffix}`);
expect(response.status).toBe(404);
}
});
});
+22
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { PermissionRegistryError } from "../authorization/index.js"; import { PermissionRegistryError } from "../authorization/index.js";
import { import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModuleSchemas, discoverModuleSchemas,
ModuleDiscoveryError, ModuleDiscoveryError,
ModulePermissionCompositionError, ModulePermissionCompositionError,
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
return; return;
} }
// Built from the same registry the API publishes from, so this command can never disagree with
// what a running platform would actually serve. A contribution whose target capability nobody
// provides — or whose provider moved outside its declared range — is otherwise silent: the
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
const publication = createModuleUiPublicationRegistry(modules);
const compatibleContributions = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
console.log( console.log(
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`, `Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
); );
for (const discoveredModule of modules) { for (const discoveredModule of modules) {
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`); console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
for (const contribution of discoveredModule.contributions) {
const resolved = compatibleContributions.has(
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
);
console.log(
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
resolved ? "compatible provider installed" : "no compatible provider installed"
}`,
);
}
} }
} }
+30 -1
View File
@@ -57,6 +57,7 @@ import {
createModuleConfigurationStore, createModuleConfigurationStore,
createModuleLifecycleDatabase, createModuleLifecycleDatabase,
createModuleLifecycleStore, createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModuleSchemas, discoverModuleSchemas,
discoverModules, discoverModules,
@@ -316,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) }); return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
} }
/**
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
* — how many of them have a compatible provider among the installed set.
*
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
* capability nobody provides, or whose provider moved outside its declared range, is simply not
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
* are derived from the same publication registry the API serves from, so this can never disagree
* with what the running platform would actually publish.
*
* Static only, like every other Doctor check: it reports what the *manifests* support and never
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
*/
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] { function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
const publication = createModuleUiPublicationRegistry(modules);
const compatible = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
return modules.map((module) => { return modules.map((module) => {
const id = module.manifest.id; const id = module.manifest.id;
const version = module.manifest.version; const version = module.manifest.version;
@@ -326,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
) { ) {
throw new TypeError("Unsafe module diagnostic value."); throw new TypeError("Unsafe module diagnostic value.");
} }
return `${id} ${version}`; const declared = module.contributions.length;
if (declared === 0) {
return `${id} ${version}`;
}
const resolved = module.contributions.filter((contribution) =>
compatible.has(`${id}\u0000${contribution.id}`),
).length;
return `${id} ${version} · ${declared} browser contribution${
declared === 1 ? "" : "s"
} (${resolved} with a compatible provider)`;
}); });
} }
+85 -7
View File
@@ -57,6 +57,19 @@ export interface ModuleDiscoveryIssue {
readonly path?: string; readonly path?: string;
} }
/**
* One declared browser contribution, with its artifact resolved and validated exactly like every
* other compiled entrypoint (ADR 0041). Discovery never imports or evaluates it, and never learns
* what it renders.
*/
export interface ResolvedModuleContribution {
readonly id: string;
readonly version: string;
readonly capability: string;
readonly capabilityRange: string;
readonly artifactPath: string;
}
export interface ResolvedModuleEntrypoints { export interface ResolvedModuleEntrypoints {
readonly server: string; readonly server: string;
readonly schema?: string; readonly schema?: string;
@@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
export interface DiscoveredModule { export interface DiscoveredModule {
readonly directory: string; readonly directory: string;
/** Declared browser contributions, in manifest order, each resolved to a canonical artifact path
* inside this module package. Empty for a module that contributes nothing. */
readonly contributions: readonly ResolvedModuleContribution[];
readonly entrypoints: ResolvedModuleEntrypoints; readonly entrypoints: ResolvedModuleEntrypoints;
readonly manifest: ReadonlyModuleManifest; readonly manifest: ReadonlyModuleManifest;
readonly manifestPath: string; readonly manifestPath: string;
@@ -110,6 +126,7 @@ interface ModulePackageData {
} }
interface ModuleCandidate { interface ModuleCandidate {
contributions: readonly ResolvedModuleContribution[];
directory: string; directory: string;
directoryName: string; directoryName: string;
entrypoints: ResolvedModuleEntrypoints; entrypoints: ResolvedModuleEntrypoints;
@@ -257,13 +274,19 @@ function validatePackageData(
return { name, version }; return { name, version };
} }
interface ResolvedModuleArtifacts {
readonly contributions: readonly ResolvedModuleContribution[];
readonly entrypoints: ResolvedModuleEntrypoints;
}
async function resolveEntrypoints( async function resolveEntrypoints(
moduleDirectory: string, moduleDirectory: string,
directoryName: string, directoryName: string,
manifest: ModuleManifest, manifest: ModuleManifest,
issues: ModuleDiscoveryIssue[], issues: ModuleDiscoveryIssue[],
): Promise<ResolvedModuleEntrypoints | undefined> { ): Promise<ResolvedModuleArtifacts | undefined> {
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {}; const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
const resolvedContributions = new Map<string, string>();
const canonicalModuleDirectory = await realpath(moduleDirectory); const canonicalModuleDirectory = await realpath(moduleDirectory);
const { display, ...ordinaryEntrypoints } = manifest.entrypoints; const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints) const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
@@ -273,6 +296,15 @@ async function resolveEntrypoints(
issuePath: `entrypoints.${entrypointName}`, issuePath: `entrypoints.${entrypointName}`,
relativeEntrypoint, relativeEntrypoint,
})); }));
// A contribution artifact is resolved through the identical loop as every other compiled
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
// are one implementation, so a contribution can never quietly receive weaker checks than
// `entrypoints.ui` (ADR 0041).
const configuredContributions = manifest.contributions.map((contribution, index) => ({
entrypointName: `contribution:${contribution.id}`,
issuePath: `contributions.${index}.artifact`,
relativeEntrypoint: contribution.artifact,
}));
const configuredEntrypoints = [ const configuredEntrypoints = [
...ordinaryConfiguredEntrypoints, ...ordinaryConfiguredEntrypoints,
...(display === undefined ...(display === undefined
@@ -284,6 +316,7 @@ async function resolveEntrypoints(
relativeEntrypoint: display.artifact, relativeEntrypoint: display.artifact,
}, },
]), ]),
...configuredContributions,
]; ];
let entrypointCollision = false; let entrypointCollision = false;
@@ -361,7 +394,14 @@ async function resolveEntrypoints(
}); });
continue; continue;
} }
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint; if (entrypointName.startsWith("contribution:")) {
resolvedContributions.set(
entrypointName.slice("contribution:".length),
canonicalEntrypoint,
);
} else {
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
}
} catch (error) { } catch (error) {
const code = errorCode(error); const code = errorCode(error);
issues.push({ issues.push({
@@ -394,11 +434,46 @@ async function resolveEntrypoints(
} }
} }
if (!resolved.server || entrypointCollision) { // A contribution artifact must be its own file. Two declarations resolving to one path — through
// a link, since the manifest already rejects two identical declared paths — would make one
// contribution silently serve another's bytes, and the loader's identity check would then reject
// whichever one lost.
const canonicalContributionPaths = new Map<string, string>();
for (const [contributionId, canonicalPath] of resolvedContributions) {
const claimedBy = canonicalContributionPaths.get(canonicalPath);
if (claimedBy !== undefined || Object.values(resolved).includes(canonicalPath)) {
entrypointCollision = true;
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `contribution ${contributionId} must not resolve to the same file as ${claimedBy ?? "another entrypoint"}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "contributions",
});
continue;
}
canonicalContributionPaths.set(canonicalPath, contributionId);
}
if (
!resolved.server ||
entrypointCollision ||
resolvedContributions.size !== manifest.contributions.length
) {
return undefined; return undefined;
} }
return { const contributions = manifest.contributions.map((contribution) =>
Object.freeze({
artifactPath: resolvedContributions.get(contribution.id) as string,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: contribution.id,
version: contribution.version,
}),
);
const entrypoints: ResolvedModuleEntrypoints = {
server: resolved.server, server: resolved.server,
...(resolved.schema ? { schema: resolved.schema } : {}), ...(resolved.schema ? { schema: resolved.schema } : {}),
...(resolved.migrations ? { migrations: resolved.migrations } : {}), ...(resolved.migrations ? { migrations: resolved.migrations } : {}),
@@ -413,6 +488,8 @@ async function resolveEntrypoints(
} }
: {}), : {}),
}; };
return { contributions: Object.freeze(contributions), entrypoints };
} }
async function scanModuleDirectory( async function scanModuleDirectory(
@@ -510,15 +587,16 @@ async function scanModuleDirectory(
}); });
} }
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues); const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!entrypoints) { if (!artifacts) {
return undefined; return undefined;
} }
return { return {
contributions: artifacts.contributions,
directory: canonicalModuleDirectory, directory: canonicalModuleDirectory,
directoryName, directoryName,
entrypoints, entrypoints: artifacts.entrypoints,
manifest, manifest,
manifestPath: canonicalManifestPath, manifestPath: canonicalManifestPath,
packageName: packageData.name, packageName: packageData.name,
+2
View File
@@ -60,6 +60,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROLLBACK_TIMEOUT" | "MODULE_ROLLBACK_TIMEOUT"
| "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_DUPLICATE" | "MODULE_ROUTE_DUPLICATE"
| "MODULE_ROUTE_BODY_MODE_INVALID"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
| "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_OVERLAP" | "MODULE_ROUTE_OVERLAP"
@@ -467,6 +468,7 @@ class StagedModuleHttpRoutes {
shapes.add(shapeKey); shapes.add(shapeKey);
local.push( local.push(
Object.freeze({ Object.freeze({
bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler, handler: parsed.data.handler,
maxBodyBytes: parsed.data.maxBodyBytes, maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method, method: parsed.data.method,
+155 -1
View File
@@ -1,4 +1,8 @@
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts"; import {
isVersionCompatible,
type PermissionDeclaration,
type PermissionId,
} from "@ekklesia/contracts";
import type { DiscoveredModule } from "./discovery.js"; import type { DiscoveredModule } from "./discovery.js";
/** /**
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
readonly permissions: readonly PermissionDeclaration[]; readonly permissions: readonly PermissionDeclaration[];
} }
/**
* One installed module's browser contribution that the platform is willing to publish (ADR 0041).
*
* Publishable means three things, and nothing beyond them: the contributing module survived
* discovery and is in the eligible set, some **other** eligible module provides the targeted
* capability, and that provider's declared capability version satisfies the contribution's declared
* range. A contribution whose capability nobody provides, or whose provider moved outside its range,
* is simply absent — never published-but-broken, and never mounted with a shrug.
*
* Note what is *not* here. There is no payload schema, no slot, no ordering, no rendering hint, and
* no domain vocabulary of any kind: Core carries identity, version, compatibility, and one file
* path. What a contributed renderer draws, and what shape its input takes, are the consumer module's
* business and travel between the two modules in the browser without passing through the platform.
*/
export interface PublishableModuleContribution {
/** The module that *contributes* the renderer. */
readonly moduleId: string;
readonly moduleVersion: string;
readonly contributionId: string;
/** The contribution's own version, independent of its module's. */
readonly version: string;
readonly capability: string;
/** The version the providing module declares for that capability — the value the contribution's
* declared range was actually checked against. */
readonly capabilityVersion: string;
readonly artifactPath: string;
}
/** /**
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?" * The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
* *
@@ -89,6 +121,38 @@ export interface ModuleUiPublicationRegistry {
* a display merely because a caller appends `/display` to its publication URL. * a display merely because a caller appends `/display` to its publication URL.
*/ */
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined; resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
/** Every publishable browser contribution, in discovery order then manifest order. Frozen. */
listContributions(): readonly PublishableModuleContribution[];
/**
* Every publishable contribution targeting one capability, in the same stable order.
*
* This is the lookup a *consumer* module's descriptor is built from, and the reason a consumer can
* only ever learn about renderers aimed at a capability it actually provides: the caller passes
* its own declared capability id and gets back exactly the contributions for it.
*/
contributionsForCapability(capabilityId: unknown): readonly PublishableModuleContribution[];
/**
* Every publishable contribution aimed at **any** capability the named module provides.
*
* This is the one lookup a descriptor for a consuming module is built from. Framing it per module
* rather than per capability is what keeps capability ownership inside Core: a caller hands over a
* module id and gets that module's own extension points filled in, with no way to ask about
* someone else's.
*/
contributionsForModule(moduleId: unknown): readonly PublishableModuleContribution[];
/**
* The publishable record for an exact contributing module id, installed module version, and
* contribution id, or `undefined`.
*
* All three must match. A contribution that is not installed, one whose module is at a different
* version, one whose capability has no compatible provider, and an id that was never declared are
* all indistinguishable here — each returns `undefined`.
*/
resolveContribution(
moduleId: unknown,
moduleVersion: unknown,
contributionId: unknown,
): PublishableModuleContribution | undefined;
} }
/** /**
@@ -124,6 +188,41 @@ export function createModuleUiPublicationRegistry(
): ModuleUiPublicationRegistry { ): ModuleUiPublicationRegistry {
const byModuleId = new Map<string, PublishableModuleUi>(); const byModuleId = new Map<string, PublishableModuleUi>();
const displaysByModuleId = new Map<string, PublishableModuleDisplay>(); const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
const contributions: PublishableModuleContribution[] = [];
// Every capability the eligible set actually provides, at the version its provider declares. Built
// from the same list everything else here is built from, so a contribution can never be checked
// against a provider the platform is not running.
const capabilityVersions = new Map<string, string>();
for (const module of modules) {
for (const capability of module.manifest.capabilities) {
capabilityVersions.set(capability.id, capability.version);
}
}
for (const module of modules) {
for (const contribution of module.contributions) {
const capabilityVersion = capabilityVersions.get(contribution.capability);
// Fails closed on both halves: an absent provider and an incompatible one are equally
// unpublishable, so a range mismatch can never be papered over by mounting anyway.
if (
capabilityVersion === undefined ||
!isVersionCompatible(capabilityVersion, contribution.capabilityRange)
) {
continue;
}
contributions.push(
Object.freeze({
artifactPath: contribution.artifactPath,
capability: contribution.capability,
capabilityVersion,
contributionId: contribution.id,
moduleId: module.manifest.id,
moduleVersion: module.manifest.version,
version: contribution.version,
}),
);
}
}
for (const module of modules) { for (const module of modules) {
const artifactPath = module.entrypoints.ui; const artifactPath = module.entrypoints.ui;
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
const published = Object.freeze([...byModuleId.values()]); const published = Object.freeze([...byModuleId.values()]);
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]); const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
const publishedContributions = Object.freeze([...contributions]);
const contributionsByCapability = new Map<string, PublishableModuleContribution[]>();
const contributionsByKey = new Map<string, PublishableModuleContribution>();
for (const contribution of publishedContributions) {
const forCapability = contributionsByCapability.get(contribution.capability) ?? [];
forCapability.push(contribution);
contributionsByCapability.set(contribution.capability, forCapability);
// Module id and contribution id are both namespaced and unique, so this key cannot collide.
contributionsByKey.set(
`${contribution.moduleId}\u0000${contribution.contributionId}`,
contribution,
);
}
const frozenByCapability = new Map(
[...contributionsByCapability].map(([capability, list]) => [capability, Object.freeze(list)]),
);
const noContributions: readonly PublishableModuleContribution[] = Object.freeze([]);
const byConsumingModule = new Map<string, readonly PublishableModuleContribution[]>(
modules.map((module) => [
module.manifest.id,
Object.freeze(
module.manifest.capabilities.flatMap(
(capability) => contributionsByCapability.get(capability.id) ?? [],
),
),
]),
);
return Object.freeze({ return Object.freeze({
contributionsForCapability: (
capabilityId: unknown,
): readonly PublishableModuleContribution[] =>
typeof capabilityId === "string"
? (frozenByCapability.get(capabilityId) ?? noContributions)
: noContributions,
contributionsForModule: (moduleId: unknown): readonly PublishableModuleContribution[] =>
typeof moduleId === "string"
? (byConsumingModule.get(moduleId) ?? noContributions)
: noContributions,
listContributions: (): readonly PublishableModuleContribution[] => publishedContributions,
resolveContribution: (
moduleId: unknown,
moduleVersion: unknown,
contributionId: unknown,
): PublishableModuleContribution | undefined => {
if (
typeof moduleId !== "string" ||
typeof moduleVersion !== "string" ||
typeof contributionId !== "string"
) {
return undefined;
}
const candidate = contributionsByKey.get(`${moduleId}\u0000${contributionId}`);
// Exact installed-version equality, exactly as the UI and display lookups require: a request
// naming a version the platform does not have installed resolves to nothing at all.
return candidate?.moduleVersion === moduleVersion ? candidate : undefined;
},
list: (): readonly PublishableModuleUi[] => published, list: (): readonly PublishableModuleUi[] => published,
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays, listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => { resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}), : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
+2 -1
View File
@@ -69,6 +69,7 @@ const mailEnvironment = Object.freeze({
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule { function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
return { return {
contributions: [],
directory: `/unpublished/${id}`, directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` }, entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: { manifest: {
@@ -81,7 +82,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
+90 -1
View File
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
return { return {
contributions: [],
directory: `/unpublished/${id}`, directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` }, entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: { manifest: {
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
@@ -1471,3 +1472,91 @@ describe("Ekklesia Doctor — object storage", () => {
expect(check(report, "object-storage.bindings").status).toBe("skipped"); expect(check(report, "object-storage.bindings").status).toBe("skipped");
}); });
}); });
/**
* A browser contribution whose target capability nobody provides is otherwise silent: the manifest
* is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041).
* Doctor names it.
*/
describe("Doctor reports declared browser contributions", () => {
function contributing(
id: string,
contribution: { capability: string; capabilityRange: string },
): DiscoveredModule {
const base = discoveredModule(id);
return {
...base,
contributions: [
{
artifactPath: `/unpublished/${id}/contributions/slide.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${id}.slide`,
version: "1.0.0",
},
],
};
}
function providing(id: string, capability: { id: string; version: string }): DiscoveredModule {
const base = discoveredModule(id);
return {
...base,
manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"],
};
}
async function detailsFor(modules: readonly DiscoveredModule[]): Promise<readonly string[]> {
const report = await diagnoseEkklesia(
options(),
dependencies({
discoverModules: vi.fn(async () => Object.freeze([...modules])),
discoverSchemas: vi.fn(async () => schemaAggregate(modules)),
}),
);
return check(report, "modules.discovery").details;
}
it("says nothing extra for a module that contributes nothing", async () => {
expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]);
});
it("reports a contribution whose provider satisfies its declared range", async () => {
const details = await detailsFor([
providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }),
contributing("announcements", {
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
}),
]);
expect(details).toEqual([
"signage 0.1.0",
"announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)",
]);
});
it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => {
expect(
await detailsFor([
contributing("announcements", {
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
}),
]),
).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]);
expect(
await detailsFor([
providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }),
contributing("announcements", {
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
}),
]),
).toEqual([
"signage 0.1.0",
"announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)",
]);
});
});
@@ -1,5 +1,5 @@
{ {
"manifestVersion": 6, "manifestVersion": 7,
"id": "db-facade", "id": "db-facade",
"name": "Database Facade Integration Test Module", "name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.", "description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
@@ -17,7 +17,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -27,6 +27,7 @@ function fakeModule(
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -194,7 +194,7 @@ afterAll(async () => {
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") { function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({ return parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements", id = "announcements",
) { ) {
return parseModuleManifest({ return parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => { it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => { it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => { it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
], ],
])("rejects %s", (_label, declaration) => { ])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
"valueOf", "valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => { ])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
"", "",
])("rejects the malformed configuration key %s", (key) => { ])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+177 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> { function manifestFor(id: string): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -957,3 +957,179 @@ describe("module discovery", () => {
}); });
}); });
}); });
/**
* A contribution artifact is resolved through the identical loop as every other compiled entrypoint
* (ADR 0041), so it inherits every protection `entrypoints.ui` already had rather than receiving a
* second, weaker implementation of them.
*/
describe("browser contribution artifact resolution", () => {
function contributingManifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
...manifestFor("announcements"),
contributions: [
{
id: "announcements.signage-slide",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/signage-slide/index.js",
...overrides,
},
],
};
}
it("resolves a declared contribution to a canonical path inside the package", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
extraFiles: { "dist/contributions/signage-slide/index.js": "export default {};" },
manifest: contributingManifest(),
});
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const contribution = modules[0]?.contributions[0];
expect(contribution).toMatchObject({
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
id: "announcements.signage-slide",
version: "1.0.0",
});
expect(contribution?.artifactPath.endsWith("dist/contributions/signage-slide/index.js")).toBe(
true,
);
expect(path.isAbsolute(contribution?.artifactPath ?? "")).toBe(true);
});
it("reports an empty contribution list for a module that contributes nothing", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "calendar");
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(modules[0]?.contributions).toEqual([]);
});
it("rejects a missing contribution artifact", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", { manifest: contributingManifest() });
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ code: "MODULE_ENTRYPOINT_MISSING" }),
]),
});
});
it("rejects a contribution artifact that is not compiled JavaScript", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
extraFiles: { "src/contributions/signage-slide/index.tsx": "export default {};" },
manifest: contributingManifest({ artifact: "./src/contributions/signage-slide/index.tsx" }),
});
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID" }),
]),
});
});
it("rejects a contribution artifact that escapes the package through a link", async () => {
const modulesDirectory = await createModulesDirectory();
const outside = await createModulesDirectory();
await writeFile(path.join(outside, "elsewhere.js"), "export default {};");
await createModule(modulesDirectory, "announcements", {
manifest: contributingManifest(),
});
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/signage-slide"), {
recursive: true,
});
await symlink(
path.join(outside, "elsewhere.js"),
path.join(modulesDirectory, "announcements/dist/contributions/signage-slide/index.js"),
);
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
path: "contributions.0.artifact",
}),
]),
});
});
it("rejects two contributions that resolve through a link to the same file", async () => {
const modulesDirectory = await createModulesDirectory();
const manifest = {
...manifestFor("announcements"),
contributions: [
{
id: "announcements.first",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/first/index.js",
},
{
id: "announcements.second",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/second/index.js",
},
],
};
await createModule(modulesDirectory, "announcements", {
extraFiles: { "dist/contributions/first/index.js": "export default {};" },
manifest,
});
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/second"), {
recursive: true,
});
await symlink(
path.join(modulesDirectory, "announcements/dist/contributions/first/index.js"),
path.join(modulesDirectory, "announcements/dist/contributions/second/index.js"),
);
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID", path: "contributions" }),
]),
});
});
});
@@ -200,7 +200,7 @@ afterAll(async () => {
* by eligibility resolution. */ * by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule { function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -209,6 +209,7 @@ function fakeModule(id: string, dependencies?: Record<string, unknown>): Discove
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
+3 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -36,6 +36,7 @@ function fakeModule(
}, },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -65,7 +65,7 @@ function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -1
View File
@@ -28,7 +28,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */ * is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule { function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D
}, },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
+1 -1
View File
@@ -31,7 +31,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [], permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -1
View File
@@ -57,7 +57,7 @@ function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -861,6 +861,7 @@ describe("module HTTP routes", () => {
expect(runtime.routes).toHaveLength(3); expect(runtime.routes).toHaveLength(3);
for (const route of runtime.routes) { for (const route of runtime.routes) {
expect(Object.keys(route).sort()).toEqual([ expect(Object.keys(route).sort()).toEqual([
"bodyMode",
"handler", "handler",
"maxBodyBytes", "maxBodyBytes",
"method", "method",
+1 -1
View File
@@ -41,7 +41,7 @@ function manifestFor(
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {}, options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -29,6 +29,7 @@ function fakeModule(
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
+152 -1
View File
@@ -29,6 +29,15 @@ interface ModuleFixture {
readonly ui?: string; readonly ui?: string;
/** A dedicated full-viewport artifact, independent of the primary UI. */ /** A dedicated full-viewport artifact, independent of the primary UI. */
readonly display?: string; readonly display?: string;
/** Capabilities this module provides — what another module's contributions can target. */
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
/** Browser renderers this module contributes to another module's capability. */
readonly contributions?: readonly {
readonly localName: string;
readonly version: string;
readonly capability: string;
readonly capabilityRange: string;
}[];
} }
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> { async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
@@ -61,8 +70,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
}, },
}), }),
}, },
capabilities: fixture.capabilities ?? [],
contributions: (fixture.contributions ?? []).map((contribution) => ({
artifact: `./dist/contributions/${contribution.localName}/index.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${fixture.id}.${contribution.localName}`,
version: contribution.version,
})),
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: permissions:
fixture.display === undefined fixture.display === undefined
@@ -89,6 +106,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display); await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
} }
for (const contribution of fixture.contributions ?? []) {
const contributionDirectory = path.join(
moduleDirectory,
"dist/contributions",
contribution.localName,
);
await mkdir(contributionDirectory, { recursive: true });
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};");
}
} }
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that /** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
@@ -294,3 +320,128 @@ describe("module display UI publication registry", () => {
expect(registry.listDisplays()).toBe(registry.listDisplays()); expect(registry.listDisplays()).toBe(registry.listDisplays());
}); });
}); });
/**
* The generic contribution mechanism (ADR 0041), exercised through the exact relationship it exists
* for and with no platform knowledge of what any of it means: `signage` provides a capability,
* `announcements` contributes a renderer to it, and neither module imports the other.
*/
describe("module contribution publication", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
};
const contributor = (capabilityRange: string) => ({
contributions: [
{
capability: "signage.slide-renderer",
capabilityRange,
localName: "signage-slide",
version: "1.0.0",
},
],
id: "announcements",
});
it("publishes a contribution whose target capability is provided within range", async () => {
const modules = await discover([signage, contributor("^1.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.listContributions()).toHaveLength(1);
expect(
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
).toMatchObject({
capability: "signage.slide-renderer",
// The provider's declared version, so the range was checked against something real.
capabilityVersion: "1.2.0",
contributionId: "announcements.signage-slide",
moduleId: "announcements",
version: "1.0.0",
});
});
it("serves exactly the canonical artifact path discovery validated", async () => {
const modules = await discover([signage, contributor("^1.0.0")]);
const announcements = modules.find((module) => module.manifest.id === "announcements");
const registry = createModuleUiPublicationRegistry(modules);
const published = registry.resolveContribution(
"announcements",
"0.1.0",
"announcements.signage-slide",
);
expect(published?.artifactPath).toBe(announcements?.contributions[0]?.artifactPath);
expect(path.isAbsolute(published?.artifactPath ?? "")).toBe(true);
});
it("withholds a contribution whose capability no installed module provides", async () => {
const modules = await discover([contributor("^1.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.listContributions()).toEqual([]);
expect(
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
).toBeUndefined();
});
it("withholds a contribution whose provider is outside its declared compatible range", async () => {
// Fails closed: an incompatible renderer is absent rather than mounted and broken.
const modules = await discover([signage, contributor("^2.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.listContributions()).toEqual([]);
expect(registry.contributionsForModule("signage")).toEqual([]);
});
it("gives a consuming module exactly the contributions aimed at its own capabilities", async () => {
const modules = await discover([
signage,
{ capabilities: [{ id: "calendar.week-view", version: "1.0.0" }], id: "calendar" },
contributor("^1.0.0"),
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.contributionsForModule("signage").map((entry) => entry.contributionId)).toEqual(
["announcements.signage-slide"],
);
// A module that provides a different capability sees nothing, and the *contributing* module
// does not see its own contribution: it provides no capability it was aimed at.
expect(registry.contributionsForModule("calendar")).toEqual([]);
expect(registry.contributionsForModule("announcements")).toEqual([]);
});
it("fails a lookup closed for an unknown, mismatched, or non-string identity", async () => {
const modules = await discover([signage, contributor("^1.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
expect(
registry.resolveContribution("announcements", "9.9.9", "announcements.signage-slide"),
).toBeUndefined();
expect(
registry.resolveContribution("signage", "0.1.0", "announcements.signage-slide"),
).toBeUndefined();
expect(
registry.resolveContribution("announcements", "0.1.0", "announcements.nope"),
).toBeUndefined();
expect(registry.resolveContribution(7, "0.1.0", "announcements.signage-slide")).toBeUndefined();
expect(registry.contributionsForCapability(undefined)).toEqual([]);
});
it("carries no domain vocabulary at all", async () => {
const modules = await discover([signage, contributor("^1.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
const published = registry.listContributions()[0];
// Core knows identity, version, compatibility, and one path. There is deliberately no payload
// schema, slot, ordering, or rendering hint — what a renderer draws is not the platform's.
expect(Object.keys(published ?? {}).sort()).toEqual([
"artifactPath",
"capability",
"capabilityVersion",
"contributionId",
"moduleId",
"moduleVersion",
"version",
]);
});
});
@@ -100,6 +100,15 @@ This is deliberately the smallest viable styling story. A real stylesheet contra
field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing
them here would expand the manifest and runtime contract before a host can validate them. them here would expand the manifest and runtime contract before a host can validate them.
> **Correction — this rule is superseded.**
> [ADR 0040](0040-module-stylesheets-and-artifact-isolation.md) supplies the three missing pieces and
> reverses this one decision: a module may import plain `.css` from a browser-artifact source root,
> the canonical builder compiles it **into** the declared artifact as text (so no sidecar is ever
> emitted, which is what this section was protecting), and the host installs it into a Shadow DOM
> root it creates for every artifact. No manifest field was needed after all. The rest of this ADR —
> esbuild, bundled React, fixed source paths, no author bundler configuration, unminified output — is
> unchanged.
### Browser TypeScript stays scoped to UI source ### Browser TypeScript stays scoped to UI source
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets `@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
@@ -0,0 +1,156 @@
# 0039: Shared design tokens and foundational elements
- Status: Accepted
- Date: 2026-08-27
- Supersedes: none
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
[ADR 0026](0026-minimal-ekklesia-module-ui-host.md),
[ADR 0033](0033-display-kiosk-host-contract.md)
## Context
ADR 0021 made a module an independently versioned product that owns its own UI, and ADR 0022 chose
React as the way to author it. Since then the platform has been able to *mount* a module's screen but
has said nothing at all about what that screen should look like. The consequences are now concrete:
- Every screen in this repository — the Dashboard shell, the display host, the module template —
hardcodes its own hex colours, font stack, spacing, and radii. They already disagree with each
other.
- A module author has no vocabulary for "the ordinary panel background" or "the danger text colour",
so the only available answer is to invent one. Two modules will invent two.
- Deferred repeatedly and named in ADR 0022, ADR 0023, and ADR 0033's deferral lists: "a design
system, component library, or theme/design-token contract."
- A theme editor is a real product requirement for a church platform, and it is only implementable
if the values it would edit are enumerable with human-readable names — which is a property of the
*contract*, not something that can be retrofitted onto a pile of literals.
The constraint that shapes everything below is ADR 0024's browser boundary. Whatever the shared
design layer is, module UI imports it, so it must not be able to pull a backend type graph — and with
it Node's global declarations — into a browser program.
## Decision
Add two shared browser-safe packages and open exactly two boundary roles to module UI.
### `@ekklesia/design-tokens` — the semantic token registry
A dependency-free package (like `@ekklesia/module-ui-sdk`, and for the same reason) holding one
validated registry of **semantic** tokens: `background.page`, `text.danger`, `state.hover-background`,
`typography.size-heading-1`, and so on. Nothing is named for the colour it currently happens to be,
because the point is that a value can change without a consumer's meaning changing with it.
Each token declares a name, a human-readable label, a description, and a default value. Its
**category and CSS custom-property name are derived** from the name rather than declared, so a token
can never claim one category while being named for another, and there is exactly one spelling of
`--ekklesia-<name>`. That is what makes the registry enumerable: a future theme editor reads
`list()`, groups by `category`, labels each field with `label`, explains it with `description`, and
needs no second source of truth.
Construction validates every rule rather than leaving it to review: an invalid or single-segment
name, an unknown category, a malformed derived identifier, an empty or overlong label or description,
a duplicate name, and a value that is not a **bounded literal** each throw. A value may not contain a
declaration terminator, a rule block, a comment, an at-rule, `url(`, `!important`, a backslash
escape, a control character, or markup, and is length-bounded. A registry that constructs is
therefore a registry that can be emitted as CSS without escaping, because nothing needing escaping
can be in it.
The registry emits its defaults as one CSS rule, scoped to `:root` or `:host` — a closed set, never a
caller-supplied selector, since an arbitrary selector string is exactly the unbounded value the
package refuses everywhere else.
### `@ekklesia/ui` — the foundational elements
A small React package whose every colour, type, spacing, radius, shadow, and interaction state is a
`var(--ekklesia-…)` reference. It contains the set a real module dashboard cannot be built without:
button and icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and
list primitives, badge, toast presentation, and empty/error/loading states, with `:focus-visible`
focus treatment on every interactive element.
Each element is a thin, accessible shell over the real HTML element rather than a re-implementation
of it, so native attributes, form association, and assistive-technology semantics pass straight
through. **This is deliberately not a large catalogue**: an element earns its place when two
independent consumers need the same one.
**Status is never signalled by colour alone.** Every status family carries a symbol token beside its
colours, and the elements render it. A status that is only red is invisible to a person who cannot
distinguish it, and unreadable on a projector or a washed-out lobby screen (WCAG 2.2 §1.4.1).
### The stylesheet is a string, not a `.css` file
These elements are mounted in two very different places: a document (the Dashboard shell, a
standalone host page) and an isolated root (a module's own artifact). A `.css` file would need a
bundler on both sides and a served asset on one of them. A string is installed into whichever root
the caller actually has, with identical bytes — and it is what lets `:focus-visible`, `:hover`, and
`:disabled` be real CSS rather than JavaScript approximations of them, which is the difference
between a focus treatment that works for a keyboard user and one that only looks like it does.
`<EkklesiaUiStyles />` renders that string into whichever root its React tree lives in. There is
deliberately no `document.head` hoisting and no `precedence` attribute: hoisting an isolated
artifact's styles into the host document is precisely the leak the platform must not have.
### Modules consume tokens; they never invent global ones
A module may reference any registered token and may derive **private, differently-prefixed aliases**
from them inside its own artifact. A module may not define an `--ekklesia-*` custom property: that
would add a global theme variable outside the registry, which a theme editor could never enumerate
and another module could unknowingly depend on. The typed `designToken(name)` reference makes an
unregistered name a compile error and a runtime failure.
### The boundary opens by name, not by category
`pnpm boundaries` adds `design-tokens` and `ui` to the shared roles a module may depend on. It is a
named allowlist, not "browser-safe shared packages are fine": `api-client`, `auth-client`,
`surface-sdk`, and `module-ui-host` all stay rejected for modules, and `contracts`/`module-sdk`
remain excluded from surfaces for the reason ADR 0024 measured.
The two new packages are pinned at the bottom of the presentation stack by their own rule:
`design-tokens` may depend on nothing but dev-time tooling, and `ui` may depend on `design-tokens`
and dev-time tooling. A design package that could reach an API client would put that client inside
every module artifact that renders a button.
### The host installs the theme once, at its root
`surfaces/shell` installs the default token values at the document for both the Dashboard and the
display application. CSS custom properties inherit through a shadow boundary, so every module
artifact — including one mounted in an isolated root — reads `var(--ekklesia-…)` without the host
reaching into the artifact or the artifact importing the host.
## Consequences
- Two packages exist where none did; both are browser-safe by construction and proven so by a real
compile of module-shaped source under the exact project a module uses for UI, where `process.env`
must fail to typecheck.
- The Dashboard shell and display host no longer hardcode a colour, and a module can match them.
- A theme editor becomes implementable without a contract change: the values it would edit are
already enumerable, labelled, described, categorised, and validated.
- A module now has more it *may* import. The allowlist is exactly two roles and is enforced, with
fixtures proving both the acceptance and the continued rejection of everything else.
- Every module artifact bundles its own copy of the elements, exactly as it already bundles React
(ADR 0023). That cost is accepted for now and is optimisable later without a contract change,
because the contract is still a mount function.
## Alternatives
- **Ship a full design system.** Rejected: a large catalogue is a large maintenance and review
surface, and most of it would be speculative. The set here is what a real module dashboard needs.
- **Put the tokens in `@ekklesia/module-ui-sdk`.** Rejected: that package is the *host contract*, and
a host that renders nothing still has to satisfy it. Design values are not something a host
supplies, and merging them would make the mount ABI carry a theme.
- **Expose tokens through `ModuleUiHostContext`.** Rejected: a host handing a module a token map
makes every module re-implement styling from data, and makes the tokens a runtime negotiation
rather than inherited CSS. Custom properties already inherit into artifacts for free.
- **Ship a `.css` file.** Rejected for this pass: it forces a bundler and a served asset on every
consumer, including the isolated artifacts that most need the styles. A *module's own* stylesheets
are a separate question — the module's build already exists to compile them — and are decided by
their own ADR.
- **A theme editor now.** Deferred deliberately. The registry is the prerequisite; the editor needs
persistence, an authorization story, per-installation overrides, and a preview surface, none of
which exist.
## Deferred
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
documentation site, and a focus-trap utility.
@@ -0,0 +1,166 @@
# 0040: Module stylesheets, and every browser artifact gets an isolated root
- Status: Accepted
- Date: 2026-08-27
- Supersedes: none
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
[ADR 0023](0023-module-ui-build-pipeline.md),
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0039](0039-shared-design-tokens-and-elements.md)
## Context
ADR 0023 rejected stylesheet imports in module UI, and gave an honest reason: esbuild would emit a
`.css` sidecar beside the artifact, nothing would declare it, no host would fetch it, and it would
*work in a local test and silently do nothing in a real host*. Inline style objects were the whole
supported story, and a stylesheet contract was deferred to "a manifest field, a host loading
mechanism, and a style-isolation decision, none of which exist yet."
Two of those three now exist or are unnecessary, and the remaining gap is blocking real work:
- Inline style objects cannot express `:hover`, `:focus-visible`, `:disabled`, `@media`, or
`@keyframes`. A production management screen needs all of them, and an accessible focus treatment
needs `:focus-visible` specifically. This is not a preference; it is a capability inline styles do
not have.
- ADR 0039's shared elements are built from real CSS for exactly that reason, so a module using them
already needs somewhere for a stylesheet to live.
- A contributed renderer (a later ADR) makes the isolation question urgent rather than theoretical:
one module's renderer mounted inside another module's screen is precisely where a leaked selector
does damage nobody can trace.
The manifest field turns out not to be needed, and the loading mechanism and the isolation decision
turn out to be the same decision.
## Decision
### A stylesheet is compiled **into** the declared artifact, as text
A module imports its local `.css` files. `@ekklesia/module-ui-build` compiles each one — resolving
local `@import`s through a nested bundle — into a JavaScript module whose default export is that
stylesheet's text. The author passes the text to `defineModuleUi({ styles })`.
That is why no manifest field is required. There is still exactly **one** declared file per browser
role, containing everything: nothing new to publish, nothing for a host to fetch, no second URL, and
no way for a stylesheet to become an undeclared sidecar — the failure ADR 0023 named. The builder
additionally fails if any CSS output file is ever produced beside an artifact, so a future option or
loader change cannot quietly reintroduce one.
Two rules are enforced on the compiled text, each with its own sanitized error code:
- **A module stylesheet may reference Ekklesia's global theme variables and must never declare one.**
Declaring `--ekklesia-*` would invent a global outside the registry a future theme editor
enumerates (ADR 0039). A private alias under the module's own prefix is the supported way to name
a local value, and is explicitly allowed.
- **An `@import` that survives compilation is rejected.** A local import is inlined and leaves
nothing behind, so anything remaining is a remote or unresolvable reference, and an artifact that
fetches a stylesheet at run time is not self-contained.
Plain `.css` only. `.scss`, `.less`, `.styl`, and friends stay rejected: adding a preprocessor is a
language and toolchain decision needing its own ADR, not a detail of a build pass.
`pnpm boundaries` allows `.css` inside a module **only** under the browser-artifact source roots the
canonical builder actually compiles (`src/ui/`, `src/display/`, and the contribution sources a later
ADR adds). Anywhere else in a module a stylesheet is a file no build reads and no host loads — the
undeclared sidecar again, just relocated. Every other stylesheet dialect stays rejected everywhere.
### Every browser artifact mounts inside an isolated root
The generic loader creates a Shadow DOM root inside the host's container, installs the artifact's
stylesheet there, and hands the artifact an element inside that root:
```text
host container ← the host's element; the loader never mutates it beyond this one child
└─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
#shadow-root (open)
├─ <style data-ekklesia-module-ui-styles> base rules, then the artifact's declared CSS
└─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
```
Shadow DOM is chosen because it is the one mechanism in a browser that **enforces** the rule rather
than describing it: rules inside a shadow root do not apply outside it, and document rules do not
apply inside it. A class-name convention, a build-time prefix, or a "please scope your selectors"
instruction are conventions that one mistake defeats, and the failure mode is a module silently
restyling the Dashboard or another module's renderer.
This applies to **every** role — primary UI, display UI, and contributed renderers — unconditionally,
including artifacts that declare no stylesheet. One mounting path, not a styled one and an unstyled
one, because a conditional isolation boundary is one a module can accidentally fall out of.
Both wrapper elements are `display: contents` through a base stylesheet the loader installs *before*
the artifact's own, so the loader's two elements contribute no boxes and an artifact lays out exactly
as it did before isolation existed — and an artifact that genuinely needs a box (a full-viewport
display) overrides `:host` by ordinary cascade order rather than by anything the host special-cases.
`mode: "open"` is deliberate. **This is not a security boundary, and must not be described as one.**
An installed module is trusted first-party code running in the host's own realm (ADR 0022, ADR 0024);
it could reach `document` directly if it wanted to. `closed` would buy no protection while making the
artifact's DOM unreachable to the host's own tests and diagnostics. This is a correctness boundary
for *styles*.
Design tokens still reach the artifact: CSS custom properties inherit through a shadow boundary, so a
host that installed the theme at its root (ADR 0039) has already supplied every artifact on the page.
### Mount and unmount are deterministic, in both directions
Each mount builds a fresh root with exactly one `<style>`. Unmount runs the module's cleanup and then
removes the whole root — content and styles together — in a `finally`, so a module whose cleanup
throws still has its stylesheet taken out of the page. Remounting cannot accumulate a second copy
because there is nothing left to accumulate onto. A mount that throws disposes the root it built, so
a failed artifact leaves neither partial DOM nor installed styles behind.
### The mount ABI is versioned, and the cutover is hard
An artifact's DOM is no longer a direct child of the host's element, and the definition gained a
field. That is observable, so `MODULE_UI_ABI_VERSION` is now `2` and `defineModuleUi` stamps it —
the author never writes it, so it can never drift from the SDK the artifact actually compiled
against.
There is no dual acceptance, exactly as there is none for a manifest version (ADR 0006, ADR 0014,
ADR 0021, ADR 0033). A version-1 artifact fails with `MODULE_UI_ABI_UNSUPPORTED` — checked first and
on its own, so the message is "rebuild your module" rather than a developer hunting for a typo.
Migration is one `pnpm build`: the canonical builder owns every option, and a module that declares no
stylesheet needs no source change at all.
## Consequences
- Module UI can express hover, focus-visible, disabled, media queries, and keyframes, so ADR 0039's
accessible focus treatment actually works inside a module artifact.
- One module's stylesheet provably cannot reach the Dashboard, another module, or another renderer,
and the Dashboard's cannot reach in. Two artifacts of the *same* module are isolated from each
other too: they share styles only if they share a source file.
- A host's own tests reach an artifact's DOM through its open root rather than through
`container.querySelector`. Every mounting test in this repository was updated accordingly; that
churn is the visible price of the boundary being real.
- Every module must be rebuilt once. Nothing else changes for an author who uses no stylesheet.
- The artifact is larger by the size of its CSS, exactly as it is larger by the size of React
(ADR 0023). It remains one file.
- A module still cannot reference a bundled image or font from CSS: no asset loader is configured, so
such a reference fails the build. Data URLs work; object storage remains the answer for real media.
## Alternatives
- **Keep inline styles only.** Rejected: it cannot express the pseudo-classes an accessible control
needs, and the workaround — JavaScript hover and focus tracking — is worse in every way.
- **Publish a `.css` sidecar and have the host `<link>` it.** Rejected: it needs a manifest field, a
second publication route, a second cache policy, and a second failure mode, and it reintroduces the
exact "a file nothing declares" hazard ADR 0023 refused. Compiling into the one declared artifact
costs none of that.
- **Scope styles by build-time class prefixing instead of Shadow DOM.** Rejected: a convention a
single unprefixed selector defeats, with a silent failure mode and no way for the host to prove
containment. It also cannot stop the *host's* rules from reaching into an artifact.
- **Isolate only artifacts that declare a stylesheet.** Rejected: an artifact would fall in and out
of isolation as a source file changed, and a module rendering the shared elements' `<style>` from
inside React would leak into the host document without ever importing a `.css` file.
- **`mode: "closed"`.** Rejected as security theatre: the artifact is same-realm trusted code, and
closing the root only blinds the host's own diagnostics.
- **Accept both ABI versions.** Rejected for consistency with every manifest cutover this repository
has made: dual acceptance means one artifact can be read two ways, and the migration here is a
rebuild rather than an edit.
## Deferred
CSS preprocessors, CSS Modules, bundled image and font assets, `adoptedStyleSheets` for shared
constructable stylesheets across artifacts, a host-supplied reset inside artifacts beyond the two
layout rules, and any claim that Shadow DOM confines module *code*.
@@ -0,0 +1,193 @@
# 0041: One module may contribute a browser renderer to another module's capability
- Status: Accepted
- Date: 2026-08-27
- Supersedes: none
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md)
## Context
The platform already has a complete story for one module using another's *backend*: capabilities,
versioned dependency ranges, and provider registration (ADR 0006). It has no story at all for one
module using another's *browser* code, and the first real product needs one.
The concrete shape, stated so the mechanism can be judged against it rather than against an
abstraction:
```text
Signage provides a backend capability and owns a screen made of timeline slots
Announcements requires that capability and registers a backend provider for it
Announcements also ships a React renderer that knows how an announcement should look
Signage mounts that renderer for one slot, drives it, and takes it down
```
Every alternative available today is wrong in a way that matters. Signage cannot import
Announcements: modules are independently installed, independently versioned peers, and
`pnpm boundaries` rejects it outright. Signage cannot re-implement the renderer: it does not own the
domain and the two would drift on the first change. Announcements cannot declare a second primary UI:
`entrypoints.ui` and `entrypoints.display` are artifacts a *host* mounts in surfaces the host owns,
and nothing about "a slot inside another module's timeline" is a host concern. And the platform must
not learn what a slide is — the moment Core has a `slide` type, every future consumer inherits
Signage's vocabulary.
## Decision
Add a generic **browser contribution**: a compiled renderer one module declares for a capability
another module provides, mounted by that other module inside a container it owns.
### The manifest declaration is inert and small
`manifestVersion: 7` adds a top-level `contributions` array. Each entry declares:
| Field | Meaning |
| --- | --- |
| `id` | `<module-id>.<local-name>` — namespaced, so uniqueness needs no registry |
| `version` | The contribution's own version, independent of its module's |
| `capability` | The capability it plugs into, owned by whichever module provides it |
| `capabilityRange` | The provider versions it is compatible with |
| `artifact` | One compiled browser artifact, package-relative |
That is the complete list, and the omissions are the decision. There is no payload schema, no slot,
no ordering, no rendering hint, and no domain vocabulary of any kind. **Core cannot learn what a
slide is from this declaration, because the declaration cannot express it.**
Validation is static and fails closed: the id must be namespaced to the declaring module and have
exactly one local segment; ids and artifact paths must be unique within a module; the artifact must
not collide with any entrypoint; and a contribution may not target a capability its own module
provides — a module contributing to itself needs no platform mechanism and would make the
compatibility check a comparison of a module with itself.
Version 7 is a hard cutover, exactly as every prior manifest version has been (ADR 0006, 0014, 0021,
0033). Upgrading a module that contributes nothing is a one-character edit.
### Publication is the existing mechanism, extended
Discovery resolves each contribution artifact through **the identical loop** every other compiled
entrypoint uses, so a contribution inherits the path grammar, containment, symlink-resolution,
extension, and file-kind checks rather than receiving a second, weaker implementation of them. Two
contributions that resolve through a link to one file are rejected.
The publication registry adds contributions on three conditions, all of which must hold: the
contributing module is in the eligible set, *some other* eligible module provides the target
capability, and that provider's declared capability version satisfies the declared range. A
contribution that fails any of them is **absent** — never published-but-broken, and never mounted
with a shrug.
`apps/api` serves it at `GET /api/v1/module-ui/<module-id>/<version>/contributions/<contribution-id>`
— a sibling of the artifact and display routes, under the same prefix, with the same policy: three
lookup keys, one exact registry match, one already-validated canonical path, no request value ever
composed into a filesystem path, and one indistinguishable `404` for unknown, undeclared, stale, and
incompatible alike. Source maps, sibling chunks, and package-root files stay unreachable by
construction.
### A consumer discovers through the host, never through a URL
The consuming module's descriptor gains a `contributions` array: identity, version, capability, and
the **server-produced** URL. A module UI sees only the first three; the URL lives in the host, which
resolves it exactly as it resolves `api.request("records")`. A module that built
`/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would be welded to Ekklesia's private
deployment detail and could never run under a standalone host.
`ModuleUiHostContext` gains a sixth member, `contributions`, with `list(capabilityId)` and
`mount({ contributionId, container, input })`. A consumer can only ever see contributions aimed at a
capability **it** provides, because that is the only thing the server put in its descriptor — and it
cannot name its way to anything else, because a `mount` for an id the server withheld fails without
fetching anything.
### The consumer owns the domain contract, entirely
`input` is opaque JSON. The platform validates that it *is* genuinely JSON-serializable — structurally,
so a `Date`, a function, `undefined`, a non-finite number, or a cycle fails at the boundary rather
than vanishing somewhere far from the mistake — and validates nothing about its shape or meaning. A
consumer publishes its own input contract alongside its capability and versions it through its own
capability version. Neither the manifest, the registry, the route, nor the SDK has a place to put a
schema, which is what stops one from appearing.
### The contribution ABI is separate and versioned
`MODULE_CONTRIBUTION_ABI_VERSION` is stamped by `defineModuleContribution`, never written by an
author. It is deliberately **not** the same number as `MODULE_UI_ABI_VERSION`: the two contracts have
different lifecycles — one receives a host context, the other receives a consumer's input and can be
updated in place — and versioning them together would force every module to rebuild whenever either
moved. A mismatched artifact fails with its own code before mounting.
The ABI supports exactly what a long-running screen needs:
- **asynchronous readiness** — `mount` may return a promise, so a consumer can sequence a transition
instead of guessing when a renderer has decoded its media;
- **update without remounting** — remounting per change would destroy DOM, restart animations, and
reload media on every tick. A renderer that genuinely cannot update in place omits `update`, and
the consumer is told to remount rather than left rendering stale content behind a silently ignored
call;
- **deterministic cleanup** — the loader runs the renderer's cleanup and removes its isolated root in
a `finally`, so a renderer that fails to clean up still has its styles taken out of the page;
- **sanitized failure** — every code is fixed, and no message, stack, or `cause` ever carries the
consumer's domain data.
Each contribution mounts inside its own Shadow DOM root (ADR 0040) with its own compiled stylesheet,
so a contributed renderer cannot restyle the consuming module's screen around it, and that module's
rules cannot reach in.
### What a contribution is not given
No database, no object storage, no credential, no session, no API client, and no reach into any
module — including the one that contributed it. Its context is two members: its own id and the
consumer's input.
**Loading a contribution grants its module nothing.** Server-side authorization is unchanged and
remains the only authority. Everything a contributed renderer needs arrives through the consumer's
input, because the consumer is the party that was authorized to obtain it.
A contribution artifact is still installed, trusted, first-party code in the host's realm (ADR 0022,
ADR 0024). Its isolated root is a correctness boundary for styles and must never be described as a
sandbox.
## Consequences
- One module can extend another's screen without either importing the other, and without the
platform learning either one's vocabulary.
- The Dashboard bundle now contains two dynamic imports rather than one, both the generic loader's:
a module UI artifact and a contributed renderer.
- Manifest version 7 requires every module to update one field. Nothing else changes for a module
that contributes nothing.
- `ModuleUiHostContext` grew from five members to six. It is present on every context and empty for
the overwhelming majority of modules, so a module writes the same code whether or not anything was
contributed — and a standalone host running one module supplies an empty implementation that is
the *correct* one rather than a stub.
- A consuming module must handle an empty list: a contribution can be absent because nobody
contributed, because the contributor is disabled, or because it is outside the compatible range.
That is the same discipline an optional capability dependency already requires.
## Alternatives
- **Let the consumer import the contributor.** Rejected: it is the layer violation the boundary
checker exists to prevent, and it would make two independently installable modules one unit.
- **Put the renderer in the consumer.** Rejected: the consumer does not own the domain, so the
renderer and the data it draws would drift on the first change to either.
- **A second `entrypoints` role.** Rejected: the two existing roles are artifacts a *host* mounts in
surfaces the host owns. A contribution is mounted by a module, at a moment and in a place only that
module understands; calling it an entrypoint would put the platform in charge of deciding when a
slot should be filled.
- **A platform-defined payload schema.** Rejected, emphatically. It would make Core the owner of a
vocabulary it cannot possibly own, force every future consumer to inherit the first one's shape,
and require a platform release to change a domain contract between two modules.
- **Give the contribution its own API client.** Rejected: a renderer with its own authority is a
privilege the consumer never granted, and a second authorization path to reason about. The consumer
was authorized to fetch the data; it passes what it fetched.
- **Let a consumer construct the artifact URL.** Rejected for the same reason a module may not build
its own API prefix (ADR 0022): it welds the module to Ekklesia's deployment detail.
- **One shared ABI version for UI artifacts and contributions.** Rejected: it couples two contracts
with different shapes and lifecycles, and would make every module rebuild whenever either moved.
## Deferred
Contribution ordering and priority, a consumer-declared "extension point" manifest declaration,
per-contribution permissions, contributions to *backend* extension points, host-mediated
communication back from a contribution to its own module, contribution-level configuration, hot
reload, and a module store or installer that would let contributions arrive without a restart.
@@ -0,0 +1,160 @@
# 0042: Explicit, bounded streaming request bodies
- Status: Accepted
- Date: 2026-08-27
- Supersedes: none
- Related: [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
[ADR 0017](0017-public-request-perimeter-hardening.md),
[ADR 0030](0030-host-provided-module-object-storage.md),
[ADR 0031](0031-direct-object-delivery.md)
## Context
ADR 0017 gave every public route an enforced request-body ceiling and capped a module route at
1 MiB, with an explicit note that "large media transfer belongs to a future dedicated storage
design, never to widening this ceiling."
The storage design arrived — ADR 0030 gave modules a scoped object store, ADR 0031 took Ekklesia out
of the *read* path with signed delivery URLs — and it did not solve the *write* path. ADR 0031
deliberately declined to offer signed write URLs, so the only way bytes reach a module's bucket is
through that module's own HTTP route. That route is capped at 1 MiB, and an ordinary photograph is
not 1 MiB. A church uploading a slide for a lobby screen cannot, today, get the file in.
Two properties of the existing implementation also need naming, because they are what the change has
to preserve and what it has to fix:
- The pinned body-limit middleware **fully buffers** any body with no declared `Content-Length`
before deciding. At 1 MiB that is a bounded, acceptable cost. At image sizes it would mean the
platform holding every concurrent upload in memory.
- With a declared `Content-Length` within the limit it *counts nothing at all*, trusting the header.
Node's HTTP framing makes that safe today, but it is trust the platform does not need to extend.
## Decision
Extend the existing route contract. There is no second HTTP system, no upload endpoint, no signed
write URL, and no new middleware stack — one route declaration, one limiter, one `413`.
### A route declares how its body reaches it
`ModuleRouteDefinitionInput` gains one optional field:
```ts
http.route({
method: "POST",
path: "/uploads",
permission: "signage.slides.write",
maxBodyBytes: 33_554_432,
bodyMode: "streamed",
handler,
});
```
- **`buffered`** (the default, and what every route written before this meant) — the platform reads
and counts the whole body **before** the handler runs, so the handler is promised a fully
available, bounded body and an oversized request is rejected before any authorization or handler
work happens. Ceiling: 1 MiB, unchanged.
- **`streamed`** — the platform counts bytes **as the handler consumes them** and never materializes
the body. Ceiling: 32 MiB.
The mode is **explicit rather than inferred from the size**. Inferring it — "over 1 MiB means
streamed" — would mean an author who raised a limit silently changed how their own handler receives
its body, and when a `413` can arrive relative to their own code. That is exactly the class of
invisible change this repository refuses.
Omitting the field means `buffered`, so no existing route changed behaviour, and the canonical parsed
form always carries a mode so nothing downstream re-applies the default or disagrees about it.
### The ceilings, and why 32 MiB
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` stays at 1 MiB for buffered bodies: a body the platform
materializes is a body the platform is holding, and 1 MiB is the most a JSON request has any business
being.
`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` is 32 MiB, sized to hold one 25 MiB image plus bounded
overhead — a multipart envelope, its boundaries and part headers, and room for a client that pads. It
is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: **no route, in any mode, may declare or accept a
larger single request body, and no unbounded option exists anywhere in the contract.** A route
declaring more than its mode's ceiling fails registration, exactly as an over-ceiling value always
has.
The unit is deliberately **one file per request**. A UI offering twenty images issues twenty
individually bounded requests, each of which the platform can reject cheaply and each of which fails
alone; a 500 MiB aggregate request would make one bad byte waste everything already sent, and nothing
in this contract can express one.
### Enforcement, in one limiter
The platform's own limiter replaces the buffering dependency. It is the same code for both modes and
differs only in *when* it counts:
1. A declared `Content-Length` above the limit is refused synchronously — before authorization,
before the handler, before a single body byte is read. That is the cheap rejection an ordinary
too-large upload takes. A malformed or contradictory header is treated as *absent* rather than as
a rejection, because the byte counting bounds the request either way and refusing could only turn
a legitimate request away.
2. `buffered`: bytes are counted as they arrive and the request is rejected the moment the running
total passes the limit — so a lying or absent `Content-Length` is bounded by what actually
arrived rather than by what was claimed, which the previous implementation did not do. At most
the route's own declared allowance is ever held.
3. `streamed`: the body is wrapped in a counting transform and handed to the handler. The moment the
total passes the limit the stream errors, the source is cancelled, and the response is replaced
with the same sanitized `413` — **even if the handler caught the error and answered anyway**. A
limit a handler can swallow is not a limit.
A body no handler read is cancelled after the request, so an abandoned upload releases its socket
rather than draining. An aborted upload surfaces as an ordinary failed request, never a `413`:
nothing exceeded a limit.
Ordering is unchanged and remains deliberate: trusted-origin check, then the body contract, then
permission evaluation, then the handler. In `streamed` mode no body byte is read until the handler
reads it and the handler runs only after authorization succeeds, so an unauthorized caller can never
push an upload through the platform at all — strictly better than the buffered path.
Timeouts stay where they were: the server's own `headersTimeout`/`requestTimeout`. A slow upload is a
slow *request*, and the platform already has one answer for that rather than a second per-route one.
### What a module author must know
In `streamed` mode a `413` can arrive **after** the handler has started. A handler that persists as
it reads must be prepared for a partially consumed upload — write to a temporary key and commit only
after the read completes. The platform cannot do that for the module, because only the module knows
what a partial write means to its own data.
## Consequences
- An ordinary image upload works, through the route contract a module already had, with the platform
holding none of it.
- The buffered path now counts bytes it previously trusted a header for, closing a gap rather than
opening one.
- Every existing route and every existing test is unchanged: the default is the old behaviour, and
the `413` contract, ordering, and sanitization are identical.
- `apps/api` owns ~60 lines of stream plumbing it previously delegated. That is the price of a
limiter that neither buffers a large body nor can be swallowed by a handler, and it is enforced by
tests rather than by reading the dependency's source.
- Media larger than 32 MiB — a video — still has no path in. That is deliberate and named below.
## Alternatives
- **Raise the single ceiling to 32 MiB.** Rejected: every existing route would silently gain the
ability to have 32 MiB buffered in memory on its behalf, and `await request.json()` in a module
handler would stop being a bounded operation.
- **Infer streaming from `maxBodyBytes`.** Rejected: it makes a size change silently change delivery
semantics and error timing for the author's own code.
- **A separate upload endpoint or second HTTP system.** Rejected: a second route table, a second
authorization path, a second CORS and origin policy, and a second failure vocabulary, to solve
something the existing route contract can express with one field.
- **Signed write URLs to object storage.** Deliberately still deferred (ADR 0031). A signed write URL
is a bearer capability to *create* an object, which needs quota, content-type, and lifecycle
answers this pass does not have — and a module that wants to validate, transform, or authorize per
byte cannot use one anyway.
- **Let the handler enforce its own limit.** Rejected: a limit enforced by the party being protected
against is not a limit, and every module would reimplement it slightly differently.
- **Trust `Content-Length` in streamed mode.** Rejected: the running byte count costs nothing and
removes the need to reason about framing at all.
## Deferred
Signed write URLs, resumable or chunked multi-request uploads, per-module or per-installation upload
quotas, content-type or magic-byte validation, virus scanning, image transformation, progress
reporting, and any body larger than the absolute ceiling — a video pipeline is its own design, not a
larger number here.
@@ -0,0 +1,173 @@
# 0043: The display caches its shell and declared code, and nothing else
- Status: Accepted
- Date: 2026-08-27
- Supersedes: none
- Related: [ADR 0025](0025-trusted-module-ui-asset-publication.md),
[ADR 0031](0031-direct-object-delivery.md),
[ADR 0032](0032-native-module-realtime-transport.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0036](0036-managed-device-identity-and-pairing.md),
[ADR 0041](0041-module-browser-contributions.md)
## Context
A display is the one surface in this platform that is expected to outlive its network. It is a
television on a wall in a building whose uplink is a consumer router, nobody is standing in front of
it, and the person who could press a button is not in the building on a Tuesday.
Today, when that network drops and the screen reboots — a power blip, a scheduled restart, a
janitor's foot — the browser shows **its own error page**. Not the display shell, not an Ekklesia
message, not a retry: a network failure with no Ekklesia in it at all, until someone notices and
does something about it. Nothing about that page recovers on its own, because there is no Ekklesia
code running on it to recover.
The temptation is to solve this by caching more: the descriptor, the principal, the module's data,
so a screen can keep showing content through an outage. That is exactly the wrong instinct. A cached
descriptor is a cached permission decision, and a screen replaying one is a revoked device still
showing content it is no longer entitled to.
## Decision
Cache **the shell and the declared code**, and nothing else, in a service worker scoped to the
display.
### What is cached
- The display page (`/display/<module-id>`).
- The fixed display host script.
- The versioned browser artifacts the display actually loaded: its module's display artifact, and
every contributed renderer it mounted (ADR 0041).
All three are code. All three are already public bytes that confer no authority (ADR 0025, ADR 0041).
All three are addressed by a URL that carries an exact version, so a cache entry is unambiguous about
what it is.
### What is never cached, under any condition
The display descriptor, `/api/v1/principal`, every other platform and module API response, the
realtime stream, pairing requests and responses, and **every cross-origin request** — which is what a
signed object-delivery URL is (ADR 0031), a bearer capability with an expiry that a cache would
outlive.
This is an **allowlist**, not a deny list. A deny list has to be updated every time the platform gains
an endpoint, and the cost of forgetting is a stored permission decision. Here the cost of forgetting
is that a new kind of code is simply not cached, which is a bug rather than a vulnerability.
### The consequence, stated rather than papered over
**A display cannot render module content while its network is down.** Mounting requires a descriptor;
a descriptor is authorization-bearing; it is never served from a cache. A revoked device therefore
stays revoked, and cached code can never manufacture current authorization — the two properties this
pass must not trade away.
What the cache buys is real and worth having on its own:
- A kiosk that reboots during an outage boots into **Ekklesia's** honest offline state instead of the
browser's error page, and keeps retrying by itself.
- The moment connectivity returns it resolves and mounts with **nothing left to download** — the
600 KB artifact is already there.
- A momentary failure fetching an artifact, after a descriptor already resolved, falls back to the
identical bytes instead of failing the whole display. The descriptor is tiny and the artifact is
not, so this is the failure that actually happens.
Module-owned offline content — a module persisting its own state and media so a screen can keep
showing yesterday's slides — remains the module's responsibility. This is the code foundation it
would build on.
### Network first, always
Every cached kind is fetched from the network first and only falls back when that fetch fails. A
kiosk therefore picks up new code the moment the server returns and can never be trapped on an
obsolete build — which is precisely the failure mode a cache-first worker produces on an unattended
screen, and the one that requires physically visiting it to fix.
Only a complete, successful, same-origin `200` is ever stored. A `304`, a `404`, a redirect, an
opaque response, and a `503` from an API mid-restart are all things a screen must never later replay
as though they were the artifact.
### Versioned keys and pruning
Cache names carry a policy version; superseded generations are deleted on activation, so a policy
change cannot leave a screen serving entries from rules that no longer apply.
Artifact URLs already carry an exact module version, so a new build lands *beside* the old one. After
storing an artifact the worker removes every entry for the same module at a different version — and
deliberately keeps every entry at the current version, because a module's display artifact and each
contributed renderer are separate URLs under one version and dropping any of them would half-cache a
working display. Another module's artifacts are never touched.
The worker never caches **itself**, which is the other way a screen gets stuck: it is served
`no-cache` with a strong validator, exactly like the host script.
### Scope is the directory
The worker is served at `/display/sw.js`, chosen for its *directory*: a service worker's default
scope is where it was served from, so this one controls display pages and nothing else. The Dashboard
is never controlled by it and no Dashboard request is ever intercepted. Serving it beside the host
script under `/assets/` would scope it to a directory where no page lives; widening it with
`Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
A controlled page's requests are intercepted wherever they point, which is how an artifact under
`/api/v1/module-ui/` is cached without widening the scope to the whole origin.
### Reporting, not deciding
The page gets a small browser-safe signal built from two independent sources, because either alone
would lie: `navigator.onLine` (cheap, immediate, famously optimistic — a captive portal reports
`true`) and the worker's own report that it actually served from cache (ground truth about this
application). A display renders a quiet corner indicator, never a full-page failure: a screen whose
switch rebooted must not blank a lobby wall.
When the browser reports the network is back, the display resolves its descriptor again on its own.
Nobody presses Retry on a lobby television, and that fresh descriptor is also what stops a screen
sitting on cached code after a deployment — it names the current version, and the network-first worker
fetches it.
Registration is an **enhancement and never a prerequisite**: a browser without service workers, a page
served over plain HTTP in development, and a policy that refuses all leave a display that works
exactly as it did before, online only.
### Testable by construction
Every caching decision lives in a module that touches no global: the cache storage, `fetch`, and the
way a status reaches the page all arrive as an environment. The worker file is thin wiring that
supplies the real ones. That is what lets the behaviour be exercised directly — first load, offline
reload, a missing uncached artifact, contribution fallback, version upgrade and pruning, reconnect,
and the proof that nothing authorization-bearing is ever stored — rather than through a service
worker a test runner cannot host.
## Consequences
- An unattended screen degrades to an Ekklesia message and recovers by itself, instead of degrading
to a browser error page and staying there.
- The platform serves a third fixed display asset, read once before the listener binds like the other
two.
- A display page is now controlled by a service worker, which is a real operational surface: an
operator debugging a screen must know a worker is in front of it. It is `no-cache`, claims clients
immediately, and caches only code, so the ways it can go wrong are bounded.
- Offline *content* is still absent, and that is the honest state of this foundation rather than an
oversight.
## Alternatives
- **Cache the descriptor so a display can mount offline.** Rejected outright. It is a permission
decision and a device identity; replaying one is a revoked screen still showing content.
- **Cache-first for speed.** Rejected: it is how an unattended screen ends up serving a build from
months ago with no way to notice, and fixing it means visiting the screen.
- **Precache everything at install.** Rejected: the worker would have to know which module a screen
runs and at which version — facts only the descriptor carries — and an install would fail whenever
an asset a future version needs did not exist yet.
- **Cache at the whole origin with `Service-Worker-Allowed`.** Rejected: the Dashboard gains a worker
it has no use for, for no benefit.
- **Use `navigator.onLine` alone.** Rejected: it reports `true` behind a captive portal and a dead
uplink, so a screen would insist it was fine while showing nothing.
- **Store artifacts in IndexedDB and import them from a blob URL.** Rejected: it costs a host its
`script-src` CSP, exactly as ADR 0024 rejected for the loader, and reimplements what a cache
already does correctly.
## Deferred
Module-owned offline content and media persistence, background sync, periodic background update,
push, a Dashboard service worker, offline pairing, precaching a display's artifact before its first
visit, and any cached response that carries identity or authorization.
+5
View File
@@ -47,3 +47,8 @@ ADRs capture durable choices that affect more than one package or phase.
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted - [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted - [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted - [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
+81 -2
View File
@@ -228,7 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
independent. independent.
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
dependencies, consumed by module authors and hosts alike. Its host context is five members — dependencies, consumed by module authors and hosts alike. Its host context was five members at that
checkpoint and is six today (ADR 0041) —
`moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through `moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
`api.request(path)` with a path relative to the module's own API root, never by constructing `api.request(path)` with a path relative to the module's own API root, never by constructing
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible. Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
@@ -244,7 +245,8 @@ it, and hosts perform their own dynamic import.
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler `src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
option in one `tooling` package so module authors write no build configuration. React is bundled option in one `tooling` package so module authors write no build configuration. React is bundled
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host; rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an styling was inline style objects only at that checkpoint and is real compiled CSS today (ADR 0040),
and a stylesheet import then failed the build rather than emitting an
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
@@ -573,3 +575,80 @@ An authenticated result preserves the response, session, and mounted UI. Display
remains deferred and the display bundle gains no Dashboard login. Public Website routing, remains deferred and the display bundle gains no Dashboard login. Public Website routing,
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling, production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
and the missing production first-user creation path are also outside this pass. and the missing production first-user creation path are also outside this pass.
**Phase 8 supplies the five platform prerequisites the first production feature module needs.** None
of it is Signage, and none of it names Signage: each capability is generic, and each is exercised by
something other than a feature module.
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md) adds the design layer the platform
never had. `@ekklesia/design-tokens` is one validated, enumerable registry of **semantic** tokens —
`background.raised`, `text.danger`, `state.hover-background` — where a token's category and
`--ekklesia-*` identifier are *derived* from its name and every value must be a bounded literal, so a
registry that constructs is one that can be emitted as CSS without escaping. That validation is
exactly what makes a future theme editor implementable rather than aspirational: the values it would
edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of
elements a real module dashboard cannot be built without, every value a token reference, with real
`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
else. The Dashboard and display install the defaults at their document root; custom properties
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
run time and nothing for a host to forget to pass.
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) reverses ADR 0023's
inline-styles-only rule, and supplies the three things that ADR said were missing. A module imports
plain `.css`; the canonical builder compiles it **into** the declared artifact as text, so there is
still exactly one file per role and no sidecar can exist — the failure ADR 0023 was protecting
against. Two rules are enforced with their own codes: a module stylesheet may reference
`--ekklesia-*` and never declare one, and every `@import` must resolve locally. The generic loader
then mounts **every** browser artifact inside its own open Shadow DOM root with its own stylesheet
installed, unconditionally, because a conditional isolation boundary is one a module can fall out of
by editing a source file. That root is a correctness boundary for *styles* and is never described as
a sandbox: it is `open` precisely because module code is trusted, same-realm code that could reach
`document` anyway. The browser mount ABI is versioned (`MODULE_UI_ABI_VERSION`, stamped by
`defineModuleUi`) with no dual acceptance; migration is one `pnpm build`.
[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser
renderer to a capability another module provides — the browser half of the capability system Core has
had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target
capability, a compatible range, and one artifact. That is the complete list: there is no payload
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the
identical loop every other compiled entrypoint uses; publication requires that *another* eligible
module provides the capability within the declared range, and a contribution that fails is absent
rather than broken. A consumer discovers through `ModuleUiHostContext.contributions` and mounts by
id — never by building a publication URL — and can only see contributions aimed at a capability it
itself provides. The input is opaque JSON validated for serializability and nothing else, so the
domain contract stays entirely between the two modules. A contributed renderer receives its own id
and that input: no API client, realtime, principal, configuration, database, storage, or credential,
and loading one grants its module nothing.
[ADR 0042](decisions/0042-bounded-streaming-request-bodies.md) closes the write path ADR 0031 left
open. ADR 0030 gave modules a bucket and ADR 0031 took Ekklesia out of the *read* path, but declined
signed write URLs — so bytes reach a bucket only through a module's own route, which was capped at
1 MiB. A route now declares `bodyMode`: `buffered` (the default, and what every existing route
meant) keeps that ceiling and materializes the body before the handler runs; `streamed` raises it to
32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather
than inferred from the size, because inferring it would silently change how an author's own handler
receives its body. 32 MiB is also the platform's absolute ceiling in every mode, sized for one
25 MiB image plus bounded overhead, and the unit is deliberately one file per request. The platform's
own limiter replaces the buffering dependency: a declared oversize is refused before authorization,
the buffered path now counts what actually arrived rather than trusting a header, and a streamed
overflow errors the stream, cancels the source, and replaces the response with the same sanitized
`413` even if the handler swallowed the error.
[ADR 0043](decisions/0043-display-offline-code-fallback.md) gives the display host-owned offline
resilience, scoped precisely to **the shell and the declared code**. A service worker scoped to
`/display/` caches the page, the host script, the module's display artifact, and every contributed
renderer that display mounted — and never the descriptor, `/api/v1/principal`, any API response, the
realtime stream, a pairing exchange, or any cross-origin request, which is what a signed
object-delivery URL is. The consequence is stated rather than papered over: a display cannot render
module content while offline, because mounting needs an authorization-bearing descriptor that is
never cached, so a revoked device stays revoked and cached code can never manufacture current
authorization. What it buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline
state and recovers by itself, and that reconnecting mounts with nothing left to download. Caching is
network-first everywhere, so a screen can never be trapped on obsolete code.
Still deliberately absent after Phase 8: a theme editor, a brand builder, or any second theme; a
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
included. Nothing in this pass claims any of them exists.
+55 -6
View File
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
| --- | --- | --- | | --- | --- | --- |
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app | | `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries | | `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries | | `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, the approved design packages, public contracts, React, and external backend libraries |
| `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework | | `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework |
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts | | `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level | | `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
@@ -20,10 +20,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in - A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components, `@ekklesia/module-ui-sdk`. Since
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
`.css`, but **only** inside a browser-artifact source root the canonical builder compiles
(`src/ui/`, `src/display/`, `src/contributions/`); a stylesheet anywhere else in a module is a file
no build reads and no host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role (including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
packages. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package, beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
a role modules could already depend on — owns the entire module UI build a role modules could already depend on — owns the entire module UI build
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its ([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
real target. real target.
@@ -31,12 +35,47 @@ These rules map platform ownership to filesystem and package boundaries. The exe
import Core, an app, a surface, a builder, or another module. It also may not import import Core, an app, a surface, a builder, or another module. It also may not import
`@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader `@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader
would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)). would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)).
- A module may consume exactly two shared **design** roles: `design-tokens` (`@ekklesia/design-tokens`)
and `ui` (`@ekklesia/ui`), added by
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md). This is a named allowlist, not a
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
layer, an API/auth client, or the backend contracts barrel, because a design package that could
would put all of it inside every module artifact that renders a button.
- A module **references** registered global semantic tokens and may derive private,
differently-prefixed aliases from them inside its own artifact. It may never define an
`--ekklesia-*` custom property: that would invent a global theme variable outside the registry,
which a future theme editor could not enumerate and another module could unknowingly depend on.
- `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only - `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only
on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it
Node's global declarations — into browser code. Core still owns the containing Node's global declarations — into browser code. Core still owns the containing
application shell and all platform policy, and module UI reaches the host only through application shell and all platform policy, and module UI reaches the host only through
`@ekklesia/module-ui-sdk`. `@ekklesia/module-ui-sdk`.
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display - A module stylesheet is compiled **into** the one declared JavaScript artifact as text and installed
by the host at mount; there is no sidecar file, no second publication route, and no manifest field
for one. Every browser artifact is mounted inside its own Shadow DOM root the generic loader
creates, so one module's styles cannot reach the Dashboard, another module, or another renderer,
and the host's cannot reach in. That root is a correctness boundary for styles, never a sandbox:
module code remains trusted, same-realm code. The browser mount ABI is versioned
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
semantics.
- A module may declare **browser contributions** (`manifest.contributions`, v7): compiled renderers
it offers to capabilities *other* modules provide
([ADR 0041](decisions/0041-module-browser-contributions.md)). Core validates identity,
compatibility, and the artifact path through the identical resolution every other compiled
entrypoint uses, and knows nothing about what a contribution renders — there is no payload schema,
slot, ordering, or rendering hint anywhere in the platform. A contribution is published only when
another eligible module provides its target capability within the declared range; otherwise it is
absent rather than broken. A consuming module discovers contributions through
`ModuleUiHostContext.contributions` and mounts them by id; it never constructs a publication URL,
and it can only ever see contributions aimed at a capability it itself provides. A contributed
renderer receives its own id and the consumer's opaque JSON input, and no database, storage,
credential, session, or API client of its own.
- Manifest v7 permits independently optional primary (`entrypoints.ui`) and full-viewport display
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their (`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
@@ -80,7 +119,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe
receiving only the host-neutral principal and permission hints already in the UI SDK. receiving only the host-neutral principal and permission hints already in the UI SDK.
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also - The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
statically imports no module and mounts only the exact display descriptor/artifact supplied by the statically imports no module and mounts only the exact display descriptor/artifact supplied by the
API. It contains no Dashboard chrome and does not move display rendering into the surface—the API. It ships a third fixed asset, a service worker scoped to `/display/`, that caches **only** the
display shell and the declared browser artifacts it actually loaded
([ADR 0043](decisions/0043-display-offline-code-fallback.md)). It never caches the descriptor,
`/api/v1/principal`, any module or platform API response, the realtime stream, a pairing exchange,
or any cross-origin request — so a revoked device stays revoked and cached code can never
manufacture current authorization. Caching is network-first, so a screen cannot be trapped on
obsolete code. It contains no Dashboard chrome and does not move display rendering into the surface—the
module still owns that renderer. module still owns that renderer.
- Separate surfaces do not import each other's internals. - Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or - Builder code composes contracts and surface renderer identifiers; it does not import module or
@@ -96,6 +141,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
direct table imports and foreign keys. Modules may not import another module's Drizzle tables, direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
columns, relations, schema entrypoint, or migration internals. columns, relations, schema entrypoint, or migration internals.
The design packages are covered the same way: one fixture proves a module may depend on both, one
proves every other shared role stays rejected, and one proves neither design package may depend
upward.
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
rejects every currently machine-checkable repository rule even before production packages fill rejects every currently machine-checkable repository rule even before production packages fill
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
+32 -10
View File
@@ -408,16 +408,28 @@ protection across restarts or replicas.
## Request body limits ## Request body limits
Every public body-consuming route has an explicit, platform-enforced byte ceiling Every public body-consuming route has an explicit, platform-enforced byte ceiling, enforced by the
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified platform's own limiter (`apps/api/src/body-limits.ts`):
directly against its source to reject both an oversized declared `Content-Length` and an oversized
declared-length-free streamed body without ever buffering the whole payload first):
| Surface | Limit | | Surface | Limit |
| --- | --- | | --- | --- |
| `/api/auth/*` (every Better Auth route) | 8 KiB | | `/api/auth/*` (every Better Auth route) | 8 KiB |
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB | | `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
| A module route (`http.route(...)`'s own `maxBodyBytes`) | Up to 1 MiB, the platform ceiling (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) | | A module route declaring `bodyMode: "buffered"` (the default) | Up to 1 MiB (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
| A module route declaring `bodyMode: "streamed"` | Up to 32 MiB (`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING`), which is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES` |
A declared `Content-Length` over the limit is refused synchronously, before authorization and before
a single body byte is read. Beyond that the two module modes differ only in *when* the platform
counts ([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
- **`buffered`** materializes the body before the handler runs, counting as it arrives, so a lying or
absent `Content-Length` is bounded by what actually arrived. The handler is promised a fully
available body within its declared allowance, and the platform holds at most that allowance.
- **`streamed`** never materializes the body: it is counted as the handler consumes it, and the
moment the total passes the limit the stream errors, the source is cancelled, and the response
becomes the same `413` — even if the handler caught the error and answered anyway. A `413` can
therefore arrive *after* a streamed handler has started, which is the one consequence a module
author must design for.
An oversized request always receives the identical stable, sanitized contract — An oversized request always receives the identical stable, sanitized contract —
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better `413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
@@ -425,10 +437,20 @@ Auth's business logic, a Core mutation, permission evaluation (where avoidable),
or module database behavior. For a state-changing route the body limit is enforced after or module database behavior. For a state-changing route the body limit is enforced after
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body `requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
is never read for size at all, while an oversized same-origin request still fails before any is never read for size at all, while an oversized same-origin request still fails before any
authorization work runs. See [Module HTTP routes](#module-http-routes) for the module-route body authorization work runs. In `streamed` mode no body byte is read until the handler reads it, and the
contract itself, and [Module development](../module-development/README.md#http-routes) for the handler runs only after authorization succeeds, so an unauthorized caller can never push an upload
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module through the platform at all.
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout` below, not by a
second per-route timeout. A body no handler read is cancelled after the request, so an abandoned
upload releases its socket rather than draining; an aborted upload surfaces as an ordinary failed
request rather than a `413`, because nothing exceeded a limit.
See [Module HTTP routes](#module-http-routes) for the module-route body contract itself, and
[Module development](../module-development/README.md#http-routes) for the module-authoring side of
it. The absolute ceiling is one image-sized file per request: aggregate uploads and anything larger
than 32 MiB remain out of scope, and signed write URLs are still deliberately not offered
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)).
## Node request and header timeouts ## Node request and header timeouts
@@ -1210,7 +1232,7 @@ its role-specific URL only from the protected descriptor below.
## Module display host ## Module display host
Manifest version 6 adds one independently optional display declaration The manifest carries one independently optional display declaration
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)): ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
```json ```json
+3 -2
View File
@@ -137,7 +137,8 @@ enforces those permissions while the module is disabled, because it mounts no ro
## Module permission composition ## Module permission composition
A module manifest's `permissions` array (`manifestVersion: 4`, A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict
manifest is version 7 — see
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in [Module development](../module-development/README.md#canonical-manifest)) declares permissions in
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
well-formed and correctly namespaced under the module's own id; it does not, by itself, register well-formed and correctly namespaced under the module's own id; it does not, by itself, register
@@ -667,7 +668,7 @@ module database usage), and exactly one `pool.end()` call regardless of how many
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation: - `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
rejection, malformed permission id rejection. rejection, malformed permission id rejection.
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 4` acceptance and version - `packages/contracts/tests/module-manifest.test.ts` — exact-version acceptance and version
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above), 2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
path, and default-empty-list isolation across separate parses. path, and default-empty-list isolation across separate parses.
+6 -2
View File
@@ -37,7 +37,10 @@ the root verification command discovers them automatically.
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A `pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
missing or empty modules directory is valid and reports zero installed modules. Installed modules missing or empty modules directory is valid and reports zero installed modules. Installed modules
must produce the compiled JavaScript entrypoints declared by their static manifests. Optional must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
declared browser contributions are listed with their target capability and range, and marked
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
silent, since the manifest is valid and the artifact exists while nothing renders (ADR 0041). Optional
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
foreign-key isolation; this command does not connect to PostgreSQL or run migrations. foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
@@ -82,7 +85,8 @@ not a broken deployment).
Doctor reports a fixed, deterministic set of checks: Doctor reports a fixed, deterministic set of checks:
- The active Node.js version against the repository's declared engine range. - The active Node.js version against the repository's declared engine range.
- Static module discovery and dependency-first ordering. - Static module discovery and dependency-first ordering. A module that declares browser
contributions also reports how many of them have a compatible provider among the installed set.
- Schema discovery, including validated module and table counts. This imports compiled schema - Schema discovery, including validated module and table counts. This imports compiled schema
definitions, which the module contract requires to perform no configuration reads, I/O, or definitions, which the module contract requires to perform no configuration reads, I/O, or
database connection. database connection.
+1 -1
View File
@@ -201,7 +201,7 @@ separate deployment and threat-model decision.
### A display page is authorized; its artifact bytes are not authority ### A display page is authorized; its artifact bytes are not authority
Manifest version 6 binds a display artifact to one permission declared by the same module The manifest binds a display artifact to one permission declared by the same module
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
a `public` baseline: the current display host requires an ordinary authenticated cookie principal, a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
and public/device identity is not approximated with an anonymous exception. Both the stable and public/device identity is not approximated with an anonymous exception. Both the stable
+71
View File
@@ -69,6 +69,77 @@ protocol-relative, backslash-prefixed, and scheme-bearing values cannot become n
Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out
of the separate display bundle. of the separate display bundle.
Phase 8 adds evidence for the five platform prerequisites.
**Design tokens and shared elements** (ADR 0039). The token registry's tests prove it is
deterministic and enumerable, that every token derives a unique `--ekklesia-*` identifier from its
name and carries a label, description, and category, that each status family pairs a colour with a
non-colour symbol, and that a duplicate name, an unknown category, an invalid or single-segment name,
and a value carrying a declaration terminator, a rule block, a comment, an at-rule, `url(`,
`!important`, an escape, a control character, or markup are each rejected — with an error that never
echoes the rejected declaration. The elements' tests assert accessible structure (label/hint/error
association, icon-button naming, tab roles and arrow-key movement, announced error and loading
states), that status never depends on colour alone, that a dialog renders in place rather than
portalling out of an isolated root, and — mechanically — that the stylesheet and every component
source contain no literal colour, font stack, or theme value and reference only registered tokens.
Both packages compile module-shaped source under the exact browser project a module uses, where
`process.env` must fail to typecheck, so the browser boundary is measured rather than asserted.
**Module stylesheets and artifact isolation** (ADR 0040). The builder's tests prove a plain
stylesheet is compiled into the declared artifact with no `.css` emitted beside it, that a local
`@import` is inlined, and that an unresolved `@import`, a stylesheet declaring an `--ekklesia-*`
variable, and a non-CSS dialect are each rejected with their own sanitized code — while a private
alias derived from a registered token is accepted. The loader's tests prove every artifact mounts
inside an open shadow root, that the artifact receives that root rather than the host's element, that
exactly one `<style>` is installed per mount and never accumulates across remounts, that the host's
base rules precede the artifact's so a display can override `:host`, that unmount removes the whole
root even when the module's cleanup throws, that a failed mount leaves neither DOM nor styles behind,
that an environment without Shadow DOM fails closed rather than mounting unisolated, and that an
artifact built against the previous ABI is refused with `MODULE_UI_ABI_UNSUPPORTED`. The template's
suite builds real React artifacts through the canonical pipeline and asserts the compiled stylesheet
travels inside the one declared file.
**Browser contributions** (ADR 0041). Manifest tests cover namespacing, the single local segment,
duplicate ids and artifact paths, collision with an entrypoint, and a contribution targeting a
capability its own module provides. Discovery tests prove a contribution artifact inherits the exact
path, symlink, containment, extension, and file-kind protections every other compiled entrypoint has,
including two contributions linked to one file. Publication tests prove a contribution is published
only when another eligible module provides its capability *within the declared range*, that the
contributing module never sees its own contribution, that a lookup fails closed on an unknown,
mismatched, or non-string identity, and that the published record carries no domain vocabulary at
all. Route tests prove the artifact is served byte-for-byte at a URL that cannot select a neighbour,
and that an unknown id, another module's id, a traversal, a source map, a wrong version, an absent
provider, and an incompatible provider all collapse into one indistinguishable `404`. The loader's
tests cover mount, update-without-remount, a renderer that declares no `update`, deterministic and
idempotent unmount, a sanitized update failure, and input that is not genuinely JSON — a `Date`, a
function, `NaN`, `undefined`, and a cycle — refused *before* anything is imported and without ever
echoing the consumer's data. The flagship shell fixture drives the whole relationship: a `signage`
module discovers, mounts, updates, and unmounts an `announcements` React renderer with neither module
importing the other, the consumer seeing identity but never a URL, the host resolving publication,
and a contribution id the server withheld refused without a fetch.
**Bounded streaming uploads** (ADR 0042). Contract tests pin the two ceilings, that `maxBodyBytes` is
checked against the ceiling the declared mode selects, that 32 MiB holds a 25 MiB image and one byte
more is refused, and that no mode admits an unbounded body. HTTP tests cover exact-boundary success,
a declared `Content-Length` refused before the handler runs, a misleading smaller `Content-Length`
that cannot smuggle a megabyte past the limit, a chunked overflow, a `413` that still wins when the
handler swallowed the stream error, proof that the body is genuinely not buffered (a handler sees the
first chunk before the last is produced), an aborted upload surfacing as an ordinary failure rather
than a `413`, an unread body being released, authorization ordering in both directions, and a
rejection that names no limit, size, or body content. The live suite uploads a real 25 MiB body over
a real socket end to end and proves the server is healthy immediately afterwards.
**Display offline fallback** (ADR 0043). The worker's caching logic is exercised directly, because it
touches no global: first online load, offline reload after one successful visit, a missing uncached
artifact failing honestly, contributed-renderer fallback, version upgrade with pruning that keeps
every artifact of the current version and never touches another module's, superseded cache
generations removed while unrelated ones survive, network-first behaviour picking up rebuilt bytes,
and an explicit inventory proving the descriptor, principal, module API, realtime stream, pairing
exchange, auth routes, cross-origin signed object URLs, and the worker itself are **never** stored —
so a revoked device's refusal is what a reconnected display sees. Page-level tests cover the quiet
offline indicator, a cache fallback reported while the browser still claims to be online, automatic
re-resolution when the network returns, and a revoked device dropping back to pairing on reconnect.
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live `pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database. database.
+395 -34
View File
@@ -73,7 +73,7 @@ without executing module code:
```json ```json
{ {
"manifestVersion": 6, "manifestVersion": 7,
"id": "calendar", "id": "calendar",
"name": "Calendar", "name": "Calendar",
"version": "0.1.0", "version": "0.1.0",
@@ -122,6 +122,16 @@ without executing module code:
} }
] ]
}, },
"contributions": [
{
"id": "calendar.signage-slide",
"version": "1.0.0",
"capability": "signage.slide-renderer",
"capabilityRange": "^1.0.0",
"artifact": "./dist/contributions/signage-slide/index.js",
"description": "Renders an upcoming-events slide for a signage timeline."
}
],
"entrypoints": { "entrypoints": {
"server": "./dist/server/index.js", "server": "./dist/server/index.js",
"schema": "./dist/schema/index.js", "schema": "./dist/schema/index.js",
@@ -140,12 +150,13 @@ has its own exact semantic version because its public contract can evolve indepe
module release. Capability dependencies use semantic-version ranges. Optional means the provider module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible. may be absent; if it is installed, its capability version must be compatible.
`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a `manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 5 manifest, and any other version, fails validation outright rather than being silently version 6 manifest, and any other version, fails validation outright rather than being silently
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions. reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display Version 7 adds the optional `contributions` array
changes only the version literal; `ui` remains independently optional ([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). contributes nothing changes only the version literal; every browser artifact remains independently
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what `restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
@@ -214,6 +225,16 @@ are separate artifacts because a full-viewport display is not a Dashboard screen
```json ```json
{ {
"contributions": [
{
"id": "calendar.signage-slide",
"version": "1.0.0",
"capability": "signage.slide-renderer",
"capabilityRange": "^1.0.0",
"artifact": "./dist/contributions/signage-slide/index.js",
"description": "Renders an upcoming-events slide for a signage timeline."
}
],
"entrypoints": { "entrypoints": {
"server": "./dist/server/index.js", "server": "./dist/server/index.js",
"schema": "./dist/schema/index.js", "schema": "./dist/schema/index.js",
@@ -271,7 +292,7 @@ artifact is the role selection.
### What the host gives you ### What the host gives you
`mount` receives exactly five members, and nothing else is available: `mount` receives exactly six members, and nothing else is available:
| Member | What it is | | Member | What it is |
| --- | --- | | --- | --- |
@@ -280,10 +301,17 @@ artifact is the role selection.
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` | | `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
| `permissions` | `has(permissionId)` — a presentation hint, never a security check | | `permissions` | `has(permissionId)` — a presentation hint, never a security check |
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) | | `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
Navigation, host theme and design tokens, module configuration, organization context, and Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a contract. They need designs that do not exist, and a speculative field frozen into a public contract
speculative field frozen into a public contract is harder to remove than to add. is harder to remove than to add.
The host **theme** is deliberately not a context member either, and that is not a gap: design tokens
reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import
`@ekklesia/design-tokens` and write `var(--ekklesia-…)`; the host installs the values at its own
root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that
can forget to pass one.
### Never build your own API URL ### Never build your own API URL
@@ -413,20 +441,157 @@ loads several React copies, and artifacts are large (the template's is roughly 5
an operator can review it). That is accepted for now and can be optimized later **without changing an operator can review it). That is accepted for now and can be optimized later **without changing
the contract**, because the contract is a mount function rather than a component. the contract**, because the contract is a mount function rather than a component.
### Styling: inline style objects only, for now ### Design tokens and shared elements
Style with React's `style` prop: Your UI may import two shared design packages, and only those two
([ADR 0039](../architecture/decisions/0039-shared-design-tokens-and-elements.md)):
| Package | What it is |
| --- | --- |
| `@ekklesia/design-tokens` | The platform's global semantic theme — a validated, enumerable registry of named values and the `var(--ekklesia-…)` references for them |
| `@ekklesia/ui` | The foundational Ekklesia elements, every one of them built from those tokens |
```tsx ```tsx
<section style={{ display: "grid", gap: "0.5rem" }}>…</section> import { designToken } from "@ekklesia/design-tokens";
import { Badge, Button, EkklesiaUiStyles, Panel } from "@ekklesia/ui";
export function Slides({ context }: { readonly context: ModuleUiHostContext }) {
return (
<>
<EkklesiaUiStyles />
<Panel actions={<Button variant="primary">Add slide</Button>} title="Slides">
<p style={{ color: designToken("text.secondary") }}>Nothing scheduled yet.</p>
<Badge tone="warning">Paused</Badge>
</Panel>
</>
);
}
``` ```
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than Render `<EkklesiaUiStyles />` once, near the top of your tree. It puts the element stylesheet into
an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no whichever root your artifact is mounted in, so you never have to know whether that is a document or
manifest field declares it and no host loads it — so it would work in your tests and silently do an isolated root.
nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and
a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for **Use semantic names, not colours.** `designToken("text.danger")` says what the text *means*;
this phase. `#9f1239` says what it looks like today. An installation that changes its theme changes the first
and not the second. `designToken` only accepts registered names, so a typo is a compile error.
**Never define an `--ekklesia-*` custom property.** Those are the platform's global theme variables,
and inventing one would create a global a future theme editor could not enumerate and another module
could unknowingly depend on. You may derive as many *private* aliases as you like inside your own
artifact, under your own prefix:
```tsx
<div style={{ "--calendar-row-gap": designToken("spacing.sm") } as CSSProperties}>
```
The host installs the default token values at its own root, and CSS custom properties inherit
through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at
run time and nothing for the host to hand you.
Every other shared package stays rejected inside a module: `@ekklesia/api-client`,
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
import, and `pnpm boundaries` says so.
### Styling: a stylesheet compiled into your artifact
Author plain `.css` beside your UI source, import it, and declare it
([ADR 0040](../architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)):
```text
src/ui/index.tsx
src/ui/styles.css
```
```tsx
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
import styles from "./styles.css";
export default defineModuleUi({
id: "announcements",
version: "0.1.0",
styles: `${EKKLESIA_UI_STYLESHEET}\n${styles}`,
mount(container, context) { /* … */ },
});
```
TypeScript needs to know what a `.css` import is. Copy the template's `src/module-styles.d.ts` into
your module and include it in your UI TypeScript project:
```ts
declare module "*.css" {
const stylesheet: string;
export default stylesheet;
}
```
**There is no sidecar file.** `@ekklesia/module-ui-build` compiles each stylesheet — resolving local
`@import`s — into your declared JavaScript artifact as text. One declared file still contains
everything, so there is nothing extra to publish, nothing for a host to fetch, and no way for your
CSS to become an asset nobody serves. That was the exact hazard that kept stylesheets out until now.
Two rules are enforced by the build, each with its own error:
- **Reference Ekklesia's global theme variables; never declare one.** `--ekklesia-*` belongs to the
registry a future theme editor enumerates (ADR 0039). Derive private aliases under your own prefix
freely: `--announcements-slide-gap: var(--ekklesia-spacing-sm)`.
- **Every `@import` must resolve to a local file.** A remote import would make your artifact fetch a
stylesheet at run time, and a declared artifact has to be self-contained.
Plain `.css` only. `.scss`, `.less`, and other dialects stay rejected — adding a preprocessor is a
toolchain decision that needs its own ADR. A CSS `url()` pointing at a local image or font also fails
the build: no asset loader is configured. Use a data URL, or serve real media from your module's
object storage.
`pnpm boundaries` allows `.css` inside `src/ui/` and `src/display/` only. A stylesheet anywhere else
in your module is a file no build reads and no host loads.
### Your artifact renders in its own isolated root
Every browser artifact — primary UI, display UI, and any renderer you contribute — is mounted inside
a Shadow DOM root the host creates, with your stylesheet installed in it:
```text
host container
└─ <div data-ekklesia-module-ui>
#shadow-root (open)
├─ <style> host base rules, then your declared CSS
└─ <div> ← the container your mount() receives
```
What that buys you, and what it costs:
- **Your selectors cannot leak out**, and the Dashboard's cannot leak in. Write `.slide` without a
prefix and without worrying about another module's `.slide`.
- **Design tokens still reach you.** CSS custom properties inherit through the boundary, so
`var(--ekklesia-…)` resolves from the values the host installed at its root. Nothing to import at
run time, nothing for the host to pass.
- **Two artifacts of your own module are isolated from each other too.** Your management UI and your
display share styles only if they share a source file.
- **Your DOM is not in the host document's tree.** `document.querySelector` from outside will not
find your elements — including in your own tests, which should mount the artifact and query inside
the root the loader hands `mount`.
- **You get no box by default.** The host's two wrapper elements are `display: contents`, so you lay
out exactly where the host put you. A full-viewport display that needs a real box overrides it:
`:host { display: block }`, which works because the host installs its base rules *before* yours.
- **Mount and unmount are deterministic.** The host installs your stylesheet once per mount and
removes the whole root on unmount, so repeated mounts never accumulate styles and a cleanup that
throws still cannot leave your CSS behind. You never install or remove your own `<style>`.
**This is not a sandbox.** Your artifact is trusted first-party code in the host's own JavaScript
realm and can reach `document` if it wants to; the root is `open` for exactly that reason. It is a
correctness boundary for styles, and nothing more. See
[Security model and deployment trust boundaries](../development/security.md).
### The mount ABI is versioned
`defineModuleUi` stamps the browser mount ABI version onto your artifact — you never write it. A host
verifies it before mounting, and an artifact built against an older SDK fails with
`MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics.
Migrating from ABI 1 is one `pnpm build`. If you declare no stylesheet, no source change is needed at
all; if your own tests assert on module DOM, they need to look inside the isolated root.
### What you may and may not use today ### What you may and may not use today
@@ -435,9 +600,9 @@ the build pipeline** — adding the pipeline required no boundary relaxation at
| Allowed in a module | Still rejected | | Allowed in a module | Still rejected |
| --- | --- | | --- | --- |
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` | | `.tsx` source, and plain `.css` inside `src/ui/` or `src/display/` | `.jsx` (strict TypeScript only), `.css` outside a browser source root, `.scss`/`.less`/`.styl`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` | | `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build` | `@ekklesia/ui` and other shared UI-role packages | | `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build`, `@ekklesia/design-tokens`, `@ekklesia/ui` | Every other shared role: `api-client`, `auth-client`, `surface-sdk`, `module-ui-host` |
You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because
`@ekklesia/module-ui-build` owns the bundler entirely. `@ekklesia/module-ui-build` owns the bundler entirely.
@@ -523,8 +688,9 @@ Four things about it matter to you as a module author:
- **Only the artifact that role declares is served.** The module id and version in either URL are - **Only the artifact that role declares is served.** The module id and version in either URL are
lookup keys, never path components — there is no way to request a second file from your package. lookup keys, never path components — there is no way to request a second file from your package.
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a an asset, it must be inside the bundle: that is exactly why your stylesheet is compiled into the
stylesheet import still fails the build. artifact as text rather than published beside it (ADR 0040), and why a bundled image or font is
still not supported.
- **The version must match exactly.** A request for any other version is refused rather than served - **The version must match exactly.** A request for any other version is refused rather than served
your installed artifact, so a stale host cannot mount something it did not ask for. your installed artifact, so a stale host cannot mount something it did not ask for.
- **Your artifact bytes are public.** These two routes require no authentication — fetching code - **Your artifact bytes are public.** These two routes require no authentication — fetching code
@@ -580,7 +746,7 @@ eligible, running, and declares `entrypoints.display`, Ekklesia registers exactl
The page and descriptor both require the manifest's display permission through the ordinary Core The page and descriptor both require the manifest's display permission through the ordinary Core
authorization evaluator and an authenticated cookie session. A `public` display permission is authorization evaluator and an authenticated cookie session. A `public` display permission is
rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are rejected; there is no anonymous exception or device token. Artifact bytes are
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown, anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart, sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
@@ -598,8 +764,31 @@ artifact remains compatible with a future standalone host.
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API. The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
feature experience and cleanup inside its container. There is one display per module. Display feature experience and cleanup inside its container. There is one display per module. Display
inventory/management, named variants, targeting, public/device identity, a standalone host, shared inventory/management, named variants, targeting, public/device identity, a standalone host,
CSS, Announcements migration, and Companion integration are deferred. Announcements migration, and Companion integration are deferred.
### Your display survives a network outage — as code, not as content
A display page is controlled by a service worker that caches **the shell and the declared code**: the
page, the host script, your display artifact, and every contributed renderer that display actually
mounted ([ADR 0043](../architecture/decisions/0043-display-offline-code-fallback.md)). Caching is
network-first, so a screen picks up a new build the moment the server returns and can never be
trapped on an obsolete one.
What that means for you, concretely:
- **Your artifact will be served from cache when a fetch fails.** The bytes are identical and the
loader still verifies the artifact's declared id and version, so a fallback can never mount
something the descriptor did not name.
- **Your display cannot render while the network is down.** Mounting requires the descriptor, the
descriptor is authorization-bearing, and it is never cached — so a device whose access was revoked
stays revoked, and cached code can never manufacture authorization it no longer has. The screen
shows Ekklesia's own quiet offline indicator and resolves again by itself when connectivity
returns.
- **Nothing of yours is cached but code.** Your module's API responses, your realtime stream, and
every signed object-delivery URL are deliberately never stored. If you want a screen to keep
showing content through an outage, persisting that content is *your module's* job — this is the
foundation it would build on, not a substitute for it.
### What a module UI will never own ### What a module UI will never own
@@ -626,6 +815,132 @@ A Dashboard may later group modules into folders (`Media` holding Video and Live
configuration an administrator can reorganize freely. They are not dependencies, they are not configuration an administrator can reorganize freely. They are not dependencies, they are not
declared in your manifest, and your module must never require one to work correctly. declared in your manifest, and your module must never require one to work correctly.
## Browser contributions
Sometimes another module owns a screen, and *you* own what belongs on part of it. A signage module
owns a timeline; an announcements module knows what an announcement should look like. Neither can
import the other — modules are independently installed, independently versioned peers — and neither
should re-implement the other's job.
A **browser contribution** is the mechanism for exactly that
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)): a compiled renderer you
declare for a capability *another* module provides, which that module mounts inside a container it
owns.
```text
signage provides the capability signage.slide-renderer, and publishes its input contract
announcements contributes a React renderer for it
signage mounts that renderer in one timeline slot, updates it, and takes it down
```
### Contributing a renderer
Declare it in your manifest and put the source at the fixed path your contribution id implies:
```json
{
"contributions": [
{
"id": "announcements.signage-slide",
"version": "1.0.0",
"capability": "signage.slide-renderer",
"capabilityRange": "^1.0.0",
"artifact": "./dist/contributions/signage-slide/index.js"
}
]
}
```
```text
src/contributions/signage-slide/index.tsx → the contribution's compiled artifact
```
```tsx
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
import { createRoot } from "react-dom/client";
import styles from "./styles.css";
import { Slide, type SlideInput } from "./Slide.js";
export default defineModuleContribution({
id: "announcements.signage-slide",
version: "1.0.0",
styles,
mount(container, context) {
const root = createRoot(container);
root.render(<Slide input={context.input as SlideInput} />);
return {
update(input) {
root.render(<Slide input={input as SlideInput} />);
},
unmount() {
root.unmount();
},
};
},
});
```
The id is `<your-module-id>.<local-name>`, which is what makes it unique platform-wide without a
registry. Your contribution carries **its own version**, independent of your module's, and the
platform verifies the artifact claims it before mounting — a stale bundle is caught rather than
rendered. `capabilityRange` says which provider versions you work with: if no installed module
provides that capability inside your range, your contribution is simply not published. It is never
mounted and broken.
`pnpm build` compiles it alongside your other artifacts. You configure no paths and no bundler.
### What a contributed renderer receives
Two things: its own id, and the consumer's `input`.
That is the whole context. **No API client, no realtime subscription, no principal, no permissions,
no configuration, no database, no storage, and no credential.** Mounting your renderer grants your
module nothing — server-side authorization is untouched, and everything the renderer needs arrives
through the consumer's input, because the consumer is the party that was authorized to fetch it.
The input is **opaque JSON to the platform**. Ekklesia validates that it is genuinely
JSON-serializable and nothing else: not its shape, not its meaning, not its version. The consuming
module publishes that contract alongside its capability and versions it through its capability
version. Validate what you receive and render a sensible default when it is not what you expected —
you may be running against an older or newer consumer than the one you developed against.
`update` is what lets a long-running screen change content without restarting animations or reloading
media on every tick. Implement it if you can render new input in place; omit it if you genuinely
cannot, and the consumer will unmount and mount you again.
Your renderer mounts in its own isolated root with its own stylesheet, exactly like a module UI
artifact (ADR 0040): your rules cannot reach the consuming module's screen around you, and its rules
cannot reach in.
### Consuming contributions
If *your* module provides a capability other modules can render for, ask the host what was
contributed:
```tsx
const renderers = context.contributions.list("signage.slide-renderer");
const slide = await context.contributions.mount({
container: slotElement, // an element your module owns
contributionId: renderers[0].id,
input: { headline, seconds }, // your own published contract
});
await slide.update({ headline: next, seconds });
await slide.unmount();
```
You see identity, version, and capability — never a URL. Asking the host to mount by id is what keeps
your module hostable somewhere other than Ekklesia, exactly as `context.api.request("records")` does.
You will only ever see contributions aimed at a capability **your** module provides, and only ones
the server judged compatible. Handle an empty list: a contributor may be absent, disabled, or outside
your compatible range, and that is ordinary rather than exceptional.
You own the input contract completely. Publish it beside your capability, version it with your
capability version, and remember that a renderer written against an older version of it may still be
installed.
## Runtime server definition ## Runtime server definition
The source entrypoint exports one typed runtime definition and does not repeat the static manifest: The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
@@ -713,8 +1028,8 @@ export default defineModuleRuntime({
}); });
``` ```
A route definition has exactly five fields — `method`, `path`, `permission`, `maxBodyBytes`, A route definition has exactly five required fields — `method`, `path`, `permission`,
`handler` — and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy, `maxBodyBytes`, `handler` — plus one optional `bodyMode`, and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
middleware, audit metadata, authentication behavior, or another module's namespace) fails middleware, audit metadata, authentication behavior, or another module's namespace) fails
registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`, registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`,
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no `DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
@@ -779,15 +1094,61 @@ ones. The platform continues to own authentication, authorization, `Origin`, COR
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
before your handler runs — an unauthorized caller can never reach your validation at all. before your handler runs — an unauthorized caller can never reach your validation at all.
### Request bodies: `maxBodyBytes` and `bodyMode`
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field: `maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed `0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
accepted. It must be a non-negative integer no greater than the platform ceiling, accepted. A negative, non-integer, non-finite, or over-ceiling value fails registration with
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (`@ekklesia/contracts`, currently 1 MiB) — a negative, `MODULE_ROUTE_MAX_BODY_BYTES_INVALID`.
non-integer, non-finite, or over-ceiling value fails registration with
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`. The platform enforces this limit itself, ahead of permission `bodyMode` chooses how that body reaches you, and therefore which ceiling applies
evaluation and the handler, using the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}` ([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
contract every other body-consuming route in the platform uses — see
| `bodyMode` | Ceiling | What the platform does |
| --- | --- | --- |
| `"buffered"` (default) | 1 MiB | Reads and counts the whole body **before** your handler runs |
| `"streamed"` | 32 MiB | Counts bytes **as you consume them**, and never materializes the body |
```ts
http.route({
method: "POST",
path: "/slides",
permission: "signage.slides.write",
maxBodyBytes: 33_554_432,
bodyMode: "streamed",
async handler(request, context) { /* read request.body as a stream */ },
});
```
Omitting `bodyMode` means `buffered`, which is what every route written before this existed meant.
**Memory.** In `buffered` mode the platform holds at most your declared allowance, which is why that
ceiling is small and why `await request.json()` is safe there. In `streamed` mode the platform holds
nothing: read `request.body` as a stream. Calling `request.arrayBuffer()` on a streamed route works
but throws away the entire point — you have just buffered 32 MiB yourself.
**Streaming.** A declared `Content-Length` over your limit is refused synchronously, before
authorization and before a byte is read. Otherwise the platform counts what actually arrives, so a
misleading or absent `Content-Length` bounds nothing.
**A `413` can arrive after your handler starts.** This is the one consequence of `streamed` mode you
must design for. When the running total passes your limit the stream errors, the source is cancelled,
and the platform replaces your response with the sanitized `413` — even if you caught the error and
answered anyway. If you persist as you read, write to a temporary key and commit only once the read
completes; the platform cannot do that for you, because only your module knows what a partial write
means to its own data.
**Timeouts.** Body reading is bounded by the server's own header and request timeouts, not by a
per-route setting. A slow upload is a slow request, and the platform has one answer for that.
**One file per request.** 32 MiB is the platform's absolute ceiling in every mode — there is no
unbounded option anywhere in the contract. A UI uploading twenty images issues twenty individually
bounded requests; nothing here can express a 500 MiB aggregate upload, deliberately, because one bad
byte in one would waste everything already sent.
Every rejection uses the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}` contract every
other body-consuming route in the platform uses — see
[API](../development/api.md#request-body-limits). [API](../development/api.md#request-body-limits).
`path` is local to the module, never the path a caller actually sends. The platform alone owns the `path` is local to the module, never the path a caller actually sends. The platform alone owns the
+1
View File
@@ -2,6 +2,7 @@ export * from "./authorization.js";
export * from "./identifiers.js"; export * from "./identifiers.js";
export * from "./identity.js"; export * from "./identity.js";
export * from "./module-configuration.js"; export * from "./module-configuration.js";
export * from "./module-contribution.js";
export * from "./module-database.js"; export * from "./module-database.js";
export * from "./module-http.js"; export * from "./module-http.js";
export * from "./module-lifecycle.js"; export * from "./module-lifecycle.js";
@@ -0,0 +1,66 @@
import { z } from "zod";
import { capabilityIdSchema, packageRelativePathSchema } from "./identifiers.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
/**
* A **browser contribution**: one module's compiled renderer, declared for a capability another
* module provides, and mounted by that other module inside a container it owns (ADR 0041).
*
* This is deliberately not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts
* *the platform* mounts, in surfaces the platform owns. A contribution is mounted by a **consumer
* module**, at a moment and in a place only that module understands — a slot in its own layout —
* and the platform never renders one on its own.
*
* ## What the platform knows, and the complete list of it
*
* Identity, version, target capability, compatible range, and one compiled artifact. That is all,
* and the omissions are the point: nothing here says what the renderer draws, what shape its input
* has, when it should be mounted, or what any of it means. Core could not learn what a "slide" or a
* "timeline slot" is from this declaration, because the declaration cannot express it — the
* domain contract belongs entirely to the consumer's own versioned input.
*
* Inert, like every other manifest declaration: reading one causes no import, no evaluation, and no
* network or database access.
*/
/** `<module-id>.<local-name>` — exactly two segments. The prefix is checked against the declaring
* module in `moduleManifestSchema`, which is what makes a contribution id globally unique without a
* registry: module ids are already unique, so two modules cannot produce the same contribution id. */
export const contributionIdSchema = z
.string()
.min(3)
.max(160)
.regex(
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u,
"Must be <module-id>.<local-name>, both lowercase kebab-case.",
);
export const moduleContributionDeclarationSchema = z
.object({
id: contributionIdSchema,
/** This contribution's own version, independent of the module's. A consumer sees it and a host
* verifies the artifact claims it, so a stale bundle is caught exactly as it is for a module
* UI artifact. */
version: semanticVersionSchema,
/** The capability this renderer plugs into — owned by whichever module provides it, never by
* the contributing module. */
capability: capabilityIdSchema,
/** The provider capability versions this renderer is compatible with. A provider outside this
* range makes the contribution unpublishable rather than mounted and broken. */
capabilityRange: semanticVersionRangeSchema,
/** The compiled browser artifact, resolved and validated during discovery exactly like every
* other declared entrypoint: package-relative, inside the package before and after symlink
* resolution, and a real `.js`/`.mjs` file. */
artifact: packageRelativePathSchema,
description: z.string().trim().min(1).max(240).optional(),
})
.strict();
export type ModuleContributionDeclaration = z.output<typeof moduleContributionDeclarationSchema>;
/** The local segment of a contribution id — the part after the declaring module's own id. Used by
* the canonical builder to locate the fixed source directory, so a module author configures no
* paths and cannot point the build somewhere else. */
export function contributionLocalName(contributionId: string): string {
return contributionId.slice(contributionId.indexOf(".") + 1);
}
+93 -9
View File
@@ -25,21 +25,72 @@ export function isModuleHttpMethod(value: unknown): value is ModuleHttpMethod {
} }
/** /**
* The platform-owned hard ceiling on any single module route's declared request-body allowance, in * How a route's request body reaches its handler, and therefore which ceiling applies to it
* bytes. No module route may declare a `maxBodyBytes` above this value — validated at registration * (ADR 0042).
* (`safeParseModuleRouteDefinition`, below), not merely documented. This is a security contract, *
* not a manifest capability: it does not gate on `manifestVersion` and is enforced identically for * - `buffered` (the default, and everything that existed before): the platform reads and counts the
* every installed module regardless of its declared compatibility range. Large media transfer * whole body **before** the handler runs, so the handler is promised a fully available, bounded
* belongs to a future dedicated storage design, never to widening this ceiling. * body and an oversized request is rejected before any authorization or handler work happens.
* Bounded by {@link MODULE_ROUTE_MAX_BODY_BYTES_CEILING}, deliberately small.
* - `streamed`: the platform counts bytes **as the handler consumes them** and never materializes
* the body. That is what makes an ordinary image upload possible without the platform holding it
* in memory, and it is the whole reason the larger ceiling exists.
*
* Selecting the mode is an explicit, conscious decision rather than something inferred from the size
* a route asked for. Inferring it would mean an author who raised a limit silently changed how their
* own handler receives its body — and when a `413` can arrive relative to their own code.
*/
export const MODULE_HTTP_BODY_MODES = ["buffered", "streamed"] as const;
export type ModuleRouteBodyMode = (typeof MODULE_HTTP_BODY_MODES)[number];
const moduleRouteBodyModes: ReadonlySet<string> = new Set(MODULE_HTTP_BODY_MODES);
export function isModuleRouteBodyMode(value: unknown): value is ModuleRouteBodyMode {
return typeof value === "string" && moduleRouteBodyModes.has(value);
}
/**
* The platform-owned hard ceiling on a **buffered** module route's declared request-body allowance,
* in bytes. No buffered route may declare a `maxBodyBytes` above this value — validated at
* registration (`safeParseModuleRouteDefinition`, below), not merely documented. This is a security
* contract, not a manifest capability: it does not gate on `manifestVersion` and is enforced
* identically for every installed module regardless of its declared compatibility range.
*
* It stays exactly where it was. A body the platform materializes before a handler runs is a body
* the platform is holding in memory, and 1 MiB is the most a JSON request has any business being.
*/ */
export const MODULE_ROUTE_MAX_BODY_BYTES_CEILING = 1_048_576; export const MODULE_ROUTE_MAX_BODY_BYTES_CEILING = 1_048_576;
/**
* The platform-owned hard ceiling on a **streamed** module route's declared allowance, in bytes,
* and the platform's absolute request-body ceiling: nothing in Ekklesia may declare or accept a
* larger single request body, and there is no unbounded option anywhere in the contract.
*
* 32 MiB, chosen to hold one 25 MiB image plus bounded request overhead — a multipart envelope, its
* boundaries and part headers, and a modest allowance for a client that pads. The unit is
* deliberately *one file*: a UI offering twenty images issues twenty individually bounded requests,
* each of which the platform can reject cheaply, rather than one 500 MiB request whose failure
* wastes everything already sent. Nothing in this contract can express an aggregate upload.
*/
export const MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING = 33_554_432;
/** The largest body any module route may declare, in any mode. */
export const MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES = MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING;
/** The ceiling that applies to one delivery mode. */
export function moduleRouteBodyCeiling(mode: ModuleRouteBodyMode): number {
return mode === "streamed"
? MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING
: MODULE_ROUTE_MAX_BODY_BYTES_CEILING;
}
const moduleRouteMaxBodyBytesSchema = z const moduleRouteMaxBodyBytesSchema = z
.number() .number()
.int() .int()
.finite() .finite()
.min(0) .min(0)
.max(MODULE_ROUTE_MAX_BODY_BYTES_CEILING); .max(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES);
const MODULE_ROUTE_PATH_MAX_LENGTH = 256; const MODULE_ROUTE_PATH_MAX_LENGTH = 256;
const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
@@ -265,6 +316,12 @@ export interface ModuleRouteDefinitionInput {
readonly path: string; readonly path: string;
readonly permission: string; readonly permission: string;
readonly maxBodyBytes: number; readonly maxBodyBytes: number;
/**
* How the body reaches the handler, and therefore which ceiling `maxBodyBytes` is checked
* against (ADR 0042). Omitted means `buffered`, which is what every route written before this
* existed meant, so adding this field changed no existing route's behaviour.
*/
readonly bodyMode?: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
@@ -275,6 +332,9 @@ export interface ModuleRouteDefinition {
readonly path: ModuleRoutePath; readonly path: ModuleRoutePath;
readonly permission: PermissionId; readonly permission: PermissionId;
readonly maxBodyBytes: number; readonly maxBodyBytes: number;
/** Always present in the canonical form, defaulted to `buffered`, so no consumer downstream has
* to re-apply the default and none of them can disagree about it. */
readonly bodyMode: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
@@ -294,10 +354,20 @@ export interface RegisteredModuleHttpRoute {
readonly path: ModuleRoutePath; readonly path: ModuleRoutePath;
readonly permission: PermissionId; readonly permission: PermissionId;
readonly maxBodyBytes: number; readonly maxBodyBytes: number;
readonly bodyMode: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
const moduleRouteDefinitionKeys = new Set([ const moduleRouteDefinitionKeys = new Set([
"method",
"path",
"permission",
"maxBodyBytes",
"bodyMode",
"handler",
]);
const requiredModuleRouteDefinitionKeys = new Set([
"method", "method",
"path", "path",
"permission", "permission",
@@ -314,15 +384,18 @@ function hasModuleRouteDefinitionShape(value: unknown): value is {
path: unknown; path: unknown;
permission: unknown; permission: unknown;
maxBodyBytes: unknown; maxBodyBytes: unknown;
bodyMode: unknown;
handler: unknown; handler: unknown;
} { } {
if (!isRecord(value)) { if (!isRecord(value)) {
return false; return false;
} }
const keys = Object.keys(value); const keys = Object.keys(value);
// Every required field present, `bodyMode` optional, and nothing else at all: a route definition
// still cannot carry a baseline, a mount prefix, middleware, or another module's namespace.
return ( return (
keys.length === moduleRouteDefinitionKeys.size &&
keys.every((key) => moduleRouteDefinitionKeys.has(key)) && keys.every((key) => moduleRouteDefinitionKeys.has(key)) &&
[...requiredModuleRouteDefinitionKeys].every((key) => keys.includes(key)) &&
typeof value.handler === "function" typeof value.handler === "function"
); );
} }
@@ -332,6 +405,7 @@ export type ModuleRouteDefinitionIssueCode =
| "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_PATH_INVALID" | "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID" | "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_BODY_MODE_INVALID"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"; | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID";
export type ModuleRouteDefinitionParseResult = export type ModuleRouteDefinitionParseResult =
@@ -360,13 +434,23 @@ export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefin
if (!parsedPermission.success) { if (!parsedPermission.success) {
return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" }; return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" };
} }
if (value.bodyMode !== undefined && !isModuleRouteBodyMode(value.bodyMode)) {
return { success: false, code: "MODULE_ROUTE_BODY_MODE_INVALID" };
}
const bodyMode: ModuleRouteBodyMode = isModuleRouteBodyMode(value.bodyMode)
? value.bodyMode
: "buffered";
const parsedMaxBodyBytes = moduleRouteMaxBodyBytesSchema.safeParse(value.maxBodyBytes); const parsedMaxBodyBytes = moduleRouteMaxBodyBytesSchema.safeParse(value.maxBodyBytes);
if (!parsedMaxBodyBytes.success) { // The ceiling is chosen by the declared mode, so a route asking for more than a buffered body may
// hold has to say `streamed` — and thereby accept that its handler consumes a stream and that a
// `413` can arrive after its own code has started running.
if (!parsedMaxBodyBytes.success || parsedMaxBodyBytes.data > moduleRouteBodyCeiling(bodyMode)) {
return { success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" }; return { success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" };
} }
return { return {
success: true, success: true,
data: Object.freeze({ data: Object.freeze({
bodyMode,
method: value.method, method: value.method,
path: parsedPath.data, path: parsedPath.data,
permission: parsedPermission.data, permission: parsedPermission.data,
+73 -10
View File
@@ -11,6 +11,10 @@ import {
type ModuleConfigurationDeclaration, type ModuleConfigurationDeclaration,
moduleConfigurationDeclarationSchema, moduleConfigurationDeclarationSchema,
} from "./module-configuration.js"; } from "./module-configuration.js";
import {
type ModuleContributionDeclaration,
moduleContributionDeclarationSchema,
} from "./module-contribution.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js"; import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema); const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
@@ -118,9 +122,9 @@ function reportDuplicates(
} }
/** /**
* The one manifest shape Core accepts. Version 5's strict `entrypoints` object rejects `display`, so * The one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`,
* the static display declaration is an incompatible shape change and the canonical manifest moves * so the static browser-contribution declaration is an incompatible shape change and the canonical
* to `manifestVersion: 6` rather than silently reinterpreting version 5. * manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6.
* *
* Configuration is declared **statically, in this inert document**, rather than registered by * Configuration is declared **statically, in this inert document**, rather than registered by
* executable module code, for one decisive reason: the platform validates a module's configuration * executable module code, for one decisive reason: the platform validates a module's configuration
@@ -129,13 +133,14 @@ function reportDuplicates(
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without * inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
* importing a single line of module code, exactly as they always have. * importing a single line of module code, exactly as they always have.
* *
* Version 5, and every other version, fails validation: there is no compatibility adapter, no * Version 6, and every other version, fails validation: there is no compatibility adapter, no
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2 * automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One * (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 5
* manifest document can therefore never be read as two different versions. Upgrading a version 5 * by version 6 (ADR 0033). One manifest document can therefore never be read as two different
* manifest is a one-character edit for a module that declares no display: the new field is optional. * versions. Upgrading a version 6 manifest is a one-character edit for a module that contributes
* nothing: the new field defaults to empty.
*/ */
export const MODULE_MANIFEST_VERSION = 6; export const MODULE_MANIFEST_VERSION = 7;
export const moduleManifestSchema = z export const moduleManifestSchema = z
.object({ .object({
@@ -158,6 +163,10 @@ export const moduleManifestSchema = z
* module code during discovery. A module that needs nothing configured omits the field. */ * module code during discovery. A module that needs nothing configured omits the field. */
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []), configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
storage: moduleStorageSchema, storage: moduleStorageSchema,
/** Compiled browser renderers this module offers to capabilities **other** modules provide
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
* never learns what a contribution renders. A module that contributes nothing omits the field. */
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
entrypoints: entrypointsSchema, entrypoints: entrypointsSchema,
}) })
.strict() .strict()
@@ -200,6 +209,58 @@ export const moduleManifestSchema = z
} }
}); });
reportDuplicates(
manifest.contributions.map((contribution) => contribution.id),
["contributions"],
context,
"id",
);
const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id));
const contributionArtifacts = new Set<string>();
manifest.contributions.forEach((contribution, index) => {
// Namespacing is what makes a contribution id globally unique without a registry: module ids
// are already unique, so two modules can never produce the same contribution id.
if (!contribution.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`,
path: ["contributions", index, "id"],
});
}
// A contribution extends *another* module's capability. Targeting one this module already
// provides would be a module contributing to itself, which needs no platform mechanism at all
// and would make the compatibility check meaningless.
if (ownedCapabilityIds.has(contribution.capability)) {
context.addIssue({
code: "custom",
message: "A browser contribution must target a capability another module provides.",
path: ["contributions", index, "capability"],
});
}
if (
Object.entries(manifest.entrypoints).some(([entrypointName, configured]) =>
entrypointName === "display"
? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact
: configured === contribution.artifact,
)
) {
context.addIssue({
code: "custom",
message: "A contribution artifact must use a different path from every entrypoint.",
path: ["contributions", index, "artifact"],
});
}
if (contributionArtifacts.has(contribution.artifact)) {
context.addIssue({
code: "custom",
message: "Two contributions must not declare the same artifact path.",
path: ["contributions", index, "artifact"],
});
}
contributionArtifacts.add(contribution.artifact);
});
manifest.permissions.forEach((permission, index) => { manifest.permissions.forEach((permission, index) => {
if (!permission.id.startsWith(`${manifest.id}.`)) { if (!permission.id.startsWith(`${manifest.id}.`)) {
context.addIssue({ context.addIssue({
@@ -263,10 +324,9 @@ export const moduleManifestSchema = z
} }
} }
const ownedCapabilities = new Set(manifest.capabilities.map((capability) => capability.id));
for (const dependencyKind of ["required", "optional"] as const) { for (const dependencyKind of ["required", "optional"] as const) {
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) { for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
if (ownedCapabilities.has(capability)) { if (ownedCapabilityIds.has(capability)) {
context.addIssue({ context.addIssue({
code: "custom", code: "custom",
message: "A module does not need to depend on a capability it owns.", message: "A module does not need to depend on a capability it owns.",
@@ -297,6 +357,9 @@ export function safeParseModuleManifest(input: unknown) {
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>; export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
export type ModuleManifest = z.output<typeof moduleManifestSchema>; export type ModuleManifest = z.output<typeof moduleManifestSchema>;
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>; export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
/** A module manifest's `contributions` array entry — the same `ModuleContributionDeclaration` shape
* Core's publication registry consumes (`packages/contracts/src/module-contribution.ts`). */
export type ModuleManifestContributionDeclaration = ModuleContributionDeclaration;
export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>; export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>;
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape /** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased * Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import { import {
isModuleHttpMethod, isModuleHttpMethod,
MODULE_HTTP_METHODS, MODULE_HTTP_METHODS,
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING, MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
moduleRouteBodyCeiling,
type ModuleRouteDefinitionInput, type ModuleRouteDefinitionInput,
moduleRoutePathSchema, moduleRoutePathSchema,
moduleRouteShapeKey, moduleRouteShapeKey,
@@ -153,6 +156,7 @@ describe("safeParseModuleRouteDefinition", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) { if (result.success) {
expect(result.data).toEqual({ expect(result.data).toEqual({
bodyMode: "buffered",
method: "GET", method: "GET",
path: "/events/:eventId", path: "/events/:eventId",
permission: "calendar.events.read", permission: "calendar.events.read",
@@ -258,6 +262,81 @@ describe("safeParseModuleRouteDefinition", () => {
}); });
}); });
it("defaults an omitted bodyMode to buffered, so an existing route means what it always meant", () => {
const parsed = safeParseModuleRouteDefinition(validDefinition());
expect(parsed.success && parsed.data.bodyMode).toBe("buffered");
});
it("accepts either declared body mode and carries it through", () => {
for (const bodyMode of ["buffered", "streamed"] as const) {
const parsed = safeParseModuleRouteDefinition({ ...validDefinition(), bodyMode });
expect(parsed.success && parsed.data.bodyMode).toBe(bodyMode);
}
});
it("rejects a body mode outside the closed set", () => {
for (const bodyMode of ["stream", "chunked", "", 1, null]) {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), bodyMode })).toEqual({
success: false,
code: "MODULE_ROUTE_BODY_MODE_INVALID",
});
}
});
it("checks maxBodyBytes against the ceiling the declared mode selects", () => {
// A route asking for more than a buffered body may hold must say `streamed` — and thereby accept
// that its handler consumes a stream (ADR 0042).
expect(
safeParseModuleRouteDefinition(
validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1 }),
),
).toEqual({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" });
expect(
safeParseModuleRouteDefinition({
...validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1 }),
bodyMode: "streamed",
}).success,
).toBe(true);
});
it("holds one 25 MiB image plus bounded overhead, and refuses one byte more", () => {
const image = 25 * 1024 * 1024;
expect(MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING).toBeGreaterThan(image);
expect(
safeParseModuleRouteDefinition({
...validDefinition({ maxBodyBytes: MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING }),
bodyMode: "streamed",
}).success,
).toBe(true);
expect(
safeParseModuleRouteDefinition({
...validDefinition({ maxBodyBytes: MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING + 1 }),
bodyMode: "streamed",
}),
).toEqual({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" });
});
it("has one absolute ceiling and no unbounded option in any mode", () => {
expect(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES).toBe(MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING);
expect(moduleRouteBodyCeiling("buffered")).toBe(MODULE_ROUTE_MAX_BODY_BYTES_CEILING);
expect(moduleRouteBodyCeiling("streamed")).toBe(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES);
for (const bodyMode of ["buffered", "streamed"] as const) {
for (const maxBodyBytes of [
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER,
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES + 1,
]) {
expect(
safeParseModuleRouteDefinition({ ...validDefinition({ maxBodyBytes }), bodyMode })
.success,
).toBe(false);
}
}
});
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => { it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
const source = { ...validDefinition() }; const source = { ...validDefinition() };
const result = safeParseModuleRouteDefinition(source); const result = safeParseModuleRouteDefinition(source);
@@ -16,7 +16,7 @@ import {
function validManifest(): Record<string, unknown> { function validManifest(): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id: "calendar", id: "calendar",
name: "Calendar", name: "Calendar",
version: "0.1.0", version: "0.1.0",
@@ -226,15 +226,21 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest(version4Manifest).success).toBe(false); expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
}); });
it("rejects a version 5 manifest rather than silently interpreting it as version 6", () => { it("rejects a version 5 manifest rather than silently interpreting it as a later version", () => {
const version5Manifest = { ...validManifest(), manifestVersion: 5 }; const version5Manifest = { ...validManifest(), manifestVersion: 5 };
expect(safeParseModuleManifest(version5Manifest).success).toBe(false); expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
}); });
it("rejects a version 6 manifest rather than silently interpreting it as version 7", () => {
const version6Manifest = { ...validManifest(), manifestVersion: 6 };
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => { it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(6); expect(MODULE_MANIFEST_VERSION).toBe(7);
for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) { for (const manifestVersion of [1, 2, 3, 4, 5, 6, 8, "7", 7.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false); expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
} }
expect( expect(
@@ -243,6 +249,124 @@ describe("module manifest contract", () => {
).toBe(true); ).toBe(true);
}); });
it("defaults contributions to an empty list for a module that contributes nothing", () => {
const parsed = safeParseModuleManifest(validManifest());
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.contributions).toEqual([]);
});
it("accepts a well-formed browser contribution", () => {
const parsed = safeParseModuleManifest({
...validManifest(),
contributions: [
{
id: "calendar.signage-slide",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/signage-slide/index.js",
description: "Renders an upcoming-events slide.",
},
],
});
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.contributions[0]?.capability).toBe(
"signage.slide-renderer",
);
});
it.each<[string, Record<string, unknown>]>([
["an id outside this module's namespace", { id: "signage.slide" }],
["an id with no local segment", { id: "calendar" }],
["an id with more than one local segment", { id: "calendar.signage.slide" }],
["an unknown property", { slot: "primary" }],
["a non-semver version", { version: "1" }],
["an invalid capability range", { capabilityRange: "not a range" }],
["an artifact escaping the package", { artifact: "../elsewhere/index.js" }],
["an absolute artifact path", { artifact: "/etc/passwd" }],
])("rejects a contribution with %s", (_label, override) => {
const contribution = {
id: "calendar.signage-slide",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/signage-slide/index.js",
...override,
};
expect(
safeParseModuleManifest({ ...validManifest(), contributions: [contribution] }).success,
).toBe(false);
});
it("rejects a contribution targeting a capability this module already provides", () => {
// A module contributing to itself needs no platform mechanism, and would make the compatibility
// check meaningless: it would always be comparing a module against itself.
expect(
safeParseModuleManifest({
...validManifest(),
contributions: [
{
id: "calendar.self",
version: "1.0.0",
capability: "calendar.events",
capabilityRange: "^2.0.0",
artifact: "./dist/contributions/self/index.js",
},
],
}).success,
).toBe(false);
});
it("rejects two contributions sharing an id or an artifact path", () => {
const base = {
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
};
expect(
safeParseModuleManifest({
...validManifest(),
contributions: [
{ ...base, id: "calendar.a", artifact: "./dist/contributions/a/index.js" },
{ ...base, id: "calendar.a", artifact: "./dist/contributions/b/index.js" },
],
}).success,
).toBe(false);
expect(
safeParseModuleManifest({
...validManifest(),
contributions: [
{ ...base, id: "calendar.a", artifact: "./dist/contributions/a/index.js" },
{ ...base, id: "calendar.b", artifact: "./dist/contributions/a/index.js" },
],
}).success,
).toBe(false);
});
it("rejects a contribution artifact that collides with a declared entrypoint", () => {
const manifest = validManifest();
const entrypoints = manifest.entrypoints as Record<string, unknown>;
expect(
safeParseModuleManifest({
...manifest,
contributions: [
{
id: "calendar.signage-slide",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: entrypoints.server,
},
],
}).success,
).toBe(false);
});
it("rejects a bare permission-id string array instead of complete declarations", () => { it("rejects a bare permission-id string array instead of complete declarations", () => {
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] }; const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@ekklesia/design-tokens",
"version": "0.1.0",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/design-tokens",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3",
"happy-dom": "20.0.11",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "design-tokens"
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./install.js";
export * from "./registry.js";
export * from "./token.js";
+72
View File
@@ -0,0 +1,72 @@
import { ekklesiaDesignTokenStylesheet } from "./registry.js";
/**
* The attribute that marks a token stylesheet this package installed. Used to make installation
* idempotent, and to let a host remove exactly what it added.
*/
export const DESIGN_TOKEN_STYLE_ATTRIBUTE = "data-ekklesia-design-tokens";
/**
* A root that can own a `<style>` element: a whole document, or one artifact's shadow root.
*
* Structural rather than nominal (`Document | ShadowRoot`) on purpose. This package must not
* reference DOM interfaces it does not need, and the two things a caller actually passes differ in
* everything except these three members.
*/
export interface DesignTokenStyleRoot {
querySelector(selectors: string): Element | null;
appendChild<Node_ extends Node>(node: Node_): Node_;
readonly ownerDocument?: Document | null;
}
function documentFor(root: DesignTokenStyleRoot): Document | undefined {
if (typeof (root as Partial<Document>).createElement === "function") {
return root as Document;
}
return root.ownerDocument ?? undefined;
}
/**
* Installs Ekklesia's default token values at a host root, once.
*
* Idempotent by construction: a second call finds the element it inserted the first time and
* updates nothing, so a re-render, a re-mount, or two independent callers can never leave two
* competing copies of the theme in one root.
*
* CSS custom properties **inherit through shadow boundaries**, so installing this at the document
* root is what makes the same tokens available inside every isolated module artifact without the
* artifact importing anything or the host reaching into it. A caller that genuinely owns an isolated
* root with no inheriting ancestor — a standalone display, a test — installs with `scope: "host"`
* into that root instead.
*
* Returns a function that removes exactly the element this call inserted, and nothing else; calling
* it twice is safe. Returns a no-op when the tokens were already present, so a caller can never
* uninstall a theme it did not install.
*/
export function installEkklesiaDesignTokens(
root: DesignTokenStyleRoot,
options: { readonly scope?: "root" | "host" } = {},
): () => void {
if (root.querySelector(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`)) {
return () => undefined;
}
const ownerDocument = documentFor(root);
if (!ownerDocument) {
return () => undefined;
}
const style = ownerDocument.createElement("style");
style.setAttribute(DESIGN_TOKEN_STYLE_ATTRIBUTE, "");
style.textContent = ekklesiaDesignTokenStylesheet(options.scope ?? "root");
const target =
(ownerDocument === (root as unknown as Document) ? ownerDocument.head : undefined) ?? root;
target.appendChild(style);
let removed = false;
return () => {
if (removed) {
return;
}
removed = true;
style.remove();
};
}
+550
View File
@@ -0,0 +1,550 @@
import {
createDesignTokenRegistry,
type DesignTokenInput,
type DesignTokenScope,
} from "./token.js";
/**
* Ekklesia's global semantic theme.
*
* These are **semantic** tokens, not a palette. Nothing here is named for the colour it currently
* happens to be, because the whole point is that a future theme editor can change the value without
* every consumer's meaning changing with it: `background.raised` stays "the surface that sits above
* an ordinary surface" whatever a church's brand makes it.
*
* The default values are a neutral, high-contrast light theme. They are defaults, not a brand —
* a deployment overrides them by installing different values at its host root, and this pass
* deliberately ships no theme editor, brand builder, or dark-mode variant to do that with.
*
* ## Status is never colour alone
*
* Each status family carries a `symbol` token alongside its colours. A status that is only red is
* invisible to a person who cannot distinguish it, and unreadable on a projector or a washed-out
* lobby screen. Component code renders the symbol next to the label, so the meaning survives losing
* the colour entirely (WCAG 2.2 §1.4.1).
*/
const declarations = [
// ---------------------------------------------------------------------------------------------
// Backgrounds — the elevation ladder, from the page behind everything to the overlay above it.
// ---------------------------------------------------------------------------------------------
{
description: "The furthest-back background of a whole page or a full-viewport display.",
label: "Page background",
name: "background.page",
value: "#f6f6f5",
},
{
description:
"A large working area inside a page — a scrolling list region, a preview area, an editor body.",
label: "Canvas background",
name: "background.canvas",
value: "#fbfbfa",
},
{
description: "An ordinary panel, card, table, or dialog body sitting on the page.",
label: "Surface background",
name: "background.surface",
value: "#ffffff",
},
{
description: "A surface deliberately lifted above an ordinary surface, such as a popover.",
label: "Raised surface background",
name: "background.raised",
value: "#ffffff",
},
{
description: "The scrim drawn behind a modal dialog to separate it from the page beneath.",
label: "Overlay background",
name: "background.overlay",
value: "rgba(23, 23, 21, 0.55)",
},
{
description: "A recessed area such as a text input, a code block, or a well.",
label: "Inset background",
name: "background.inset",
value: "#f2f2f0",
},
// ---------------------------------------------------------------------------------------------
// Text.
// ---------------------------------------------------------------------------------------------
{
description: "Body copy and headings — the default foreground for readable content.",
label: "Primary text",
name: "text.primary",
value: "#1c1917",
},
{
description: "Supporting text that is still content: captions, secondary columns, help text.",
label: "Secondary text",
name: "text.secondary",
value: "#57534e",
},
{
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
label: "Muted text",
name: "text.muted",
value: "#79716b",
},
{
description: "Text drawn on a strongly coloured background, such as a primary button.",
label: "Inverse text",
name: "text.inverse",
value: "#ffffff",
},
{
description: "Text reporting a successful outcome, paired with the success symbol.",
label: "Success text",
name: "text.success",
value: "#166534",
},
{
description: "Text reporting a caution or a degraded state, paired with the warning symbol.",
label: "Warning text",
name: "text.warning",
value: "#854d0e",
},
{
description: "Text reporting a failure or a destructive consequence, paired with its symbol.",
label: "Danger text",
name: "text.danger",
value: "#9f1239",
},
// ---------------------------------------------------------------------------------------------
// Accent and interactive states.
// ---------------------------------------------------------------------------------------------
{
description: "The primary action colour: primary buttons, selected tabs, active links.",
label: "Accent",
name: "accent.base",
value: "#1d4ed8",
},
{
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
label: "Strong accent",
name: "accent.strong",
value: "#1e3a8a",
},
{
description: "A faint accent wash for selected rows and subtle accent backgrounds.",
label: "Subtle accent",
name: "accent.subtle",
value: "#e8effc",
},
{
description: "Foreground drawn on top of the accent background.",
label: "Accent foreground",
name: "accent.foreground",
value: "#ffffff",
},
{
description: "The focus ring colour. Never the only signal that something has focus.",
label: "Focus ring",
name: "accent.focus",
value: "#1d4ed8",
},
// ---------------------------------------------------------------------------------------------
// Borders.
// ---------------------------------------------------------------------------------------------
{
description: "The default border around panels, inputs, and table cells.",
label: "Border",
name: "border.base",
value: "#d7d3d0",
},
{
description: "A heavier border for emphasis, active inputs, and selected outlines.",
label: "Strong border",
name: "border.strong",
value: "#a8a29e",
},
{
description: "A hairline rule separating rows or sections inside one surface.",
label: "Divider",
name: "border.divider",
value: "#e7e5e4",
},
{
description: "The border width used by controls and panels.",
label: "Border width",
name: "border.width",
value: "1px",
},
// ---------------------------------------------------------------------------------------------
// Status families. Each carries a symbol so meaning never depends on colour.
// ---------------------------------------------------------------------------------------------
{
description: "Background of a success badge, banner, or row highlight.",
label: "Success background",
name: "status.success-background",
value: "#dcfce7",
},
{
description: "Border of a success badge or banner.",
label: "Success border",
name: "status.success-border",
value: "#86efac",
},
{
description: "The non-colour signal for success, rendered beside the label.",
label: "Success symbol",
name: "status.success-symbol",
value: '"✓"',
},
{
description: "Background of a warning badge, banner, or row highlight.",
label: "Warning background",
name: "status.warning-background",
value: "#fef3c7",
},
{
description: "Border of a warning badge or banner.",
label: "Warning border",
name: "status.warning-border",
value: "#fcd34d",
},
{
description: "The non-colour signal for a warning, rendered beside the label.",
label: "Warning symbol",
name: "status.warning-symbol",
value: '"!"',
},
{
description: "Background of a danger badge, banner, or row highlight.",
label: "Danger background",
name: "status.danger-background",
value: "#ffe4e6",
},
{
description: "Border of a danger badge or banner.",
label: "Danger border",
name: "status.danger-border",
value: "#fda4af",
},
{
description: "The non-colour signal for danger or failure, rendered beside the label.",
label: "Danger symbol",
name: "status.danger-symbol",
value: '"✕"',
},
{
description: "Background of a neutral informational badge or banner.",
label: "Info background",
name: "status.info-background",
value: "#e8effc",
},
{
description: "Border of a neutral informational badge or banner.",
label: "Info border",
name: "status.info-border",
value: "#bfd3f7",
},
{
description: "The non-colour signal for information, rendered beside the label.",
label: "Info symbol",
name: "status.info-symbol",
value: '"i"',
},
// ---------------------------------------------------------------------------------------------
// Interaction states.
// ---------------------------------------------------------------------------------------------
{
description: "Background wash under a hovered control or row.",
label: "Hover background",
name: "state.hover-background",
value: "#f2f2f0",
},
{
description: "Background wash under a control being pressed.",
label: "Pressed background",
name: "state.pressed-background",
value: "#e7e5e4",
},
{
description: "Background of a selected row, tab, or list item.",
label: "Selected background",
name: "state.selected-background",
value: "#e8effc",
},
{
description: "Foreground of a selected row, tab, or list item.",
label: "Selected foreground",
name: "state.selected-foreground",
value: "#1e3a8a",
},
{
description: "Opacity applied to a disabled control. Disabled state is never colour alone.",
label: "Disabled opacity",
name: "state.disabled-opacity",
value: "0.55",
},
{
description: "Thickness of the keyboard focus ring.",
label: "Focus ring width",
name: "state.focus-visible-width",
value: "2px",
},
{
description: "Gap between a focused control and its focus ring.",
label: "Focus ring offset",
name: "state.focus-visible-offset",
value: "2px",
},
// ---------------------------------------------------------------------------------------------
// Typography.
// ---------------------------------------------------------------------------------------------
{
description: "The interface font stack for all ordinary text.",
label: "Interface font family",
name: "typography.family-sans",
value: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
},
{
description: "The monospace stack for identifiers, keys, and code.",
label: "Monospace font family",
name: "typography.family-mono",
value: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
},
{
description: "Smallest supported text size, for dense metadata only.",
label: "Extra small font size",
name: "typography.size-xs",
value: "0.75rem",
},
{
description: "Small text: captions, table metadata, help text.",
label: "Small font size",
name: "typography.size-sm",
value: "0.875rem",
},
{
description: "The default body text size.",
label: "Body font size",
name: "typography.size-md",
value: "1rem",
},
{
description: "Large body text and small headings.",
label: "Large font size",
name: "typography.size-lg",
value: "1.125rem",
},
{
description: "Third-level heading size.",
label: "Heading 3 font size",
name: "typography.size-heading-3",
value: "1.25rem",
},
{
description: "Second-level heading size.",
label: "Heading 2 font size",
name: "typography.size-heading-2",
value: "1.5rem",
},
{
description: "First-level heading size — one per screen.",
label: "Heading 1 font size",
name: "typography.size-heading-1",
value: "1.875rem",
},
{
description: "Normal text weight.",
label: "Regular font weight",
name: "typography.weight-regular",
value: "400",
},
{
description: "Weight for labels, table headers, and emphasised body text.",
label: "Medium font weight",
name: "typography.weight-medium",
value: "500",
},
{
description: "Weight for headings and primary actions.",
label: "Bold font weight",
name: "typography.weight-bold",
value: "600",
},
{
description: "Tight line height for headings and single-line controls.",
label: "Tight line height",
name: "typography.line-height-tight",
value: "1.2",
},
{
description: "Default line height for body copy.",
label: "Normal line height",
name: "typography.line-height-normal",
value: "1.5",
},
{
description: "Relaxed line height for long-form reading.",
label: "Relaxed line height",
name: "typography.line-height-relaxed",
value: "1.7",
},
// ---------------------------------------------------------------------------------------------
// Spacing scale.
// ---------------------------------------------------------------------------------------------
{
description: "No space. Named so a component never hardcodes a bare zero.",
label: "No spacing",
name: "spacing.none",
value: "0",
},
{
description: "Hairline spacing between tightly related inline elements.",
label: "Extra extra small spacing",
name: "spacing.2xs",
value: "0.125rem",
},
{
description: "Spacing inside a compact control.",
label: "Extra small spacing",
name: "spacing.xs",
value: "0.25rem",
},
{
description: "Spacing between a label and its field.",
label: "Small spacing",
name: "spacing.sm",
value: "0.5rem",
},
{
description: "The default gap between related elements.",
label: "Medium spacing",
name: "spacing.md",
value: "0.75rem",
},
{
description: "Padding inside a panel or dialog.",
label: "Large spacing",
name: "spacing.lg",
value: "1rem",
},
{
description: "Spacing between sections of one screen.",
label: "Extra large spacing",
name: "spacing.xl",
value: "1.5rem",
},
{
description: "Spacing between major regions of a page.",
label: "Extra extra large spacing",
name: "spacing.2xl",
value: "2.5rem",
},
// ---------------------------------------------------------------------------------------------
// Radii and shadows.
// ---------------------------------------------------------------------------------------------
{
description: "Square corners.",
label: "No radius",
name: "radius.none",
value: "0",
},
{
description: "Corner radius for badges and small inline elements.",
label: "Small radius",
name: "radius.sm",
value: "0.25rem",
},
{
description: "Corner radius for controls, inputs, and buttons.",
label: "Medium radius",
name: "radius.md",
value: "0.375rem",
},
{
description: "Corner radius for panels and dialogs.",
label: "Large radius",
name: "radius.lg",
value: "0.75rem",
},
{
description: "Fully rounded ends, for pills and status dots.",
label: "Pill radius",
name: "radius.pill",
value: "9999px",
},
{
description: "No shadow.",
label: "No shadow",
name: "shadow.none",
value: "none",
},
{
description: "Subtle lift for a resting panel.",
label: "Small shadow",
name: "shadow.sm",
value: "0 1px 2px rgba(23, 23, 21, 0.08)",
},
{
description: "Shadow for a popover or a raised surface.",
label: "Medium shadow",
name: "shadow.md",
value: "0 4px 12px rgba(23, 23, 21, 0.12)",
},
{
description: "Shadow for a modal dialog.",
label: "Large shadow",
name: "shadow.lg",
value: "0 16px 40px rgba(23, 23, 21, 0.22)",
},
// ---------------------------------------------------------------------------------------------
// Control sizing.
// ---------------------------------------------------------------------------------------------
{
description: "Height of a compact control.",
label: "Small control height",
name: "control.height-sm",
value: "1.75rem",
},
{
description: "Height of an ordinary button, input, or select.",
label: "Medium control height",
name: "control.height-md",
value: "2.25rem",
},
{
description: "Height of a prominent primary control.",
label: "Large control height",
name: "control.height-lg",
value: "2.75rem",
},
{
description: "Horizontal padding inside a control.",
label: "Control padding",
name: "control.padding-inline",
value: "0.75rem",
},
] as const satisfies readonly DesignTokenInput[];
/** Every registered token name, as a union — the compile-time half of "modules consume registered
* tokens and never invent new global theme variables". */
export type EkklesiaDesignTokenName = (typeof declarations)[number]["name"];
/**
* The one global semantic token registry. Built (and therefore validated) at module evaluation, so
* an invalid declaration fails where it was written rather than in a browser.
*/
export const ekklesiaDesignTokens = createDesignTokenRegistry(declarations);
/** `var(--ekklesia-…)` for a registered token. The typed reference component and module code use;
* an unregistered name is a compile error, and also a runtime `DesignTokenError`. */
export function designToken(name: EkklesiaDesignTokenName): string {
return ekklesiaDesignTokens.reference(name);
}
/** The complete default theme as one installable CSS rule. */
export function ekklesiaDesignTokenStylesheet(scope: DesignTokenScope = "root"): string {
return ekklesiaDesignTokens.stylesheet(scope);
}
+282
View File
@@ -0,0 +1,282 @@
/**
* The design-token contract: what a semantic token is, what makes one valid, and how a validated
* registry is built from a declaration list.
*
* This package has **no dependencies at all**, exactly like `@ekklesia/module-ui-sdk` and for the
* same reason (ADR 0024): it is imported by browser programs — the Dashboard shell, a module's own
* UI artifact, a contributed renderer — and a design contract that dragged a backend type graph
* behind it would put Node's global declarations back into every one of them. Nothing here reads
* `process`, touches a filesystem, or imports a platform contract.
*/
/**
* The closed set of token families. A token's category is **derived from the first segment of its
* name** rather than declared separately, so a token can never claim one category while being named
* for another, and a future theme editor can group the registry without a second source of truth.
*/
export const DESIGN_TOKEN_CATEGORIES = [
"background",
"text",
"accent",
"border",
"status",
"state",
"typography",
"spacing",
"radius",
"shadow",
"control",
] as const;
export type DesignTokenCategory = (typeof DESIGN_TOKEN_CATEGORIES)[number];
const categories: ReadonlySet<string> = new Set(DESIGN_TOKEN_CATEGORIES);
/** The one prefix every Ekklesia global theme variable uses. A module may reference these and may
* define its own differently-prefixed private aliases; it may never define one of these. */
export const DESIGN_TOKEN_VARIABLE_PREFIX = "--ekklesia-";
/**
* Dotted, lowercase, kebab-cased segments with at least two segments — `background.page`, not
* `page`. The first segment is the category, so a one-segment name has no family to belong to.
*/
const TOKEN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/u;
/** The CSS custom-property identifier grammar this package will emit, checked against the derived
* name rather than trusted. A value that cannot pass this can never reach a stylesheet. */
const CSS_VARIABLE_PATTERN = /^--ekklesia-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
/**
* The bound on a token's default value.
*
* A design token is a small, declarative constant — a color, a length, a font stack, a shadow — and
* every fragment rejected below is either an injection vector or a sign that arbitrary CSS is being
* smuggled through the token registry into a `<style>` element. Rejecting them is what "no unbounded
* arbitrary values" means concretely: the registry stays a value table rather than a second,
* unreviewed stylesheet.
*/
export const DESIGN_TOKEN_VALUE_MAX_LENGTH = 160;
const FORBIDDEN_VALUE_FRAGMENTS = [
";",
"{",
"}",
"<",
">",
"/*",
"*/",
"@",
"url(",
"expression(",
"!important",
] as const;
export const DESIGN_TOKEN_LABEL_MAX_LENGTH = 80;
export const DESIGN_TOKEN_DESCRIPTION_MAX_LENGTH = 240;
/** What a token declaration says. Everything else about a token is derived. */
export interface DesignTokenInput {
/** `category.rest-of-name`, lowercase kebab-case segments. */
readonly name: string;
/** Short human-readable name, for a future theme editor's field label. */
readonly label: string;
/** What the token means and when to reach for it. Enumerable, never rendered by the platform. */
readonly description: string;
/** The default CSS value installed at a host root. */
readonly value: string;
}
/** A validated token: its declaration plus the two derived facts callers actually consume. */
export interface DesignToken extends DesignTokenInput {
readonly category: DesignTokenCategory;
/** `--ekklesia-<name with dots replaced by dashes>`. */
readonly cssVariable: string;
}
export type DesignTokenErrorCode =
| "DESIGN_TOKEN_NAME_INVALID"
| "DESIGN_TOKEN_CATEGORY_UNKNOWN"
| "DESIGN_TOKEN_VARIABLE_INVALID"
| "DESIGN_TOKEN_LABEL_INVALID"
| "DESIGN_TOKEN_DESCRIPTION_INVALID"
| "DESIGN_TOKEN_VALUE_INVALID"
| "DESIGN_TOKEN_DUPLICATE"
| "DESIGN_TOKEN_UNKNOWN"
| "DESIGN_TOKEN_SCOPE_INVALID";
const errorMessages: Readonly<Record<DesignTokenErrorCode, string>> = Object.freeze({
DESIGN_TOKEN_NAME_INVALID:
"A design token name must be at least two lowercase kebab-case segments separated by dots.",
DESIGN_TOKEN_CATEGORY_UNKNOWN: "A design token's first name segment must be a known category.",
DESIGN_TOKEN_VARIABLE_INVALID:
"A design token must derive a valid --ekklesia- CSS custom property identifier.",
DESIGN_TOKEN_LABEL_INVALID: "A design token must declare a bounded, non-empty label.",
DESIGN_TOKEN_DESCRIPTION_INVALID: "A design token must declare a bounded, non-empty description.",
DESIGN_TOKEN_VALUE_INVALID:
"A design token value must be a bounded literal CSS value with no declaration, comment, at-rule, url(), or !important syntax.",
DESIGN_TOKEN_DUPLICATE: "A design token name is declared more than once.",
DESIGN_TOKEN_UNKNOWN: "That design token is not registered.",
DESIGN_TOKEN_SCOPE_INVALID: 'A design token stylesheet scope must be "root" or "host".',
});
/**
* Carries a stable code and a fixed message, never the offending token name or value — the same
* sanitization rule every other Ekklesia error boundary follows. The rejected declaration is
* visible to the developer who wrote it, in the source file that failed to load.
*/
export class DesignTokenError extends Error {
readonly code: DesignTokenErrorCode;
constructor(code: DesignTokenErrorCode) {
super(errorMessages[code]);
this.name = "DesignTokenError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function isBoundedText(value: unknown, maxLength: number): value is string {
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
}
/** Written as a code-point scan rather than a character-class regex: a regex spelling of this range
* has to contain the control characters themselves, which is both unreadable and exactly the kind of
* literal the linter refuses. */
function containsControlCharacter(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (codePoint < 0x20 || codePoint === 0x7f) {
return true;
}
}
return false;
}
function isTokenValue(value: unknown): value is string {
if (!isBoundedText(value, DESIGN_TOKEN_VALUE_MAX_LENGTH)) {
return false;
}
if (value !== value.trim() || containsControlCharacter(value)) {
return false;
}
if (value.includes("\\")) {
return false;
}
return !FORBIDDEN_VALUE_FRAGMENTS.some((fragment) => value.includes(fragment));
}
export function designTokenVariableName(name: string): string {
return `${DESIGN_TOKEN_VARIABLE_PREFIX}${name.split(".").join("-")}`;
}
function validateToken(input: DesignTokenInput): DesignToken {
if (typeof input.name !== "string" || !TOKEN_NAME_PATTERN.test(input.name)) {
throw new DesignTokenError("DESIGN_TOKEN_NAME_INVALID");
}
const category = input.name.split(".")[0] as string;
if (!categories.has(category)) {
throw new DesignTokenError("DESIGN_TOKEN_CATEGORY_UNKNOWN");
}
const cssVariable = designTokenVariableName(input.name);
if (!CSS_VARIABLE_PATTERN.test(cssVariable)) {
throw new DesignTokenError("DESIGN_TOKEN_VARIABLE_INVALID");
}
if (!isBoundedText(input.label, DESIGN_TOKEN_LABEL_MAX_LENGTH)) {
throw new DesignTokenError("DESIGN_TOKEN_LABEL_INVALID");
}
if (!isBoundedText(input.description, DESIGN_TOKEN_DESCRIPTION_MAX_LENGTH)) {
throw new DesignTokenError("DESIGN_TOKEN_DESCRIPTION_INVALID");
}
if (!isTokenValue(input.value)) {
throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID");
}
return Object.freeze({
category: category as DesignTokenCategory,
cssVariable,
description: input.description,
label: input.label,
name: input.name,
value: input.value,
});
}
/** Where a generated token stylesheet installs its declarations. `root` targets a document, `host`
* targets the element a shadow root is attached to. There is deliberately no caller-chosen selector:
* an arbitrary selector string is exactly the unbounded value this package refuses everywhere else. */
export type DesignTokenScope = "root" | "host";
export interface DesignTokenRegistry<Name extends string = string> {
/** Every token, in declaration order. Stable, frozen, and the enumeration a theme editor reads. */
list(): readonly DesignToken[];
/** Every token in one category, in declaration order. */
listByCategory(category: DesignTokenCategory): readonly DesignToken[];
has(name: unknown): boolean;
/** The validated token, or `undefined`. Takes `unknown` so a lookup from untyped data fails
* closed rather than assuming validation already happened. */
get(name: unknown): DesignToken | undefined;
/** `--ekklesia-…` for a registered token. Throws `DesignTokenError` for anything else. */
variable(name: Name): string;
/** `var(--ekklesia-…)` for a registered token — what component code actually writes. */
reference(name: Name): string;
/** The complete default theme as one CSS rule, ready to install at a host root. */
stylesheet(scope?: DesignTokenScope): string;
}
/**
* Validates a declaration list and returns the immutable registry built from it.
*
* Every rule is enforced here rather than at review time: an invalid name, an unknown category, a
* malformed derived CSS identifier, an empty or overlong label or description, a value that is not
* a bounded literal, and a duplicate name each throw. A registry that constructs is therefore a
* registry that can be emitted as CSS without escaping, because nothing that needs escaping can be
* in it.
*/
export function createDesignTokenRegistry<const Tokens extends readonly DesignTokenInput[]>(
declarations: Tokens,
): DesignTokenRegistry<Tokens[number]["name"]> {
const byName = new Map<string, DesignToken>();
for (const declaration of declarations) {
const token = validateToken(declaration);
if (byName.has(token.name)) {
throw new DesignTokenError("DESIGN_TOKEN_DUPLICATE");
}
byName.set(token.name, token);
}
const tokens = Object.freeze([...byName.values()]);
const byCategory = new Map<DesignTokenCategory, readonly DesignToken[]>(
DESIGN_TOKEN_CATEGORIES.map((category) => [
category,
Object.freeze(tokens.filter((token) => token.category === category)),
]),
);
function requireToken(name: unknown): DesignToken {
const token = typeof name === "string" ? byName.get(name) : undefined;
if (!token) {
throw new DesignTokenError("DESIGN_TOKEN_UNKNOWN");
}
return token;
}
return Object.freeze({
get: (name: unknown): DesignToken | undefined =>
typeof name === "string" ? byName.get(name) : undefined,
has: (name: unknown): boolean => typeof name === "string" && byName.has(name),
list: (): readonly DesignToken[] => tokens,
listByCategory: (category: DesignTokenCategory): readonly DesignToken[] =>
byCategory.get(category) ?? Object.freeze([]),
reference: (name: string): string => `var(${requireToken(name).cssVariable})`,
stylesheet: (scope: DesignTokenScope = "root"): string => {
if (scope !== "root" && scope !== "host") {
throw new DesignTokenError("DESIGN_TOKEN_SCOPE_INVALID");
}
const declarationText = tokens
.map((token) => ` ${token.cssVariable}: ${token.value};`)
.join("\n");
return `${scope === "root" ? ":root" : ":host"} {\n${declarationText}\n}\n`;
},
variable: (name: string): string => requireToken(name).cssVariable,
});
}
@@ -0,0 +1,52 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
async function sourceFiles(): Promise<readonly string[]> {
const directory = path.join(packageDirectory, "src");
const entries = await readdir(directory);
return entries
.filter((entry) => entry.endsWith(".ts"))
.map((entry) => path.join(directory, entry));
}
/**
* The design contract is imported by the Dashboard shell, by module UI artifacts, and by contributed
* renderers. It inherits exactly the rule ADR 0024 established for `@ekklesia/module-ui-sdk`: it
* must not be able to pull a backend type graph — and with it Node's global declarations — into a
* browser program.
*/
describe("the design token package stays browser-safe", () => {
it("declares no runtime dependency at all", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
expect(manifest.dependencies).toBeUndefined();
expect(manifest.peerDependencies).toBeUndefined();
});
it("imports nothing outside its own source", async () => {
for (const file of await sourceFiles()) {
const source = await readFile(file, "utf8");
const specifiers = [...source.matchAll(/from\s+"([^"]+)"/gmu)].map(([, specifier]) =>
String(specifier),
);
expect(specifiers.every((specifier) => specifier.startsWith("."))).toBe(true);
}
});
it("never names a Node built-in or reads the environment", async () => {
for (const file of await sourceFiles()) {
const source = await readFile(file, "utf8");
expect(source).not.toContain('"node:');
expect(source).not.toContain("process.env");
expect(source).not.toContain("globalThis.process");
}
});
});
@@ -0,0 +1,137 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
DESIGN_TOKEN_CATEGORIES,
DESIGN_TOKEN_STYLE_ATTRIBUTE,
DESIGN_TOKEN_VARIABLE_PREFIX,
designToken,
ekklesiaDesignTokens,
ekklesiaDesignTokenStylesheet,
installEkklesiaDesignTokens,
} from "../src/index.js";
describe("the Ekklesia design token registry", () => {
it("is enumerable and deterministic", () => {
const first = ekklesiaDesignTokens.list();
const second = ekklesiaDesignTokens.list();
expect(first).toBe(second);
expect(Object.isFrozen(first)).toBe(true);
expect(first.length).toBeGreaterThan(0);
expect(first.map((token) => token.name)).toEqual(second.map((token) => token.name));
expect(ekklesiaDesignTokenStylesheet()).toBe(ekklesiaDesignTokenStylesheet());
});
it("declares a unique name and CSS variable for every token", () => {
const names = ekklesiaDesignTokens.list().map((token) => token.name);
const variables = ekklesiaDesignTokens.list().map((token) => token.cssVariable);
expect(new Set(names).size).toBe(names.length);
expect(new Set(variables).size).toBe(variables.length);
expect(variables.every((variable) => variable.startsWith(DESIGN_TOKEN_VARIABLE_PREFIX))).toBe(
true,
);
});
it("gives every token a human-readable label, description, and derived category", () => {
for (const token of ekklesiaDesignTokens.list()) {
expect(token.label.trim().length).toBeGreaterThan(0);
expect(token.description.trim().length).toBeGreaterThan(0);
expect(DESIGN_TOKEN_CATEGORIES).toContain(token.category);
expect(token.name.startsWith(`${token.category}.`)).toBe(true);
}
});
it("covers the semantic families the platform promises", () => {
const names = new Set(ekklesiaDesignTokens.list().map((token) => token.name));
for (const required of [
"background.page",
"background.canvas",
"background.surface",
"background.raised",
"background.overlay",
"background.inset",
"text.primary",
"text.secondary",
"text.muted",
"text.inverse",
"text.success",
"text.warning",
"text.danger",
"accent.base",
"accent.focus",
"border.base",
"border.divider",
"state.hover-background",
"state.pressed-background",
"state.selected-background",
"state.disabled-opacity",
"state.focus-visible-width",
"typography.family-sans",
"typography.size-md",
"typography.weight-bold",
"typography.line-height-normal",
"typography.size-heading-1",
"spacing.md",
"radius.md",
"shadow.md",
"control.height-md",
]) {
expect(names).toContain(required);
}
});
it("pairs every status colour with a non-colour symbol", () => {
for (const status of ["success", "warning", "danger", "info"]) {
expect(ekklesiaDesignTokens.has(`status.${status}-background`)).toBe(true);
expect(ekklesiaDesignTokens.has(`status.${status}-border`)).toBe(true);
const symbol = ekklesiaDesignTokens.get(`status.${status}-symbol`);
expect(symbol?.value.length).toBeGreaterThan(0);
}
});
it("hands component code a var() reference rather than a raw colour", () => {
expect(designToken("text.primary")).toBe("var(--ekklesia-text-primary)");
expect(ekklesiaDesignTokens.variable("text.primary")).toBe("--ekklesia-text-primary");
});
it("emits a stylesheet declaring every registered token exactly once", () => {
const stylesheet = ekklesiaDesignTokenStylesheet();
for (const token of ekklesiaDesignTokens.list()) {
expect(stylesheet.split(`${token.cssVariable}:`).length).toBe(2);
}
});
it("installs the default theme at a document root exactly once and removes what it added", () => {
const remove = installEkklesiaDesignTokens(document);
const installedAgain = installEkklesiaDesignTokens(document);
expect(document.querySelectorAll(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`).length).toBe(1);
expect(
document
.querySelector(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`)
?.textContent?.includes("--ekklesia-background-page"),
).toBe(true);
installedAgain();
expect(document.querySelectorAll(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`).length).toBe(1);
remove();
remove();
expect(document.querySelectorAll(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`).length).toBe(0);
});
it("installs host-scoped values into an isolated shadow root", () => {
const host = document.createElement("div");
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
installEkklesiaDesignTokens(shadow, { scope: "host" });
const style = shadow.querySelector(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`);
expect(style?.textContent?.startsWith(":host {")).toBe(true);
host.remove();
});
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
createDesignTokenRegistry,
DESIGN_TOKEN_CATEGORIES,
DESIGN_TOKEN_VALUE_MAX_LENGTH,
DesignTokenError,
type DesignTokenInput,
} from "../src/index.js";
function tokens(...overrides: readonly Partial<DesignTokenInput>[]): readonly DesignTokenInput[] {
return overrides.map((override) => ({
description: "A token used by this test.",
label: "Test token",
name: "background.page",
value: "#ffffff",
...override,
}));
}
describe("design token validation", () => {
it("accepts a minimal well-formed declaration and derives category and CSS variable", () => {
const registry = createDesignTokenRegistry(tokens({ name: "spacing.2xs", value: "0.125rem" }));
const token = registry.get("spacing.2xs");
expect(token).toEqual({
category: "spacing",
cssVariable: "--ekklesia-spacing-2xs",
description: "A token used by this test.",
label: "Test token",
name: "spacing.2xs",
value: "0.125rem",
});
expect(Object.isFrozen(token)).toBe(true);
});
it("rejects a duplicate token name", () => {
expect(() => createDesignTokenRegistry(tokens({}, {}))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_DUPLICATE" }),
);
});
it.each([
["a single segment", "page"],
["an empty name", ""],
["an uppercase segment", "background.Page"],
["an underscore", "background.page_alt"],
["a trailing dot", "background.page."],
["a space", "background.page alt"],
])("rejects %s as a token name", (_label, name) => {
expect(() => createDesignTokenRegistry(tokens({ name }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_NAME_INVALID" }),
);
});
it("rejects a name whose first segment is not a known category", () => {
expect(() => createDesignTokenRegistry(tokens({ name: "brand.primary" }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_CATEGORY_UNKNOWN" }),
);
});
it.each([
["a declaration terminator", "#fff; color: red"],
["a rule block", "#fff } body { display: none"],
["a comment", "#fff /* hidden */"],
["an at-rule", "@import 'evil.css'"],
["a url reference", "url(https://example.invalid/x.png)"],
["an override", "#fff !important"],
["a markup fragment", "</style><script>"],
["an escape sequence", "\\0041"],
["an empty value", " "],
])("rejects %s as a token value", (_label, value) => {
expect(() => createDesignTokenRegistry(tokens({ value }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }),
);
});
it("rejects an unbounded value", () => {
expect(() =>
createDesignTokenRegistry(tokens({ value: "a".repeat(DESIGN_TOKEN_VALUE_MAX_LENGTH + 1) })),
).toThrowError(expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }));
});
it("rejects a control character hidden inside a value", () => {
expect(() => createDesignTokenRegistry(tokens({ value: "#fff\u0007" }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }),
);
expect(() => createDesignTokenRegistry(tokens({ value: "#fff\nred" }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }),
);
});
it("rejects an empty or overlong label and description", () => {
expect(() => createDesignTokenRegistry(tokens({ label: " " }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_LABEL_INVALID" }),
);
expect(() => createDesignTokenRegistry(tokens({ description: "" }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_DESCRIPTION_INVALID" }),
);
expect(() => createDesignTokenRegistry(tokens({ description: "d".repeat(241) }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_DESCRIPTION_INVALID" }),
);
});
it("never echoes the rejected declaration in the error", () => {
let caught: unknown;
try {
createDesignTokenRegistry(tokens({ value: "#fff; content: 'leak-me'" }));
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(DesignTokenError);
expect(String((caught as Error).message)).not.toContain("leak-me");
expect(String((caught as Error).stack)).not.toContain("leak-me");
});
it("fails a lookup closed for an unregistered or non-string name", () => {
const registry = createDesignTokenRegistry(tokens({}));
expect(registry.get("nope.nope")).toBeUndefined();
expect(registry.get(42)).toBeUndefined();
expect(registry.has(undefined)).toBe(false);
expect(() => registry.variable("nope.nope")).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_UNKNOWN" }),
);
});
it("rejects a stylesheet scope outside the closed set", () => {
const registry = createDesignTokenRegistry(tokens({}));
expect(() => registry.stylesheet("body" as "root")).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_SCOPE_INVALID" }),
);
});
it("emits one rule per scope containing every declared token", () => {
const registry = createDesignTokenRegistry(
tokens({ name: "background.page", value: "#fff" }, { name: "text.primary", value: "#000" }),
);
expect(registry.stylesheet()).toBe(
":root {\n --ekklesia-background-page: #fff;\n --ekklesia-text-primary: #000;\n}\n",
);
expect(registry.stylesheet("host").startsWith(":host {")).toBe(true);
});
it("groups tokens by category without a second source of truth", () => {
const registry = createDesignTokenRegistry(
tokens({ name: "background.page" }, { name: "text.primary", value: "#000" }),
);
expect(registry.listByCategory("background").map((token) => token.name)).toEqual([
"background.page",
]);
expect(registry.listByCategory("shadow")).toEqual([]);
expect(DESIGN_TOKEN_CATEGORIES).toContain("background");
});
});
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["tests/**/*.ts"]
}
+165 -30
View File
@@ -1,6 +1,11 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { moduleDisplayEntrypointSchema, packageRelativePathSchema } from "@ekklesia/contracts"; import {
contributionLocalName,
moduleContributionDeclarationSchema,
moduleDisplayEntrypointSchema,
packageRelativePathSchema,
} from "@ekklesia/contracts";
import { build, type Plugin } from "esbuild"; import { build, type Plugin } from "esbuild";
/** /**
@@ -13,6 +18,14 @@ import { build, type Plugin } from "esbuild";
*/ */
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx"; export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx"; export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx";
/** Where a declared browser contribution's source lives: `src/contributions/<local-name>/index.tsx`,
* where the local name is the part of the contribution id after the module's own id. Derived rather
* than configured, exactly like the two role entrypoints, so an author points the build nowhere. */
export const MODULE_CONTRIBUTION_SOURCE_DIRECTORY = "src/contributions";
export function moduleContributionSourceEntrypoint(contributionId: string): string {
return `${MODULE_CONTRIBUTION_SOURCE_DIRECTORY}/${contributionLocalName(contributionId)}/index.tsx`;
}
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */ /** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
@@ -23,9 +36,11 @@ export type ModuleUiBuildErrorCode =
| "MODULE_UI_ENTRYPOINT_UNSAFE" | "MODULE_UI_ENTRYPOINT_UNSAFE"
| "MODULE_UI_ENTRYPOINT_COLLISION" | "MODULE_UI_ENTRYPOINT_COLLISION"
| "MODULE_UI_SOURCE_MISSING" | "MODULE_UI_SOURCE_MISSING"
| "MODULE_UI_STYLESHEET_INVALID"
| "MODULE_UI_STYLESHEET_UNDECLARED"
| "MODULE_UI_BUILD_FAILED"; | "MODULE_UI_BUILD_FAILED";
export type ModuleUiArtifactKind = "ui" | "display"; export type ModuleUiArtifactKind = "ui" | "display" | "contribution";
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`, MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
@@ -34,6 +49,10 @@ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.f
MODULE_UI_ENTRYPOINT_COLLISION: MODULE_UI_ENTRYPOINT_COLLISION:
"A display UI entrypoint must not overwrite another declared artifact.", "A display UI entrypoint must not overwrite another declared artifact.",
MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.", MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.",
MODULE_UI_STYLESHEET_INVALID:
"A module stylesheet may not define a global Ekklesia theme variable or keep an unresolved @import.",
MODULE_UI_STYLESHEET_UNDECLARED:
"A module UI build produced a stylesheet beside its artifact. Every style must be compiled into the declared artifact.",
MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.", MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.",
}); });
@@ -58,32 +77,97 @@ export class ModuleUiBuildError extends Error {
} }
/** /**
* Rejects a stylesheet import instead of quietly emitting one. * A CSS custom property in Ekklesia's own global theme namespace, being **declared** rather than
* * referenced. `var(--ekklesia-x)` and `var(--ekklesia-x, fallback)` are reads and are fine; only a
* esbuild would happily bundle `import "./styles.css"` into a sidecar `index.css` next to the * `--ekklesia-x:` declaration matches, because that is what would invent a global theme variable
* artifact. Nothing declares that file, no host loads it, and the manifest has no field for it — so * outside the registry a future theme editor enumerates (ADR 0039).
* it would work in a local test and silently do nothing in a real host. Module styling has no
* public loading contract yet (ADR 0023), so the build fails loudly rather than producing an
* undeclared asset. `scripts/check-boundaries.ts` rejects a `.css` file authored *inside* a module;
* this additionally covers a stylesheet reached through a dependency.
*/ */
function rejectStylesheetImports(): Plugin { const GLOBAL_THEME_DECLARATION = /--ekklesia-[a-z0-9-]*\s*:/iu;
/** An `@import` that survived compilation — a remote or otherwise unresolvable one. A local import
* is inlined by the nested bundle below and leaves nothing behind, so anything still here would make
* the artifact fetch a stylesheet at run time, which is exactly what "self-contained" excludes. */
const SURVIVING_IMPORT = /@import\s/iu;
/**
* Compiles each imported stylesheet into the artifact instead of emitting a sidecar.
*
* esbuild's default for `import "./styles.css"` from JavaScript is to collect every such stylesheet
* into an `index.css` output beside the artifact. Nothing declares that file, no host fetches it,
* and the manifest has no field for it — it would work in a local test and silently do nothing in a
* real host. ADR 0023 therefore rejected the import outright; ADR 0040 compiles it instead.
*
* Each `.css` import becomes a JavaScript module whose default export is that stylesheet's compiled
* text, produced by a nested esbuild run so local `@import`s are resolved and the result is one
* self-contained string. The author passes that string to `defineModuleUi({ styles })` and the host
* installs it into the artifact's isolated root. The declared `.js` artifact therefore remains the
* only file the platform publishes, exactly as before stylesheets existed.
*
* Other stylesheet dialects stay rejected. Adding one is a language and toolchain decision, not a
* detail of this pass, and would need its own ADR.
*/
const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports";
function compileStylesheetImports(): Plugin {
const decoder = new TextDecoder();
return { return {
name: "ekklesia-reject-stylesheet-imports", name: STYLESHEET_PLUGIN_NAME,
setup(pluginBuild) { setup(pluginBuild) {
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which // No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). This pattern is // rejects `(?iu)` outright ("filter is not a valid Go regular expression"). These patterns are
// pure ASCII, so the flag would change nothing anyway. // pure ASCII, so the flag would change nothing anyway.
pluginBuild.onResolve({ filter: /\.(css|s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({ pluginBuild.onResolve({ filter: /\.(s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
errors: [ errors: [
{ {
text: text:
"Module UI cannot import a stylesheet yet: there is no declared way for a host to " + "Module UI supports plain .css only. Other stylesheet dialects have no compilation " +
"load one. Use React inline style objects for now (ADR 0023).", "contract in this platform (ADR 0040).",
location: { file: args.importer }, location: { file: args.importer },
}, },
], ],
})); }));
pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => {
const compiled = await build({
bundle: true,
entryPoints: [args.path],
logLevel: "silent",
minify: false,
target: MODULE_UI_BUILD_TARGET,
write: false,
});
const stylesheet = compiled.outputFiles
.map((outputFile) => decoder.decode(outputFile.contents))
.join("\n");
if (GLOBAL_THEME_DECLARATION.test(stylesheet)) {
return {
errors: [
{
text:
"A module stylesheet may reference Ekklesia's global theme variables but must " +
"never declare one. Derive a private alias under your own prefix instead " +
"(ADR 0039).",
location: { file: args.path },
},
],
};
}
if (SURVIVING_IMPORT.test(stylesheet)) {
return {
errors: [
{
text:
"A module stylesheet left an unresolved @import. A declared artifact must be " +
"self-contained, so every import has to resolve to a local file (ADR 0040).",
location: { file: args.path },
},
],
};
}
return { contents: `export default ${JSON.stringify(stylesheet)};`, loader: "js" };
});
}, },
}; };
} }
@@ -94,13 +178,17 @@ export interface BuildModuleUiOptions {
} }
export interface ModuleUiBuildResult { export interface ModuleUiBuildResult {
/** Built artifacts in stable role order: primary UI first, then display. Empty for backend-only /** Built artifacts in stable role order: primary UI, then display, then each declared contribution
* modules, which makes the canonical command usable unchanged by every module package. */ * in manifest order. Empty for backend-only modules, which makes the canonical command usable
* unchanged by every module package. */
readonly artifacts: readonly ModuleUiBuildArtifactResult[]; readonly artifacts: readonly ModuleUiBuildArtifactResult[];
} }
export interface ModuleUiBuildArtifactResult { export interface ModuleUiBuildArtifactResult {
readonly kind: ModuleUiArtifactKind; readonly kind: ModuleUiArtifactKind;
/** For a contribution, the declared contribution id — so a multi-contribution build reports which
* artifact it produced. Absent for the two role entrypoints. */
readonly contributionId?: string;
/** Absolute path of the artifact actually written, exactly as the manifest declares. */ /** Absolute path of the artifact actually written, exactly as the manifest declares. */
readonly outputFile: string; readonly outputFile: string;
/** Absolute path of the fixed source entrypoint that produced it. */ /** Absolute path of the fixed source entrypoint that produced it. */
@@ -132,7 +220,15 @@ async function resolveDeclaredArtifacts(
} }
const display = entrypoints.display; const display = entrypoints.display;
const manifestContributions = isRecord(manifest) ? manifest.contributions : undefined;
const declaredContributions = Array.isArray(manifestContributions) ? manifestContributions : [];
for (const contribution of declaredContributions) {
if (!moduleContributionDeclarationSchema.safeParse(contribution).success) {
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "contribution");
}
}
const declarations: readonly { const declarations: readonly {
readonly contributionId?: string;
readonly declared: unknown; readonly declared: unknown;
readonly kind: ModuleUiArtifactKind; readonly kind: ModuleUiArtifactKind;
readonly present: boolean; readonly present: boolean;
@@ -150,6 +246,13 @@ async function resolveDeclaredArtifacts(
present: display !== undefined, present: display !== undefined,
source: MODULE_DISPLAY_SOURCE_ENTRYPOINT, source: MODULE_DISPLAY_SOURCE_ENTRYPOINT,
}, },
...(declaredContributions as { artifact: string; id: string }[]).map((contribution) => ({
contributionId: contribution.id,
declared: contribution.artifact,
kind: "contribution" as const,
present: true,
source: moduleContributionSourceEntrypoint(contribution.id),
})),
]; ];
const artifacts: DeclaredModuleUiArtifact[] = []; const artifacts: DeclaredModuleUiArtifact[] = [];
@@ -168,6 +271,9 @@ async function resolveDeclaredArtifacts(
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind); throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
} }
artifacts.push({ artifacts.push({
...(declaration.contributionId === undefined
? {}
: { contributionId: declaration.contributionId }),
kind: declaration.kind, kind: declaration.kind,
outputFile: path.resolve(moduleDirectory, declared), outputFile: path.resolve(moduleDirectory, declared),
sourceFile: path.join(moduleDirectory, declaration.source), sourceFile: path.join(moduleDirectory, declaration.source),
@@ -178,17 +284,24 @@ async function resolveDeclaredArtifacts(
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display"); throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
} }
const displayArtifact = artifacts.find((artifact) => artifact.kind === "display"); // No declared artifact may overwrite another declared file — an entrypoint this build does not
if (displayArtifact !== undefined) { // produce (`server`, `schema`, ...), or another artifact it does. Checked before esbuild runs, so
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) { // a collision can never destroy output that was already correct.
const configured = entrypoints[entrypointName]; const backendEntrypointPaths = new Set(
if ( (["server", "schema", "migrations", "jobs"] as const)
typeof configured === "string" && .map((entrypointName) => entrypoints[entrypointName])
path.resolve(moduleDirectory, configured) === displayArtifact.outputFile .filter((configured): configured is string => typeof configured === "string")
) { .map((configured) => path.resolve(moduleDirectory, configured)),
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", "display"); );
} const claimedOutputs = new Set<string>();
for (const artifact of artifacts) {
if (
backendEntrypointPaths.has(artifact.outputFile) ||
claimedOutputs.has(artifact.outputFile)
) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", artifact.kind);
} }
claimedOutputs.add(artifact.outputFile);
} }
// Complete preflight before esbuild starts: a missing second source must not leave the first role's // Complete preflight before esbuild starts: a missing second source must not leave the first role's
@@ -237,7 +350,7 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
minify: false, minify: false,
outfile: artifact.outputFile, outfile: artifact.outputFile,
platform: "browser", platform: "browser",
plugins: [rejectStylesheetImports()], plugins: [compileStylesheetImports()],
sourcemap: true, sourcemap: true,
target: MODULE_UI_BUILD_TARGET, target: MODULE_UI_BUILD_TARGET,
// Fail rather than silently shipping a partial bundle. // Fail rather than silently shipping a partial bundle.
@@ -246,6 +359,17 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set. // atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
write: false, write: false,
}); });
// Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into
// JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or
// loader change could reintroduce a sidecar, and a sidecar is precisely the failure this
// design exists to make impossible: a file nothing declares, no host fetches, and every local
// test silently passes without. Producing one fails the build rather than shipping it.
const sidecar = result.outputFiles.find(
(outputFile) => path.extname(outputFile.path).toLowerCase() === ".css",
);
if (sidecar) {
throw new ModuleUiBuildError("MODULE_UI_STYLESHEET_UNDECLARED", artifact.kind);
}
pendingWrites.push( pendingWrites.push(
result.outputFiles.map((outputFile) => ({ result.outputFiles.map((outputFile) => ({
contents: outputFile.contents, contents: outputFile.contents,
@@ -253,10 +377,21 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
})), })),
); );
} catch (error) { } catch (error) {
if (error instanceof ModuleUiBuildError) {
throw error;
}
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a // esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
// programmatic caller never receives raw paths or internals. // programmatic caller never receives raw paths or internals.
if (isRecord(error) && Array.isArray(error.errors)) { if (isRecord(error) && Array.isArray(error.errors)) {
throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind), { // A stylesheet the module authored broke one of this platform's own rules, rather than
// failing to compile. Reported with its own code so a caller can tell "your CSS is not
// allowed to do that" from "your TSX does not compile".
const code = error.errors.some(
(diagnostic) => isRecord(diagnostic) && diagnostic.pluginName === STYLESHEET_PLUGIN_NAME,
)
? "MODULE_UI_STYLESHEET_INVALID"
: "MODULE_UI_BUILD_FAILED";
throw Object.assign(new ModuleUiBuildError(code, artifact.kind), {
diagnostics: error.errors, diagnostics: error.errors,
}); });
} }
+1 -1
View File
@@ -26,7 +26,7 @@ async function main(): Promise<number> {
} }
for (const artifact of result.artifacts) { for (const artifact of result.artifacts) {
process.stdout.write( process.stdout.write(
`Built module UI (${artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`, `Built module UI (${artifact.contributionId ?? artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
); );
} }
return 0; return 0;
@@ -34,7 +34,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
const manifest = const manifest =
options.manifest ?? options.manifest ??
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id: "example", id: "example",
name: "Example", name: "Example",
version: "0.1.0", version: "0.1.0",
@@ -342,10 +342,10 @@ describe("module UI build", () => {
}); });
it.each([ it.each([
"styles.css",
"theme.scss", "theme.scss",
"layout.less", "layout.less",
])("rejects a %s import rather than emitting an undeclared sidecar asset", async (stylesheet) => { "tokens.styl",
])("rejects a %s import: only plain CSS has a compilation contract", async (stylesheet) => {
const directory = await createModule({ const directory = await createModule({
ui: "./dist/ui/index.js", ui: "./dist/ui/index.js",
files: { files: {
@@ -358,47 +358,131 @@ describe("module UI build", () => {
await buildModuleUi({ moduleDirectory: directory }); await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the stylesheet import to be rejected"); expect.unreachable("expected the stylesheet import to be rejected");
} catch (error) { } catch (error) {
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED"); expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? []; const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe( expect(diagnostics.some((entry) => entry.text?.includes("plain .css only"))).toBe(true);
true,
);
} }
}); });
it("never leaves a stylesheet sidecar next to the artifact", async () => { it("compiles a plain stylesheet into the artifact instead of emitting a sidecar", async () => {
const directory = await createModule({ const directory = await createModule({
ui: "./dist/ui/index.js", ui: "./dist/ui/index.js",
files: { files: {
"src/ui/index.tsx": 'import "./styles.css";\nexport default {};', "src/ui/index.tsx": `
"src/ui/styles.css": ".example { color: red; }", import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css": ".example { color: var(--ekklesia-text-primary); }",
}, },
}); });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toBeInstanceOf( const result = await buildModuleUi({ moduleDirectory: directory });
ModuleUiBuildError, const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
);
// The whole stylesheet travels inside the one declared artifact.
expect(artifact).toContain(".example");
expect(artifact).toContain("--ekklesia-text-primary");
// And nothing was emitted beside it. A sidecar is the exact failure this design prevents: a
// file nothing declares, no host fetches, and every local test silently passes without.
await expect( await expect(
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"), readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
).rejects.toThrow(); ).rejects.toThrow();
}); });
it("enforces the stylesheet boundary for display UI too", async () => { it("resolves a local @import so the compiled stylesheet is self-contained", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css": '@import "./base.css";\n.example { color: red; }',
"src/ui/base.css": ".base { display: grid; }",
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
expect(artifact).toContain(".base");
expect(artifact).toContain(".example");
});
it("rejects a stylesheet that keeps an unresolved @import, so the artifact stays self-contained", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css": '@import url("https://fonts.example.invalid/family.css");',
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_STYLESHEET_INVALID",
});
});
it("rejects a stylesheet that declares a global Ekklesia theme variable", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
// Referencing a token is the contract; *declaring* one would invent a global theme variable
// outside the registry a future theme editor enumerates (ADR 0039).
"src/ui/styles.css": ":host { --ekklesia-text-primary: hotpink; }",
},
});
try {
await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the stylesheet to be rejected");
} catch (error) {
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
expect(diagnostics.some((entry) => entry.text?.includes("never declare one"))).toBe(true);
}
});
it("accepts a private alias derived from a registered token", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css":
":host { --example-gap: var(--ekklesia-spacing-sm); }\n.example { gap: var(--example-gap); }",
},
});
const artifact = await readFile(
(await buildModuleUi({ moduleDirectory: directory })).artifacts[0]?.outputFile ?? "",
"utf8",
);
expect(artifact).toContain("--example-gap");
});
it("compiles a display artifact's stylesheet the same way", async () => {
const directory = await createModule({ const directory = await createModule({
display: { display: {
artifact: "./dist/display/index.js", artifact: "./dist/display/index.js",
permission: "example.display.view", permission: "example.display.view",
}, },
files: { files: {
"src/display/index.tsx": 'import "./styles.css";\nexport default {};', "src/display/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/display/styles.css": ".display { position: fixed; }", "src/display/styles.css": ".display { position: fixed; }",
}, },
}); });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({ const result = await buildModuleUi({ moduleDirectory: directory });
code: "MODULE_UI_BUILD_FAILED", const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
kind: "display",
}); expect(artifact).toContain(".display");
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow(); await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
}); });
+2
View File
@@ -1 +1,3 @@
export * from "./isolated-root.js";
export * from "./load-module-contribution.js";
export * from "./load-module-ui.js"; export * from "./load-module-ui.js";
@@ -0,0 +1,108 @@
/**
* The isolated root a host mounts a browser artifact into (ADR 0040).
*
* Every declared browser artifact — a module's primary UI, its display UI, and a contributed
* renderer — is mounted the same way, into its own Shadow DOM root the loader creates and owns:
*
* ```text
* host container ← the host's element; the loader never mutates it beyond this child
* └─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
* #shadow-root (open)
* ├─ <style data-ekklesia-module-ui-styles> base rules + the artifact's declared CSS
* └─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
* ```
*
* Shadow DOM is chosen because it is the one mechanism in a browser that actually **enforces** the
* rule rather than describing it: rules inside a shadow root do not apply outside it, and document
* rules do not apply inside it. A class-name convention, a build-time prefix, or a "please scope
* your selectors" instruction are all conventions a single mistake defeats, and the failure mode is
* a module silently restyling the Dashboard or another module's renderer.
*
* It is **not** a security boundary, and nothing here should be read as one. `mode: "open"` is
* deliberate: an installed module is trusted first-party code running in the host's own realm
* (ADR 0022, ADR 0024), it could reach `document` directly if it wanted to, and `mode: "closed"`
* would buy no protection while making the artifact's DOM unreachable to the host's own tests and
* diagnostics. This is a correctness boundary for *styles*, not a sandbox.
*
* Custom properties inherit through a shadow boundary, so the design tokens a host installed at its
* root (ADR 0039) are available inside every artifact with nothing passed and nothing imported.
*/
export const MODULE_UI_ISOLATION_ATTRIBUTE = "data-ekklesia-module-ui";
export const MODULE_UI_ISOLATION_STYLE_ATTRIBUTE = "data-ekklesia-module-ui-styles";
export const MODULE_UI_ISOLATION_ROOT_ATTRIBUTE = "data-ekklesia-module-ui-root";
/**
* The rules the host always installs, before any artifact CSS.
*
* Both wrappers are `display: contents`, so the two elements the loader adds contribute no boxes of
* their own and an artifact's own top-level elements lay out inside the host's container exactly as
* they did before isolation existed. They are declared as CSS rather than inline styles precisely so
* an artifact's own stylesheet — which the host appends *after* these — can override them when it
* genuinely needs a box to size.
*/
const BASE_STYLESHEET = `:host{display:contents}\n[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]{display:contents}`;
export interface IsolatedArtifactRoot {
/** The element handed to the artifact's `mount`. */
readonly mountTarget: Element;
/** The open shadow root, for a host's own tests and diagnostics. */
readonly shadowRoot: ShadowRoot;
/** Removes the whole isolated root — content and styles together. Idempotent. */
dispose(): void;
}
/**
* Builds one isolated root inside `container` and returns it.
*
* Nothing is attached to `container` until the shadow root, its stylesheet, and its mount target
* have all been created, so a host whose environment cannot provide Shadow DOM gets a thrown error
* and an untouched container rather than a half-built, unisolated mount.
*
* Each call creates a **fresh** root, and `dispose` removes it whole. That is what makes styles
* deterministic in both directions: a mount installs exactly one `<style>`, an unmount removes it
* with the element that owns it, and remounting cannot accumulate a second copy because there is
* nothing left to accumulate onto.
*/
export function createIsolatedArtifactRoot(
container: Element,
styles: string | undefined,
): IsolatedArtifactRoot {
const ownerDocument = container.ownerDocument;
if (!ownerDocument) {
throw new TypeError("A module UI container must belong to a document.");
}
const shadowHost = ownerDocument.createElement("div");
shadowHost.setAttribute(MODULE_UI_ISOLATION_ATTRIBUTE, "");
if (typeof shadowHost.attachShadow !== "function") {
throw new TypeError("This environment does not support Shadow DOM.");
}
const shadowRoot = shadowHost.attachShadow({ mode: "open" });
const style = ownerDocument.createElement("style");
style.setAttribute(MODULE_UI_ISOLATION_STYLE_ATTRIBUTE, "");
// The artifact's own CSS comes last, so it wins over the base rules above by ordinary cascade
// order rather than by anything the host has to special-case.
style.textContent = styles === undefined ? BASE_STYLESHEET : `${BASE_STYLESHEET}\n${styles}`;
shadowRoot.appendChild(style);
const mountTarget = ownerDocument.createElement("div");
mountTarget.setAttribute(MODULE_UI_ISOLATION_ROOT_ATTRIBUTE, "");
shadowRoot.appendChild(mountTarget);
container.appendChild(shadowHost);
let disposed = false;
return {
dispose: () => {
if (disposed) {
return;
}
disposed = true;
shadowHost.remove();
},
mountTarget,
shadowRoot,
};
}
@@ -0,0 +1,142 @@
import {
isModuleContributionInput,
type ModuleContributionDefinition,
ModuleContributionError,
type ModuleContributionInput,
type ModuleContributionInstance,
type MountedModuleContribution,
parseModuleContributionExport,
} from "@ekklesia/module-ui-sdk";
import { createIsolatedArtifactRoot, type IsolatedArtifactRoot } from "./isolated-root.js";
import { type ModuleUiImporter, ModuleUiHostError } from "./load-module-ui.js";
/**
* How a host obtains one contribution artifact.
*
* `url` is a **browser-loadable module URL** the host produced, never a path a module composed. The
* consuming module names a contribution id; turning that into a URL is asset publication, which this
* generic package deliberately does not own — exactly as it does not own it for a module UI artifact
* (ADR 0024, ADR 0025). Ekklesia implements it at its API edge (ADR 0041).
*
* `contributionId` and `version` are the identity the host already read from the contributing
* module's validated manifest, and are checked against the artifact before anything is mounted.
*/
export interface ModuleContributionSource {
readonly contributionId: string;
readonly version: string;
readonly url: string;
}
export interface LoadModuleContributionOptions {
readonly source: ModuleContributionSource;
/** A container the **consuming module** owns. The loader builds the contribution's isolated root
* inside it and never reaches outside it. */
readonly container: Element;
/** The consumer's own versioned input, validated as JSON before it crosses the boundary. */
readonly input: ModuleContributionInput;
readonly importModule?: ModuleUiImporter;
}
const defaultImporter: ModuleUiImporter = (url) => import(url);
/**
* Imports a published contribution artifact, verifies it is the contribution the host expected, and
* mounts it into an isolated root inside the consumer's container (ADR 0040, ADR 0041).
*
* **This executes trusted first-party code in the host's own page**, exactly like a module UI
* artifact. The isolated root keeps the renderer's *styles* to itself; it is not a sandbox, and
* mounting a contribution grants its module no authority whatsoever. Server-side authorization is
* untouched: the consumer was already authorized to obtain the data it passes as input, and the
* contribution receives nothing else.
*
* The consumer's `input` is validated as genuinely JSON-serializable **before** the artifact is even
* imported. A `Date`, a function, a cyclic object, or `undefined` would either vanish or throw
* somewhere far from the mistake, so it fails here, at the boundary, with a sanitized code that
* never echoes the domain data itself.
*/
export async function loadModuleContribution(
options: LoadModuleContributionOptions,
): Promise<MountedModuleContribution> {
const { container, input, source } = options;
if (!isModuleContributionInput(input)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_INPUT_INVALID");
}
const importModule = options.importModule ?? defaultImporter;
let namespace: unknown;
try {
namespace = await importModule(source.url);
} catch (error) {
throw new ModuleUiHostError("MODULE_UI_IMPORT_FAILED", { cause: error });
}
// The one contract parser, reused rather than reimplemented: it validates the default export's
// shape, its ABI marker, and that its id/version match what the host expected. A mismatched or
// stale artifact fails before it can render.
const definition: ModuleContributionDefinition = parseModuleContributionExport(namespace, {
id: source.contributionId,
version: source.version,
});
let isolated: IsolatedArtifactRoot;
try {
isolated = createIsolatedArtifactRoot(container, definition.styles);
} catch (error) {
throw new ModuleUiHostError("MODULE_UI_ISOLATION_UNAVAILABLE", { cause: error });
}
let instance: ModuleContributionInstance | undefined;
try {
const result = await definition.mount(isolated.mountTarget, {
contributionId: definition.id,
input,
});
if (result && typeof result === "object") {
instance = result;
}
} catch (error) {
isolated.dispose();
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
}
let mounted = true;
return {
contributionId: definition.id,
version: definition.version,
get mounted() {
return mounted;
},
async update(next: ModuleContributionInput): Promise<void> {
if (!mounted) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN");
}
if (!isModuleContributionInput(next)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_INPUT_INVALID");
}
// A renderer that cannot update in place says so by returning no `update`, and the consumer is
// told to remount rather than left rendering stale content behind a silently ignored call.
if (!instance?.update) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_NOT_UPDATABLE");
}
try {
await instance.update(next);
} catch (error) {
throw new ModuleUiHostError("MODULE_CONTRIBUTION_UPDATE_FAILED", { cause: error });
}
},
async unmount(): Promise<void> {
if (!mounted) {
return;
}
// Marked unmounted before cleanup runs, so a throwing cleanup surfaces once and a second call
// never re-runs it. The isolated root goes in a `finally`, so a renderer that fails to clean
// up still has its styles taken out of the consumer's page.
mounted = false;
try {
await instance?.unmount?.();
} finally {
isolated.dispose();
}
},
};
}
+49 -8
View File
@@ -4,6 +4,7 @@ import {
type ModuleUiUnmount, type ModuleUiUnmount,
parseModuleUiExport, parseModuleUiExport,
} from "@ekklesia/module-ui-sdk"; } from "@ekklesia/module-ui-sdk";
import { createIsolatedArtifactRoot, type IsolatedArtifactRoot } from "./isolated-root.js";
/** /**
* How a host obtains a module's UI artifact. * How a host obtains a module's UI artifact.
@@ -45,7 +46,11 @@ const defaultImporter: ModuleUiImporter = (url) => import(url);
export interface LoadModuleUiOptions { export interface LoadModuleUiOptions {
readonly source: ModuleUiSource; readonly source: ModuleUiSource;
/** The element the module renders into. The host owns it; the module owns its contents. */ /**
* The element the loader builds this artifact's isolated root inside. The host owns it, the
* module owns its own root's contents, and the loader adds and removes exactly one child of its
* own (ADR 0040). The artifact never receives this element.
*/
readonly container: Element; readonly container: Element;
/** Supplied whole by the host. This package forwards it and never adds to it. */ /** Supplied whole by the host. This package forwards it and never adds to it. */
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
@@ -61,17 +66,31 @@ export interface MountedModuleUi {
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */ /** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
readonly mounted: boolean; readonly mounted: boolean;
/** /**
* Runs the module's cleanup, if it returned one. Idempotent: the first call cleans up, later * This artifact's open shadow root. Exposed so a host's own tests and diagnostics can see what an
* artifact actually rendered; a host has no reason to write to it, and doing so would be reaching
* into content the module owns.
*/
readonly shadowRoot: ShadowRoot;
/**
* Runs the module's cleanup, if it returned one, then removes the isolated root the loader
* created — content and installed styles together. Idempotent: the first call cleans up, later
* calls do nothing. Safe when the module returned no cleanup function. * calls do nothing. Safe when the module returned no cleanup function.
*/ */
unmount(): Promise<void>; unmount(): Promise<void>;
} }
export type ModuleUiHostErrorCode = "MODULE_UI_IMPORT_FAILED" | "MODULE_UI_MOUNT_FAILED"; export type ModuleUiHostErrorCode =
| "MODULE_UI_IMPORT_FAILED"
| "MODULE_UI_ISOLATION_UNAVAILABLE"
| "MODULE_UI_MOUNT_FAILED"
| "MODULE_CONTRIBUTION_UPDATE_FAILED";
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.", MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
MODULE_UI_ISOLATION_UNAVAILABLE:
"A module UI artifact could not be given an isolated root in this environment.",
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.", MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
MODULE_CONTRIBUTION_UPDATE_FAILED: "A contributed renderer failed while applying new input.",
}); });
/** /**
@@ -103,6 +122,12 @@ export class ModuleUiHostError extends Error {
* *
* Nothing is mounted until the artifact's declared identity matches the manifest identity the host * Nothing is mounted until the artifact's declared identity matches the manifest identity the host
* passed in, so a stale or mismatched bundle fails before it can render or run `mount`. * passed in, so a stale or mismatched bundle fails before it can render or run `mount`.
*
* Every artifact is mounted into its own isolated root the loader creates inside `container`
* (ADR 0040), and the artifact's declared `styles` are installed there. That is what keeps one
* module's stylesheet out of the Dashboard, out of another module, and out of another contributed
* renderer — and it applies identically to every role, so there is one mounting path rather than a
* styled one and an unstyled one.
*/ */
export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> { export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> {
const { container, context, source } = options; const { container, context, source } = options;
@@ -124,16 +149,26 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
version: source.version, version: source.version,
}); });
// Built before `mount` runs and torn down again if `mount` throws, so a failed artifact leaves
// neither DOM nor styles behind.
let isolated: IsolatedArtifactRoot;
try {
isolated = createIsolatedArtifactRoot(container, definition.styles);
} catch (error) {
throw new ModuleUiHostError("MODULE_UI_ISOLATION_UNAVAILABLE", { cause: error });
}
let cleanup: ModuleUiUnmount | undefined; let cleanup: ModuleUiUnmount | undefined;
try { try {
const result = await definition.mount(container, context); const result = await definition.mount(isolated.mountTarget, context);
if (typeof result === "function") { if (typeof result === "function") {
cleanup = result; cleanup = result;
} }
} catch (error) { } catch (error) {
// No cleanup is registered for a mount that never completed, so the returned handle can never // No cleanup is registered for a mount that never completed, so the returned handle can never
// claim a failed module is mounted. The container is deliberately left untouched: the host owns // claim a failed module is mounted. The host's own container is deliberately left as it was:
// it and decides what a failed mount should look like. // the host owns it and decides what a failed mount should look like.
isolated.dispose();
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error }); throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
} }
@@ -144,14 +179,20 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
get mounted() { get mounted() {
return mounted; return mounted;
}, },
shadowRoot: isolated.shadowRoot,
async unmount() { async unmount() {
if (!mounted) { if (!mounted) {
return; return;
} }
// Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once // Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once
// and a second call never re-runs it. // and a second call never re-runs it. The isolated root is removed in a `finally`, so a
// module whose cleanup throws still has its styles taken out of the page.
mounted = false; mounted = false;
await cleanup?.(); try {
await cleanup?.();
} finally {
isolated.dispose();
}
}, },
}; };
} }
@@ -1,5 +1,5 @@
{ {
"manifestVersion": 6, "manifestVersion": 7,
"id": "example", "id": "example",
"name": "Example", "name": "Example",
"version": "0.1.0", "version": "0.1.0",
@@ -0,0 +1,4 @@
declare module "*.css" {
const stylesheet: string;
export default stylesheet;
}
@@ -1,15 +1,19 @@
import { defineModuleUi } from "@ekklesia/module-ui-sdk"; import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import styles from "./styles.css";
/** /**
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built * A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader. * artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
* *
* React is deliberately absent — the module template already proves the React path end to end, and * React is deliberately absent — the module template already proves the React path end to end, and
* omitting it keeps this artifact small enough to serve as a `data:` URL. * omitting it keeps this artifact small enough to serve as a `data:` URL. It does import a local
* stylesheet, so the built artifact also proves the CSS half of that loop: `.css` source -> compiled
* text inside the declared artifact -> installed in the isolated root at mount (ADR 0040).
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "example", id: "example",
version: "0.1.0", version: "0.1.0",
styles,
async mount(container, context) { async mount(container, context) {
const response = await context.api.request("records"); const response = await context.api.request("records");
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest"; const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
@@ -0,0 +1,5 @@
/* Compiled into the artifact by the canonical builder and installed into its isolated root by the
* loader (ADR 0040). It references a global token and declares none of its own. */
.artifact-label {
color: var(--ekklesia-text-primary);
}
@@ -0,0 +1,350 @@
// @vitest-environment happy-dom
import { MODULE_CONTRIBUTION_ABI_VERSION, ModuleContributionError } from "@ekklesia/module-ui-sdk";
import { describe, expect, it } from "vitest";
import {
loadModuleContribution,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
MODULE_UI_ISOLATION_STYLE_ATTRIBUTE,
ModuleUiHostError,
} from "../src/index.js";
function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
}
const contributionArtifact = (
options: {
id?: string;
version?: string;
styles?: string;
updatable?: boolean;
cleanup?: boolean;
mountThrows?: boolean;
updateThrows?: boolean;
} = {},
) => `
export default {
abi: ${MODULE_CONTRIBUTION_ABI_VERSION},
id: ${JSON.stringify(options.id ?? "announcements.signage-slide")},
version: ${JSON.stringify(options.version ?? "1.0.0")},
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
mount(container, context) {
${options.mountThrows ? 'throw new Error("mount exploded");' : ""}
globalThis.__contributionMounts ??= [];
globalThis.__contributionMounts.push({ container, contributionId: context.contributionId });
container.textContent = "slide:" + JSON.stringify(context.input);
return {
${
options.updatable === false
? ""
: `update(input) {
${options.updateThrows ? 'throw new Error("update exploded");' : ""}
container.textContent = "slide:" + JSON.stringify(input);
},`
}
${
options.cleanup === false
? ""
: `unmount() {
globalThis.__contributionUnmounts = (globalThis.__contributionUnmounts ?? 0) + 1;
container.textContent = "";
},`
}
};
},
};
`;
function container(): Element {
const element = document.createElement("div");
document.body.append(element);
return element;
}
function renderedText(element: Element): string {
const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return (
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
""
);
}
const source = { contributionId: "announcements.signage-slide", version: "1.0.0" };
describe("generic contribution loader", () => {
it("mounts a contribution with the consumer's input, in its own isolated root", async () => {
const element = container();
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{color:red}" })) },
});
expect(mounted.contributionId).toBe("announcements.signage-slide");
expect(mounted.version).toBe("1.0.0");
expect(mounted.mounted).toBe(true);
expect(renderedText(element)).toBe('slide:{"slide":1}');
// Same isolation as every other browser artifact (ADR 0040): the renderer's styles cannot reach
// the consuming module's own screen around it.
const shadowRoot = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`)?.shadowRoot;
expect(
shadowRoot?.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)?.textContent,
).toContain(".slide{color:red}");
expect(element.textContent).toBe("");
});
it("gives the renderer its own id and nothing else from the platform", async () => {
const element = container();
await loadModuleContribution({
container: element,
input: null,
source: { ...source, url: moduleUrl(contributionArtifact()) },
});
const mounts = (
globalThis as { __contributionMounts?: { contributionId: string; container: Element }[] }
).__contributionMounts;
expect(mounts?.at(-1)?.contributionId).toBe("announcements.signage-slide");
// The isolated root, never the consumer's own element.
expect(mounts?.at(-1)?.container).not.toBe(element);
});
it("updates in place without remounting", async () => {
const element = container();
const before = (globalThis as { __contributionMounts?: unknown[] }).__contributionMounts
?.length;
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact()) },
});
await mounted.update({ slide: 2 });
expect(renderedText(element)).toBe('slide:{"slide":2}');
// One mount, two renders: exactly the property a long-running screen needs.
expect(
((globalThis as { __contributionMounts?: unknown[] }).__contributionMounts?.length ?? 0) -
(before ?? 0),
).toBe(1);
});
it("tells a consumer to remount when the renderer cannot update in place", async () => {
const element = container();
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact({ updatable: false })) },
});
await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({
code: "MODULE_CONTRIBUTION_NOT_UPDATABLE",
});
});
it("reports a throwing update as a sanitized failure", async () => {
const element = container();
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact({ updateThrows: true })) },
});
try {
await mounted.update({ slide: 2 });
expect.unreachable("expected the update to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_CONTRIBUTION_UPDATE_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("exploded");
expect((error as { cause?: Error }).cause?.message).toBe("update exploded");
}
});
it("unmounts deterministically, idempotently, and removes the isolated root", async () => {
const element = container();
const before = (globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0;
const mounted = await loadModuleContribution({
container: element,
input: { slide: 1 },
source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{}" })) },
});
await mounted.unmount();
await mounted.unmount();
expect(mounted.mounted).toBe(false);
expect(element.children).toHaveLength(0);
expect(
((globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0) - before,
).toBe(1);
await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({
code: "MODULE_CONTRIBUTION_UNKNOWN",
});
});
it("unmounts safely when the renderer registered no cleanup", async () => {
const element = container();
const mounted = await loadModuleContribution({
container: element,
input: null,
source: { ...source, url: moduleUrl(contributionArtifact({ cleanup: false })) },
});
await expect(mounted.unmount()).resolves.toBeUndefined();
expect(element.children).toHaveLength(0);
});
it("refuses input that is not genuinely JSON, before importing anything", async () => {
const element = container();
let imported = false;
for (const input of [
new Date() as unknown as null,
(() => undefined) as unknown as null,
Number.NaN as unknown as null,
{ nested: undefined } as unknown as null,
]) {
await expect(
loadModuleContribution({
container: element,
input,
importModule: async () => {
imported = true;
return {};
},
source: { ...source, url: "unused" },
}),
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" });
}
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
await expect(
loadModuleContribution({
container: element,
input: cyclic as never,
source: { ...source, url: "unused" },
}),
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" });
expect(imported).toBe(false);
expect(element.children).toHaveLength(0);
});
it("never echoes the consumer's domain data in a failure", async () => {
const element = container();
try {
await loadModuleContribution({
container: element,
input: { secretHeading: new Date() } as unknown as null,
source: { ...source, url: "unused" },
});
expect.unreachable("expected the input to be refused");
} catch (error) {
expect(error).toBeInstanceOf(ModuleContributionError);
expect(String((error as Error).message)).not.toContain("secretHeading");
expect(String((error as Error).stack)).not.toContain("secretHeading");
}
});
it.each([
[
"a different contribution id",
contributionArtifact({ id: "calendar.week" }),
"MODULE_CONTRIBUTION_IDENTITY_MISMATCH",
],
[
"a different version",
contributionArtifact({ version: "2.0.0" }),
"MODULE_CONTRIBUTION_IDENTITY_MISMATCH",
],
[
"a different contribution ABI",
'export default { abi: 99, id: "announcements.signage-slide", version: "1.0.0", mount() {} };',
"MODULE_CONTRIBUTION_ABI_UNSUPPORTED",
],
["no default export", "export const contribution = {};", "MODULE_CONTRIBUTION_EXPORT_MISSING"],
[
"a malformed definition",
`export default { abi: ${MODULE_CONTRIBUTION_ABI_VERSION}, id: "announcements.signage-slide" };`,
"MODULE_CONTRIBUTION_DEFINITION_INVALID",
],
])("refuses %s before mounting", async (_label, artifact, code) => {
const element = container();
await expect(
loadModuleContribution({
container: element,
input: null,
source: { ...source, url: moduleUrl(artifact) },
}),
).rejects.toMatchObject({ code });
expect(element.children).toHaveLength(0);
});
it("reports a throwing mount as a sanitized failure and leaves nothing behind", async () => {
const element = container();
try {
await loadModuleContribution({
container: element,
input: null,
source: { ...source, url: moduleUrl(contributionArtifact({ mountThrows: true })) },
});
expect.unreachable("expected the mount to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_MOUNT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("exploded");
}
expect(element.children).toHaveLength(0);
});
it("reports an unreachable artifact as an import failure with no URL in the message", async () => {
const element = container();
try {
await loadModuleContribution({
container: element,
input: null,
source: { ...source, url: "data:text/javascript,this is not(valid javascript" },
});
expect.unreachable("expected the import to fail");
} catch (error) {
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("data:");
}
});
it("keeps two mounted contributions independent", async () => {
const first = container();
const second = container();
const url = moduleUrl(contributionArtifact());
const a = await loadModuleContribution({
container: first,
input: { slide: "a" },
source: { ...source, url },
});
const b = await loadModuleContribution({
container: second,
input: { slide: "b" },
source: { ...source, url },
});
await a.unmount();
expect(a.mounted).toBe(false);
expect(b.mounted).toBe(true);
expect(first.children).toHaveLength(0);
expect(renderedText(second)).toBe('slide:{"slide":"b"}');
});
});
@@ -1,7 +1,19 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { ModuleUiContractError, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import {
MODULE_UI_ABI_VERSION,
ModuleUiContractError,
type ModuleUiHostContext,
NO_MODULE_UI_CONTRIBUTIONS,
} from "@ekklesia/module-ui-sdk";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { loadModuleUi, ModuleUiHostError, type ModuleUiImporter } from "../src/index.js"; import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
MODULE_UI_ISOLATION_STYLE_ATTRIBUTE,
ModuleUiHostError,
type ModuleUiImporter,
} from "../src/index.js";
/** /**
* A browser-loadable URL for a module built from source text. * A browser-loadable URL for a module built from source text.
@@ -15,10 +27,14 @@ function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`; return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
} }
const validArtifact = (options: { id?: string; version?: string; cleanup?: boolean } = {}) => ` const validArtifact = (
options: { id?: string; version?: string; cleanup?: boolean; styles?: string } = {},
) => `
export default { export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(options.id ?? "announcements")}, id: ${JSON.stringify(options.id ?? "announcements")},
version: ${JSON.stringify(options.version ?? "1.2.0")}, version: ${JSON.stringify(options.version ?? "1.2.0")},
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
mount(container, context) { mount(container, context) {
globalThis.__mounts ??= []; globalThis.__mounts ??= [];
globalThis.__mounts.push({ moduleId: context.moduleId, container }); globalThis.__mounts.push({ moduleId: context.moduleId, container });
@@ -35,6 +51,7 @@ export default {
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext { function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
return { return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId, moduleId,
api: { api: {
async request(path) { async request(path) {
@@ -54,6 +71,20 @@ function container(): Element {
return element; return element;
} }
/**
* What the artifact actually rendered, reached the way a host's own diagnostics would: through the
* open shadow root the loader created. The host's container itself holds only the loader's one
* wrapper element, which is exactly the isolation ADR 0040 introduced.
*/
function artifactRoot(element: Element): Element | null {
const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`) ?? null;
}
function artifactText(element: Element): string {
return artifactRoot(element)?.textContent ?? "";
}
const source = { moduleId: "announcements", version: "1.2.0" }; const source = { moduleId: "announcements", version: "1.2.0" };
describe("generic module UI host loader", () => { describe("generic module UI host loader", () => {
@@ -70,10 +101,99 @@ describe("generic module UI host loader", () => {
expect(mounted.moduleId).toBe("announcements"); expect(mounted.moduleId).toBe("announcements");
expect(mounted.version).toBe("1.2.0"); expect(mounted.version).toBe("1.2.0");
expect(mounted.mounted).toBe(true); expect(mounted.mounted).toBe(true);
expect(element.textContent).toBe("mounted:announcements"); expect(artifactText(element)).toBe("mounted:announcements");
}); });
it("passes the host's own container and context straight through", async () => { it("mounts the artifact into an isolated root rather than the host's own container", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
// The host's container gained exactly one child: the loader's shadow host.
expect(element.children).toHaveLength(1);
const shadowHost = element.firstElementChild as Element;
expect(shadowHost.hasAttribute(MODULE_UI_ISOLATION_ATTRIBUTE)).toBe(true);
expect(shadowHost.shadowRoot).toBe(mounted.shadowRoot);
// Open, deliberately: this is a correctness boundary for styles, not a sandbox — an installed
// module is trusted code in the host's own realm and could reach `document` regardless.
expect(mounted.shadowRoot.mode).toBe("open");
// The artifact's DOM is not reachable from the host's own document tree.
expect(element.textContent).toBe("");
});
it("installs a declared stylesheet inside the isolated root, exactly once", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact({ styles: ".posts{color:red}" })) },
container: element,
context: hostContext([]),
});
const styles = mounted.shadowRoot.querySelectorAll(
`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`,
);
expect(styles).toHaveLength(1);
expect(styles[0]?.textContent).toContain(".posts{color:red}");
// The host document never receives the artifact's rules.
expect(document.head.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)).toBeNull();
});
it("installs the host's base rules before the artifact's own, so an artifact can override them", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact({ styles: ":host{display:block}" })) },
container: element,
context: hostContext([]),
});
const stylesheet =
mounted.shadowRoot.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)
?.textContent ?? "";
expect(stylesheet.indexOf(":host{display:contents}")).toBeLessThan(
stylesheet.indexOf(":host{display:block}"),
);
});
it("still mounts an artifact that declares no stylesheet", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
expect(artifactText(element)).toBe("mounted:announcements");
expect(
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
).toHaveLength(1);
});
it("never accumulates styles across repeated mounts of the same container", async () => {
const element = container();
const url = moduleUrl(validArtifact({ styles: ".posts{color:red}" }));
for (let attempt = 0; attempt < 3; attempt += 1) {
const mounted = await loadModuleUi({
source: { ...source, url },
container: element,
context: hostContext([]),
});
expect(
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
).toHaveLength(1);
await mounted.unmount();
expect(element.children).toHaveLength(0);
}
});
it("passes the isolated mount target and the host's context straight through", async () => {
const element = container(); const element = container();
const requested: string[] = []; const requested: string[] = [];
const context = hostContext(requested); const context = hostContext(requested);
@@ -86,7 +206,9 @@ describe("generic module UI host loader", () => {
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] }) const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
.__mounts; .__mounts;
expect(mounts?.at(-1)?.container).toBe(element); // The artifact receives the loader's isolated root, never the host's own element.
expect(mounts?.at(-1)?.container).toBe(artifactRoot(element));
expect(mounts?.at(-1)?.container).not.toBe(element);
expect(mounts?.at(-1)?.moduleId).toBe("announcements"); expect(mounts?.at(-1)?.moduleId).toBe("announcements");
}); });
@@ -104,7 +226,7 @@ describe("generic module UI host loader", () => {
expect(requested.join(" ")).not.toContain("/api/v1/modules"); expect(requested.join(" ")).not.toContain("/api/v1/modules");
}); });
it("runs the module's cleanup on unmount without touching the host container", async () => { it("runs the module's cleanup and removes the isolated root without touching the host container", async () => {
const element = container(); const element = container();
const parent = element.parentElement; const parent = element.parentElement;
@@ -116,7 +238,8 @@ describe("generic module UI host loader", () => {
await mounted.unmount(); await mounted.unmount();
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
expect(element.textContent).toBe(""); // The artifact's root, and with it its styles, are gone.
expect(element.children).toHaveLength(0);
// The host owns the container; unmounting a module never removes it. // The host owns the container; unmounting a module never removes it.
expect(element.parentElement).toBe(parent); expect(element.parentElement).toBe(parent);
expect(document.body.contains(element)).toBe(true); expect(document.body.contains(element)).toBe(true);
@@ -151,13 +274,15 @@ describe("generic module UI host loader", () => {
await expect(mounted.unmount()).resolves.toBeUndefined(); await expect(mounted.unmount()).resolves.toBeUndefined();
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
expect(element.children).toHaveLength(0);
}); });
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => { it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
const element = container(); const element = container();
const artifact = ` const artifact = `
export default { export default {
id: "announcements", version: "1.2.0", abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
styles: ".x{color:red}",
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; }, mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
};`; };`;
@@ -169,6 +294,8 @@ describe("generic module UI host loader", () => {
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded"); await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
// A module that fails to clean up must not leave its stylesheet installed in the page.
expect(element.children).toHaveLength(0);
await expect(mounted.unmount()).resolves.toBeUndefined(); await expect(mounted.unmount()).resolves.toBeUndefined();
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1); expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
}); });
@@ -189,16 +316,17 @@ describe("generic module UI host loader", () => {
context: hostContext([]), context: hostContext([]),
}); });
expect(first.textContent).toBe("mounted:announcements"); expect(artifactText(first)).toBe("mounted:announcements");
expect(second.textContent).toBe("mounted:announcements"); expect(artifactText(second)).toBe("mounted:announcements");
expect(a.shadowRoot).not.toBe(b.shadowRoot);
await a.unmount(); await a.unmount();
// Each handle owns its own cleanup state; unmounting one leaves the other alone. // Each handle owns its own cleanup state; unmounting one leaves the other alone.
expect(a.mounted).toBe(false); expect(a.mounted).toBe(false);
expect(b.mounted).toBe(true); expect(b.mounted).toBe(true);
expect(first.textContent).toBe(""); expect(first.children).toHaveLength(0);
expect(second.textContent).toBe("mounted:announcements"); expect(artifactText(second)).toBe("mounted:announcements");
}); });
describe("rejects an artifact before mounting it", () => { describe("rejects an artifact before mounting it", () => {
@@ -209,7 +337,7 @@ describe("generic module UI host loader", () => {
await expect( await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }), loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" }); ).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.textContent).toBe(""); expect(element.children).toHaveLength(0);
}); });
it("when the version does not match the manifest", async () => { it("when the version does not match the manifest", async () => {
@@ -219,7 +347,19 @@ describe("generic module UI host loader", () => {
await expect( await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }), loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" }); ).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.textContent).toBe(""); expect(element.children).toHaveLength(0);
});
it("when it was built against the previous mount ABI", async () => {
const element = container();
const url = moduleUrl(
'export default { id: "announcements", version: "1.2.0", mount() {} };',
);
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_ABI_UNSUPPORTED" });
expect(element.children).toHaveLength(0);
}); });
it("when there is no default export", async () => { it("when there is no default export", async () => {
@@ -233,7 +373,9 @@ describe("generic module UI host loader", () => {
it("when the default export is not a module UI definition", async () => { it("when the default export is not a module UI definition", async () => {
const element = container(); const element = container();
const url = moduleUrl('export default { id: "announcements", version: "1.2.0" };'); const url = moduleUrl(
`export default { abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0" };`,
);
const failure = loadModuleUi({ const failure = loadModuleUi({
source: { ...source, url }, source: { ...source, url },
@@ -263,11 +405,12 @@ describe("generic module UI host loader", () => {
} }
}); });
it("reports a throwing mount as a mount failure and registers no cleanup", async () => { it("reports a throwing mount as a mount failure, registers no cleanup, and removes the root", async () => {
const element = container(); const element = container();
const url = moduleUrl(` const url = moduleUrl(`
export default { export default {
id: "announcements", version: "1.2.0", abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
styles: ".x{color:red}",
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); }, mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
};`); };`);
@@ -284,9 +427,38 @@ describe("generic module UI host loader", () => {
expect((error as ModuleUiHostError).message).not.toContain("exploded"); expect((error as ModuleUiHostError).message).not.toContain("exploded");
expect((error as { cause?: Error }).cause?.message).toBe("mount exploded"); expect((error as { cause?: Error }).cause?.message).toBe("mount exploded");
} }
// No handle exists, so nothing can claim the module is mounted. The container is left exactly // No handle exists, so nothing can claim the module is mounted — and a failed mount leaves
// as the module left it: the host owns it and decides what a failed mount looks like. // neither partial DOM nor an installed stylesheet behind in the host's container.
expect(element.textContent).toBe("partial"); expect(element.children).toHaveLength(0);
});
it("reports an environment with no Shadow DOM as an isolation failure, never as an unisolated mount", async () => {
const element = container();
const detached = {
ownerDocument: document,
appendChild: element.appendChild.bind(element),
} as unknown as Element;
// A container whose created element cannot take a shadow root: the loader must refuse rather
// than fall back to mounting the artifact — and its styles — straight into the host's page.
const noShadow = new Proxy(detached, {
get(target, property, receiver) {
if (property === "ownerDocument") {
return {
createElement: () => ({ setAttribute: () => undefined }),
};
}
return Reflect.get(target, property, receiver);
},
});
await expect(
loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: noShadow,
context: hostContext([]),
}),
).rejects.toMatchObject({ code: "MODULE_UI_ISOLATION_UNAVAILABLE" });
expect(element.children).toHaveLength(0);
}); });
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => { it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
@@ -294,7 +466,14 @@ describe("generic module UI host loader", () => {
const seen: string[] = []; const seen: string[] = [];
const importModule: ModuleUiImporter = async (url) => { const importModule: ModuleUiImporter = async (url) => {
seen.push(url); seen.push(url);
return { default: { id: "announcements", version: "1.2.0", mount: () => undefined } }; return {
default: {
abi: MODULE_UI_ABI_VERSION,
id: "announcements",
version: "1.2.0",
mount: () => undefined,
},
};
}; };
const mounted = await loadModuleUi({ const mounted = await loadModuleUi({
@@ -3,9 +3,22 @@ import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { buildModuleUi } from "@ekklesia/module-ui-build"; import { buildModuleUi } from "@ekklesia/module-ui-build";
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { type ModuleUiHostContext, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadModuleUi } from "../src/index.js"; import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
} from "../src/index.js";
/** What the artifact rendered, reached through the open shadow root the loader created (ADR 0040). */
function artifactText(container: Element): string {
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return (
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
""
);
}
const fixtureDirectory = path.join( const fixtureDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
@@ -19,6 +32,7 @@ let artifactSource: string;
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */ /** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
function ekklesiaContext(requested: string[]): ModuleUiHostContext { function ekklesiaContext(requested: string[]): ModuleUiHostContext {
return { return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId: "example", moduleId: "example",
api: { api: {
async request(pathname) { async request(pathname) {
@@ -35,6 +49,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */ /** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
function standaloneContext(requested: string[]): ModuleUiHostContext { function standaloneContext(requested: string[]): ModuleUiHostContext {
return { return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId: "example", moduleId: "example",
api: { api: {
async request(pathname) { async request(pathname) {
@@ -80,12 +95,32 @@ describe("loading a real compiled artifact", () => {
context: ekklesiaContext(requested), context: ekklesiaContext(requested),
}); });
expect(container.textContent).toBe("example:200:user-123"); expect(artifactText(container)).toBe("example:200:user-123");
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(container.children).toHaveLength(0);
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
}); });
it("compiles the fixture's stylesheet into the artifact and installs it in the isolated root", async () => {
const container = document.createElement("div");
// No sidecar: the whole stylesheet travels inside the one declared JavaScript artifact.
expect(artifactSource).toContain(".artifact-label");
const mounted = await loadModuleUi({
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
container,
context: ekklesiaContext([]),
});
const stylesheet = mounted.shadowRoot.querySelector("style")?.textContent ?? "";
expect(stylesheet).toContain(".artifact-label");
expect(stylesheet).toContain("var(--ekklesia-text-primary)");
expect(document.head.textContent ?? "").not.toContain(".artifact-label");
await mounted.unmount();
});
it("still rejects the real artifact when the host expects a different version", async () => { it("still rejects the real artifact when the host expects a different version", async () => {
await expect( await expect(
loadModuleUi({ loadModuleUi({
@@ -118,8 +153,8 @@ describe("loading a real compiled artifact", () => {
// the module asked for "records" both times and resolved nothing itself. // the module asked for "records" both times and resolved nothing itself.
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]); expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
expect(standaloneRequests).toEqual(["https://example.test/api/records"]); expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
expect(ekklesiaContainer.textContent).toBe("example:200:user-123"); expect(artifactText(ekklesiaContainer)).toBe("example:200:user-123");
expect(standaloneContainer.textContent).toBe("example:200:guest"); expect(artifactText(standaloneContainer)).toBe("example:200:guest");
await inEkklesia.unmount(); await inEkklesia.unmount();
await inStandalone.unmount(); await inStandalone.unmount();
+9
View File
@@ -0,0 +1,9 @@
/**
* A value or a promise of one.
*
* In its own file rather than beside either contract that uses it: `module-ui.ts` and
* `contribution.ts` both need it, and `module-ui.ts` reaches `host-context.ts`, which reaches
* `contribution.ts` — so declaring it in either would close a source cycle that
* `scripts/check-boundaries.ts` rejects, including one formed only by `import type`.
*/
export type Awaitable<Value> = Value | PromiseLike<Value>;
+339
View File
@@ -0,0 +1,339 @@
import type { Awaitable } from "./awaitable.js";
import type { ModuleUiJsonValue } from "./json.js";
/**
* The browser **contribution** contract: what a module's contributed renderer exports, and what a
* consuming module can do with one (ADR 0041).
*
* A contribution is not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts a
* *host* mounts, in surfaces the host owns. A contribution is mounted by a **consumer module**,
* inside a container that consumer owns, at a moment only that consumer understands — a slot in its
* own layout. The platform loads the code and validates its identity; it never decides that a
* contribution should be on screen.
*
* ## The consumer owns the domain contract, entirely
*
* `input` is opaque JSON. The platform validates that it is JSON and nothing else: not its shape,
* not its meaning, not its version. A consumer that provides a "slide renderer" capability publishes
* *its own* input contract, versions it through its own capability version, and every contributor
* writes against that. Core cannot learn what a slide is from this file, which is the point.
*
* ## What a contribution does not receive
*
* No database, no object storage, no credential, no session, no API client, and no reach into any
* module — including the one that contributed it. Loading a contribution grants its module nothing:
* server-side authorization is unchanged and remains the only authority. Everything a contributed
* renderer needs arrives through the consumer's input, because the consumer is the party that was
* authorized to obtain it.
*
* ## Still trusted code
*
* A contribution artifact is installed first-party JavaScript running in the host's realm, exactly
* like every other module artifact (ADR 0022, ADR 0024). Its isolated root (ADR 0040) is a
* correctness boundary for styles, not a sandbox, and this contract must never be described as one.
*/
/**
* The version of the contribution ABI this package defines, and the only one a host mounts.
*
* Separate from `MODULE_UI_ABI_VERSION` on purpose: a contributed renderer and a mounted module UI
* are different contracts with different lifecycles — one is handed a host context, the other is
* handed a consumer's input and can be updated in place — and versioning them together would force
* every module to rebuild whenever either moved.
*
* `defineModuleContribution` stamps it. An author never writes it.
*/
export const MODULE_CONTRIBUTION_ABI_VERSION = 1;
/** The consumer's own versioned input. Opaque to the platform. */
export type ModuleContributionInput = ModuleUiJsonValue;
/**
* Everything a contributed renderer is given, and the complete list of it.
*
* Deliberately two members. Anything else — a principal, an API client, a realtime subscription,
* configuration — would either be authority the contribution has not earned or state the consumer
* should be passing in its own input, where it can version it.
*/
export interface ModuleContributionContext {
/** This contribution's own id, so it never re-derives its identity from anything. */
readonly contributionId: string;
/** The consumer's input for this mount. */
readonly input: ModuleContributionInput;
}
/**
* What a contribution's `mount` returns: how to give it new input, and how to release it.
*
* `update` exists so a consumer can drive a renderer through changing state — a new slide, a new
* selection — **without** remounting. Remounting for every change would destroy DOM, restart
* animations, and reload media on every tick, which is exactly wrong for the long-running screens
* this contract is for. A contribution that genuinely cannot update in place omits it, and the
* consumer remounts.
*
* Returning nothing at all declares that the mount acquired nothing and cannot be updated.
*/
export interface ModuleContributionInstance {
readonly update?: (input: ModuleContributionInput) => Awaitable<void>;
readonly unmount?: () => Awaitable<void>;
}
/** What a module author passes to {@link defineModuleContribution}. */
export interface ModuleContributionDefinitionInput {
/** `<module-id>.<local-name>`, matching the manifest declaration exactly. */
readonly id: string;
/** This contribution's own version, matching the manifest declaration exactly. */
readonly version: string;
/** The artifact's compiled CSS text, installed by the host into this artifact's isolated root
* (ADR 0040). Exactly the same mechanism a module UI artifact uses. */
readonly styles?: string;
/**
* Asynchronous by contract. A renderer that has to decode media, measure a layout, or wait for a
* font before it is honestly ready returns a promise, and the consumer's `mount` resolves only
* once it has settled — so a consumer can sequence a transition instead of guessing.
*/
readonly mount: (
container: Element,
context: ModuleContributionContext,
) => Awaitable<ModuleContributionInstance | void>;
}
/** What a compiled contribution artifact default-exports: the author's declaration plus the ABI
* marker `defineModuleContribution` stamped on it. */
export interface ModuleContributionDefinition extends ModuleContributionDefinitionInput {
readonly abi: number;
}
export type ModuleContributionErrorCode =
| "MODULE_CONTRIBUTION_EXPORT_MISSING"
| "MODULE_CONTRIBUTION_DEFINITION_INVALID"
| "MODULE_CONTRIBUTION_ABI_UNSUPPORTED"
| "MODULE_CONTRIBUTION_STYLES_INVALID"
| "MODULE_CONTRIBUTION_IDENTITY_MISMATCH"
| "MODULE_CONTRIBUTION_INPUT_INVALID"
| "MODULE_CONTRIBUTION_UNKNOWN"
| "MODULE_CONTRIBUTION_NOT_UPDATABLE";
const errorMessages: Readonly<Record<ModuleContributionErrorCode, string>> = Object.freeze({
MODULE_CONTRIBUTION_EXPORT_MISSING:
"A module contribution artifact must have a default export produced by defineModuleContribution.",
MODULE_CONTRIBUTION_DEFINITION_INVALID:
"A module contribution definition must declare a non-empty id and version and a mount function, and nothing else.",
MODULE_CONTRIBUTION_ABI_UNSUPPORTED:
"This contribution artifact was built for a different contribution ABI. Rebuild the module against the current module UI SDK.",
MODULE_CONTRIBUTION_STYLES_INVALID:
"A module contribution's styles must be the artifact's compiled CSS text, as a non-empty string.",
MODULE_CONTRIBUTION_IDENTITY_MISMATCH:
"The contribution artifact's id and version do not match the contribution it was loaded for.",
MODULE_CONTRIBUTION_INPUT_INVALID:
"A contribution's input must be a JSON-serializable value supplied by the consuming module.",
MODULE_CONTRIBUTION_UNKNOWN: "No contribution with that id is available to this module.",
MODULE_CONTRIBUTION_NOT_UPDATABLE:
"This contribution does not support updating in place. Unmount it and mount it again.",
});
/** Carries a stable code and a fixed message, never the offending value, the artifact URL, or the
* consumer's input — which is domain data and never belongs in a platform error. */
export class ModuleContributionError extends Error {
readonly code: ModuleContributionErrorCode;
constructor(code: ModuleContributionErrorCode) {
super(errorMessages[code]);
this.name = "ModuleContributionError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
const definitionKeys = new Set(["abi", "id", "mount", "styles", "version"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Whether a value is genuinely JSON-serializable, checked structurally rather than by attempting
* `JSON.stringify` and hoping.
*
* A consumer's input crosses from one module's code to another's. `undefined`, a function, a
* `Symbol`, a `Date`, a cyclic object, and a class instance all either vanish or throw when
* serialized, and a contribution that received one would fail somewhere far from the mistake. This
* is a bounded structural walk, so a cycle is caught rather than followed.
*/
export function isModuleContributionInput(value: unknown, seen = new Set<object>()): boolean {
if (value === null || typeof value === "boolean" || typeof value === "string") {
return true;
}
if (typeof value === "number") {
return Number.isFinite(value);
}
if (typeof value !== "object") {
return false;
}
if (seen.has(value)) {
return false;
}
seen.add(value);
const entries = Array.isArray(value) ? value : Object.values(value);
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) {
return false;
}
const valid = entries.every((entry) => isModuleContributionInput(entry, seen));
seen.delete(value);
return valid;
}
export function isModuleContributionDefinition(
value: unknown,
): value is ModuleContributionDefinition {
if (!isRecord(value) || value.abi !== MODULE_CONTRIBUTION_ABI_VERSION) {
return false;
}
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
return false;
}
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
return false;
}
return (
isNonEmptyString(value.id) &&
isNonEmptyString(value.version) &&
typeof value.mount === "function"
);
}
/** Parses a contribution artifact's default export. The ABI marker is checked first and on its own,
* so an artifact built against a different contribution ABI is reported as exactly that. */
export function parseModuleContributionDefinition(value: unknown): ModuleContributionDefinition {
if (!isRecord(value)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
}
if (value.abi !== MODULE_CONTRIBUTION_ABI_VERSION) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_ABI_UNSUPPORTED");
}
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_STYLES_INVALID");
}
if (!isModuleContributionDefinition(value)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
}
return Object.freeze({
abi: MODULE_CONTRIBUTION_ABI_VERSION,
id: value.id,
mount: value.mount,
...(value.styles === undefined ? {} : { styles: value.styles }),
version: value.version,
});
}
/** The identity a host already knows from the contributing module's validated manifest. */
export interface ModuleContributionIdentity {
readonly id: string;
readonly version: string;
}
/** The one entry point a host uses after importing a contribution artifact. Imports nothing itself,
* exactly like `parseModuleUiExport`, so this package stays free of any loader or URL assumption. */
export function parseModuleContributionExport(
namespace: unknown,
expected: ModuleContributionIdentity,
): ModuleContributionDefinition {
if (!isRecord(namespace) || !("default" in namespace)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_EXPORT_MISSING");
}
const definition = parseModuleContributionDefinition(namespace.default);
if (definition.id !== expected.id || definition.version !== expected.version) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_IDENTITY_MISMATCH");
}
return definition;
}
/**
* The contributing module author's entry point, mirroring `defineModuleUi`: validate at authoring
* time, freeze, and stamp the contribution ABI this artifact was built against.
*/
export function defineModuleContribution(
definition: ModuleContributionDefinitionInput,
): ModuleContributionDefinition {
return parseModuleContributionDefinition({
...definition,
abi: MODULE_CONTRIBUTION_ABI_VERSION,
});
}
/**
* What a **consumer** module sees of the contributions aimed at a capability it provides.
*
* Identity and version only. There is deliberately no artifact URL: a consumer names a contribution
* and the host resolves it, exactly as a module names a relative API path and the host resolves that
* (ADR 0022). A module that built `/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would
* be welded to Ekklesia's private deployment detail and unhostable anywhere else.
*/
export interface ModuleContributionDescriptor {
readonly id: string;
readonly version: string;
/** The capability this renderer was contributed to — one the consuming module provides. */
readonly capability: string;
}
/** One mounted contribution, owned by the consumer that mounted it. */
export interface MountedModuleContribution {
readonly contributionId: string;
readonly version: string;
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
readonly mounted: boolean;
/**
* Hands the renderer new input without remounting it.
*
* Rejects if the contribution declared no `update`, so a consumer learns that it must remount
* rather than silently rendering stale content.
*/
update(input: ModuleContributionInput): Promise<void>;
/** Runs the renderer's cleanup and removes its isolated root. Idempotent. */
unmount(): Promise<void>;
}
export interface MountModuleContributionOptions {
/** The exact contribution id from a {@link ModuleContributionDescriptor}. */
readonly contributionId: string;
/** A container the **consumer** owns — its own slot. The host builds the renderer's isolated root
* inside it and never reaches outside it. */
readonly container: Element;
/** The consumer's own versioned input. */
readonly input: ModuleContributionInput;
}
/**
* A consuming module's access to the renderers contributed to its own capabilities.
*
* The host supplies this; a module never constructs one. It can only ever see contributions aimed at
* a capability this module actually provides, because that is the only thing the server put in its
* descriptor — a module cannot enumerate the platform's contributions, only its own extension
* points.
*/
export interface ModuleUiContributions {
/** Contributions declared for one capability this module provides, in a stable order. Returns an
* empty list for a capability this module does not provide, rather than throwing: a consumer
* asking about an extension point nobody filled is the ordinary case. */
list(capabilityId: string): readonly ModuleContributionDescriptor[];
/** Loads, verifies, and mounts one contribution into a consumer-owned container. */
mount(options: MountModuleContributionOptions): Promise<MountedModuleContribution>;
}
/**
* What a host with no contribution mechanism supplies.
*
* A standalone host running exactly one module has no other module to contribute anything, so an
* empty implementation is the *correct* one rather than a stub — which is precisely why this member
* is always present on the context: a module writes the same code either way, and finds nothing.
*/
export const NO_MODULE_UI_CONTRIBUTIONS: ModuleUiContributions = Object.freeze({
list: (): readonly ModuleContributionDescriptor[] => Object.freeze([]),
mount: (): Promise<MountedModuleContribution> =>
Promise.reject(new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN")),
});
+13 -7
View File
@@ -1,12 +1,18 @@
import { type ModuleUiDefinition, parseModuleUiDefinition } from "./module-ui.js"; import {
MODULE_UI_ABI_VERSION,
type ModuleUiDefinition,
type ModuleUiDefinitionInput,
parseModuleUiDefinition,
} from "./module-ui.js";
/** /**
* The module author's entry point, mirroring `defineModuleRuntime` (ADR 0006): validate at authoring * The module author's entry point, mirroring `defineModuleRuntime` (ADR 0006): validate at authoring
* time, freeze, and preserve the literal type. A malformed definition fails where it was written * time, freeze, and stamp the browser mount ABI this artifact was built against. A malformed
* rather than in a host, at mount time, in a browser. * definition fails where it was written rather than in a host, at mount time, in a browser.
*
* The ABI marker is added here rather than written by the author, so it can never drift from the SDK
* the artifact actually compiled against — which is the whole point of having one (ADR 0040).
*/ */
export function defineModuleUi<const Definition extends ModuleUiDefinition>( export function defineModuleUi(definition: ModuleUiDefinitionInput): ModuleUiDefinition {
definition: Definition, return parseModuleUiDefinition({ ...definition, abi: MODULE_UI_ABI_VERSION });
): Readonly<Definition> {
return parseModuleUiDefinition(definition) as Readonly<Definition>;
} }
+20 -4
View File
@@ -1,3 +1,4 @@
import type { ModuleUiContributions } from "./contribution.js";
import type { ModuleUiRealtimeClient } from "./realtime.js"; import type { ModuleUiRealtimeClient } from "./realtime.js";
/** /**
@@ -54,10 +55,15 @@ export type ModuleUiPrincipal =
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists. * Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
* *
* This is deliberately the smallest set that proves the architecture rather than everything a real * This is deliberately the smallest set that proves the architecture rather than everything a real
* screen will eventually want. Navigation, host theme and design tokens, module configuration, * screen will eventually want. Navigation, module configuration, organization/platform context, and
* organization/platform context, and diagnostics reporting are **not** here yet: each needs a design * diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative
* that does not exist, and a speculative field frozen into a public contract is harder to remove * field frozen into a public contract is harder to remove than to add. See ADR 0022.
* than to add. See ADR 0022. *
* The host **theme** is deliberately absent for a different reason. Design tokens reach a module as
* inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own
* root, they inherit into the artifact's own root, and module code writes `var(--ekklesia-…)`. A
* token map on this context would make styling a run-time negotiation, force every module to
* re-implement it from data, and give a host something it could forget to supply.
* *
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone * Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
* host that never runs Core, which is what makes one module UI hostable by both. The same context is * host that never runs Core, which is what makes one module UI hostable by both. The same context is
@@ -78,6 +84,16 @@ export interface ModuleUiHostContext {
readonly permissions: ModuleUiPermissions; readonly permissions: ModuleUiPermissions;
/** Host-owned delivery of this module's own runtime-published state. */ /** Host-owned delivery of this module's own runtime-published state. */
readonly realtime: ModuleUiRealtimeClient; readonly realtime: ModuleUiRealtimeClient;
/**
* The renderers other modules contributed to capabilities **this** module provides (ADR 0041).
*
* Present on every context, and empty for the overwhelming majority of modules — a module that
* provides no extension point simply finds nothing in it. It is the one member that lets one
* module's UI extend another's without either importing the other, and it deliberately exposes
* identity and a mount operation rather than a URL: the host resolves publication, exactly as it
* resolves `api.request`.
*/
readonly contributions: ModuleUiContributions;
} }
/** /**
+2
View File
@@ -1,4 +1,6 @@
export * from "./contribution.js";
export * from "./define-module-ui.js"; export * from "./define-module-ui.js";
export * from "./host-context.js"; export * from "./host-context.js";
export * from "./json.js";
export * from "./module-ui.js"; export * from "./module-ui.js";
export * from "./realtime.js"; export * from "./realtime.js";
+15
View File
@@ -0,0 +1,15 @@
/**
* A JSON value crossing a host-owned boundary — a realtime snapshot, a contribution's input.
*
* Data, not vocabulary. Everything the platform carries between two pieces of module code is
* serializable and opaque to the host: the host validates that it *is* JSON and never what it
* means. That is what keeps a domain contract (a slide, a timeline, an announcement) entirely
* between the two modules that share it, with nothing for Core to learn or version.
*/
export type ModuleUiJsonValue =
| boolean
| number
| string
| null
| readonly ModuleUiJsonValue[]
| { readonly [key: string]: ModuleUiJsonValue };
+72 -6
View File
@@ -1,18 +1,40 @@
import type { ModuleUiHostContext } from "./host-context.js"; import type { ModuleUiHostContext } from "./host-context.js";
export type Awaitable<Value> = Value | PromiseLike<Value>; export type { Awaitable } from "./awaitable.js";
import type { Awaitable } from "./awaitable.js";
/**
* The version of the browser mount ABI this package defines, and the only one a host accepts.
*
* Version 1 was `{ id, version, mount }` mounted straight into the host's own container. Version 2
* adds a declared `styles` stylesheet and makes every artifact mount inside an **isolated root** the
* host creates (ADR 0040). That is a behavioural change an artifact can observe — its DOM is no
* longer a direct child of the host's element — so it is versioned explicitly rather than
* introduced silently.
*
* There is deliberately no dual acceptance, exactly as there is none for a manifest version
* (ADR 0006, ADR 0014, ADR 0021, ADR 0033): an artifact built against version 1 fails to mount with
* `MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics. Migration is to
* rebuild the module, which is one `pnpm build` — the canonical builder owns every option, and a
* module that declares no stylesheet needs no source change at all.
*
* `defineModuleUi` stamps this for the author. A module never writes it.
*/
export const MODULE_UI_ABI_VERSION = 2;
/** /**
* Released by a host when the module UI should be torn down. A module that acquires anything during * Released by a host when the module UI should be torn down. A module that acquires anything during
* `mount` — a framework root, a subscription, a timer, an event listener — must return one of these * `mount` — a framework root, a subscription, a timer, an event listener — must return one of these
* and release that work here, exactly as a backend module releases resources in its `stop` hook * and release that work here, exactly as a backend module releases resources in its `stop` hook
* (ADR 0006). Returning nothing declares that the mount acquired nothing to release. * (ADR 0006). Returning nothing declares that the mount acquired nothing to release.
*
* A module never has to remove its declared `styles`: the host installed them into the isolated root
* it owns, and it removes that whole root after this runs.
*/ */
export type ModuleUiUnmount = () => Awaitable<void>; export type ModuleUiUnmount = () => Awaitable<void>;
/** /**
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`) * What a module author passes to {@link defineModuleUi}.
* default-exports (ADR 0021, ADR 0022, ADR 0033).
* *
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition` * `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it * exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
@@ -23,19 +45,38 @@ export type ModuleUiUnmount = () => Awaitable<void>;
* write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function, * write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function,
* so a module's internal framework choice, and a host's, can each change without renegotiating the * so a module's internal framework choice, and a host's, can each change without renegotiating the
* platform boundary. * platform boundary.
*
* `styles` is the artifact's own compiled CSS **text**, not a URL, a filename, or a sidecar
* (ADR 0040). A module imports its local `.css` files and the canonical builder compiles them into
* this string, so the declared JavaScript artifact stays exactly as self-contained as it was before
* stylesheets existed: there is no second file to publish, nothing for a host to fetch, and no way
* for a stylesheet to become an asset nothing serves. The host installs it into the artifact's
* isolated root at mount and removes it at unmount.
*/ */
export interface ModuleUiDefinition { export interface ModuleUiDefinitionInput {
readonly id: string; readonly id: string;
readonly version: string; readonly version: string;
readonly styles?: string;
readonly mount: ( readonly mount: (
container: Element, container: Element,
context: ModuleUiHostContext, context: ModuleUiHostContext,
) => Awaitable<ModuleUiUnmount | void>; ) => Awaitable<ModuleUiUnmount | void>;
} }
/**
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
* default-exports (ADR 0021, ADR 0022, ADR 0033, ADR 0040): the author's declaration plus the ABI
* marker `defineModuleUi` stamped on it.
*/
export interface ModuleUiDefinition extends ModuleUiDefinitionInput {
readonly abi: number;
}
export type ModuleUiContractErrorCode = export type ModuleUiContractErrorCode =
| "MODULE_UI_EXPORT_MISSING" | "MODULE_UI_EXPORT_MISSING"
| "MODULE_UI_DEFINITION_INVALID" | "MODULE_UI_DEFINITION_INVALID"
| "MODULE_UI_ABI_UNSUPPORTED"
| "MODULE_UI_STYLES_INVALID"
| "MODULE_UI_IDENTITY_MISMATCH"; | "MODULE_UI_IDENTITY_MISMATCH";
const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({
@@ -43,6 +84,10 @@ const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Objec
"A module UI entrypoint must have a default export produced by defineModuleUi.", "A module UI entrypoint must have a default export produced by defineModuleUi.",
MODULE_UI_DEFINITION_INVALID: MODULE_UI_DEFINITION_INVALID:
"A module UI definition must declare a non-empty id and version and a mount function, and nothing else.", "A module UI definition must declare a non-empty id and version and a mount function, and nothing else.",
MODULE_UI_ABI_UNSUPPORTED:
"This module UI artifact was built for a different browser mount ABI. Rebuild the module against the current module UI SDK.",
MODULE_UI_STYLES_INVALID:
"A module UI definition's styles must be the artifact's compiled CSS text, as a non-empty string.",
MODULE_UI_IDENTITY_MISMATCH: MODULE_UI_IDENTITY_MISMATCH:
"The module UI artifact's id and version do not match the module it was loaded for.", "The module UI artifact's id and version do not match the module it was loaded for.",
}); });
@@ -64,7 +109,7 @@ export class ModuleUiContractError extends Error {
} }
} }
const definitionKeys = new Set(["id", "mount", "version"]); const definitionKeys = new Set(["abi", "id", "mount", "styles", "version"]);
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -80,12 +125,15 @@ function isNonEmptyString(value: unknown): value is string {
* grammar — there is exactly one place each of those is defined, and it is not here. * grammar — there is exactly one place each of those is defined, and it is not here.
*/ */
export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition { export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition {
if (!isRecord(value)) { if (!isRecord(value) || value.abi !== MODULE_UI_ABI_VERSION) {
return false; return false;
} }
if (Object.keys(value).some((key) => !definitionKeys.has(key))) { if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
return false; return false;
} }
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
return false;
}
return ( return (
isNonEmptyString(value.id) && isNonEmptyString(value.id) &&
isNonEmptyString(value.version) && isNonEmptyString(value.version) &&
@@ -93,13 +141,31 @@ export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinitio
); );
} }
/**
* Parses an artifact's default export.
*
* The ABI marker is checked **first and on its own**, so an artifact built against the previous
* mount ABI is reported as exactly that rather than as a malformed definition — the difference
* between "rebuild your module" and a developer hunting for a typo.
*/
export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition { export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition {
if (!isRecord(value)) {
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
}
if (value.abi !== MODULE_UI_ABI_VERSION) {
throw new ModuleUiContractError("MODULE_UI_ABI_UNSUPPORTED");
}
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
throw new ModuleUiContractError("MODULE_UI_STYLES_INVALID");
}
if (!isModuleUiDefinition(value)) { if (!isModuleUiDefinition(value)) {
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID"); throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
} }
return Object.freeze({ return Object.freeze({
abi: MODULE_UI_ABI_VERSION,
id: value.id, id: value.id,
mount: value.mount, mount: value.mount,
...(value.styles === undefined ? {} : { styles: value.styles }),
version: value.version, version: value.version,
}); });
} }
+6 -7
View File
@@ -1,17 +1,16 @@
import type { ModuleUiJsonValue } from "./json.js";
/** /**
* A JSON value carried from a module's server runtime to its browser UI. * 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, * 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 * 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. * owns all of that and gives the module only typed snapshots and updates.
*
* An alias of the SDK's one {@link ModuleUiJsonValue}, kept under its original name because it is
* the published realtime contract. There is one JSON shape in this package, not one per feature.
*/ */
export type ModuleUiRealtimeJsonValue = export type ModuleUiRealtimeJsonValue = ModuleUiJsonValue;
| boolean
| number
| string
| null
| readonly ModuleUiRealtimeJsonValue[]
| { readonly [key: string]: ModuleUiRealtimeJsonValue };
/** Connection state useful for rendering a small, honest status indicator. */ /** Connection state useful for rendering a small, honest status indicator. */
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed"; export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
@@ -2,23 +2,33 @@ import { beforeEach, describe, expect, it } from "vitest";
import { import {
defineModuleUi, defineModuleUi,
isModuleUiDefinition, isModuleUiDefinition,
MODULE_UI_ABI_VERSION,
ModuleUiContractError, ModuleUiContractError,
NO_MODULE_UI_CONTRIBUTIONS,
type ModuleUiApiClient, type ModuleUiApiClient,
type ModuleUiDefinition, type ModuleUiDefinition,
type ModuleUiDefinitionInput,
type ModuleUiHostContext, type ModuleUiHostContext,
type ModuleUiRealtimeClient, type ModuleUiRealtimeClient,
parseModuleUiDefinition, parseModuleUiDefinition,
parseModuleUiExport, parseModuleUiExport,
} from "../src/index.js"; } from "../src/index.js";
function validDefinition(): ModuleUiDefinition { function definitionInput(): ModuleUiDefinitionInput {
return { id: "announcements", version: "0.1.0", mount: (): void => undefined }; return { id: "announcements", version: "0.1.0", mount: (): void => undefined };
} }
/** What a compiled artifact actually default-exports: the author's declaration plus the ABI marker
* `defineModuleUi` stamps on it (ADR 0040). */
function validDefinition(): ModuleUiDefinition {
return { abi: MODULE_UI_ABI_VERSION, ...definitionInput() };
}
/** The contract treats the container as an opaque host-supplied mount target, so a plain object /** The contract treats the container as an opaque host-supplied mount target, so a plain object
* with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the * with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the
* contract, and this package deliberately does not take a DOM dependency to obtain one. */ * contract, and this package deliberately does not take a DOM dependency to obtain one. */
const noop = (): void => undefined; const noop = (): void => undefined;
const abi = MODULE_UI_ABI_VERSION;
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({ const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
subscribe: () => Object.freeze({ close: noop }), subscribe: () => Object.freeze({ close: noop }),
@@ -29,23 +39,66 @@ function fakeContainer(): Element & { textContent: string } {
} }
describe("module UI definition contract", () => { describe("module UI definition contract", () => {
it("accepts and freezes a valid definition", () => { it("accepts, stamps the ABI on, and freezes a valid definition", () => {
const definition = defineModuleUi(validDefinition()); const definition = defineModuleUi(definitionInput());
expect(definition.id).toBe("announcements"); expect(definition.id).toBe("announcements");
expect(definition.version).toBe("0.1.0"); expect(definition.version).toBe("0.1.0");
// Stamped by the SDK the artifact compiled against, never written by the author, so it can
// never drift from the contract the artifact was actually built for.
expect(definition.abi).toBe(MODULE_UI_ABI_VERSION);
expect(definition.styles).toBeUndefined();
expect(Object.isFrozen(definition)).toBe(true); expect(Object.isFrozen(definition)).toBe(true);
}); });
it("carries a declared stylesheet through as the artifact's own compiled CSS text", () => {
const definition = defineModuleUi({ ...definitionInput(), styles: ".a{color:red}" });
expect(definition.styles).toBe(".a{color:red}");
});
it("rejects an artifact built against a different mount ABI, with its own code", () => {
// The whole point of the marker: a version-1 artifact is reported as "rebuild your module",
// not as a malformed definition a developer would hunt for a typo in.
const versionOne = { id: "announcements", version: "0.1.0", mount: noop };
expect(isModuleUiDefinition(versionOne)).toBe(false);
try {
parseModuleUiDefinition(versionOne);
expect.unreachable("expected a contract error");
} catch (error) {
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_ABI_UNSUPPORTED");
}
expect(() =>
parseModuleUiDefinition({ ...versionOne, abi: MODULE_UI_ABI_VERSION + 1 }),
).toThrowError(expect.objectContaining({ code: "MODULE_UI_ABI_UNSUPPORTED" }));
});
it.each<[string, unknown]>([ it.each<[string, unknown]>([
["a missing id", { version: "0.1.0", mount: noop }], ["a blank stylesheet", " "],
["a blank id", { id: " ", version: "0.1.0", mount: noop }], ["a non-string stylesheet", 7],
["a non-string id", { id: 7, version: "0.1.0", mount: noop }], ["an array of stylesheets", [".a{}"]],
["a missing version", { id: "announcements", mount: noop }], ])("rejects %s", (_label, styles) => {
["a blank version", { id: "announcements", version: "", mount: noop }], const candidate = { ...validDefinition(), styles };
["a missing mount", { id: "announcements", version: "0.1.0" }],
["a non-function mount", { id: "announcements", version: "0.1.0", mount: "render" }], expect(isModuleUiDefinition(candidate)).toBe(false);
["an unknown property", { id: "announcements", version: "0.1.0", mount: noop, routes: [] }], expect(() => parseModuleUiDefinition(candidate)).toThrowError(
expect.objectContaining({ code: "MODULE_UI_STYLES_INVALID" }),
);
});
it.each<[string, unknown]>([
["a missing id", { abi, version: "0.1.0", mount: noop }],
["a blank id", { abi, id: " ", version: "0.1.0", mount: noop }],
["a non-string id", { abi, id: 7, version: "0.1.0", mount: noop }],
["a missing version", { abi, id: "announcements", mount: noop }],
["a blank version", { abi, id: "announcements", version: "", mount: noop }],
["a missing mount", { abi, id: "announcements", version: "0.1.0" }],
["a non-function mount", { abi, id: "announcements", version: "0.1.0", mount: "render" }],
[
"an unknown property",
{ abi, id: "announcements", version: "0.1.0", mount: noop, routes: [] },
],
])("rejects a definition with %s", (_label, candidate) => { ])("rejects a definition with %s", (_label, candidate) => {
expect(isModuleUiDefinition(candidate)).toBe(false); expect(isModuleUiDefinition(candidate)).toBe(false);
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError); expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
@@ -64,7 +117,7 @@ describe("module UI definition contract", () => {
it("reports a malformed definition with a stable, sanitized code", () => { it("reports a malformed definition with a stable, sanitized code", () => {
try { try {
parseModuleUiDefinition({ id: "announcements" }); parseModuleUiDefinition({ abi, id: "announcements" });
expect.unreachable("expected a contract error"); expect.unreachable("expected a contract error");
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(ModuleUiContractError); expect(error).toBeInstanceOf(ModuleUiContractError);
@@ -132,6 +185,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
name: "ekklesia", name: "ekklesia",
requestedUrls, requestedUrls,
context: { context: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId, moduleId,
api, api,
principal: { kind: "anonymous" }, principal: { kind: "anonymous" },
@@ -156,6 +210,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
name: "standalone", name: "standalone",
requestedUrls, requestedUrls,
context: { context: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId, moduleId,
api, api,
principal: { kind: "anonymous" }, principal: { kind: "anonymous" },

Some files were not shown because too many files have changed in this diff Show More