diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1833ffc..d12cd6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,13 +35,21 @@ pnpm infrastructure:config private source paths. - 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 - contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI - framework, `.jsx`, stylesheets, and other presentation formats remain rejected by - `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and + contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author + plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`, + 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 - the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no - bundler configuration, and styling is React inline style objects only for now. Both roles use the - same UI SDK context with no presentation-mode flag. + the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared + browser contribution at `src/contributions//index.tsx` (ADR 0041). Module authors write no + 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 `@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//...`, which is Ekklesia's @@ -51,10 +59,17 @@ pnpm infrastructure:config primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display through `GET /api/v1/module-displays/`, mounts both with `@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 `/display/`, never Dashboard UI with hidden chrome. Its page and descriptor require the 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 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 diff --git a/README.md b/README.md index 76a8e47..777e180 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,8 @@ artifacts cannot drift. 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); 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, 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 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 -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 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 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 Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. diff --git a/apps/api/package.json b/apps/api/package.json index 2932788..5b50008 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -28,11 +28,13 @@ "devDependencies": { "@biomejs/biome": "2.5.3", "@ekklesia/module-ui-host": "workspace:*", + "@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/typescript-config": "workspace:*", "@types/node": "24.13.3", "@types/pg": "8.20.0", "@types/smtp-server": "3.5.13", "better-auth": "1.6.23", + "happy-dom": "20.0.11", "pg": "8.22.0", "smtp-server": "3.19.3", "typescript": "7.0.2", diff --git a/apps/api/src/body-limits.ts b/apps/api/src/body-limits.ts index 12e4ea9..f4551f0 100644 --- a/apps/api/src/body-limits.ts +++ b/apps/api/src/body-limits.ts @@ -1,6 +1,10 @@ -import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts"; -import type { Context } from "hono"; -import { bodyLimit } from "hono/body-limit"; +import { + MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES, + 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 @@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit"; * field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting * abuse. * - `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 - * ceiling no module route may exceed and doubles as the platform's overall request-body ceiling: - * nothing in this platform ever declares, or enforces, a larger single request body than this. - * Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to - * a future dedicated storage design, not to widening this ceiling. + * constant a module's own **buffered** route declaration is validated against at registration) is + * the ceiling for every body the platform materializes before a handler runs. It has not moved. + * - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it + * explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as + * `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 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 * 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); } -/** 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 - * `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 - * ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as - * soon as the running total exceeds `maxSize` rather than buffering the whole body first. */ -export function platformBodyLimit(maxSize: number) { - return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse }); +/** + * A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one. + * + * A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately: + * the byte counting below bounds the request either way, so refusing here could only ever turn a + * legitimate request away without making an illegitimate one any less bounded. Node's own HTTP + * parser rejects genuinely malformed framing before this code sees the request at all. + */ +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, + maxSize: number, +): Promise { + 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 { + return new ReadableStream({ + 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({ + 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; + }; } diff --git a/apps/api/src/display-host-assets.ts b/apps/api/src/display-host-assets.ts index b5a16f3..5371ea2 100644 --- a/apps/api/src/display-host-assets.ts +++ b/apps/api/src/display-host-assets.ts @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"; 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 * 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 script: 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 @@ -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 * failed host build rather than a valid page that can never mount anything. */ -export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets { - if (html.trim().length === 0 || script.trim().length === 0) { +export function createDisplayHostAssets( + html: string, + script: string, + serviceWorker: string, +): DisplayHostAssets { + if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) { throw new DisplayHostAssetError(); } return Object.freeze({ html, script, 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. */ export async function loadDisplayHostAssets(): Promise { try { @@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise { // rejected sibling promise is left running after this function has already failed. const html = await readFile(new URL(displayHostAssetUrls.html), "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 { throw new DisplayHostAssetError(); } diff --git a/apps/api/src/module-display-routes.ts b/apps/api/src/module-display-routes.ts index a72dbac..de192c8 100644 --- a/apps/api/src/module-display-routes.ts +++ b/apps/api/src/module-display-routes.ts @@ -9,7 +9,11 @@ import { import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js"; import { moduleApiBaseUrl } from "./module-http-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"; /** 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_PAGE_PREFIX = "/display"; 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/` 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 { return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`; @@ -37,6 +53,9 @@ export interface HostModuleDisplayDescriptor { readonly api: { readonly baseUrl: string }; readonly realtime: { readonly baseUrl: 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. */ function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets { try { - const snapshot = createDisplayHostAssets(input.html, input.script); - if (snapshot.scriptEtag !== input.scriptEtag) { + const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker); + if ( + snapshot.scriptEtag !== input.scriptEtag || + snapshot.serviceWorkerEtag !== input.serviceWorkerEtag + ) { throw new ModuleDisplayConfigurationError(); } return snapshot; @@ -132,6 +154,7 @@ async function describeDisplay( viewer, module: { api: { baseUrl: moduleApiBaseUrl(display.moduleId) }, + contributions: describeModuleContributions(options.moduleUi, display.moduleId), display: { url: moduleDisplayUiArtifactUrl(display) }, id: display.moduleId, 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) { mountDisplay(app, { ...options, assets }, display); } diff --git a/apps/api/src/module-http-routes.ts b/apps/api/src/module-http-routes.ts index d58253c..3bbd4bc 100644 --- a/apps/api/src/module-http-routes.ts +++ b/apps/api/src/module-http-routes.ts @@ -235,9 +235,12 @@ export function mountModuleHttpRoutes( const requireRoutePermission = requirePermission(middlewareOptions, route.permission); // 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 - // reaches authorization work, let alone the handler or the module database. - const routeBodyLimit = platformBodyLimit(route.maxBodyBytes); + // all) is enforced before permission evaluation ever runs, so a request *declared* oversized + // never reaches authorization work, let alone the handler or the module database. 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 + // (ADR 0042). + const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode); const path = moduleRoutePath(route.moduleId, route.path); const finalHandler = async (c: Context): Promise => { const context: ModuleRouteHandlerContext = Object.freeze({ diff --git a/apps/api/src/module-ui-inventory.ts b/apps/api/src/module-ui-inventory.ts index 297c9e0..bcb680c 100644 --- a/apps/api/src/module-ui-inventory.ts +++ b/apps/api/src/module-ui-inventory.ts @@ -1,6 +1,10 @@ import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts"; 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 type { Context } from "hono"; import { @@ -10,7 +14,11 @@ import { } from "./authorization-middleware.js"; import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; -import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js"; +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 @@ -64,12 +72,56 @@ export interface HostModuleUiDescriptor { * nor trusts anything in this array. */ 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 { 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 { /** The same publication registry the artifact route serves from — the single accepted/publishable * module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs @@ -116,9 +168,11 @@ async function describeModule( evaluator: AuthorizationMiddlewareOptions["evaluator"], principal: EkklesiaPrincipal, published: PublishableModuleUi, + registry: ModuleUiPublicationRegistry, ): Promise { return { api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, + contributions: describeModuleContributions(registry, published.moduleId), id: published.moduleId, permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions), realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) }, @@ -153,7 +207,7 @@ export async function describeMountableModules( ): Promise { return Promise.all( registry.list().map(async (published) => ({ - descriptor: await describeModule(evaluator, principal, published), + descriptor: await describeModule(evaluator, principal, published, registry), name: published.name, })), ); diff --git a/apps/api/src/module-ui-routes.ts b/apps/api/src/module-ui-routes.ts index bf8e1be..b0a1383 100644 --- a/apps/api/src/module-ui-routes.ts +++ b/apps/api/src/module-ui-routes.ts @@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string { 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 * 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 * - * `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to - * `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's - * discovery resolved and validated is read directly. No request value is ever joined, resolved, + * `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path + * components**. They are handed to the registry and, on an exact match, the already-canonical + * `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 * 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 @@ -228,6 +248,24 @@ export interface CreateModuleUiRoutesOptions { export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): 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) => { const published = options.registry.resolveDisplay( c.req.param("moduleId"), diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 1d8adac..7855e76 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -330,7 +330,7 @@ async function createGatedRouteModule( compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js" }, id, - manifestVersion: 6, + manifestVersion: 7, name: `${id} module`, permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], version: "0.1.0", @@ -374,7 +374,7 @@ async function createFixtureModule( ): Promise { const moduleDirectory = path.join(modulesDirectory, id); const manifest = { - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", @@ -432,6 +432,39 @@ async function createFixtureModule( async handler(request, context) { 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" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id, - manifestVersion: 6, + manifestVersion: 7, name: `${id} module`, version, }), @@ -2685,6 +2718,73 @@ describe.sequential("live API — installed module permission end-to-end", () => 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({ + 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 () => { const admin = await openAdministrativeClient(database.url); try { diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index 551b782..769cecc 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), }, id: fixture.id, - manifestVersion: 6, + manifestVersion: 7, name: fixture.name, version: "1.0.0", }), @@ -269,6 +269,7 @@ describe("Dashboard navigation — reading", () => { // Presentation wraps the host-neutral descriptor; it never rewrites it. expect(livestream?.module).toEqual({ + contributions: [], api: { baseUrl: "/api/v1/modules/livestream" }, id: "livestream", permissions: [], diff --git a/apps/api/tests/device-pairing.integration.ts b/apps/api/tests/device-pairing.integration.ts index 379c613..0a649b6 100644 --- a/apps/api/tests/device-pairing.integration.ts +++ b/apps/api/tests/device-pairing.integration.ts @@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise { server: "./dist/server/index.js", }, id: "announcements", - manifestVersion: 6, + manifestVersion: 7, name: "Announcements fixture", permissions: [ { diff --git a/apps/api/tests/display-host-assets.test.ts b/apps/api/tests/display-host-assets.test.ts index 9a031c8..9b8de76 100644 --- a/apps/api/tests/display-host-assets.test.ts +++ b/apps/api/tests/display-host-assets.test.ts @@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({ displayHostAssetUrls: Object.freeze({ html: "file:///surface/browser/display/index.html", script: "file:///surface/browser/display/app.js", + serviceWorker: "file:///surface/browser/display/sw.js", }), })); @@ -20,21 +21,28 @@ import { describe("display host asset loading", () => { 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 = '
'; 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(); expect(readFileMock.mock.calls).toEqual([ [new URL("file:///surface/browser/display/index.html"), "utf8"], [new URL("file:///surface/browser/display/app.js"), "utf8"], + [new URL("file:///surface/browser/display/sw.js"), "utf8"], ]); expect(assets).toEqual({ html, script, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, + serviceWorker, + serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`, }); 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", () => { - for (const [html, script] of [ - ["", "export {};"], - ["", " \n"], + it("rejects an empty page, script, or service worker as an unavailable host build", () => { + for (const [html, script, serviceWorker] of [ + ["", "export {};", "export {};"], + ["", " \n", "export {};"], + ["", "export {};", " \n"], ] as const) { - expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError); + expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow( + DisplayHostAssetError, + ); } }); }); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index ce5657c..dace951 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -88,7 +88,7 @@ async function createFixtureModule( ): Promise { const moduleDirectory = path.join(modulesDirectory, id); const manifest = { - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", @@ -254,12 +254,13 @@ function fakeDiscoveredModule( compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/does-not-exist/index.js" }, id, - manifestVersion: 6, + manifestVersion: 7, name: `${id} module`, permissions, version: "0.1.0", }); return { + contributions: [], directory: `/modules/${id}`, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, manifest, diff --git a/apps/api/tests/module-configuration-routes.test.ts b/apps/api/tests/module-configuration-routes.test.ts index 67f3dad..6f37cda 100644 --- a/apps/api/tests/module-configuration-routes.test.ts +++ b/apps/api/tests/module-configuration-routes.test.ts @@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P configuration: fixture.configuration ?? [], entrypoints: { server: "./dist/server/index.js" }, id: fixture.id, - manifestVersion: 6, + manifestVersion: 7, name: `${fixture.id} module`, version: "1.0.0", }), diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts index b5fc747..8ab3783 100644 --- a/apps/api/tests/module-display-routes.test.ts +++ b/apps/api/tests/module-display-routes.test.ts @@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js"; import { DISPLAY_HOST_SCRIPT_PATH, + DISPLAY_SERVICE_WORKER_PATH, ModuleDisplayConfigurationError, moduleDisplayDescriptorUrl, moduleDisplayPageUrl, @@ -51,6 +52,7 @@ const displayPermission = "announcements.display.view"; const displayHostAssets: DisplayHostAssets = createDisplayHostAssets( `
`, "globalThis.__ekklesiaDisplayHostLoaded = true;\n", + "globalThis.__ekklesiaDisplayWorkerLoaded = true;\n", ); const temporaryDirectories: string[] = []; @@ -101,7 +103,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = { : {}), }, id, - manifestVersion: 6, + manifestVersion: 7, name: `${id} module`, permissions: fixture.display ? [ @@ -266,6 +268,7 @@ describe("module display routes — authorization and descriptor", () => { expect(await descriptor.json()).toEqual({ viewer: { kind: "authenticated", userId: viewerId }, module: { + contributions: [], api: { baseUrl: moduleApiBaseUrl("announcements") }, display: { 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 () => { - const source = createDisplayHostAssets("display host", "export const host = 1;\n"); + const source = createDisplayHostAssets( + "display host", + "export const host = 1;\n", + "export const worker = 1;\n", + ); const mutable = { ...source }; const { app } = await install({ assets: mutable, display: true }); mutable.html = "mutated secret"; @@ -497,4 +504,55 @@ describe("module display host asset publication", () => { 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); + } + }); }); diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index f892330..c7d1b3d 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -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 * 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( - fields: Omit & { maxBodyBytes?: number }, + fields: Omit & { + bodyMode?: RegisteredModuleHttpRoute["bodyMode"]; + maxBodyBytes?: number; + }, ): 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, @@ -1634,3 +1637,375 @@ describe("module route overlap can never reach mounting", () => { 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 { + let sent = 0; + return new ReadableStream({ + 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 { + 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(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(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((resolve) => { + released = resolve; + }); + let firstChunkSeen = false; + let secondChunkQueued = false; + const body = new ReadableStream({ + 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(async (request) => { + const reader = (request.body as ReadableStream).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({ + start(controller) { + controller.enqueue(new Uint8Array(16).fill(97)); + controller.error(new Error("client went away")); + }, + }); + const handler = vi.fn(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({ + 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(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(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(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); + }); +}); diff --git a/apps/api/tests/module-lifecycle-routes.test.ts b/apps/api/tests/module-lifecycle-routes.test.ts index b075c16..df10630 100644 --- a/apps/api/tests/module-lifecycle-routes.test.ts +++ b/apps/api/tests/module-lifecycle-routes.test.ts @@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id: fixture.id, - manifestVersion: 6, + manifestVersion: 7, name: `${fixture.id} module`, version: "1.0.0", }), diff --git a/apps/api/tests/module-realtime-live.test.ts b/apps/api/tests/module-realtime-live.test.ts index deceb09..8a393ad 100644 --- a/apps/api/tests/module-realtime-live.test.ts +++ b/apps/api/tests/module-realtime-live.test.ts @@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{ compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js" }, id: "announcements", - manifestVersion: 6, + manifestVersion: 7, name: "Announcements fixture", permissions: [ { diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index cc857b4..69f7623 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -20,7 +20,13 @@ import { discoverModules, startDiscoveredModuleRuntime, } 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 { bootstrapDisplayHost, bootstrapHost, @@ -121,7 +127,7 @@ async function createFixtureModule( : {}), }, id, - manifestVersion: 6, + manifestVersion: 7, name, permissions: [ { @@ -165,6 +171,7 @@ async function createFixtureModule( await writeFile( path.join(moduleDirectory, "dist/ui/index.js"), `export default { + abi: ${MODULE_UI_ABI_VERSION}, id: ${JSON.stringify(id)}, version: "0.1.0", async mount(container, context) { @@ -184,6 +191,7 @@ async function createFixtureModule( await writeFile( path.join(moduleDirectory, "dist/display/index.js"), `export default { + abi: ${MODULE_UI_ABI_VERSION}, id: ${JSON.stringify(id)}, version: "0.1.0", async mount(container, context) { @@ -270,6 +278,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { displayHostAssets: createDisplayHostAssets( '
', "export const displayHost = true;\n", + "export const displayWorker = true;\n", ), }), moduleLifecycle: { @@ -310,10 +319,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis 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. */ -function containerSink(): { textContent: string } { - return { textContent: "" }; +/** + * A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's + * test environment. + * + * 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[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. expect(descriptor).toEqual({ apiBaseUrl: "/api/v1/modules/example", + contributions: [], id: "example", permissions: ["example.records.read"], 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. - 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. expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); // 7. And it tears down cleanly. await mounted.unmount(); - expect(container.textContent).toBe(""); + expect(mountedText(container)).toBe(""); expect(mounted.mounted).toBe(false); } finally { 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({ apiBaseUrl: "/api/v1/modules/example", + contributions: [], id: "example", permissions: ["example.records.read", "example.display.view"], 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([ "api", + "contributions", "moduleId", "permissions", "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"]); await mounted.unmount(); - expect(container.textContent).toBe(""); + expect(mountedText(container)).toBe(""); expect(mounted.mounted).toBe(false); } finally { 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. - expect(container.textContent).toBe("example:denied"); + expect(mountedText(container)).toBe("example:denied"); expect(harness.moduleApiCalls).toEqual([]); } finally { 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, }, }); - expect(container.textContent).toBe("example:200:2"); + expect(mountedText(container)).toBe("example:200:2"); await mounted.unmount(); // 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, }, }); - expect(movedContainer.textContent).toBe("example:200:2"); + expect(mountedText(movedContainer)).toBe("example:200:2"); expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([ "GET /api/v1/modules/example/records", ]); diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index 1b7bbc5..6ab8d9d 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -59,6 +59,12 @@ interface ModuleFixture { readonly id: string; 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 { @@ -82,8 +88,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P server: "./dist/server/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, - manifestVersion: 6, + manifestVersion: 7, name: `${fixture.id} module`, permissions: (fixture.permissions ?? []).map((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`, ); } + 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 { @@ -219,6 +242,7 @@ describe("module UI inventory — what it lists", () => { expect(response.status).toBe(200); expect(modules).toEqual([ { + contributions: [], api: { baseUrl: "/api/v1/modules/example" }, id: "example", 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. expect(Object.keys(modules[0] ?? {}).sort()).toEqual([ "api", + "contributions", "id", "permissions", "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>; + + expect(body).not.toContain(modulesDirectory); + expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([ + "capability", + "id", + "url", + "version", + ]); + }); +}); diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index a59dd9a..67237bc 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -16,6 +16,8 @@ import { EMPTY_MODULE_REALTIME_REGISTRY, } from "@ekklesia/core/modules"; 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 { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; @@ -25,6 +27,7 @@ import { MODULE_UI_ASSET_PREFIX, MODULE_UI_CACHE_CONTROL, MODULE_UI_CONTENT_TYPE, + moduleContributionArtifactUrl, moduleDisplayUiArtifactUrl, moduleUiArtifactUrl, } from "../src/module-ui-routes.js"; @@ -59,6 +62,7 @@ async function createModulesDirectory(): Promise { * sequence whose exact publication and loader-compatibility can be asserted. */ function artifactSource(id: string, version: string): string { return `export default { + abi: ${MODULE_UI_ABI_VERSION}, id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { @@ -83,6 +87,15 @@ interface ModuleFixture { * 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. */ readonly extraFiles?: Readonly>; + /** 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 { @@ -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, - manifestVersion: 6, + manifestVersion: 7, name: `${fixture.id} module`, permissions: 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 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) { await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }); await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display); @@ -209,6 +240,7 @@ async function install( displayHostAssets: createDisplayHostAssets( '
', "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", () => { /** * 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, - * so a plain sink is enough and no DOM implementation is required. The container's type is read - * back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles - * against Node libs only — naming a DOM type here would be the same browser/server confusion this - * route exists to keep apart. + * A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM + * root inside whatever it is given. The container's type is read back off `loadModuleUi` itself + * rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a + * DOM type here would be the same browser/server confusion this route exists to keep apart. */ type LoaderContainer = Parameters[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() { return { api: { request: async () => new Response(null, { status: 200 }) }, + contributions: NO_MODULE_UI_CONTRIBUTIONS, moduleId: "announcements", permissions: { has: () => false }, 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" }, }); - expect(container.textContent).toBe("announcements:mounted"); + expect(mountedText(container)).toBe("announcements:mounted"); 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 () => { @@ -935,3 +987,109 @@ describe("module UI artifact publication — the generic loader consumes what is ).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); + } + }); +}); diff --git a/core/src/cli/validate-modules.ts b/core/src/cli/validate-modules.ts index 71b3f70..4e74ff1 100644 --- a/core/src/cli/validate-modules.ts +++ b/core/src/cli/validate-modules.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { PermissionRegistryError } from "../authorization/index.js"; import { composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, discoverModuleSchemas, ModuleDiscoveryError, ModulePermissionCompositionError, @@ -45,11 +46,32 @@ async function main(): Promise { 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( `Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`, ); for (const discoveredModule of modules) { 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" + }`, + ); + } } } diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 0ac0467..c267e5f 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -57,6 +57,7 @@ import { createModuleConfigurationStore, createModuleLifecycleDatabase, createModuleLifecycleStore, + createModuleUiPublicationRegistry, type DiscoveredModule, discoverModuleSchemas, discoverModules, @@ -316,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport { 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[] { + const publication = createModuleUiPublicationRegistry(modules); + const compatible = new Set( + publication + .listContributions() + .map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`), + ); return modules.map((module) => { const id = module.manifest.id; const version = module.manifest.version; @@ -326,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin ) { 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)`; }); } diff --git a/core/src/modules/discovery.ts b/core/src/modules/discovery.ts index 3bb6427..63a7a18 100644 --- a/core/src/modules/discovery.ts +++ b/core/src/modules/discovery.ts @@ -57,6 +57,19 @@ export interface ModuleDiscoveryIssue { 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 { readonly server: string; readonly schema?: string; @@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly; export interface DiscoveredModule { 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 manifest: ReadonlyModuleManifest; readonly manifestPath: string; @@ -110,6 +126,7 @@ interface ModulePackageData { } interface ModuleCandidate { + contributions: readonly ResolvedModuleContribution[]; directory: string; directoryName: string; entrypoints: ResolvedModuleEntrypoints; @@ -257,13 +274,19 @@ function validatePackageData( return { name, version }; } +interface ResolvedModuleArtifacts { + readonly contributions: readonly ResolvedModuleContribution[]; + readonly entrypoints: ResolvedModuleEntrypoints; +} + async function resolveEntrypoints( moduleDirectory: string, directoryName: string, manifest: ModuleManifest, issues: ModuleDiscoveryIssue[], -): Promise { +): Promise { const resolved: Partial> = {}; + const resolvedContributions = new Map(); const canonicalModuleDirectory = await realpath(moduleDirectory); const { display, ...ordinaryEntrypoints } = manifest.entrypoints; const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints) @@ -273,6 +296,15 @@ async function resolveEntrypoints( issuePath: `entrypoints.${entrypointName}`, 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 = [ ...ordinaryConfiguredEntrypoints, ...(display === undefined @@ -284,6 +316,7 @@ async function resolveEntrypoints( relativeEntrypoint: display.artifact, }, ]), + ...configuredContributions, ]; let entrypointCollision = false; @@ -361,7 +394,14 @@ async function resolveEntrypoints( }); 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) { const code = errorCode(error); 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(); + 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 { + 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, ...(resolved.schema ? { schema: resolved.schema } : {}), ...(resolved.migrations ? { migrations: resolved.migrations } : {}), @@ -413,6 +488,8 @@ async function resolveEntrypoints( } : {}), }; + + return { contributions: Object.freeze(contributions), entrypoints }; } async function scanModuleDirectory( @@ -510,15 +587,16 @@ async function scanModuleDirectory( }); } - const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues); - if (!entrypoints) { + const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues); + if (!artifacts) { return undefined; } return { + contributions: artifacts.contributions, directory: canonicalModuleDirectory, directoryName, - entrypoints, + entrypoints: artifacts.entrypoints, manifest, manifestPath: canonicalManifestPath, packageName: packageData.name, diff --git a/core/src/modules/runtime.ts b/core/src/modules/runtime.ts index 93908ba..810caea 100644 --- a/core/src/modules/runtime.ts +++ b/core/src/modules/runtime.ts @@ -60,6 +60,7 @@ export type ModuleRuntimeIssueCode = | "MODULE_ROLLBACK_TIMEOUT" | "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DUPLICATE" + | "MODULE_ROUTE_BODY_MODE_INVALID" | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" | "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_OVERLAP" @@ -467,6 +468,7 @@ class StagedModuleHttpRoutes { shapes.add(shapeKey); local.push( Object.freeze({ + bodyMode: parsed.data.bodyMode, handler: parsed.data.handler, maxBodyBytes: parsed.data.maxBodyBytes, method: parsed.data.method, diff --git a/core/src/modules/ui-publication.ts b/core/src/modules/ui-publication.ts index c9d6b3c..43a1ceb 100644 --- a/core/src/modules/ui-publication.ts +++ b/core/src/modules/ui-publication.ts @@ -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"; /** @@ -60,6 +64,34 @@ export interface PublishableModuleDisplay { 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?" * @@ -89,6 +121,38 @@ export interface ModuleUiPublicationRegistry { * a display merely because a caller appends `/display` to its publication URL. */ 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 { const byModuleId = new Map(); const displaysByModuleId = new Map(); + 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(); + 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) { const artifactPath = module.entrypoints.ui; @@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry( const published = Object.freeze([...byModuleId.values()]); const publishedDisplays = Object.freeze([...displaysByModuleId.values()]); + const publishedContributions = Object.freeze([...contributions]); + const contributionsByCapability = new Map(); + const contributionsByKey = new Map(); + 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( + modules.map((module) => [ + module.manifest.id, + Object.freeze( + module.manifest.capabilities.flatMap( + (capability) => contributionsByCapability.get(capability.id) ?? [], + ), + ), + ]), + ); 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, listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays, resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => { diff --git a/core/tests/core-migration-discovery.test.ts b/core/tests/core-migration-discovery.test.ts index ef9ea23..4970924 100644 --- a/core/tests/core-migration-discovery.test.ts +++ b/core/tests/core-migration-discovery.test.ts @@ -91,7 +91,7 @@ async function createModule( writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/dashboard-flat-modules.test.ts b/core/tests/dashboard-flat-modules.test.ts index e389f34..394fdf0 100644 --- a/core/tests/dashboard-flat-modules.test.ts +++ b/core/tests/dashboard-flat-modules.test.ts @@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P : {}), entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id: fixture.id, - manifestVersion: 6, + manifestVersion: 7, name: fixture.name, version: "1.0.0", }), diff --git a/core/tests/doctor-host-configuration.test.ts b/core/tests/doctor-host-configuration.test.ts index dfa01a4..23b927f 100644 --- a/core/tests/doctor-host-configuration.test.ts +++ b/core/tests/doctor-host-configuration.test.ts @@ -69,6 +69,7 @@ const mailEnvironment = Object.freeze({ function discoveredModule(id: string, overrides: Record = {}): DiscoveredModule { return { + contributions: [], directory: `/unpublished/${id}`, entrypoints: { server: `/unpublished/${id}/server.js` }, manifest: { @@ -81,7 +82,7 @@ function discoveredModule(id: string, overrides: Record = {}): }, entrypoints: { server: "./dist/server.js" }, id, - manifestVersion: 6, + manifestVersion: 7, name: `${id} module`, permissions: [], storage: { buckets: [] }, diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 7154b2f..1ab9b86 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -30,6 +30,7 @@ const checksumC = "c".repeat(64); function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { return { + contributions: [], directory: `/unpublished/${id}`, entrypoints: { server: `/unpublished/${id}/server.js` }, manifest: { @@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { }, entrypoints: { server: "./dist/server.js" }, id, - manifestVersion: 6, + manifestVersion: 7, name: `${id} module`, permissions: [], storage: { buckets: [] }, @@ -1471,3 +1472,91 @@ describe("Ekklesia Doctor — object storage", () => { 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 { + 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)", + ]); + }); +}); diff --git a/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json index 36d1d0c..068579b 100644 --- a/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json +++ b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 6, + "manifestVersion": 7, "id": "db-facade", "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.", diff --git a/core/tests/module-configuration-lifecycle.test.ts b/core/tests/module-configuration-lifecycle.test.ts index 1b24dd1..62a56cd 100644 --- a/core/tests/module-configuration-lifecycle.test.ts +++ b/core/tests/module-configuration-lifecycle.test.ts @@ -17,7 +17,7 @@ function fakeModule( } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "1.0.0", @@ -27,6 +27,7 @@ function fakeModule( entrypoints: { server: "./dist/does-not-exist/index.js" }, }); return { + contributions: [], directory: `/modules/${id}`, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, manifest, diff --git a/core/tests/module-configuration-store.integration.ts b/core/tests/module-configuration-store.integration.ts index f54c483..efa4a7b 100644 --- a/core/tests/module-configuration-store.integration.ts +++ b/core/tests/module-configuration-store.integration.ts @@ -194,7 +194,7 @@ afterAll(async () => { function manifestWith(configuration: readonly Record[], id = "announcements") { return parseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "1.0.0", diff --git a/core/tests/module-configuration.test.ts b/core/tests/module-configuration.test.ts index 42a6cec..e8eda50 100644 --- a/core/tests/module-configuration.test.ts +++ b/core/tests/module-configuration.test.ts @@ -21,7 +21,7 @@ function manifestWith( id = "announcements", ) { return parseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, 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", () => { const result = safeParseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id: "announcements", name: "announcements module", 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", () => { const result = safeParseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id: "announcements", name: "announcements module", 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", () => { const result = safeParseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id: "announcements", name: "announcements module", version: "1.0.0", @@ -169,7 +169,7 @@ describe("module configuration declarations", () => { ], ])("rejects %s", (_label, declaration) => { const result = safeParseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id: "announcements", name: "announcements module", version: "1.0.0", @@ -189,7 +189,7 @@ describe("module configuration declarations", () => { "valueOf", ])("rejects the reserved object property name %s as a configuration key", (key) => { const result = safeParseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id: "announcements", name: "announcements module", version: "1.0.0", @@ -210,7 +210,7 @@ describe("module configuration declarations", () => { "", ])("rejects the malformed configuration key %s", (key) => { const result = safeParseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id: "announcements", name: "announcements module", version: "1.0.0", @@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => { writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "1.0.0", diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index 583547c..3b7defe 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise { function manifestFor(id: string): Record { return { - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, 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 = {}): Record { + 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" }), + ]), + }); + }); +}); diff --git a/core/tests/module-lifecycle-store.integration.ts b/core/tests/module-lifecycle-store.integration.ts index 15a0f8d..7c56aa1 100644 --- a/core/tests/module-lifecycle-store.integration.ts +++ b/core/tests/module-lifecycle-store.integration.ts @@ -200,7 +200,7 @@ afterAll(async () => { * by eligibility resolution. */ function fakeModule(id: string, dependencies?: Record): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "1.0.0", @@ -209,6 +209,7 @@ function fakeModule(id: string, dependencies?: Record): Discove entrypoints: { server: "./dist/does-not-exist/index.js" }, }); return { + contributions: [], directory: `/modules/${id}`, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, manifest, diff --git a/core/tests/module-lifecycle.test.ts b/core/tests/module-lifecycle.test.ts index b2c599f..4b10a5d 100644 --- a/core/tests/module-lifecycle.test.ts +++ b/core/tests/module-lifecycle.test.ts @@ -22,7 +22,7 @@ function fakeModule( } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", @@ -36,6 +36,7 @@ function fakeModule( }, }); return { + contributions: [], directory: `/modules/${id}`, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, manifest, @@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () => writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-migration-discovery.test.ts b/core/tests/module-migration-discovery.test.ts index 9594bc4..1884706 100644 --- a/core/tests/module-migration-discovery.test.ts +++ b/core/tests/module-migration-discovery.test.ts @@ -65,7 +65,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts index ed39265..3719dc4 100644 --- a/core/tests/module-permissions.test.ts +++ b/core/tests/module-permissions.test.ts @@ -28,7 +28,7 @@ interface PermissionInput { * is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", @@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D }, }); return { + contributions: [], directory: `/modules/${id}`, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, manifest, diff --git a/core/tests/module-realtime-runtime.test.ts b/core/tests/module-realtime-runtime.test.ts index c274f6c..53b8edb 100644 --- a/core/tests/module-realtime-runtime.test.ts +++ b/core/tests/module-realtime-runtime.test.ts @@ -31,7 +31,7 @@ function manifest( permissions: readonly Record[] = [], ): Record { return { - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts index 6f64359..00a3059 100644 --- a/core/tests/module-runtime.test.ts +++ b/core/tests/module-runtime.test.ts @@ -57,7 +57,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", @@ -861,6 +861,7 @@ describe("module HTTP routes", () => { expect(runtime.routes).toHaveLength(3); for (const route of runtime.routes) { expect(Object.keys(route).sort()).toEqual([ + "bodyMode", "handler", "maxBodyBytes", "method", diff --git a/core/tests/module-schema-discovery.test.ts b/core/tests/module-schema-discovery.test.ts index 906a86c..1566bc3 100644 --- a/core/tests/module-schema-discovery.test.ts +++ b/core/tests/module-schema-discovery.test.ts @@ -41,7 +41,7 @@ function manifestFor( options: { dependencies?: Record; schema?: string | false } = {}, ): Record { return { - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-storage-lifecycle.test.ts b/core/tests/module-storage-lifecycle.test.ts index 220ddf6..a93552f 100644 --- a/core/tests/module-storage-lifecycle.test.ts +++ b/core/tests/module-storage-lifecycle.test.ts @@ -18,7 +18,7 @@ function fakeModule( } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 6, + manifestVersion: 7, id, name: `${id} module`, version: "1.0.0", @@ -29,6 +29,7 @@ function fakeModule( entrypoints: { server: "./dist/does-not-exist/index.js" }, }); return { + contributions: [], directory: `/modules/${id}`, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, manifest, diff --git a/core/tests/module-ui-publication.test.ts b/core/tests/module-ui-publication.test.ts index 32e81cb..0c4b81c 100644 --- a/core/tests/module-ui-publication.test.ts +++ b/core/tests/module-ui-publication.test.ts @@ -29,6 +29,15 @@ interface ModuleFixture { readonly ui?: string; /** A dedicated full-viewport artifact, independent of the primary UI. */ 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 { @@ -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, - manifestVersion: 6, + manifestVersion: 7, name: `${fixture.id} module`, permissions: 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 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 @@ -294,3 +320,128 @@ describe("module display UI publication registry", () => { 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", + ]); + }); +}); diff --git a/docs/architecture/decisions/0023-module-ui-build-pipeline.md b/docs/architecture/decisions/0023-module-ui-build-pipeline.md index 1cff059..7bc3939 100644 --- a/docs/architecture/decisions/0023-module-ui-build-pipeline.md +++ b/docs/architecture/decisions/0023-module-ui-build-pipeline.md @@ -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 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 `@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets diff --git a/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md b/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md new file mode 100644 index 0000000..1e11c1b --- /dev/null +++ b/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md @@ -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-`. 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. + +`` 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. diff --git a/docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md b/docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md new file mode 100644 index 0000000..3722420 --- /dev/null +++ b/docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md @@ -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 +└─
← shadow host, created and removed by the loader + #shadow-root (open) + ├─