Merge pull request 'Phase 8/module platform prerequisites' (#40) from phase-8/module-platform-prerequisites into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/40
This commit was merged in pull request #40.
This commit is contained in:
+22
-7
@@ -35,13 +35,21 @@ pnpm infrastructure:config
|
|||||||
private source paths.
|
private source paths.
|
||||||
- Use `workspace:` ranges for internal package dependencies.
|
- Use `workspace:` ranges for internal package dependencies.
|
||||||
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||||
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||||
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||||
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
|
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
|
||||||
bundler configuration, and styling is React inline style objects only for now. Both roles use the
|
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
|
||||||
same UI SDK context with no presentation-mode flag.
|
bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
|
||||||
|
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
|
||||||
|
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
|
||||||
|
presentation-mode flag, and the same versioned mount ABI.
|
||||||
|
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
|
||||||
|
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
|
||||||
|
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
|
||||||
|
stays rejected inside a module. Reference registered semantic tokens; never define an
|
||||||
|
`--ekklesia-*` custom property of your own.
|
||||||
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
||||||
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
||||||
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
||||||
@@ -51,10 +59,17 @@ pnpm infrastructure:config
|
|||||||
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
||||||
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
||||||
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
||||||
|
- A module may contribute a browser renderer to a capability another module provides
|
||||||
|
(`manifest.contributions`, ADR 0041). The consumer discovers and mounts it through
|
||||||
|
`ModuleUiHostContext.contributions`, never by building a publication URL, and owns the input
|
||||||
|
contract entirely: the platform carries opaque JSON and defines no payload schema, slot, or
|
||||||
|
ordering. A contributed renderer gets its own id and that input, and nothing else.
|
||||||
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
|
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
|
||||||
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
|
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
|
||||||
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
|
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
|
||||||
anonymous trusted code. Public/device identity, display inventory, and variants are deferred.
|
anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A
|
||||||
|
display-scoped service worker caches the shell and declared artifacts only, network-first, and
|
||||||
|
never a descriptor, an API response, or anything else authorization-bearing (ADR 0043).
|
||||||
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
||||||
module, child module, owning module, or group-derived dependency: real module relationships are
|
module, child module, owning module, or group-derived dependency: real module relationships are
|
||||||
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
||||||
|
|||||||
@@ -289,7 +289,8 @@ artifacts cannot drift.
|
|||||||
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
||||||
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
||||||
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
||||||
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar
|
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
|
||||||
|
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
|
||||||
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
||||||
and installing a module or starting the API never triggers one.
|
and installing a module or starting the API never triggers one.
|
||||||
|
|
||||||
@@ -488,7 +489,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
|
|||||||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||||
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||||
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||||||
manifest is version 6 after ADR 0033 added the optional display declaration.
|
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
|
||||||
|
browser contributions.
|
||||||
|
|
||||||
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||||
no key management, so a `secret` key's value is supplied by the deployment
|
no key management, so a `secret` key's value is supplied by the deployment
|
||||||
@@ -792,6 +794,74 @@ unproven launch-blockers rather than existing guarantees. Read
|
|||||||
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
||||||
module or exposing the API.
|
module or exposing the API.
|
||||||
|
|
||||||
|
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
|
||||||
|
None of them is a feature module, and none of them names one.
|
||||||
|
|
||||||
|
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
|
||||||
|
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
|
||||||
|
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
|
||||||
|
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
|
||||||
|
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
|
||||||
|
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
|
||||||
|
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
|
||||||
|
badge, toast, and empty/error/loading states), every value a token reference, with real
|
||||||
|
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
|
||||||
|
exactly two shared roles to modules by name and pins both design packages beneath every other role.
|
||||||
|
The Dashboard and display install the defaults at their document root; custom properties inherit into
|
||||||
|
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
|
||||||
|
theme editor, brand builder, second theme, or component catalogue exists.**
|
||||||
|
|
||||||
|
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
|
||||||
|
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
|
||||||
|
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
|
||||||
|
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
|
||||||
|
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
|
||||||
|
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
|
||||||
|
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
|
||||||
|
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
|
||||||
|
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
|
||||||
|
is versioned with no dual acceptance; migrating is one `pnpm build`.
|
||||||
|
|
||||||
|
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
||||||
|
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||||||
|
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
|
||||||
|
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||||||
|
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
||||||
|
means. Publication requires that another eligible module provides the capability inside the declared
|
||||||
|
range; a contribution that fails is absent rather than broken. A consumer discovers through
|
||||||
|
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
|
||||||
|
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
|
||||||
|
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
|
||||||
|
database, storage, or credential — and loading one grants its module nothing.
|
||||||
|
|
||||||
|
Phase 8 pass 4 closes the object-storage write path
|
||||||
|
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
|
||||||
|
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
|
||||||
|
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
|
||||||
|
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
|
||||||
|
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
|
||||||
|
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
|
||||||
|
oversize is refused before authorization, the buffered path now counts what arrived rather than
|
||||||
|
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
|
||||||
|
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
|
||||||
|
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
|
||||||
|
|
||||||
|
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
|
||||||
|
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
|
||||||
|
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
|
||||||
|
every contributed renderer that display mounted, network-first so a screen can never be trapped on
|
||||||
|
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
|
||||||
|
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
|
||||||
|
stated rather than papered over — a display cannot render module content while offline, because
|
||||||
|
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
|
||||||
|
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
|
||||||
|
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
|
||||||
|
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
|
||||||
|
the module's responsibility and do not exist yet.**
|
||||||
|
|
||||||
|
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||||
|
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
|
|||||||
@@ -28,11 +28,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@ekklesia/module-ui-host": "workspace:*",
|
"@ekklesia/module-ui-host": "workspace:*",
|
||||||
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
"@ekklesia/typescript-config": "workspace:*",
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/smtp-server": "3.5.13",
|
"@types/smtp-server": "3.5.13",
|
||||||
"better-auth": "1.6.23",
|
"better-auth": "1.6.23",
|
||||||
|
"happy-dom": "20.0.11",
|
||||||
"pg": "8.22.0",
|
"pg": "8.22.0",
|
||||||
"smtp-server": "3.19.3",
|
"smtp-server": "3.19.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
|
|||||||
+187
-17
@@ -1,6 +1,10 @@
|
|||||||
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
|
import {
|
||||||
import type { Context } from "hono";
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
import { bodyLimit } from "hono/body-limit";
|
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||||
|
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||||
|
type ModuleRouteBodyMode,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
|
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
|
||||||
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
|
|||||||
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
||||||
* abuse.
|
* abuse.
|
||||||
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
||||||
* constant a module's own route declaration is validated against at registration) is the hard
|
* constant a module's own **buffered** route declaration is validated against at registration) is
|
||||||
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
|
* the ceiling for every body the platform materializes before a handler runs. It has not moved.
|
||||||
* nothing in this platform ever declares, or enforces, a larger single request body than this.
|
* - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
|
||||||
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
|
* explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
|
||||||
* a future dedicated storage design, not to widening this ceiling.
|
* `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
|
||||||
|
* single request body, and no unbounded option exists anywhere in the contract.
|
||||||
*/
|
*/
|
||||||
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
||||||
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
|
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
|
||||||
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING };
|
export {
|
||||||
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
|
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||||
|
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||||
|
};
|
||||||
|
|
||||||
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
||||||
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
||||||
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
|
|||||||
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413
|
/**
|
||||||
* contract rather than redefining `onError` per route. Covers both a declared oversized
|
* A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
|
||||||
* `Content-Length` and an oversized chunked/streamed body with no declared length — see
|
*
|
||||||
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before
|
* A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
|
||||||
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
|
* the byte counting below bounds the request either way, so refusing here could only ever turn a
|
||||||
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
|
* legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
|
||||||
export function platformBodyLimit(maxSize: number) {
|
* parser rejects genuinely malformed framing before this code sees the request at all.
|
||||||
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
|
*/
|
||||||
|
function declaredContentLength(request: Request): number | undefined {
|
||||||
|
const header = request.headers.get("content-length");
|
||||||
|
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Number.parseInt(header.trim(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
|
||||||
|
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
|
||||||
|
function releaseUnreadBody(request: Request): void {
|
||||||
|
const body = request.body;
|
||||||
|
if (!body || body.locked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void body.cancel().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
|
||||||
|
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
|
||||||
|
* chunk past the limit rather than the whole upload.
|
||||||
|
*/
|
||||||
|
async function readBounded(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
maxSize: number,
|
||||||
|
): Promise<readonly Uint8Array[] | "over-limit"> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
size += value.byteLength;
|
||||||
|
if (size > maxSize) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
return "over-limit";
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's one request-body limiter, for every route in the application.
|
||||||
|
*
|
||||||
|
* ## Both modes reject a declared oversize before anything else happens
|
||||||
|
*
|
||||||
|
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
|
||||||
|
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
|
||||||
|
* too-large upload takes.
|
||||||
|
*
|
||||||
|
* ## `buffered` — the body is materialized, bounded, before the handler runs
|
||||||
|
*
|
||||||
|
* The exact behaviour every route had before streaming existed, now with the counting done here
|
||||||
|
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
|
||||||
|
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
|
||||||
|
* what actually arrived rather than by what was claimed. The handler then receives a request whose
|
||||||
|
* body is fully available and provably within its declared allowance — which is what makes
|
||||||
|
* `await request.json()` safe in a module handler.
|
||||||
|
*
|
||||||
|
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
|
||||||
|
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
|
||||||
|
*
|
||||||
|
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
|
||||||
|
*
|
||||||
|
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
|
||||||
|
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
|
||||||
|
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
|
||||||
|
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
|
||||||
|
* is not a limit.
|
||||||
|
*
|
||||||
|
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
|
||||||
|
* handler has started, so a handler that persists as it reads has to be prepared for a partially
|
||||||
|
* consumed upload. Writing to a temporary key and committing only after the read completes is the
|
||||||
|
* pattern; the platform cannot do it for you, because only the module knows what a partial write
|
||||||
|
* means to its own data.
|
||||||
|
*
|
||||||
|
* ## Ordering
|
||||||
|
*
|
||||||
|
* This runs before permission evaluation for both modes, so a request that is *declared* too large
|
||||||
|
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
|
||||||
|
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
|
||||||
|
* push a large upload through the platform at all.
|
||||||
|
*
|
||||||
|
* ## Timeouts
|
||||||
|
*
|
||||||
|
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
|
||||||
|
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
|
||||||
|
* platform already has one answer for that rather than a second per-route one.
|
||||||
|
*/
|
||||||
|
export function platformBodyLimit(
|
||||||
|
maxSize: number,
|
||||||
|
mode: ModuleRouteBodyMode = "buffered",
|
||||||
|
): MiddlewareHandler {
|
||||||
|
return async function enforceBodyLimit(c, next) {
|
||||||
|
const request = c.req.raw;
|
||||||
|
const declared = declaredContentLength(request);
|
||||||
|
if (declared !== undefined && declared > maxSize) {
|
||||||
|
return requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
if (!request.body) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "buffered") {
|
||||||
|
const chunks = await readBounded(request.body, maxSize);
|
||||||
|
if (chunks === "over-limit") {
|
||||||
|
return requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
c.req.raw = new Request(request, {
|
||||||
|
body: streamOf(chunks),
|
||||||
|
// Required by undici whenever a `Request` is constructed from a stream.
|
||||||
|
duplex: "half",
|
||||||
|
} as RequestInit);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
let overLimit = false;
|
||||||
|
let total = 0;
|
||||||
|
const counted = request.body.pipeThrough(
|
||||||
|
new TransformStream<Uint8Array, Uint8Array>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
total += chunk.byteLength;
|
||||||
|
if (total > maxSize) {
|
||||||
|
overLimit = true;
|
||||||
|
// Erroring the transform propagates back to the source and cancels it, so the socket is
|
||||||
|
// released rather than left draining an upload nobody will read.
|
||||||
|
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
if (overLimit) {
|
||||||
|
c.res = requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
releaseUnreadBody(c.req.raw);
|
||||||
|
return;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
|||||||
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two immutable, platform-owned files that make up Ekklesia's display browser host.
|
* The three immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||||
*
|
*
|
||||||
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
||||||
* surface package. Request input is never involved in this read and the package is never exposed as
|
* surface package. Request input is never involved in this read and the package is never exposed as
|
||||||
@@ -14,6 +14,10 @@ export interface DisplayHostAssets {
|
|||||||
readonly html: string;
|
readonly html: string;
|
||||||
readonly script: string;
|
readonly script: string;
|
||||||
readonly scriptEtag: string;
|
readonly scriptEtag: string;
|
||||||
|
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
|
||||||
|
* at a path whose directory is its scope so it controls display pages and nothing else. */
|
||||||
|
readonly serviceWorker: string;
|
||||||
|
readonly serviceWorkerEtag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
@@ -36,18 +40,24 @@ export class DisplayHostAssetError extends Error {
|
|||||||
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||||
* failed host build rather than a valid page that can never mount anything.
|
* failed host build rather than a valid page that can never mount anything.
|
||||||
*/
|
*/
|
||||||
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets {
|
export function createDisplayHostAssets(
|
||||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
html: string,
|
||||||
|
script: string,
|
||||||
|
serviceWorker: string,
|
||||||
|
): DisplayHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
|
||||||
throw new DisplayHostAssetError();
|
throw new DisplayHostAssetError();
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
html,
|
html,
|
||||||
script,
|
script,
|
||||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
serviceWorker,
|
||||||
|
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
/** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
|
||||||
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||||
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
|||||||
// rejected sibling promise is left running after this function has already failed.
|
// rejected sibling promise is left running after this function has already failed.
|
||||||
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||||
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||||
return createDisplayHostAssets(html, script);
|
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
|
||||||
|
return createDisplayHostAssets(html, script, serviceWorker);
|
||||||
} catch {
|
} catch {
|
||||||
throw new DisplayHostAssetError();
|
throw new DisplayHostAssetError();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
import { grantedModulePermissionIds } from "./module-ui-inventory.js";
|
import {
|
||||||
|
describeModuleContributions,
|
||||||
|
grantedModulePermissionIds,
|
||||||
|
type HostModuleContributionDescriptor,
|
||||||
|
} from "./module-ui-inventory.js";
|
||||||
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||||
|
|
||||||
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
||||||
@@ -17,6 +21,18 @@ import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
|||||||
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||||
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||||
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||||
|
/**
|
||||||
|
* Where the display's offline service worker is served (ADR 0043).
|
||||||
|
*
|
||||||
|
* The path is chosen for its **directory**: a service worker's default scope is the directory it was
|
||||||
|
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
|
||||||
|
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
|
||||||
|
* it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
|
||||||
|
*
|
||||||
|
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
|
||||||
|
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
|
||||||
|
*/
|
||||||
|
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
|
||||||
|
|
||||||
export function moduleDisplayPageUrl(moduleId: string): string {
|
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||||
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
@@ -37,6 +53,9 @@ export interface HostModuleDisplayDescriptor {
|
|||||||
readonly api: { readonly baseUrl: string };
|
readonly api: { readonly baseUrl: string };
|
||||||
readonly realtime: { readonly baseUrl: string };
|
readonly realtime: { readonly baseUrl: string };
|
||||||
readonly permissions: readonly string[];
|
readonly permissions: readonly string[];
|
||||||
|
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
|
||||||
|
* mechanism a signage-style display uses to show another module's content without importing it. */
|
||||||
|
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,8 +119,11 @@ function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
|||||||
* bytes with a stale validator. */
|
* bytes with a stale validator. */
|
||||||
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||||
try {
|
try {
|
||||||
const snapshot = createDisplayHostAssets(input.html, input.script);
|
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
|
||||||
if (snapshot.scriptEtag !== input.scriptEtag) {
|
if (
|
||||||
|
snapshot.scriptEtag !== input.scriptEtag ||
|
||||||
|
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
|
||||||
|
) {
|
||||||
throw new ModuleDisplayConfigurationError();
|
throw new ModuleDisplayConfigurationError();
|
||||||
}
|
}
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@@ -132,6 +154,7 @@ async function describeDisplay(
|
|||||||
viewer,
|
viewer,
|
||||||
module: {
|
module: {
|
||||||
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||||
|
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||||
id: display.moduleId,
|
id: display.moduleId,
|
||||||
permissions: await grantedModulePermissionIds(
|
permissions: await grantedModulePermissionIds(
|
||||||
@@ -244,6 +267,33 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
|
||||||
|
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
|
||||||
|
* how a screen avoids being trapped on an obsolete worker.
|
||||||
|
*
|
||||||
|
* Like the host script and module artifact bytes, fetching this code grants no authority. It
|
||||||
|
* caches only public code and never a permission decision, so serving it anonymously withholds
|
||||||
|
* nothing the platform was protecting.
|
||||||
|
*/
|
||||||
|
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
|
||||||
|
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(assets.serviceWorker, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/javascript; charset=utf-8",
|
||||||
|
ETag: assets.serviceWorkerEtag,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for (const display of displays) {
|
for (const display of displays) {
|
||||||
mountDisplay(app, { ...options, assets }, display);
|
mountDisplay(app, { ...options, assets }, display);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,9 +235,12 @@ export function mountModuleHttpRoutes(
|
|||||||
|
|
||||||
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
||||||
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
||||||
// all) is enforced before permission evaluation ever runs, so an oversized request never
|
// all) is enforced before permission evaluation ever runs, so a request *declared* oversized
|
||||||
// reaches authorization work, let alone the handler or the module database.
|
// never reaches authorization work, let alone the handler or the module database. In
|
||||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
|
// `streamed` mode no body byte is read until the handler reads it, and the handler runs only
|
||||||
|
// after authorization succeeds, so an unauthorized caller can never push an upload through
|
||||||
|
// (ADR 0042).
|
||||||
|
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
||||||
const path = moduleRoutePath(route.moduleId, route.path);
|
const path = moduleRoutePath(route.moduleId, route.path);
|
||||||
const finalHandler = async (c: Context): Promise<Response> => {
|
const finalHandler = async (c: Context): Promise<Response> => {
|
||||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||||
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules";
|
import type {
|
||||||
|
ModuleUiPublicationRegistry,
|
||||||
|
PublishableModuleContribution,
|
||||||
|
PublishableModuleUi,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +14,11 @@ import {
|
|||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js";
|
import {
|
||||||
|
MODULE_UI_ASSET_PREFIX,
|
||||||
|
moduleContributionArtifactUrl,
|
||||||
|
moduleUiArtifactUrl,
|
||||||
|
} from "./module-ui-routes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
|
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
|
||||||
@@ -64,12 +72,56 @@ export interface HostModuleUiDescriptor {
|
|||||||
* nor trusts anything in this array.
|
* nor trusts anything in this array.
|
||||||
*/
|
*/
|
||||||
readonly permissions: readonly string[];
|
readonly permissions: readonly string[];
|
||||||
|
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
|
||||||
|
* the overwhelming majority of modules, which provide no extension point. */
|
||||||
|
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
|
||||||
|
*
|
||||||
|
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
|
||||||
|
* consuming module names a contribution and the host resolves it, so no module ever builds
|
||||||
|
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
|
||||||
|
*
|
||||||
|
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
|
||||||
|
* contributed renderer draws and what shape its input takes are the consuming module's contract,
|
||||||
|
* carried in the browser between two modules without passing through the platform.
|
||||||
|
*/
|
||||||
|
export interface HostModuleContributionDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
/** The capability it was contributed to — one this module declares. */
|
||||||
|
readonly capability: string;
|
||||||
|
readonly url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostModuleUiInventory {
|
export interface HostModuleUiInventory {
|
||||||
readonly modules: readonly HostModuleUiDescriptor[];
|
readonly modules: readonly HostModuleUiDescriptor[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
|
||||||
|
* inventory and the display descriptor so the two can never disagree about what a consuming module
|
||||||
|
* may mount.
|
||||||
|
*/
|
||||||
|
export function describeModuleContributions(
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
|
moduleId: string,
|
||||||
|
): readonly HostModuleContributionDescriptor[] {
|
||||||
|
return registry
|
||||||
|
.contributionsForModule(moduleId)
|
||||||
|
.map((contribution: PublishableModuleContribution) => ({
|
||||||
|
capability: contribution.capability,
|
||||||
|
id: contribution.contributionId,
|
||||||
|
url: moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
|
||||||
|
contribution.contributionId,
|
||||||
|
),
|
||||||
|
version: contribution.version,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
||||||
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
||||||
@@ -116,9 +168,11 @@ async function describeModule(
|
|||||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
principal: EkklesiaPrincipal,
|
principal: EkklesiaPrincipal,
|
||||||
published: PublishableModuleUi,
|
published: PublishableModuleUi,
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
): Promise<HostModuleUiDescriptor> {
|
): Promise<HostModuleUiDescriptor> {
|
||||||
return {
|
return {
|
||||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||||
|
contributions: describeModuleContributions(registry, published.moduleId),
|
||||||
id: published.moduleId,
|
id: published.moduleId,
|
||||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||||
@@ -153,7 +207,7 @@ export async function describeMountableModules(
|
|||||||
): Promise<readonly MountableModuleDescription[]> {
|
): Promise<readonly MountableModuleDescription[]> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
registry.list().map(async (published) => ({
|
registry.list().map(async (published) => ({
|
||||||
descriptor: await describeModule(evaluator, principal, published),
|
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||||
name: published.name,
|
name: published.name,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
|||||||
return `${moduleUiArtifactUrl(identity)}/display`;
|
return `${moduleUiArtifactUrl(identity)}/display`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
|
||||||
|
*
|
||||||
|
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
|
||||||
|
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
|
||||||
|
* module id and version are: a value that somehow did not come from a validated manifest can only
|
||||||
|
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
|
||||||
|
* fragment — and a value that needed encoding simply fails to resolve against the registry.
|
||||||
|
*
|
||||||
|
* Like every other publication helper, this takes **identity only**. There is no parameter for a
|
||||||
|
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
|
||||||
|
* module package.
|
||||||
|
*/
|
||||||
|
export function moduleContributionArtifactUrl(
|
||||||
|
identity: ModuleUiIdentity,
|
||||||
|
contributionId: string,
|
||||||
|
): string {
|
||||||
|
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||||
* installed module declares no artifact for the requested UI role, or the requested version is not
|
* installed module declares no artifact for the requested UI role, or the requested version is not
|
||||||
@@ -191,9 +211,9 @@ export interface CreateModuleUiRoutesOptions {
|
|||||||
*
|
*
|
||||||
* ## The request never touches the filesystem
|
* ## The request never touches the filesystem
|
||||||
*
|
*
|
||||||
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
|
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
|
||||||
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's
|
* components**. They are handed to the registry and, on an exact match, the already-canonical
|
||||||
* discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
* `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||||
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
|
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
|
||||||
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
|
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
|
||||||
* are all unreachable by construction rather than by filtering. An installed module directory never
|
* are all unreachable by construction rather than by filtering. An installed module directory never
|
||||||
@@ -228,6 +248,24 @@ export interface CreateModuleUiRoutesOptions {
|
|||||||
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get(
|
||||||
|
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
|
||||||
|
async (c) => {
|
||||||
|
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
|
||||||
|
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
|
||||||
|
// UI artifact and on no others.
|
||||||
|
const published = options.registry.resolveContribution(
|
||||||
|
c.req.param("moduleId"),
|
||||||
|
c.req.param("version"),
|
||||||
|
c.req.param("contributionId"),
|
||||||
|
);
|
||||||
|
if (!published) {
|
||||||
|
return notPublished(c);
|
||||||
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||||
const published = options.registry.resolveDisplay(
|
const published = options.registry.resolveDisplay(
|
||||||
c.req.param("moduleId"),
|
c.req.param("moduleId"),
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ async function createGatedRouteModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -374,7 +374,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -432,6 +432,39 @@ async function createFixtureModule(
|
|||||||
async handler(request, context) {
|
async handler(request, context) {
|
||||||
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
// An image-sized streamed upload (ADR 0042). The handler counts bytes as they
|
||||||
|
// arrive and never materializes the body, which is exactly what a real module
|
||||||
|
// storing an upload would do.
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/uploads",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
maxBodyBytes: 33554432,
|
||||||
|
bodyMode: "streamed",
|
||||||
|
async handler(request) {
|
||||||
|
let bytes = 0;
|
||||||
|
if (request.body) {
|
||||||
|
const reader = request.body.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) { break; }
|
||||||
|
bytes += chunk.value.byteLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ bytes }, { status: 201 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/small-uploads",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
bodyMode: "streamed",
|
||||||
|
async handler(request) {
|
||||||
|
await request.arrayBuffer().catch(() => undefined);
|
||||||
|
return Response.json({ swallowed: true }, { status: 201 });
|
||||||
|
},
|
||||||
});`
|
});`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
@@ -479,7 +512,7 @@ async function createUiModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
@@ -2685,6 +2718,73 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("streams one image-sized upload end to end over real HTTP", async () => {
|
||||||
|
// 25 MiB — an ordinary photograph, and the size the streamed ceiling exists to accommodate. It
|
||||||
|
// travels through a real socket, a real Node server, and the platform's byte counter, and the
|
||||||
|
// platform holds none of it (ADR 0042).
|
||||||
|
const image = new Uint8Array(25 * 1024 * 1024).fill(0x41);
|
||||||
|
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/calendar/uploads`, {
|
||||||
|
body: image,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/octet-stream",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(await response.json()).toEqual({ bytes: image.byteLength });
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("refuses a declared oversize on a streamed route with a sanitized 413", async () => {
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||||
|
body: "a".repeat(65),
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/plain",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates a chunked streamed body that exceeds the limit, even when the handler swallowed it", async () => {
|
||||||
|
// No declared `Content-Length`, so only the running byte count can bound this — and the
|
||||||
|
// handler deliberately ignores the stream error and answers `201`. The platform's `413` still
|
||||||
|
// wins: a limit a handler can swallow is not a limit.
|
||||||
|
const oversize = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(512).fill(0x41));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||||
|
body: oversize,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { cookie: ownerCookie, origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("keeps the server healthy after an oversized upload", async () => {
|
||||||
|
// The socket was released rather than left draining, so the very next request is served
|
||||||
|
// normally.
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/calendar/events/evt-43`, {
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({ eventId: "evt-43" });
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
||||||
const admin = await openAdministrativeClient(database.url);
|
const admin = await openAdministrativeClient(database.url);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -269,6 +269,7 @@ describe("Dashboard navigation — reading", () => {
|
|||||||
|
|
||||||
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
||||||
expect(livestream?.module).toEqual({
|
expect(livestream?.module).toEqual({
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/livestream" },
|
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||||
id: "livestream",
|
id: "livestream",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
},
|
},
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({
|
|||||||
displayHostAssetUrls: Object.freeze({
|
displayHostAssetUrls: Object.freeze({
|
||||||
html: "file:///surface/browser/display/index.html",
|
html: "file:///surface/browser/display/index.html",
|
||||||
script: "file:///surface/browser/display/app.js",
|
script: "file:///surface/browser/display/app.js",
|
||||||
|
serviceWorker: "file:///surface/browser/display/sw.js",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -20,21 +21,28 @@ import {
|
|||||||
describe("display host asset loading", () => {
|
describe("display host asset loading", () => {
|
||||||
beforeEach(() => readFileMock.mockReset());
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||||
const script = "export const displayHost = true;\n";
|
const script = "export const displayHost = true;\n";
|
||||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
const serviceWorker = "export const displayWorker = true;\n";
|
||||||
|
readFileMock
|
||||||
|
.mockResolvedValueOnce(html)
|
||||||
|
.mockResolvedValueOnce(script)
|
||||||
|
.mockResolvedValueOnce(serviceWorker);
|
||||||
|
|
||||||
const assets = await loadDisplayHostAssets();
|
const assets = await loadDisplayHostAssets();
|
||||||
|
|
||||||
expect(readFileMock.mock.calls).toEqual([
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||||
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
|
||||||
]);
|
]);
|
||||||
expect(assets).toEqual({
|
expect(assets).toEqual({
|
||||||
html,
|
html,
|
||||||
script,
|
script,
|
||||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
serviceWorker,
|
||||||
|
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||||
});
|
});
|
||||||
expect(Object.isFrozen(assets)).toBe(true);
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -62,12 +70,15 @@ describe("display host asset loading", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty page or script as an unavailable host build", () => {
|
it("rejects an empty page, script, or service worker as an unavailable host build", () => {
|
||||||
for (const [html, script] of [
|
for (const [html, script, serviceWorker] of [
|
||||||
["", "export {};"],
|
["", "export {};", "export {};"],
|
||||||
["<html></html>", " \n"],
|
["<html></html>", " \n", "export {};"],
|
||||||
|
["<html></html>", "export {};", " \n"],
|
||||||
] as const) {
|
] as const) {
|
||||||
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError);
|
expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
|
||||||
|
DisplayHostAssetError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -254,12 +254,13 @@ function fakeDiscoveredModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions,
|
permissions,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
configuration: fixture.configuration ?? [],
|
configuration: fixture.configuration ?? [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
DISPLAY_HOST_SCRIPT_PATH,
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
DISPLAY_SERVICE_WORKER_PATH,
|
||||||
ModuleDisplayConfigurationError,
|
ModuleDisplayConfigurationError,
|
||||||
moduleDisplayDescriptorUrl,
|
moduleDisplayDescriptorUrl,
|
||||||
moduleDisplayPageUrl,
|
moduleDisplayPageUrl,
|
||||||
@@ -51,6 +52,7 @@ const displayPermission = "announcements.display.view";
|
|||||||
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||||
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
||||||
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
||||||
|
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
@@ -101,7 +103,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: fixture.display
|
permissions: fixture.display
|
||||||
? [
|
? [
|
||||||
@@ -266,6 +268,7 @@ describe("module display routes — authorization and descriptor", () => {
|
|||||||
expect(await descriptor.json()).toEqual({
|
expect(await descriptor.json()).toEqual({
|
||||||
viewer: { kind: "authenticated", userId: viewerId },
|
viewer: { kind: "authenticated", userId: viewerId },
|
||||||
module: {
|
module: {
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||||
display: {
|
display: {
|
||||||
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
@@ -476,7 +479,11 @@ describe("module display host asset publication", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
||||||
const source = createDisplayHostAssets("<html>display host</html>", "export const host = 1;\n");
|
const source = createDisplayHostAssets(
|
||||||
|
"<html>display host</html>",
|
||||||
|
"export const host = 1;\n",
|
||||||
|
"export const worker = 1;\n",
|
||||||
|
);
|
||||||
const mutable = { ...source };
|
const mutable = { ...source };
|
||||||
const { app } = await install({ assets: mutable, display: true });
|
const { app } = await install({ assets: mutable, display: true });
|
||||||
mutable.html = "<html>mutated secret</html>";
|
mutable.html = "<html>mutated secret</html>";
|
||||||
@@ -497,4 +504,55 @@ describe("module display host asset publication", () => {
|
|||||||
|
|
||||||
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serves the offline service worker from the path whose directory is its scope", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
|
||||||
|
|
||||||
|
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
|
||||||
|
// else. The Dashboard is never controlled by it (ADR 0043).
|
||||||
|
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
|
||||||
|
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates the service worker with its own validator, never the host script's", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
|
||||||
|
});
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
|
||||||
|
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||||
|
});
|
||||||
|
expect(wrongValidator.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish the service worker when no eligible display uses it", async () => {
|
||||||
|
const { app } = await install({ ui: true, withAssets: false });
|
||||||
|
|
||||||
|
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be reached through a neighbouring or request-selected path", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
|
||||||
|
"/display/sw.js/",
|
||||||
|
"/sw.js",
|
||||||
|
"/assets/ekklesia-display/sw.js",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,14 +58,17 @@ function fakeStore(): AuthorizationStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
|
/** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
|
||||||
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
||||||
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
||||||
* suite further down overrides it explicitly to prove the contract itself. */
|
* suite further down overrides them explicitly to prove the contract itself. */
|
||||||
function moduleRoute(
|
function moduleRoute(
|
||||||
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
|
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
|
||||||
|
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
|
||||||
|
maxBodyBytes?: number;
|
||||||
|
},
|
||||||
): RegisteredModuleHttpRoute {
|
): RegisteredModuleHttpRoute {
|
||||||
return Object.freeze({ maxBodyBytes: 1024, ...fields });
|
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||||
@@ -1634,3 +1637,375 @@ describe("module route overlap can never reach mounting", () => {
|
|||||||
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit, bounded streaming uploads (ADR 0042).
|
||||||
|
*
|
||||||
|
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
|
||||||
|
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
|
||||||
|
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
|
||||||
|
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
|
||||||
|
*/
|
||||||
|
describe("module route streamed body limits", () => {
|
||||||
|
const oneMebibyte = 1024 * 1024;
|
||||||
|
|
||||||
|
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
|
||||||
|
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
|
||||||
|
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
|
||||||
|
let sent = 0;
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
if (sent >= size) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = Math.min(chunkSize, size - sent);
|
||||||
|
sent += next;
|
||||||
|
controller.enqueue(new Uint8Array(next).fill(97));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamingApp(options: {
|
||||||
|
readonly handler: ModuleRouteHandler;
|
||||||
|
readonly maxBodyBytes: number;
|
||||||
|
readonly evaluator?: AuthorizationEvaluator;
|
||||||
|
}) {
|
||||||
|
return buildModuleApp({
|
||||||
|
...(options.evaluator ? { evaluator: options.evaluator } : {}),
|
||||||
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
bodyMode: "streamed",
|
||||||
|
handler: options.handler,
|
||||||
|
maxBodyBytes: options.maxBodyBytes,
|
||||||
|
method: "POST",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/uploads",
|
||||||
|
permission: writePermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
|
||||||
|
* "buffered then handed over". */
|
||||||
|
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
|
||||||
|
return async (request) => {
|
||||||
|
const body = request.body;
|
||||||
|
if (!body) {
|
||||||
|
return Response.json({ bytes: 0 });
|
||||||
|
}
|
||||||
|
const reader = body.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
received.bytes += value.byteLength;
|
||||||
|
}
|
||||||
|
return Response.json({ bytes: received.bytes });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts a body of exactly the declared limit", async () => {
|
||||||
|
const received = { bytes: 0 };
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: await countingHandler(received),
|
||||||
|
maxBodyBytes: oneMebibyte,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(oneMebibyte),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ bytes: oneMebibyte });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
|
||||||
|
const received = { bytes: 0 };
|
||||||
|
const counting = await countingHandler(received);
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(counting);
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(2048, 16),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { "content-length": String(1025), origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
|
||||||
|
// byte of the upload reached module code.
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expect(received.bytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
|
||||||
|
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
|
||||||
|
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
|
||||||
|
const received = { bytes: 0 };
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: await countingHandler(received),
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(4096, 32),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { "content-length": "8", origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
// It stopped promptly rather than draining the whole upload first.
|
||||||
|
expect(received.bytes).toBeLessThanOrEqual(96);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
|
||||||
|
// A limit a handler can swallow is not a limit.
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
try {
|
||||||
|
await request.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
// Deliberately ignored, exactly as a careless module might.
|
||||||
|
}
|
||||||
|
return Response.json({ ok: "handler won" });
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(4096, 32),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
|
||||||
|
let released: (() => void) | undefined;
|
||||||
|
const finished = new Promise<void>((resolve) => {
|
||||||
|
released = resolve;
|
||||||
|
});
|
||||||
|
let firstChunkSeen = false;
|
||||||
|
let secondChunkQueued = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
async pull(controller) {
|
||||||
|
if (!secondChunkQueued) {
|
||||||
|
secondChunkQueued = true;
|
||||||
|
controller.enqueue(new Uint8Array(16).fill(97));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
|
||||||
|
// buffering platform would deadlock here rather than reach the assertion below.
|
||||||
|
await finished;
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
const first = await reader.read();
|
||||||
|
firstChunkSeen = !first.done;
|
||||||
|
released?.();
|
||||||
|
for (;;) {
|
||||||
|
const next = await reader.read();
|
||||||
|
if (next.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(firstChunkSeen).toBe(true);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases an aborted upload without taking the platform down", async () => {
|
||||||
|
const aborted = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(16).fill(97));
|
||||||
|
controller.error(new Error("client went away"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
await request.arrayBuffer();
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: aborted,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases a body the handler never read", async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
pull(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(16).fill(97));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: async () => Response.json({ ok: true }),
|
||||||
|
maxBodyBytes: oneMebibyte,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||||
|
};
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
|
const { app } = buildModuleApp({
|
||||||
|
evaluator,
|
||||||
|
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
bodyMode: "streamed",
|
||||||
|
handler,
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
method: "POST",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/uploads",
|
||||||
|
permission: writePermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: "a".repeat(65),
|
||||||
|
headers: { "content-length": "65", origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(declaredOversize.status).toBe(413);
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A within-limit body from a denied caller is refused by authorization, and the handler — the
|
||||||
|
// only thing that ever reads a byte in streamed mode — never runs.
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(32, 8),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects an untrusted Origin before any body handling", async () => {
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(4096),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: untrustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: async () => Response.json({ ok: true }),
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: "secret-payload-".repeat(64),
|
||||||
|
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
|
||||||
|
expect(body).not.toContain("secret-payload");
|
||||||
|
expect(body).not.toContain("64");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still materializes a buffered body before the handler runs", async () => {
|
||||||
|
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
|
||||||
|
// body is already there and already within its declared allowance.
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
const text = await request.text();
|
||||||
|
return Response.json({ length: text.length });
|
||||||
|
});
|
||||||
|
const { app } = buildModuleApp({
|
||||||
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler,
|
||||||
|
maxBodyBytes: 128,
|
||||||
|
method: "POST",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/events",
|
||||||
|
permission: writePermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||||
|
body: chunkedBody(128, 16),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
expect(withinLimit.status).toBe(200);
|
||||||
|
expect(await withinLimit.json()).toEqual({ length: 128 });
|
||||||
|
|
||||||
|
const overLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||||
|
body: chunkedBody(129, 16),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
expect(overLimit.status).toBe(413);
|
||||||
|
// A buffered route's handler never sees an over-limit request at all.
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import {
|
|||||||
discoverModules,
|
discoverModules,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import {
|
||||||
|
loadModuleUi,
|
||||||
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||||
|
} from "@ekklesia/module-ui-host";
|
||||||
|
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
import {
|
import {
|
||||||
bootstrapDisplayHost,
|
bootstrapDisplayHost,
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
@@ -121,7 +127,7 @@ async function createFixtureModule(
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name,
|
name,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
@@ -165,6 +171,7 @@ async function createFixtureModule(
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
@@ -184,6 +191,7 @@ async function createFixtureModule(
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/display/index.js"),
|
path.join(moduleDirectory, "dist/display/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
@@ -270,6 +278,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
displayHostAssets: createDisplayHostAssets(
|
displayHostAssets: createDisplayHostAssets(
|
||||||
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
||||||
"export const displayHost = true;\n",
|
"export const displayHost = true;\n",
|
||||||
|
"export const displayWorker = true;\n",
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
@@ -310,10 +319,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
|
|||||||
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The loader hands the container straight to the module and never inspects it, so a plain sink is
|
/**
|
||||||
* enough and no DOM implementation is needed in this Node-environment suite. */
|
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
|
||||||
function containerSink(): { textContent: string } {
|
* test environment.
|
||||||
return { textContent: "" };
|
*
|
||||||
|
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
|
||||||
|
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
|
||||||
|
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
|
||||||
|
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
|
||||||
|
* dynamically imports real module server entrypoints from temporary directories, which then fail to
|
||||||
|
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
|
||||||
|
* this server-side suite depends on exactly as Node provides it.
|
||||||
|
*/
|
||||||
|
function containerSink(): HostContainer {
|
||||||
|
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostContainer {
|
||||||
|
readonly children: { readonly length: number };
|
||||||
|
querySelector(selectors: string): HostContainer | null;
|
||||||
|
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||||
|
function mountedText(container: HostContainer): string {
|
||||||
|
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||||
|
""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||||
@@ -340,6 +374,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
// 2. The URLs came from the server, not from anything the browser composed.
|
// 2. The URLs came from the server, not from anything the browser composed.
|
||||||
expect(descriptor).toEqual({
|
expect(descriptor).toEqual({
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read"],
|
permissions: ["example.records.read"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||||
@@ -365,13 +400,13 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 5. The module rendered, having reached its own backend through the host.
|
// 5. The module rendered, having reached its own backend through the host.
|
||||||
expect(container.textContent).toBe("example:200:2");
|
expect(mountedText(container)).toBe("example:200:2");
|
||||||
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
|
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
|
||||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
|
|
||||||
// 7. And it tears down cleanly.
|
// 7. And it tears down cleanly.
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(mountedText(container)).toBe("");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
@@ -403,6 +438,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
}
|
}
|
||||||
expect(bootstrap.descriptor).toEqual({
|
expect(bootstrap.descriptor).toEqual({
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read", "example.display.view"],
|
permissions: ["example.records.read", "example.display.view"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||||
@@ -427,6 +463,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
expect(Object.keys(context).sort()).toEqual([
|
expect(Object.keys(context).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
@@ -443,10 +480,10 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toBe("example:display:200:2");
|
expect(mountedText(container)).toBe("example:display:200:2");
|
||||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(mountedText(container)).toBe("");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
@@ -514,7 +551,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Hiding a control is a usability improvement: the module simply did not ask.
|
// Hiding a control is a usability improvement: the module simply did not ask.
|
||||||
expect(container.textContent).toBe("example:denied");
|
expect(mountedText(container)).toBe("example:denied");
|
||||||
expect(harness.moduleApiCalls).toEqual([]);
|
expect(harness.moduleApiCalls).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
@@ -593,7 +630,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
version: groupedEntry.descriptor.version,
|
version: groupedEntry.descriptor.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(container.textContent).toBe("example:200:2");
|
expect(mountedText(container)).toBe("example:200:2");
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
|
|
||||||
// Now move it somewhere else entirely.
|
// Now move it somewhere else entirely.
|
||||||
@@ -645,7 +682,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
version: movedEntry.descriptor.version,
|
version: movedEntry.descriptor.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(movedContainer.textContent).toBe("example:200:2");
|
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||||
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||||
"GET /api/v1/modules/example/records",
|
"GET /api/v1/modules/example/records",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ interface ModuleFixture {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly baseline: "authenticated" | "public" | "restricted";
|
readonly baseline: "authenticated" | "public" | "restricted";
|
||||||
}>;
|
}>;
|
||||||
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||||
|
readonly contributions?: readonly {
|
||||||
|
readonly localName: string;
|
||||||
|
readonly capability: string;
|
||||||
|
readonly capabilityRange: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
@@ -82,8 +88,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
},
|
},
|
||||||
|
capabilities: fixture.capabilities ?? [],
|
||||||
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||||
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityRange: contribution.capabilityRange,
|
||||||
|
id: `${fixture.id}.${contribution.localName}`,
|
||||||
|
version: "1.0.0",
|
||||||
|
})),
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||||
...permission,
|
...permission,
|
||||||
@@ -103,6 +117,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const contribution of fixture.contributions ?? []) {
|
||||||
|
const contributionDirectory = path.join(
|
||||||
|
moduleDirectory,
|
||||||
|
"dist/contributions",
|
||||||
|
contribution.localName,
|
||||||
|
);
|
||||||
|
await mkdir(contributionDirectory, { recursive: true });
|
||||||
|
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InstallOptions {
|
interface InstallOptions {
|
||||||
@@ -219,6 +242,7 @@ describe("module UI inventory — what it lists", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(modules).toEqual([
|
expect(modules).toEqual([
|
||||||
{
|
{
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/example" },
|
api: { baseUrl: "/api/v1/modules/example" },
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -315,6 +339,7 @@ describe("module UI inventory — what it lists", () => {
|
|||||||
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
||||||
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
"id",
|
"id",
|
||||||
"permissions",
|
"permissions",
|
||||||
"realtime",
|
"realtime",
|
||||||
@@ -520,3 +545,84 @@ describe("module UI inventory — it is not a module management API", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inventory is how a consuming module learns what was contributed to its own capabilities
|
||||||
|
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
|
||||||
|
*/
|
||||||
|
describe("module UI inventory — contributions", () => {
|
||||||
|
const signage = {
|
||||||
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||||
|
id: "signage",
|
||||||
|
permissions: [],
|
||||||
|
withUi: true,
|
||||||
|
};
|
||||||
|
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||||
|
contributions: [
|
||||||
|
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
|
||||||
|
],
|
||||||
|
id: "announcements",
|
||||||
|
permissions: [],
|
||||||
|
withUi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the providing module the contribution and its server-produced URL", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
const consumer = modules.find((module) => module.id === "signage");
|
||||||
|
|
||||||
|
expect(consumer?.contributions).toEqual([
|
||||||
|
{
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
id: "announcements.signage-slide",
|
||||||
|
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds a contribution whose provider is outside its declared range", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements("^2.0.0")],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
|
||||||
|
const { app, modulesDirectory } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body, modules } = await inventoryOf(app);
|
||||||
|
const consumer = modules.find((module) => module.id === "signage");
|
||||||
|
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
|
||||||
|
|
||||||
|
expect(body).not.toContain(modulesDirectory);
|
||||||
|
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
|
||||||
|
"capability",
|
||||||
|
"id",
|
||||||
|
"url",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||||
|
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
MODULE_UI_ASSET_PREFIX,
|
MODULE_UI_ASSET_PREFIX,
|
||||||
MODULE_UI_CACHE_CONTROL,
|
MODULE_UI_CACHE_CONTROL,
|
||||||
MODULE_UI_CONTENT_TYPE,
|
MODULE_UI_CONTENT_TYPE,
|
||||||
|
moduleContributionArtifactUrl,
|
||||||
moduleDisplayUiArtifactUrl,
|
moduleDisplayUiArtifactUrl,
|
||||||
moduleUiArtifactUrl,
|
moduleUiArtifactUrl,
|
||||||
} from "../src/module-ui-routes.js";
|
} from "../src/module-ui-routes.js";
|
||||||
@@ -59,6 +62,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
* sequence whose exact publication and loader-compatibility can be asserted. */
|
* sequence whose exact publication and loader-compatibility can be asserted. */
|
||||||
function artifactSource(id: string, version: string): string {
|
function artifactSource(id: string, version: string): string {
|
||||||
return `export default {
|
return `export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: ${JSON.stringify(version)},
|
version: ${JSON.stringify(version)},
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
@@ -83,6 +87,15 @@ interface ModuleFixture {
|
|||||||
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
||||||
* files that genuinely exist on disk rather than against hypothetical ones. */
|
* files that genuinely exist on disk rather than against hypothetical ones. */
|
||||||
readonly extraFiles?: Readonly<Record<string, string>>;
|
readonly extraFiles?: Readonly<Record<string, string>>;
|
||||||
|
/** Capabilities this module provides — what another module's contributions target. */
|
||||||
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||||
|
/** Browser renderers this module contributes to another module's capability. */
|
||||||
|
readonly contributions?: readonly {
|
||||||
|
readonly localName: string;
|
||||||
|
readonly capability: string;
|
||||||
|
readonly capabilityRange: string;
|
||||||
|
readonly source: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
@@ -115,8 +128,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
capabilities: fixture.capabilities ?? [],
|
||||||
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||||
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityRange: contribution.capabilityRange,
|
||||||
|
id: `${fixture.id}.${contribution.localName}`,
|
||||||
|
version: "1.0.0",
|
||||||
|
})),
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions:
|
permissions:
|
||||||
fixture.display === undefined
|
fixture.display === undefined
|
||||||
@@ -139,6 +160,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
||||||
}
|
}
|
||||||
|
for (const contribution of fixture.contributions ?? []) {
|
||||||
|
const contributionDirectory = path.join(
|
||||||
|
moduleDirectory,
|
||||||
|
"dist/contributions",
|
||||||
|
contribution.localName,
|
||||||
|
);
|
||||||
|
await mkdir(contributionDirectory, { recursive: true });
|
||||||
|
await writeFile(path.join(contributionDirectory, "index.js"), contribution.source);
|
||||||
|
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
|
||||||
|
}
|
||||||
if (fixture.display !== undefined) {
|
if (fixture.display !== undefined) {
|
||||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||||
@@ -209,6 +240,7 @@ async function install(
|
|||||||
displayHostAssets: createDisplayHostAssets(
|
displayHostAssets: createDisplayHostAssets(
|
||||||
'<!doctype html><div id="display-root"></div>',
|
'<!doctype html><div id="display-root"></div>',
|
||||||
"export {};\n",
|
"export {};\n",
|
||||||
|
"export {};\n",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -862,6 +894,12 @@ describe("module UI artifact URL generation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface HostContainer {
|
||||||
|
readonly children: { readonly length: number };
|
||||||
|
querySelector(selectors: string): HostContainer | null;
|
||||||
|
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
||||||
/**
|
/**
|
||||||
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
||||||
@@ -880,21 +918,35 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The loader forwards the container untouched to the module's own `mount` and never inspects it,
|
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
||||||
* so a plain sink is enough and no DOM implementation is required. The container's type is read
|
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
|
||||||
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
|
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
|
||||||
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
|
* DOM type here would be the same browser/server confusion this route exists to keep apart.
|
||||||
* route exists to keep apart.
|
|
||||||
*/
|
*/
|
||||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||||
|
|
||||||
function containerSink(): { textContent: string } {
|
/**
|
||||||
return { textContent: "" };
|
* `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
|
||||||
|
* real isolated root inside the container, and installing a DOM environment here would replace
|
||||||
|
* the module runner's resolution for a server-side suite that dynamically imports real module
|
||||||
|
* entrypoints.
|
||||||
|
*/
|
||||||
|
function containerSink(): HostContainer {
|
||||||
|
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||||
|
function mountedText(container: HostContainer): string {
|
||||||
|
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostContext() {
|
function hostContext() {
|
||||||
return {
|
return {
|
||||||
api: { request: async () => new Response(null, { status: 200 }) },
|
api: { request: async () => new Response(null, { status: 200 }) },
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId: "announcements",
|
moduleId: "announcements",
|
||||||
permissions: { has: () => false },
|
permissions: { has: () => false },
|
||||||
principal: { kind: "anonymous" as const },
|
principal: { kind: "anonymous" as const },
|
||||||
@@ -915,9 +967,9 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
source: { moduleId: "announcements", url, version: "1.4.2" },
|
source: { moduleId: "announcements", url, version: "1.4.2" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toBe("announcements:mounted");
|
expect(mountedText(container)).toBe("announcements:mounted");
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(container.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still rejects the published artifact when the host expects a different identity", async () => {
|
it("still rejects the published artifact when the host expects a different identity", async () => {
|
||||||
@@ -935,3 +987,109 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
|
||||||
|
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
|
||||||
|
*/
|
||||||
|
describe("module contribution artifact publication", () => {
|
||||||
|
const signage = {
|
||||||
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||||
|
id: "signage",
|
||||||
|
};
|
||||||
|
const contributionSource = "export default { contributed: true };\n";
|
||||||
|
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange,
|
||||||
|
localName: "signage-slide",
|
||||||
|
source: contributionSource,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: "announcements",
|
||||||
|
});
|
||||||
|
const contributionUrl = moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: "announcements", version: "0.1.0" },
|
||||||
|
"announcements.signage-slide",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("serves the declared contribution artifact byte for byte", async () => {
|
||||||
|
const { app } = await install([signage, announcements()]);
|
||||||
|
|
||||||
|
const response = await app.request(contributionUrl);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
||||||
|
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
expect(await response.text()).toBe(contributionSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a URL that cannot select a neighbouring file", () => {
|
||||||
|
expect(contributionUrl).toBe(
|
||||||
|
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||||
|
);
|
||||||
|
// The contribution id is one segment's worth of text, always — a value that needed encoding
|
||||||
|
// simply fails to resolve.
|
||||||
|
expect(
|
||||||
|
moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: "announcements", version: "0.1.0" },
|
||||||
|
"../../../etc/passwd",
|
||||||
|
),
|
||||||
|
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["an unknown contribution id", "announcements.nope"],
|
||||||
|
["another module's contribution id", "signage.slide-renderer"],
|
||||||
|
["a path traversal", "../../../etc/passwd"],
|
||||||
|
["the artifact's source map", "announcements.signage-slide.map"],
|
||||||
|
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
|
||||||
|
const { app } = await install([signage, announcements()]);
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: "announcements", version: "0.1.0" },
|
||||||
|
contributionId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a version the platform does not have installed", async () => {
|
||||||
|
const { app } = await install([signage, announcements()]);
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: "announcements", version: "9.9.9" },
|
||||||
|
"announcements.signage-slide",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes nothing when no installed module provides the target capability", async () => {
|
||||||
|
const { app } = await install([announcements()]);
|
||||||
|
|
||||||
|
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes nothing when the provider is outside the declared compatible range", async () => {
|
||||||
|
const { app } = await install([signage, announcements("^2.0.0")]);
|
||||||
|
|
||||||
|
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the sibling source map and the module's own directory unreachable", async () => {
|
||||||
|
const { app } = await install([signage, announcements()]);
|
||||||
|
|
||||||
|
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
|
||||||
|
const response = await app.request(`${contributionUrl}${suffix}`);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { PermissionRegistryError } from "../authorization/index.js";
|
import { PermissionRegistryError } from "../authorization/index.js";
|
||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
ModulePermissionCompositionError,
|
ModulePermissionCompositionError,
|
||||||
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built from the same registry the API publishes from, so this command can never disagree with
|
||||||
|
// what a running platform would actually serve. A contribution whose target capability nobody
|
||||||
|
// provides — or whose provider moved outside its declared range — is otherwise silent: the
|
||||||
|
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
|
||||||
|
const publication = createModuleUiPublicationRegistry(modules);
|
||||||
|
const compatibleContributions = new Set(
|
||||||
|
publication
|
||||||
|
.listContributions()
|
||||||
|
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||||
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
||||||
);
|
);
|
||||||
for (const discoveredModule of modules) {
|
for (const discoveredModule of modules) {
|
||||||
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
||||||
|
for (const contribution of discoveredModule.contributions) {
|
||||||
|
const resolved = compatibleContributions.has(
|
||||||
|
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
|
||||||
|
resolved ? "compatible provider installed" : "no compatible provider installed"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
createModuleConfigurationStore,
|
createModuleConfigurationStore,
|
||||||
createModuleLifecycleDatabase,
|
createModuleLifecycleDatabase,
|
||||||
createModuleLifecycleStore,
|
createModuleLifecycleStore,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
@@ -316,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
|||||||
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
|
||||||
|
* — how many of them have a compatible provider among the installed set.
|
||||||
|
*
|
||||||
|
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
|
||||||
|
* capability nobody provides, or whose provider moved outside its declared range, is simply not
|
||||||
|
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
|
||||||
|
* are derived from the same publication registry the API serves from, so this can never disagree
|
||||||
|
* with what the running platform would actually publish.
|
||||||
|
*
|
||||||
|
* Static only, like every other Doctor check: it reports what the *manifests* support and never
|
||||||
|
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
|
||||||
|
*/
|
||||||
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||||
|
const publication = createModuleUiPublicationRegistry(modules);
|
||||||
|
const compatible = new Set(
|
||||||
|
publication
|
||||||
|
.listContributions()
|
||||||
|
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||||
|
);
|
||||||
return modules.map((module) => {
|
return modules.map((module) => {
|
||||||
const id = module.manifest.id;
|
const id = module.manifest.id;
|
||||||
const version = module.manifest.version;
|
const version = module.manifest.version;
|
||||||
@@ -326,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
|
|||||||
) {
|
) {
|
||||||
throw new TypeError("Unsafe module diagnostic value.");
|
throw new TypeError("Unsafe module diagnostic value.");
|
||||||
}
|
}
|
||||||
|
const declared = module.contributions.length;
|
||||||
|
if (declared === 0) {
|
||||||
return `${id} ${version}`;
|
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)`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ export interface ModuleDiscoveryIssue {
|
|||||||
readonly path?: string;
|
readonly path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One declared browser contribution, with its artifact resolved and validated exactly like every
|
||||||
|
* other compiled entrypoint (ADR 0041). Discovery never imports or evaluates it, and never learns
|
||||||
|
* what it renders.
|
||||||
|
*/
|
||||||
|
export interface ResolvedModuleContribution {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly capability: string;
|
||||||
|
readonly capabilityRange: string;
|
||||||
|
readonly artifactPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolvedModuleEntrypoints {
|
export interface ResolvedModuleEntrypoints {
|
||||||
readonly server: string;
|
readonly server: string;
|
||||||
readonly schema?: string;
|
readonly schema?: string;
|
||||||
@@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
|||||||
|
|
||||||
export interface DiscoveredModule {
|
export interface DiscoveredModule {
|
||||||
readonly directory: string;
|
readonly directory: string;
|
||||||
|
/** Declared browser contributions, in manifest order, each resolved to a canonical artifact path
|
||||||
|
* inside this module package. Empty for a module that contributes nothing. */
|
||||||
|
readonly contributions: readonly ResolvedModuleContribution[];
|
||||||
readonly entrypoints: ResolvedModuleEntrypoints;
|
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||||
readonly manifest: ReadonlyModuleManifest;
|
readonly manifest: ReadonlyModuleManifest;
|
||||||
readonly manifestPath: string;
|
readonly manifestPath: string;
|
||||||
@@ -110,6 +126,7 @@ interface ModulePackageData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ModuleCandidate {
|
interface ModuleCandidate {
|
||||||
|
contributions: readonly ResolvedModuleContribution[];
|
||||||
directory: string;
|
directory: string;
|
||||||
directoryName: string;
|
directoryName: string;
|
||||||
entrypoints: ResolvedModuleEntrypoints;
|
entrypoints: ResolvedModuleEntrypoints;
|
||||||
@@ -257,13 +274,19 @@ function validatePackageData(
|
|||||||
return { name, version };
|
return { name, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedModuleArtifacts {
|
||||||
|
readonly contributions: readonly ResolvedModuleContribution[];
|
||||||
|
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveEntrypoints(
|
async function resolveEntrypoints(
|
||||||
moduleDirectory: string,
|
moduleDirectory: string,
|
||||||
directoryName: string,
|
directoryName: string,
|
||||||
manifest: ModuleManifest,
|
manifest: ModuleManifest,
|
||||||
issues: ModuleDiscoveryIssue[],
|
issues: ModuleDiscoveryIssue[],
|
||||||
): Promise<ResolvedModuleEntrypoints | undefined> {
|
): Promise<ResolvedModuleArtifacts | undefined> {
|
||||||
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
||||||
|
const resolvedContributions = new Map<string, string>();
|
||||||
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
||||||
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
||||||
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
||||||
@@ -273,6 +296,15 @@ async function resolveEntrypoints(
|
|||||||
issuePath: `entrypoints.${entrypointName}`,
|
issuePath: `entrypoints.${entrypointName}`,
|
||||||
relativeEntrypoint,
|
relativeEntrypoint,
|
||||||
}));
|
}));
|
||||||
|
// A contribution artifact is resolved through the identical loop as every other compiled
|
||||||
|
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
|
||||||
|
// are one implementation, so a contribution can never quietly receive weaker checks than
|
||||||
|
// `entrypoints.ui` (ADR 0041).
|
||||||
|
const configuredContributions = manifest.contributions.map((contribution, index) => ({
|
||||||
|
entrypointName: `contribution:${contribution.id}`,
|
||||||
|
issuePath: `contributions.${index}.artifact`,
|
||||||
|
relativeEntrypoint: contribution.artifact,
|
||||||
|
}));
|
||||||
const configuredEntrypoints = [
|
const configuredEntrypoints = [
|
||||||
...ordinaryConfiguredEntrypoints,
|
...ordinaryConfiguredEntrypoints,
|
||||||
...(display === undefined
|
...(display === undefined
|
||||||
@@ -284,6 +316,7 @@ async function resolveEntrypoints(
|
|||||||
relativeEntrypoint: display.artifact,
|
relativeEntrypoint: display.artifact,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
...configuredContributions,
|
||||||
];
|
];
|
||||||
let entrypointCollision = false;
|
let entrypointCollision = false;
|
||||||
|
|
||||||
@@ -361,7 +394,14 @@ async function resolveEntrypoints(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entrypointName.startsWith("contribution:")) {
|
||||||
|
resolvedContributions.set(
|
||||||
|
entrypointName.slice("contribution:".length),
|
||||||
|
canonicalEntrypoint,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = errorCode(error);
|
const code = errorCode(error);
|
||||||
issues.push({
|
issues.push({
|
||||||
@@ -394,11 +434,46 @@ async function resolveEntrypoints(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved.server || entrypointCollision) {
|
// A contribution artifact must be its own file. Two declarations resolving to one path — through
|
||||||
|
// a link, since the manifest already rejects two identical declared paths — would make one
|
||||||
|
// contribution silently serve another's bytes, and the loader's identity check would then reject
|
||||||
|
// whichever one lost.
|
||||||
|
const canonicalContributionPaths = new Map<string, string>();
|
||||||
|
for (const [contributionId, canonicalPath] of resolvedContributions) {
|
||||||
|
const claimedBy = canonicalContributionPaths.get(canonicalPath);
|
||||||
|
if (claimedBy !== undefined || Object.values(resolved).includes(canonicalPath)) {
|
||||||
|
entrypointCollision = true;
|
||||||
|
issues.push({
|
||||||
|
code: "MODULE_ENTRYPOINT_INVALID",
|
||||||
|
message: `contribution ${contributionId} must not resolve to the same file as ${claimedBy ?? "another entrypoint"}.`,
|
||||||
|
moduleDirectory: directoryName,
|
||||||
|
moduleId: manifest.id,
|
||||||
|
path: "contributions",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
canonicalContributionPaths.set(canonicalPath, contributionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resolved.server ||
|
||||||
|
entrypointCollision ||
|
||||||
|
resolvedContributions.size !== manifest.contributions.length
|
||||||
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const contributions = manifest.contributions.map((contribution) =>
|
||||||
|
Object.freeze({
|
||||||
|
artifactPath: resolvedContributions.get(contribution.id) as string,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityRange: contribution.capabilityRange,
|
||||||
|
id: contribution.id,
|
||||||
|
version: contribution.version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const entrypoints: ResolvedModuleEntrypoints = {
|
||||||
server: resolved.server,
|
server: resolved.server,
|
||||||
...(resolved.schema ? { schema: resolved.schema } : {}),
|
...(resolved.schema ? { schema: resolved.schema } : {}),
|
||||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||||
@@ -413,6 +488,8 @@ async function resolveEntrypoints(
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { contributions: Object.freeze(contributions), entrypoints };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanModuleDirectory(
|
async function scanModuleDirectory(
|
||||||
@@ -510,15 +587,16 @@ async function scanModuleDirectory(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||||
if (!entrypoints) {
|
if (!artifacts) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
contributions: artifacts.contributions,
|
||||||
directory: canonicalModuleDirectory,
|
directory: canonicalModuleDirectory,
|
||||||
directoryName,
|
directoryName,
|
||||||
entrypoints,
|
entrypoints: artifacts.entrypoints,
|
||||||
manifest,
|
manifest,
|
||||||
manifestPath: canonicalManifestPath,
|
manifestPath: canonicalManifestPath,
|
||||||
packageName: packageData.name,
|
packageName: packageData.name,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type ModuleRuntimeIssueCode =
|
|||||||
| "MODULE_ROLLBACK_TIMEOUT"
|
| "MODULE_ROLLBACK_TIMEOUT"
|
||||||
| "MODULE_ROUTE_DEFINITION_INVALID"
|
| "MODULE_ROUTE_DEFINITION_INVALID"
|
||||||
| "MODULE_ROUTE_DUPLICATE"
|
| "MODULE_ROUTE_DUPLICATE"
|
||||||
|
| "MODULE_ROUTE_BODY_MODE_INVALID"
|
||||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
||||||
| "MODULE_ROUTE_METHOD_INVALID"
|
| "MODULE_ROUTE_METHOD_INVALID"
|
||||||
| "MODULE_ROUTE_OVERLAP"
|
| "MODULE_ROUTE_OVERLAP"
|
||||||
@@ -467,6 +468,7 @@ class StagedModuleHttpRoutes {
|
|||||||
shapes.add(shapeKey);
|
shapes.add(shapeKey);
|
||||||
local.push(
|
local.push(
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
|
bodyMode: parsed.data.bodyMode,
|
||||||
handler: parsed.data.handler,
|
handler: parsed.data.handler,
|
||||||
maxBodyBytes: parsed.data.maxBodyBytes,
|
maxBodyBytes: parsed.data.maxBodyBytes,
|
||||||
method: parsed.data.method,
|
method: parsed.data.method,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts";
|
import {
|
||||||
|
isVersionCompatible,
|
||||||
|
type PermissionDeclaration,
|
||||||
|
type PermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
import type { DiscoveredModule } from "./discovery.js";
|
import type { DiscoveredModule } from "./discovery.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
|
|||||||
readonly permissions: readonly PermissionDeclaration[];
|
readonly permissions: readonly PermissionDeclaration[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One installed module's browser contribution that the platform is willing to publish (ADR 0041).
|
||||||
|
*
|
||||||
|
* Publishable means three things, and nothing beyond them: the contributing module survived
|
||||||
|
* discovery and is in the eligible set, some **other** eligible module provides the targeted
|
||||||
|
* capability, and that provider's declared capability version satisfies the contribution's declared
|
||||||
|
* range. A contribution whose capability nobody provides, or whose provider moved outside its range,
|
||||||
|
* is simply absent — never published-but-broken, and never mounted with a shrug.
|
||||||
|
*
|
||||||
|
* Note what is *not* here. There is no payload schema, no slot, no ordering, no rendering hint, and
|
||||||
|
* no domain vocabulary of any kind: Core carries identity, version, compatibility, and one file
|
||||||
|
* path. What a contributed renderer draws, and what shape its input takes, are the consumer module's
|
||||||
|
* business and travel between the two modules in the browser without passing through the platform.
|
||||||
|
*/
|
||||||
|
export interface PublishableModuleContribution {
|
||||||
|
/** The module that *contributes* the renderer. */
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly moduleVersion: string;
|
||||||
|
readonly contributionId: string;
|
||||||
|
/** The contribution's own version, independent of its module's. */
|
||||||
|
readonly version: string;
|
||||||
|
readonly capability: string;
|
||||||
|
/** The version the providing module declares for that capability — the value the contribution's
|
||||||
|
* declared range was actually checked against. */
|
||||||
|
readonly capabilityVersion: string;
|
||||||
|
readonly artifactPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
|
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
|
||||||
*
|
*
|
||||||
@@ -89,6 +121,38 @@ export interface ModuleUiPublicationRegistry {
|
|||||||
* a display merely because a caller appends `/display` to its publication URL.
|
* a display merely because a caller appends `/display` to its publication URL.
|
||||||
*/
|
*/
|
||||||
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
||||||
|
/** Every publishable browser contribution, in discovery order then manifest order. Frozen. */
|
||||||
|
listContributions(): readonly PublishableModuleContribution[];
|
||||||
|
/**
|
||||||
|
* Every publishable contribution targeting one capability, in the same stable order.
|
||||||
|
*
|
||||||
|
* This is the lookup a *consumer* module's descriptor is built from, and the reason a consumer can
|
||||||
|
* only ever learn about renderers aimed at a capability it actually provides: the caller passes
|
||||||
|
* its own declared capability id and gets back exactly the contributions for it.
|
||||||
|
*/
|
||||||
|
contributionsForCapability(capabilityId: unknown): readonly PublishableModuleContribution[];
|
||||||
|
/**
|
||||||
|
* Every publishable contribution aimed at **any** capability the named module provides.
|
||||||
|
*
|
||||||
|
* This is the one lookup a descriptor for a consuming module is built from. Framing it per module
|
||||||
|
* rather than per capability is what keeps capability ownership inside Core: a caller hands over a
|
||||||
|
* module id and gets that module's own extension points filled in, with no way to ask about
|
||||||
|
* someone else's.
|
||||||
|
*/
|
||||||
|
contributionsForModule(moduleId: unknown): readonly PublishableModuleContribution[];
|
||||||
|
/**
|
||||||
|
* The publishable record for an exact contributing module id, installed module version, and
|
||||||
|
* contribution id, or `undefined`.
|
||||||
|
*
|
||||||
|
* All three must match. A contribution that is not installed, one whose module is at a different
|
||||||
|
* version, one whose capability has no compatible provider, and an id that was never declared are
|
||||||
|
* all indistinguishable here — each returns `undefined`.
|
||||||
|
*/
|
||||||
|
resolveContribution(
|
||||||
|
moduleId: unknown,
|
||||||
|
moduleVersion: unknown,
|
||||||
|
contributionId: unknown,
|
||||||
|
): PublishableModuleContribution | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +188,41 @@ export function createModuleUiPublicationRegistry(
|
|||||||
): ModuleUiPublicationRegistry {
|
): ModuleUiPublicationRegistry {
|
||||||
const byModuleId = new Map<string, PublishableModuleUi>();
|
const byModuleId = new Map<string, PublishableModuleUi>();
|
||||||
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
||||||
|
const contributions: PublishableModuleContribution[] = [];
|
||||||
|
// Every capability the eligible set actually provides, at the version its provider declares. Built
|
||||||
|
// from the same list everything else here is built from, so a contribution can never be checked
|
||||||
|
// against a provider the platform is not running.
|
||||||
|
const capabilityVersions = new Map<string, string>();
|
||||||
|
for (const module of modules) {
|
||||||
|
for (const capability of module.manifest.capabilities) {
|
||||||
|
capabilityVersions.set(capability.id, capability.version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const module of modules) {
|
||||||
|
for (const contribution of module.contributions) {
|
||||||
|
const capabilityVersion = capabilityVersions.get(contribution.capability);
|
||||||
|
// Fails closed on both halves: an absent provider and an incompatible one are equally
|
||||||
|
// unpublishable, so a range mismatch can never be papered over by mounting anyway.
|
||||||
|
if (
|
||||||
|
capabilityVersion === undefined ||
|
||||||
|
!isVersionCompatible(capabilityVersion, contribution.capabilityRange)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
contributions.push(
|
||||||
|
Object.freeze({
|
||||||
|
artifactPath: contribution.artifactPath,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityVersion,
|
||||||
|
contributionId: contribution.id,
|
||||||
|
moduleId: module.manifest.id,
|
||||||
|
moduleVersion: module.manifest.version,
|
||||||
|
version: contribution.version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
const artifactPath = module.entrypoints.ui;
|
const artifactPath = module.entrypoints.ui;
|
||||||
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
|
|||||||
|
|
||||||
const published = Object.freeze([...byModuleId.values()]);
|
const published = Object.freeze([...byModuleId.values()]);
|
||||||
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
|
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
|
||||||
|
const publishedContributions = Object.freeze([...contributions]);
|
||||||
|
const contributionsByCapability = new Map<string, PublishableModuleContribution[]>();
|
||||||
|
const contributionsByKey = new Map<string, PublishableModuleContribution>();
|
||||||
|
for (const contribution of publishedContributions) {
|
||||||
|
const forCapability = contributionsByCapability.get(contribution.capability) ?? [];
|
||||||
|
forCapability.push(contribution);
|
||||||
|
contributionsByCapability.set(contribution.capability, forCapability);
|
||||||
|
// Module id and contribution id are both namespaced and unique, so this key cannot collide.
|
||||||
|
contributionsByKey.set(
|
||||||
|
`${contribution.moduleId}\u0000${contribution.contributionId}`,
|
||||||
|
contribution,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const frozenByCapability = new Map(
|
||||||
|
[...contributionsByCapability].map(([capability, list]) => [capability, Object.freeze(list)]),
|
||||||
|
);
|
||||||
|
const noContributions: readonly PublishableModuleContribution[] = Object.freeze([]);
|
||||||
|
const byConsumingModule = new Map<string, readonly PublishableModuleContribution[]>(
|
||||||
|
modules.map((module) => [
|
||||||
|
module.manifest.id,
|
||||||
|
Object.freeze(
|
||||||
|
module.manifest.capabilities.flatMap(
|
||||||
|
(capability) => contributionsByCapability.get(capability.id) ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
contributionsForCapability: (
|
||||||
|
capabilityId: unknown,
|
||||||
|
): readonly PublishableModuleContribution[] =>
|
||||||
|
typeof capabilityId === "string"
|
||||||
|
? (frozenByCapability.get(capabilityId) ?? noContributions)
|
||||||
|
: noContributions,
|
||||||
|
contributionsForModule: (moduleId: unknown): readonly PublishableModuleContribution[] =>
|
||||||
|
typeof moduleId === "string"
|
||||||
|
? (byConsumingModule.get(moduleId) ?? noContributions)
|
||||||
|
: noContributions,
|
||||||
|
listContributions: (): readonly PublishableModuleContribution[] => publishedContributions,
|
||||||
|
resolveContribution: (
|
||||||
|
moduleId: unknown,
|
||||||
|
moduleVersion: unknown,
|
||||||
|
contributionId: unknown,
|
||||||
|
): PublishableModuleContribution | undefined => {
|
||||||
|
if (
|
||||||
|
typeof moduleId !== "string" ||
|
||||||
|
typeof moduleVersion !== "string" ||
|
||||||
|
typeof contributionId !== "string"
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const candidate = contributionsByKey.get(`${moduleId}\u0000${contributionId}`);
|
||||||
|
// Exact installed-version equality, exactly as the UI and display lookups require: a request
|
||||||
|
// naming a version the platform does not have installed resolves to nothing at all.
|
||||||
|
return candidate?.moduleVersion === moduleVersion ? candidate : undefined;
|
||||||
|
},
|
||||||
list: (): readonly PublishableModuleUi[] => published,
|
list: (): readonly PublishableModuleUi[] => published,
|
||||||
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
||||||
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function createModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
: {}),
|
: {}),
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const mailEnvironment = Object.freeze({
|
|||||||
|
|
||||||
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/unpublished/${id}`,
|
directory: `/unpublished/${id}`,
|
||||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -81,7 +82,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
|
|||||||
|
|
||||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/unpublished/${id}`,
|
directory: `/unpublished/${id}`,
|
||||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
@@ -1471,3 +1472,91 @@ describe("Ekklesia Doctor — object storage", () => {
|
|||||||
expect(check(report, "object-storage.bindings").status).toBe("skipped");
|
expect(check(report, "object-storage.bindings").status).toBe("skipped");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A browser contribution whose target capability nobody provides is otherwise silent: the manifest
|
||||||
|
* is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041).
|
||||||
|
* Doctor names it.
|
||||||
|
*/
|
||||||
|
describe("Doctor reports declared browser contributions", () => {
|
||||||
|
function contributing(
|
||||||
|
id: string,
|
||||||
|
contribution: { capability: string; capabilityRange: string },
|
||||||
|
): DiscoveredModule {
|
||||||
|
const base = discoveredModule(id);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
artifactPath: `/unpublished/${id}/contributions/slide.js`,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityRange: contribution.capabilityRange,
|
||||||
|
id: `${id}.slide`,
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function providing(id: string, capability: { id: string; version: string }): DiscoveredModule {
|
||||||
|
const base = discoveredModule(id);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detailsFor(modules: readonly DiscoveredModule[]): Promise<readonly string[]> {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
options(),
|
||||||
|
dependencies({
|
||||||
|
discoverModules: vi.fn(async () => Object.freeze([...modules])),
|
||||||
|
discoverSchemas: vi.fn(async () => schemaAggregate(modules)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return check(report, "modules.discovery").details;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("says nothing extra for a module that contributes nothing", async () => {
|
||||||
|
expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a contribution whose provider satisfies its declared range", async () => {
|
||||||
|
const details = await detailsFor([
|
||||||
|
providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }),
|
||||||
|
contributing("announcements", {
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(details).toEqual([
|
||||||
|
"signage 0.1.0",
|
||||||
|
"announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => {
|
||||||
|
expect(
|
||||||
|
await detailsFor([
|
||||||
|
contributing("announcements", {
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await detailsFor([
|
||||||
|
providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }),
|
||||||
|
contributing("announcements", {
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
"signage 0.1.0",
|
||||||
|
"announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 6,
|
"manifestVersion": 7,
|
||||||
"id": "db-facade",
|
"id": "db-facade",
|
||||||
"name": "Database Facade Integration Test Module",
|
"name": "Database Facade Integration Test Module",
|
||||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -27,6 +27,7 @@ function fakeModule(
|
|||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function manifestWith(
|
|||||||
id = "announcements",
|
id = "announcements",
|
||||||
) {
|
) {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
|
|||||||
|
|
||||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
|
|||||||
|
|
||||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
|
|||||||
|
|
||||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
|||||||
],
|
],
|
||||||
])("rejects %s", (_label, declaration) => {
|
])("rejects %s", (_label, declaration) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
|||||||
"valueOf",
|
"valueOf",
|
||||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
|||||||
"",
|
"",
|
||||||
])("rejects the malformed configuration key %s", (key) => {
|
])("rejects the malformed configuration key %s", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(id: string): Record<string, unknown> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -957,3 +957,179 @@ describe("module discovery", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contribution artifact is resolved through the identical loop as every other compiled entrypoint
|
||||||
|
* (ADR 0041), so it inherits every protection `entrypoints.ui` already had rather than receiving a
|
||||||
|
* second, weaker implementation of them.
|
||||||
|
*/
|
||||||
|
describe("browser contribution artifact resolution", () => {
|
||||||
|
function contributingManifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...manifestFor("announcements"),
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
id: "announcements.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
artifact: "./dist/contributions/signage-slide/index.js",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("resolves a declared contribution to a canonical path inside the package", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "announcements", {
|
||||||
|
extraFiles: { "dist/contributions/signage-slide/index.js": "export default {};" },
|
||||||
|
manifest: contributingManifest(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const contribution = modules[0]?.contributions[0];
|
||||||
|
expect(contribution).toMatchObject({
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
id: "announcements.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
expect(contribution?.artifactPath.endsWith("dist/contributions/signage-slide/index.js")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(path.isAbsolute(contribution?.artifactPath ?? "")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an empty contribution list for a module that contributes nothing", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "calendar");
|
||||||
|
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(modules[0]?.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing contribution artifact", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "announcements", { manifest: contributingManifest() });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
issues: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: "MODULE_ENTRYPOINT_MISSING" }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a contribution artifact that is not compiled JavaScript", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "announcements", {
|
||||||
|
extraFiles: { "src/contributions/signage-slide/index.tsx": "export default {};" },
|
||||||
|
manifest: contributingManifest({ artifact: "./src/contributions/signage-slide/index.tsx" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
issues: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID" }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a contribution artifact that escapes the package through a link", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
const outside = await createModulesDirectory();
|
||||||
|
await writeFile(path.join(outside, "elsewhere.js"), "export default {};");
|
||||||
|
await createModule(modulesDirectory, "announcements", {
|
||||||
|
manifest: contributingManifest(),
|
||||||
|
});
|
||||||
|
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/signage-slide"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await symlink(
|
||||||
|
path.join(outside, "elsewhere.js"),
|
||||||
|
path.join(modulesDirectory, "announcements/dist/contributions/signage-slide/index.js"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
issues: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "MODULE_ENTRYPOINT_INVALID",
|
||||||
|
path: "contributions.0.artifact",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects two contributions that resolve through a link to the same file", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
const manifest = {
|
||||||
|
...manifestFor("announcements"),
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
id: "announcements.first",
|
||||||
|
version: "1.0.0",
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
artifact: "./dist/contributions/first/index.js",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "announcements.second",
|
||||||
|
version: "1.0.0",
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
artifact: "./dist/contributions/second/index.js",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await createModule(modulesDirectory, "announcements", {
|
||||||
|
extraFiles: { "dist/contributions/first/index.js": "export default {};" },
|
||||||
|
manifest,
|
||||||
|
});
|
||||||
|
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/second"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await symlink(
|
||||||
|
path.join(modulesDirectory, "announcements/dist/contributions/first/index.js"),
|
||||||
|
path.join(modulesDirectory, "announcements/dist/contributions/second/index.js"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
issues: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID", path: "contributions" }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ afterAll(async () => {
|
|||||||
* by eligibility resolution. */
|
* by eligibility resolution. */
|
||||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -209,6 +209,7 @@ function fakeModule(id: string, dependencies?: Record<string, unknown>): Discove
|
|||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -36,6 +36,7 @@ function fakeModule(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
@@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface PermissionInput {
|
|||||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function manifest(
|
|||||||
permissions: readonly Record<string, string>[] = [],
|
permissions: readonly Record<string, string>[] = [],
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -861,6 +861,7 @@ describe("module HTTP routes", () => {
|
|||||||
expect(runtime.routes).toHaveLength(3);
|
expect(runtime.routes).toHaveLength(3);
|
||||||
for (const route of runtime.routes) {
|
for (const route of runtime.routes) {
|
||||||
expect(Object.keys(route).sort()).toEqual([
|
expect(Object.keys(route).sort()).toEqual([
|
||||||
|
"bodyMode",
|
||||||
"handler",
|
"handler",
|
||||||
"maxBodyBytes",
|
"maxBodyBytes",
|
||||||
"method",
|
"method",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function manifestFor(
|
|||||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -29,6 +29,7 @@ function fakeModule(
|
|||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ interface ModuleFixture {
|
|||||||
readonly ui?: string;
|
readonly ui?: string;
|
||||||
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
||||||
readonly display?: string;
|
readonly display?: string;
|
||||||
|
/** Capabilities this module provides — what another module's contributions can target. */
|
||||||
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||||
|
/** Browser renderers this module contributes to another module's capability. */
|
||||||
|
readonly contributions?: readonly {
|
||||||
|
readonly localName: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly capability: string;
|
||||||
|
readonly capabilityRange: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
@@ -61,8 +70,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
capabilities: fixture.capabilities ?? [],
|
||||||
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||||
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityRange: contribution.capabilityRange,
|
||||||
|
id: `${fixture.id}.${contribution.localName}`,
|
||||||
|
version: contribution.version,
|
||||||
|
})),
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions:
|
permissions:
|
||||||
fixture.display === undefined
|
fixture.display === undefined
|
||||||
@@ -89,6 +106,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||||
}
|
}
|
||||||
|
for (const contribution of fixture.contributions ?? []) {
|
||||||
|
const contributionDirectory = path.join(
|
||||||
|
moduleDirectory,
|
||||||
|
"dist/contributions",
|
||||||
|
contribution.localName,
|
||||||
|
);
|
||||||
|
await mkdir(contributionDirectory, { recursive: true });
|
||||||
|
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
|
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
|
||||||
@@ -294,3 +320,128 @@ describe("module display UI publication registry", () => {
|
|||||||
expect(registry.listDisplays()).toBe(registry.listDisplays());
|
expect(registry.listDisplays()).toBe(registry.listDisplays());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generic contribution mechanism (ADR 0041), exercised through the exact relationship it exists
|
||||||
|
* for and with no platform knowledge of what any of it means: `signage` provides a capability,
|
||||||
|
* `announcements` contributes a renderer to it, and neither module imports the other.
|
||||||
|
*/
|
||||||
|
describe("module contribution publication", () => {
|
||||||
|
const signage = {
|
||||||
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||||
|
id: "signage",
|
||||||
|
};
|
||||||
|
const contributor = (capabilityRange: string) => ({
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange,
|
||||||
|
localName: "signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: "announcements",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes a contribution whose target capability is provided within range", async () => {
|
||||||
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
|
||||||
|
expect(registry.listContributions()).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
||||||
|
).toMatchObject({
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
// The provider's declared version, so the range was checked against something real.
|
||||||
|
capabilityVersion: "1.2.0",
|
||||||
|
contributionId: "announcements.signage-slide",
|
||||||
|
moduleId: "announcements",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves exactly the canonical artifact path discovery validated", async () => {
|
||||||
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||||
|
const announcements = modules.find((module) => module.manifest.id === "announcements");
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
|
||||||
|
const published = registry.resolveContribution(
|
||||||
|
"announcements",
|
||||||
|
"0.1.0",
|
||||||
|
"announcements.signage-slide",
|
||||||
|
);
|
||||||
|
expect(published?.artifactPath).toBe(announcements?.contributions[0]?.artifactPath);
|
||||||
|
expect(path.isAbsolute(published?.artifactPath ?? "")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds a contribution whose capability no installed module provides", async () => {
|
||||||
|
const modules = await discover([contributor("^1.0.0")]);
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
|
||||||
|
expect(registry.listContributions()).toEqual([]);
|
||||||
|
expect(
|
||||||
|
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds a contribution whose provider is outside its declared compatible range", async () => {
|
||||||
|
// Fails closed: an incompatible renderer is absent rather than mounted and broken.
|
||||||
|
const modules = await discover([signage, contributor("^2.0.0")]);
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
|
||||||
|
expect(registry.listContributions()).toEqual([]);
|
||||||
|
expect(registry.contributionsForModule("signage")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a consuming module exactly the contributions aimed at its own capabilities", async () => {
|
||||||
|
const modules = await discover([
|
||||||
|
signage,
|
||||||
|
{ capabilities: [{ id: "calendar.week-view", version: "1.0.0" }], id: "calendar" },
|
||||||
|
contributor("^1.0.0"),
|
||||||
|
]);
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
|
||||||
|
expect(registry.contributionsForModule("signage").map((entry) => entry.contributionId)).toEqual(
|
||||||
|
["announcements.signage-slide"],
|
||||||
|
);
|
||||||
|
// A module that provides a different capability sees nothing, and the *contributing* module
|
||||||
|
// does not see its own contribution: it provides no capability it was aimed at.
|
||||||
|
expect(registry.contributionsForModule("calendar")).toEqual([]);
|
||||||
|
expect(registry.contributionsForModule("announcements")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a lookup closed for an unknown, mismatched, or non-string identity", async () => {
|
||||||
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
registry.resolveContribution("announcements", "9.9.9", "announcements.signage-slide"),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
registry.resolveContribution("signage", "0.1.0", "announcements.signage-slide"),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
registry.resolveContribution("announcements", "0.1.0", "announcements.nope"),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(registry.resolveContribution(7, "0.1.0", "announcements.signage-slide")).toBeUndefined();
|
||||||
|
expect(registry.contributionsForCapability(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no domain vocabulary at all", async () => {
|
||||||
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
const published = registry.listContributions()[0];
|
||||||
|
|
||||||
|
// Core knows identity, version, compatibility, and one path. There is deliberately no payload
|
||||||
|
// schema, slot, ordering, or rendering hint — what a renderer draws is not the platform's.
|
||||||
|
expect(Object.keys(published ?? {}).sort()).toEqual([
|
||||||
|
"artifactPath",
|
||||||
|
"capability",
|
||||||
|
"capabilityVersion",
|
||||||
|
"contributionId",
|
||||||
|
"moduleId",
|
||||||
|
"moduleVersion",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ This is deliberately the smallest viable styling story. A real stylesheet contra
|
|||||||
field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing
|
field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing
|
||||||
them here would expand the manifest and runtime contract before a host can validate them.
|
them here would expand the manifest and runtime contract before a host can validate them.
|
||||||
|
|
||||||
|
> **Correction — this rule is superseded.**
|
||||||
|
> [ADR 0040](0040-module-stylesheets-and-artifact-isolation.md) supplies the three missing pieces and
|
||||||
|
> reverses this one decision: a module may import plain `.css` from a browser-artifact source root,
|
||||||
|
> the canonical builder compiles it **into** the declared artifact as text (so no sidecar is ever
|
||||||
|
> emitted, which is what this section was protecting), and the host installs it into a Shadow DOM
|
||||||
|
> root it creates for every artifact. No manifest field was needed after all. The rest of this ADR —
|
||||||
|
> esbuild, bundled React, fixed source paths, no author bundler configuration, unminified output — is
|
||||||
|
> unchanged.
|
||||||
|
|
||||||
### Browser TypeScript stays scoped to UI source
|
### Browser TypeScript stays scoped to UI source
|
||||||
|
|
||||||
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
|
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 0039: Shared design tokens and foundational elements
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Supersedes: none
|
||||||
|
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||||
|
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||||
|
[ADR 0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0021 made a module an independently versioned product that owns its own UI, and ADR 0022 chose
|
||||||
|
React as the way to author it. Since then the platform has been able to *mount* a module's screen but
|
||||||
|
has said nothing at all about what that screen should look like. The consequences are now concrete:
|
||||||
|
|
||||||
|
- Every screen in this repository — the Dashboard shell, the display host, the module template —
|
||||||
|
hardcodes its own hex colours, font stack, spacing, and radii. They already disagree with each
|
||||||
|
other.
|
||||||
|
- A module author has no vocabulary for "the ordinary panel background" or "the danger text colour",
|
||||||
|
so the only available answer is to invent one. Two modules will invent two.
|
||||||
|
- Deferred repeatedly and named in ADR 0022, ADR 0023, and ADR 0033's deferral lists: "a design
|
||||||
|
system, component library, or theme/design-token contract."
|
||||||
|
- A theme editor is a real product requirement for a church platform, and it is only implementable
|
||||||
|
if the values it would edit are enumerable with human-readable names — which is a property of the
|
||||||
|
*contract*, not something that can be retrofitted onto a pile of literals.
|
||||||
|
|
||||||
|
The constraint that shapes everything below is ADR 0024's browser boundary. Whatever the shared
|
||||||
|
design layer is, module UI imports it, so it must not be able to pull a backend type graph — and with
|
||||||
|
it Node's global declarations — into a browser program.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add two shared browser-safe packages and open exactly two boundary roles to module UI.
|
||||||
|
|
||||||
|
### `@ekklesia/design-tokens` — the semantic token registry
|
||||||
|
|
||||||
|
A dependency-free package (like `@ekklesia/module-ui-sdk`, and for the same reason) holding one
|
||||||
|
validated registry of **semantic** tokens: `background.page`, `text.danger`, `state.hover-background`,
|
||||||
|
`typography.size-heading-1`, and so on. Nothing is named for the colour it currently happens to be,
|
||||||
|
because the point is that a value can change without a consumer's meaning changing with it.
|
||||||
|
|
||||||
|
Each token declares a name, a human-readable label, a description, and a default value. Its
|
||||||
|
**category and CSS custom-property name are derived** from the name rather than declared, so a token
|
||||||
|
can never claim one category while being named for another, and there is exactly one spelling of
|
||||||
|
`--ekklesia-<name>`. That is what makes the registry enumerable: a future theme editor reads
|
||||||
|
`list()`, groups by `category`, labels each field with `label`, explains it with `description`, and
|
||||||
|
needs no second source of truth.
|
||||||
|
|
||||||
|
Construction validates every rule rather than leaving it to review: an invalid or single-segment
|
||||||
|
name, an unknown category, a malformed derived identifier, an empty or overlong label or description,
|
||||||
|
a duplicate name, and a value that is not a **bounded literal** each throw. A value may not contain a
|
||||||
|
declaration terminator, a rule block, a comment, an at-rule, `url(`, `!important`, a backslash
|
||||||
|
escape, a control character, or markup, and is length-bounded. A registry that constructs is
|
||||||
|
therefore a registry that can be emitted as CSS without escaping, because nothing needing escaping
|
||||||
|
can be in it.
|
||||||
|
|
||||||
|
The registry emits its defaults as one CSS rule, scoped to `:root` or `:host` — a closed set, never a
|
||||||
|
caller-supplied selector, since an arbitrary selector string is exactly the unbounded value the
|
||||||
|
package refuses everywhere else.
|
||||||
|
|
||||||
|
### `@ekklesia/ui` — the foundational elements
|
||||||
|
|
||||||
|
A small React package whose every colour, type, spacing, radius, shadow, and interaction state is a
|
||||||
|
`var(--ekklesia-…)` reference. It contains the set a real module dashboard cannot be built without:
|
||||||
|
button and icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and
|
||||||
|
list primitives, badge, toast presentation, and empty/error/loading states, with `:focus-visible`
|
||||||
|
focus treatment on every interactive element.
|
||||||
|
|
||||||
|
Each element is a thin, accessible shell over the real HTML element rather than a re-implementation
|
||||||
|
of it, so native attributes, form association, and assistive-technology semantics pass straight
|
||||||
|
through. **This is deliberately not a large catalogue**: an element earns its place when two
|
||||||
|
independent consumers need the same one.
|
||||||
|
|
||||||
|
**Status is never signalled by colour alone.** Every status family carries a symbol token beside its
|
||||||
|
colours, and the elements render it. A status that is only red is invisible to a person who cannot
|
||||||
|
distinguish it, and unreadable on a projector or a washed-out lobby screen (WCAG 2.2 §1.4.1).
|
||||||
|
|
||||||
|
### The stylesheet is a string, not a `.css` file
|
||||||
|
|
||||||
|
These elements are mounted in two very different places: a document (the Dashboard shell, a
|
||||||
|
standalone host page) and an isolated root (a module's own artifact). A `.css` file would need a
|
||||||
|
bundler on both sides and a served asset on one of them. A string is installed into whichever root
|
||||||
|
the caller actually has, with identical bytes — and it is what lets `:focus-visible`, `:hover`, and
|
||||||
|
`:disabled` be real CSS rather than JavaScript approximations of them, which is the difference
|
||||||
|
between a focus treatment that works for a keyboard user and one that only looks like it does.
|
||||||
|
|
||||||
|
`<EkklesiaUiStyles />` renders that string into whichever root its React tree lives in. There is
|
||||||
|
deliberately no `document.head` hoisting and no `precedence` attribute: hoisting an isolated
|
||||||
|
artifact's styles into the host document is precisely the leak the platform must not have.
|
||||||
|
|
||||||
|
### Modules consume tokens; they never invent global ones
|
||||||
|
|
||||||
|
A module may reference any registered token and may derive **private, differently-prefixed aliases**
|
||||||
|
from them inside its own artifact. A module may not define an `--ekklesia-*` custom property: that
|
||||||
|
would add a global theme variable outside the registry, which a theme editor could never enumerate
|
||||||
|
and another module could unknowingly depend on. The typed `designToken(name)` reference makes an
|
||||||
|
unregistered name a compile error and a runtime failure.
|
||||||
|
|
||||||
|
### The boundary opens by name, not by category
|
||||||
|
|
||||||
|
`pnpm boundaries` adds `design-tokens` and `ui` to the shared roles a module may depend on. It is a
|
||||||
|
named allowlist, not "browser-safe shared packages are fine": `api-client`, `auth-client`,
|
||||||
|
`surface-sdk`, and `module-ui-host` all stay rejected for modules, and `contracts`/`module-sdk`
|
||||||
|
remain excluded from surfaces for the reason ADR 0024 measured.
|
||||||
|
|
||||||
|
The two new packages are pinned at the bottom of the presentation stack by their own rule:
|
||||||
|
`design-tokens` may depend on nothing but dev-time tooling, and `ui` may depend on `design-tokens`
|
||||||
|
and dev-time tooling. A design package that could reach an API client would put that client inside
|
||||||
|
every module artifact that renders a button.
|
||||||
|
|
||||||
|
### The host installs the theme once, at its root
|
||||||
|
|
||||||
|
`surfaces/shell` installs the default token values at the document for both the Dashboard and the
|
||||||
|
display application. CSS custom properties inherit through a shadow boundary, so every module
|
||||||
|
artifact — including one mounted in an isolated root — reads `var(--ekklesia-…)` without the host
|
||||||
|
reaching into the artifact or the artifact importing the host.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Two packages exist where none did; both are browser-safe by construction and proven so by a real
|
||||||
|
compile of module-shaped source under the exact project a module uses for UI, where `process.env`
|
||||||
|
must fail to typecheck.
|
||||||
|
- The Dashboard shell and display host no longer hardcode a colour, and a module can match them.
|
||||||
|
- A theme editor becomes implementable without a contract change: the values it would edit are
|
||||||
|
already enumerable, labelled, described, categorised, and validated.
|
||||||
|
- A module now has more it *may* import. The allowlist is exactly two roles and is enforced, with
|
||||||
|
fixtures proving both the acceptance and the continued rejection of everything else.
|
||||||
|
- Every module artifact bundles its own copy of the elements, exactly as it already bundles React
|
||||||
|
(ADR 0023). That cost is accepted for now and is optimisable later without a contract change,
|
||||||
|
because the contract is still a mount function.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Ship a full design system.** Rejected: a large catalogue is a large maintenance and review
|
||||||
|
surface, and most of it would be speculative. The set here is what a real module dashboard needs.
|
||||||
|
- **Put the tokens in `@ekklesia/module-ui-sdk`.** Rejected: that package is the *host contract*, and
|
||||||
|
a host that renders nothing still has to satisfy it. Design values are not something a host
|
||||||
|
supplies, and merging them would make the mount ABI carry a theme.
|
||||||
|
- **Expose tokens through `ModuleUiHostContext`.** Rejected: a host handing a module a token map
|
||||||
|
makes every module re-implement styling from data, and makes the tokens a runtime negotiation
|
||||||
|
rather than inherited CSS. Custom properties already inherit into artifacts for free.
|
||||||
|
- **Ship a `.css` file.** Rejected for this pass: it forces a bundler and a served asset on every
|
||||||
|
consumer, including the isolated artifacts that most need the styles. A *module's own* stylesheets
|
||||||
|
are a separate question — the module's build already exists to compile them — and are decided by
|
||||||
|
their own ADR.
|
||||||
|
- **A theme editor now.** Deferred deliberately. The registry is the prerequisite; the editor needs
|
||||||
|
persistence, an authorization story, per-installation overrides, and a preview surface, none of
|
||||||
|
which exist.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
|
||||||
|
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
|
||||||
|
documentation site, and a focus-trap utility.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# 0040: Module stylesheets, and every browser artifact gets an isolated root
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Supersedes: none
|
||||||
|
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||||
|
[ADR 0023](0023-module-ui-build-pipeline.md),
|
||||||
|
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[ADR 0039](0039-shared-design-tokens-and-elements.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0023 rejected stylesheet imports in module UI, and gave an honest reason: esbuild would emit a
|
||||||
|
`.css` sidecar beside the artifact, nothing would declare it, no host would fetch it, and it would
|
||||||
|
*work in a local test and silently do nothing in a real host*. Inline style objects were the whole
|
||||||
|
supported story, and a stylesheet contract was deferred to "a manifest field, a host loading
|
||||||
|
mechanism, and a style-isolation decision, none of which exist yet."
|
||||||
|
|
||||||
|
Two of those three now exist or are unnecessary, and the remaining gap is blocking real work:
|
||||||
|
|
||||||
|
- Inline style objects cannot express `:hover`, `:focus-visible`, `:disabled`, `@media`, or
|
||||||
|
`@keyframes`. A production management screen needs all of them, and an accessible focus treatment
|
||||||
|
needs `:focus-visible` specifically. This is not a preference; it is a capability inline styles do
|
||||||
|
not have.
|
||||||
|
- ADR 0039's shared elements are built from real CSS for exactly that reason, so a module using them
|
||||||
|
already needs somewhere for a stylesheet to live.
|
||||||
|
- A contributed renderer (a later ADR) makes the isolation question urgent rather than theoretical:
|
||||||
|
one module's renderer mounted inside another module's screen is precisely where a leaked selector
|
||||||
|
does damage nobody can trace.
|
||||||
|
|
||||||
|
The manifest field turns out not to be needed, and the loading mechanism and the isolation decision
|
||||||
|
turn out to be the same decision.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### A stylesheet is compiled **into** the declared artifact, as text
|
||||||
|
|
||||||
|
A module imports its local `.css` files. `@ekklesia/module-ui-build` compiles each one — resolving
|
||||||
|
local `@import`s through a nested bundle — into a JavaScript module whose default export is that
|
||||||
|
stylesheet's text. The author passes the text to `defineModuleUi({ styles })`.
|
||||||
|
|
||||||
|
That is why no manifest field is required. There is still exactly **one** declared file per browser
|
||||||
|
role, containing everything: nothing new to publish, nothing for a host to fetch, no second URL, and
|
||||||
|
no way for a stylesheet to become an undeclared sidecar — the failure ADR 0023 named. The builder
|
||||||
|
additionally fails if any CSS output file is ever produced beside an artifact, so a future option or
|
||||||
|
loader change cannot quietly reintroduce one.
|
||||||
|
|
||||||
|
Two rules are enforced on the compiled text, each with its own sanitized error code:
|
||||||
|
|
||||||
|
- **A module stylesheet may reference Ekklesia's global theme variables and must never declare one.**
|
||||||
|
Declaring `--ekklesia-*` would invent a global outside the registry a future theme editor
|
||||||
|
enumerates (ADR 0039). A private alias under the module's own prefix is the supported way to name
|
||||||
|
a local value, and is explicitly allowed.
|
||||||
|
- **An `@import` that survives compilation is rejected.** A local import is inlined and leaves
|
||||||
|
nothing behind, so anything remaining is a remote or unresolvable reference, and an artifact that
|
||||||
|
fetches a stylesheet at run time is not self-contained.
|
||||||
|
|
||||||
|
Plain `.css` only. `.scss`, `.less`, `.styl`, and friends stay rejected: adding a preprocessor is a
|
||||||
|
language and toolchain decision needing its own ADR, not a detail of a build pass.
|
||||||
|
|
||||||
|
`pnpm boundaries` allows `.css` inside a module **only** under the browser-artifact source roots the
|
||||||
|
canonical builder actually compiles (`src/ui/`, `src/display/`, and the contribution sources a later
|
||||||
|
ADR adds). Anywhere else in a module a stylesheet is a file no build reads and no host loads — the
|
||||||
|
undeclared sidecar again, just relocated. Every other stylesheet dialect stays rejected everywhere.
|
||||||
|
|
||||||
|
### Every browser artifact mounts inside an isolated root
|
||||||
|
|
||||||
|
The generic loader creates a Shadow DOM root inside the host's container, installs the artifact's
|
||||||
|
stylesheet there, and hands the artifact an element inside that root:
|
||||||
|
|
||||||
|
```text
|
||||||
|
host container ← the host's element; the loader never mutates it beyond this one child
|
||||||
|
└─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
|
||||||
|
#shadow-root (open)
|
||||||
|
├─ <style data-ekklesia-module-ui-styles> base rules, then the artifact's declared CSS
|
||||||
|
└─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
|
||||||
|
```
|
||||||
|
|
||||||
|
Shadow DOM is chosen because it is the one mechanism in a browser that **enforces** the rule rather
|
||||||
|
than describing it: rules inside a shadow root do not apply outside it, and document rules do not
|
||||||
|
apply inside it. A class-name convention, a build-time prefix, or a "please scope your selectors"
|
||||||
|
instruction are conventions that one mistake defeats, and the failure mode is a module silently
|
||||||
|
restyling the Dashboard or another module's renderer.
|
||||||
|
|
||||||
|
This applies to **every** role — primary UI, display UI, and contributed renderers — unconditionally,
|
||||||
|
including artifacts that declare no stylesheet. One mounting path, not a styled one and an unstyled
|
||||||
|
one, because a conditional isolation boundary is one a module can accidentally fall out of.
|
||||||
|
|
||||||
|
Both wrapper elements are `display: contents` through a base stylesheet the loader installs *before*
|
||||||
|
the artifact's own, so the loader's two elements contribute no boxes and an artifact lays out exactly
|
||||||
|
as it did before isolation existed — and an artifact that genuinely needs a box (a full-viewport
|
||||||
|
display) overrides `:host` by ordinary cascade order rather than by anything the host special-cases.
|
||||||
|
|
||||||
|
`mode: "open"` is deliberate. **This is not a security boundary, and must not be described as one.**
|
||||||
|
An installed module is trusted first-party code running in the host's own realm (ADR 0022, ADR 0024);
|
||||||
|
it could reach `document` directly if it wanted to. `closed` would buy no protection while making the
|
||||||
|
artifact's DOM unreachable to the host's own tests and diagnostics. This is a correctness boundary
|
||||||
|
for *styles*.
|
||||||
|
|
||||||
|
Design tokens still reach the artifact: CSS custom properties inherit through a shadow boundary, so a
|
||||||
|
host that installed the theme at its root (ADR 0039) has already supplied every artifact on the page.
|
||||||
|
|
||||||
|
### Mount and unmount are deterministic, in both directions
|
||||||
|
|
||||||
|
Each mount builds a fresh root with exactly one `<style>`. Unmount runs the module's cleanup and then
|
||||||
|
removes the whole root — content and styles together — in a `finally`, so a module whose cleanup
|
||||||
|
throws still has its stylesheet taken out of the page. Remounting cannot accumulate a second copy
|
||||||
|
because there is nothing left to accumulate onto. A mount that throws disposes the root it built, so
|
||||||
|
a failed artifact leaves neither partial DOM nor installed styles behind.
|
||||||
|
|
||||||
|
### The mount ABI is versioned, and the cutover is hard
|
||||||
|
|
||||||
|
An artifact's DOM is no longer a direct child of the host's element, and the definition gained a
|
||||||
|
field. That is observable, so `MODULE_UI_ABI_VERSION` is now `2` and `defineModuleUi` stamps it —
|
||||||
|
the author never writes it, so it can never drift from the SDK the artifact actually compiled
|
||||||
|
against.
|
||||||
|
|
||||||
|
There is no dual acceptance, exactly as there is none for a manifest version (ADR 0006, ADR 0014,
|
||||||
|
ADR 0021, ADR 0033). A version-1 artifact fails with `MODULE_UI_ABI_UNSUPPORTED` — checked first and
|
||||||
|
on its own, so the message is "rebuild your module" rather than a developer hunting for a typo.
|
||||||
|
Migration is one `pnpm build`: the canonical builder owns every option, and a module that declares no
|
||||||
|
stylesheet needs no source change at all.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Module UI can express hover, focus-visible, disabled, media queries, and keyframes, so ADR 0039's
|
||||||
|
accessible focus treatment actually works inside a module artifact.
|
||||||
|
- One module's stylesheet provably cannot reach the Dashboard, another module, or another renderer,
|
||||||
|
and the Dashboard's cannot reach in. Two artifacts of the *same* module are isolated from each
|
||||||
|
other too: they share styles only if they share a source file.
|
||||||
|
- A host's own tests reach an artifact's DOM through its open root rather than through
|
||||||
|
`container.querySelector`. Every mounting test in this repository was updated accordingly; that
|
||||||
|
churn is the visible price of the boundary being real.
|
||||||
|
- Every module must be rebuilt once. Nothing else changes for an author who uses no stylesheet.
|
||||||
|
- The artifact is larger by the size of its CSS, exactly as it is larger by the size of React
|
||||||
|
(ADR 0023). It remains one file.
|
||||||
|
- A module still cannot reference a bundled image or font from CSS: no asset loader is configured, so
|
||||||
|
such a reference fails the build. Data URLs work; object storage remains the answer for real media.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keep inline styles only.** Rejected: it cannot express the pseudo-classes an accessible control
|
||||||
|
needs, and the workaround — JavaScript hover and focus tracking — is worse in every way.
|
||||||
|
- **Publish a `.css` sidecar and have the host `<link>` it.** Rejected: it needs a manifest field, a
|
||||||
|
second publication route, a second cache policy, and a second failure mode, and it reintroduces the
|
||||||
|
exact "a file nothing declares" hazard ADR 0023 refused. Compiling into the one declared artifact
|
||||||
|
costs none of that.
|
||||||
|
- **Scope styles by build-time class prefixing instead of Shadow DOM.** Rejected: a convention a
|
||||||
|
single unprefixed selector defeats, with a silent failure mode and no way for the host to prove
|
||||||
|
containment. It also cannot stop the *host's* rules from reaching into an artifact.
|
||||||
|
- **Isolate only artifacts that declare a stylesheet.** Rejected: an artifact would fall in and out
|
||||||
|
of isolation as a source file changed, and a module rendering the shared elements' `<style>` from
|
||||||
|
inside React would leak into the host document without ever importing a `.css` file.
|
||||||
|
- **`mode: "closed"`.** Rejected as security theatre: the artifact is same-realm trusted code, and
|
||||||
|
closing the root only blinds the host's own diagnostics.
|
||||||
|
- **Accept both ABI versions.** Rejected for consistency with every manifest cutover this repository
|
||||||
|
has made: dual acceptance means one artifact can be read two ways, and the migration here is a
|
||||||
|
rebuild rather than an edit.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
CSS preprocessors, CSS Modules, bundled image and font assets, `adoptedStyleSheets` for shared
|
||||||
|
constructable stylesheets across artifacts, a host-supplied reset inside artifacts beyond the two
|
||||||
|
layout rules, and any claim that Shadow DOM confines module *code*.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# 0041: One module may contribute a browser renderer to another module's capability
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Supersedes: none
|
||||||
|
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||||
|
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||||
|
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The platform already has a complete story for one module using another's *backend*: capabilities,
|
||||||
|
versioned dependency ranges, and provider registration (ADR 0006). It has no story at all for one
|
||||||
|
module using another's *browser* code, and the first real product needs one.
|
||||||
|
|
||||||
|
The concrete shape, stated so the mechanism can be judged against it rather than against an
|
||||||
|
abstraction:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Signage provides a backend capability and owns a screen made of timeline slots
|
||||||
|
Announcements requires that capability and registers a backend provider for it
|
||||||
|
Announcements also ships a React renderer that knows how an announcement should look
|
||||||
|
Signage mounts that renderer for one slot, drives it, and takes it down
|
||||||
|
```
|
||||||
|
|
||||||
|
Every alternative available today is wrong in a way that matters. Signage cannot import
|
||||||
|
Announcements: modules are independently installed, independently versioned peers, and
|
||||||
|
`pnpm boundaries` rejects it outright. Signage cannot re-implement the renderer: it does not own the
|
||||||
|
domain and the two would drift on the first change. Announcements cannot declare a second primary UI:
|
||||||
|
`entrypoints.ui` and `entrypoints.display` are artifacts a *host* mounts in surfaces the host owns,
|
||||||
|
and nothing about "a slot inside another module's timeline" is a host concern. And the platform must
|
||||||
|
not learn what a slide is — the moment Core has a `slide` type, every future consumer inherits
|
||||||
|
Signage's vocabulary.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a generic **browser contribution**: a compiled renderer one module declares for a capability
|
||||||
|
another module provides, mounted by that other module inside a container it owns.
|
||||||
|
|
||||||
|
### The manifest declaration is inert and small
|
||||||
|
|
||||||
|
`manifestVersion: 7` adds a top-level `contributions` array. Each entry declares:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | `<module-id>.<local-name>` — namespaced, so uniqueness needs no registry |
|
||||||
|
| `version` | The contribution's own version, independent of its module's |
|
||||||
|
| `capability` | The capability it plugs into, owned by whichever module provides it |
|
||||||
|
| `capabilityRange` | The provider versions it is compatible with |
|
||||||
|
| `artifact` | One compiled browser artifact, package-relative |
|
||||||
|
|
||||||
|
That is the complete list, and the omissions are the decision. There is no payload schema, no slot,
|
||||||
|
no ordering, no rendering hint, and no domain vocabulary of any kind. **Core cannot learn what a
|
||||||
|
slide is from this declaration, because the declaration cannot express it.**
|
||||||
|
|
||||||
|
Validation is static and fails closed: the id must be namespaced to the declaring module and have
|
||||||
|
exactly one local segment; ids and artifact paths must be unique within a module; the artifact must
|
||||||
|
not collide with any entrypoint; and a contribution may not target a capability its own module
|
||||||
|
provides — a module contributing to itself needs no platform mechanism and would make the
|
||||||
|
compatibility check a comparison of a module with itself.
|
||||||
|
|
||||||
|
Version 7 is a hard cutover, exactly as every prior manifest version has been (ADR 0006, 0014, 0021,
|
||||||
|
0033). Upgrading a module that contributes nothing is a one-character edit.
|
||||||
|
|
||||||
|
### Publication is the existing mechanism, extended
|
||||||
|
|
||||||
|
Discovery resolves each contribution artifact through **the identical loop** every other compiled
|
||||||
|
entrypoint uses, so a contribution inherits the path grammar, containment, symlink-resolution,
|
||||||
|
extension, and file-kind checks rather than receiving a second, weaker implementation of them. Two
|
||||||
|
contributions that resolve through a link to one file are rejected.
|
||||||
|
|
||||||
|
The publication registry adds contributions on three conditions, all of which must hold: the
|
||||||
|
contributing module is in the eligible set, *some other* eligible module provides the target
|
||||||
|
capability, and that provider's declared capability version satisfies the declared range. A
|
||||||
|
contribution that fails any of them is **absent** — never published-but-broken, and never mounted
|
||||||
|
with a shrug.
|
||||||
|
|
||||||
|
`apps/api` serves it at `GET /api/v1/module-ui/<module-id>/<version>/contributions/<contribution-id>`
|
||||||
|
— a sibling of the artifact and display routes, under the same prefix, with the same policy: three
|
||||||
|
lookup keys, one exact registry match, one already-validated canonical path, no request value ever
|
||||||
|
composed into a filesystem path, and one indistinguishable `404` for unknown, undeclared, stale, and
|
||||||
|
incompatible alike. Source maps, sibling chunks, and package-root files stay unreachable by
|
||||||
|
construction.
|
||||||
|
|
||||||
|
### A consumer discovers through the host, never through a URL
|
||||||
|
|
||||||
|
The consuming module's descriptor gains a `contributions` array: identity, version, capability, and
|
||||||
|
the **server-produced** URL. A module UI sees only the first three; the URL lives in the host, which
|
||||||
|
resolves it exactly as it resolves `api.request("records")`. A module that built
|
||||||
|
`/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would be welded to Ekklesia's private
|
||||||
|
deployment detail and could never run under a standalone host.
|
||||||
|
|
||||||
|
`ModuleUiHostContext` gains a sixth member, `contributions`, with `list(capabilityId)` and
|
||||||
|
`mount({ contributionId, container, input })`. A consumer can only ever see contributions aimed at a
|
||||||
|
capability **it** provides, because that is the only thing the server put in its descriptor — and it
|
||||||
|
cannot name its way to anything else, because a `mount` for an id the server withheld fails without
|
||||||
|
fetching anything.
|
||||||
|
|
||||||
|
### The consumer owns the domain contract, entirely
|
||||||
|
|
||||||
|
`input` is opaque JSON. The platform validates that it *is* genuinely JSON-serializable — structurally,
|
||||||
|
so a `Date`, a function, `undefined`, a non-finite number, or a cycle fails at the boundary rather
|
||||||
|
than vanishing somewhere far from the mistake — and validates nothing about its shape or meaning. A
|
||||||
|
consumer publishes its own input contract alongside its capability and versions it through its own
|
||||||
|
capability version. Neither the manifest, the registry, the route, nor the SDK has a place to put a
|
||||||
|
schema, which is what stops one from appearing.
|
||||||
|
|
||||||
|
### The contribution ABI is separate and versioned
|
||||||
|
|
||||||
|
`MODULE_CONTRIBUTION_ABI_VERSION` is stamped by `defineModuleContribution`, never written by an
|
||||||
|
author. It is deliberately **not** the same number as `MODULE_UI_ABI_VERSION`: the two contracts have
|
||||||
|
different lifecycles — one receives a host context, the other receives a consumer's input and can be
|
||||||
|
updated in place — and versioning them together would force every module to rebuild whenever either
|
||||||
|
moved. A mismatched artifact fails with its own code before mounting.
|
||||||
|
|
||||||
|
The ABI supports exactly what a long-running screen needs:
|
||||||
|
|
||||||
|
- **asynchronous readiness** — `mount` may return a promise, so a consumer can sequence a transition
|
||||||
|
instead of guessing when a renderer has decoded its media;
|
||||||
|
- **update without remounting** — remounting per change would destroy DOM, restart animations, and
|
||||||
|
reload media on every tick. A renderer that genuinely cannot update in place omits `update`, and
|
||||||
|
the consumer is told to remount rather than left rendering stale content behind a silently ignored
|
||||||
|
call;
|
||||||
|
- **deterministic cleanup** — the loader runs the renderer's cleanup and removes its isolated root in
|
||||||
|
a `finally`, so a renderer that fails to clean up still has its styles taken out of the page;
|
||||||
|
- **sanitized failure** — every code is fixed, and no message, stack, or `cause` ever carries the
|
||||||
|
consumer's domain data.
|
||||||
|
|
||||||
|
Each contribution mounts inside its own Shadow DOM root (ADR 0040) with its own compiled stylesheet,
|
||||||
|
so a contributed renderer cannot restyle the consuming module's screen around it, and that module's
|
||||||
|
rules cannot reach in.
|
||||||
|
|
||||||
|
### What a contribution is not given
|
||||||
|
|
||||||
|
No database, no object storage, no credential, no session, no API client, and no reach into any
|
||||||
|
module — including the one that contributed it. Its context is two members: its own id and the
|
||||||
|
consumer's input.
|
||||||
|
|
||||||
|
**Loading a contribution grants its module nothing.** Server-side authorization is unchanged and
|
||||||
|
remains the only authority. Everything a contributed renderer needs arrives through the consumer's
|
||||||
|
input, because the consumer is the party that was authorized to obtain it.
|
||||||
|
|
||||||
|
A contribution artifact is still installed, trusted, first-party code in the host's realm (ADR 0022,
|
||||||
|
ADR 0024). Its isolated root is a correctness boundary for styles and must never be described as a
|
||||||
|
sandbox.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- One module can extend another's screen without either importing the other, and without the
|
||||||
|
platform learning either one's vocabulary.
|
||||||
|
- The Dashboard bundle now contains two dynamic imports rather than one, both the generic loader's:
|
||||||
|
a module UI artifact and a contributed renderer.
|
||||||
|
- Manifest version 7 requires every module to update one field. Nothing else changes for a module
|
||||||
|
that contributes nothing.
|
||||||
|
- `ModuleUiHostContext` grew from five members to six. It is present on every context and empty for
|
||||||
|
the overwhelming majority of modules, so a module writes the same code whether or not anything was
|
||||||
|
contributed — and a standalone host running one module supplies an empty implementation that is
|
||||||
|
the *correct* one rather than a stub.
|
||||||
|
- A consuming module must handle an empty list: a contribution can be absent because nobody
|
||||||
|
contributed, because the contributor is disabled, or because it is outside the compatible range.
|
||||||
|
That is the same discipline an optional capability dependency already requires.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Let the consumer import the contributor.** Rejected: it is the layer violation the boundary
|
||||||
|
checker exists to prevent, and it would make two independently installable modules one unit.
|
||||||
|
- **Put the renderer in the consumer.** Rejected: the consumer does not own the domain, so the
|
||||||
|
renderer and the data it draws would drift on the first change to either.
|
||||||
|
- **A second `entrypoints` role.** Rejected: the two existing roles are artifacts a *host* mounts in
|
||||||
|
surfaces the host owns. A contribution is mounted by a module, at a moment and in a place only that
|
||||||
|
module understands; calling it an entrypoint would put the platform in charge of deciding when a
|
||||||
|
slot should be filled.
|
||||||
|
- **A platform-defined payload schema.** Rejected, emphatically. It would make Core the owner of a
|
||||||
|
vocabulary it cannot possibly own, force every future consumer to inherit the first one's shape,
|
||||||
|
and require a platform release to change a domain contract between two modules.
|
||||||
|
- **Give the contribution its own API client.** Rejected: a renderer with its own authority is a
|
||||||
|
privilege the consumer never granted, and a second authorization path to reason about. The consumer
|
||||||
|
was authorized to fetch the data; it passes what it fetched.
|
||||||
|
- **Let a consumer construct the artifact URL.** Rejected for the same reason a module may not build
|
||||||
|
its own API prefix (ADR 0022): it welds the module to Ekklesia's deployment detail.
|
||||||
|
- **One shared ABI version for UI artifacts and contributions.** Rejected: it couples two contracts
|
||||||
|
with different shapes and lifecycles, and would make every module rebuild whenever either moved.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Contribution ordering and priority, a consumer-declared "extension point" manifest declaration,
|
||||||
|
per-contribution permissions, contributions to *backend* extension points, host-mediated
|
||||||
|
communication back from a contribution to its own module, contribution-level configuration, hot
|
||||||
|
reload, and a module store or installer that would let contributions arrive without a restart.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# 0042: Explicit, bounded streaming request bodies
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Supersedes: none
|
||||||
|
- Related: [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||||
|
[ADR 0017](0017-public-request-perimeter-hardening.md),
|
||||||
|
[ADR 0030](0030-host-provided-module-object-storage.md),
|
||||||
|
[ADR 0031](0031-direct-object-delivery.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0017 gave every public route an enforced request-body ceiling and capped a module route at
|
||||||
|
1 MiB, with an explicit note that "large media transfer belongs to a future dedicated storage
|
||||||
|
design, never to widening this ceiling."
|
||||||
|
|
||||||
|
The storage design arrived — ADR 0030 gave modules a scoped object store, ADR 0031 took Ekklesia out
|
||||||
|
of the *read* path with signed delivery URLs — and it did not solve the *write* path. ADR 0031
|
||||||
|
deliberately declined to offer signed write URLs, so the only way bytes reach a module's bucket is
|
||||||
|
through that module's own HTTP route. That route is capped at 1 MiB, and an ordinary photograph is
|
||||||
|
not 1 MiB. A church uploading a slide for a lobby screen cannot, today, get the file in.
|
||||||
|
|
||||||
|
Two properties of the existing implementation also need naming, because they are what the change has
|
||||||
|
to preserve and what it has to fix:
|
||||||
|
|
||||||
|
- The pinned body-limit middleware **fully buffers** any body with no declared `Content-Length`
|
||||||
|
before deciding. At 1 MiB that is a bounded, acceptable cost. At image sizes it would mean the
|
||||||
|
platform holding every concurrent upload in memory.
|
||||||
|
- With a declared `Content-Length` within the limit it *counts nothing at all*, trusting the header.
|
||||||
|
Node's HTTP framing makes that safe today, but it is trust the platform does not need to extend.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Extend the existing route contract. There is no second HTTP system, no upload endpoint, no signed
|
||||||
|
write URL, and no new middleware stack — one route declaration, one limiter, one `413`.
|
||||||
|
|
||||||
|
### A route declares how its body reaches it
|
||||||
|
|
||||||
|
`ModuleRouteDefinitionInput` gains one optional field:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/uploads",
|
||||||
|
permission: "signage.slides.write",
|
||||||
|
maxBodyBytes: 33_554_432,
|
||||||
|
bodyMode: "streamed",
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`buffered`** (the default, and what every route written before this meant) — the platform reads
|
||||||
|
and counts the whole body **before** the handler runs, so the handler is promised a fully
|
||||||
|
available, bounded body and an oversized request is rejected before any authorization or handler
|
||||||
|
work happens. Ceiling: 1 MiB, unchanged.
|
||||||
|
- **`streamed`** — the platform counts bytes **as the handler consumes them** and never materializes
|
||||||
|
the body. Ceiling: 32 MiB.
|
||||||
|
|
||||||
|
The mode is **explicit rather than inferred from the size**. Inferring it — "over 1 MiB means
|
||||||
|
streamed" — would mean an author who raised a limit silently changed how their own handler receives
|
||||||
|
its body, and when a `413` can arrive relative to their own code. That is exactly the class of
|
||||||
|
invisible change this repository refuses.
|
||||||
|
|
||||||
|
Omitting the field means `buffered`, so no existing route changed behaviour, and the canonical parsed
|
||||||
|
form always carries a mode so nothing downstream re-applies the default or disagrees about it.
|
||||||
|
|
||||||
|
### The ceilings, and why 32 MiB
|
||||||
|
|
||||||
|
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` stays at 1 MiB for buffered bodies: a body the platform
|
||||||
|
materializes is a body the platform is holding, and 1 MiB is the most a JSON request has any business
|
||||||
|
being.
|
||||||
|
|
||||||
|
`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` is 32 MiB, sized to hold one 25 MiB image plus bounded
|
||||||
|
overhead — a multipart envelope, its boundaries and part headers, and room for a client that pads. It
|
||||||
|
is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: **no route, in any mode, may declare or accept a
|
||||||
|
larger single request body, and no unbounded option exists anywhere in the contract.** A route
|
||||||
|
declaring more than its mode's ceiling fails registration, exactly as an over-ceiling value always
|
||||||
|
has.
|
||||||
|
|
||||||
|
The unit is deliberately **one file per request**. A UI offering twenty images issues twenty
|
||||||
|
individually bounded requests, each of which the platform can reject cheaply and each of which fails
|
||||||
|
alone; a 500 MiB aggregate request would make one bad byte waste everything already sent, and nothing
|
||||||
|
in this contract can express one.
|
||||||
|
|
||||||
|
### Enforcement, in one limiter
|
||||||
|
|
||||||
|
The platform's own limiter replaces the buffering dependency. It is the same code for both modes and
|
||||||
|
differs only in *when* it counts:
|
||||||
|
|
||||||
|
1. A declared `Content-Length` above the limit is refused synchronously — before authorization,
|
||||||
|
before the handler, before a single body byte is read. That is the cheap rejection an ordinary
|
||||||
|
too-large upload takes. A malformed or contradictory header is treated as *absent* rather than as
|
||||||
|
a rejection, because the byte counting bounds the request either way and refusing could only turn
|
||||||
|
a legitimate request away.
|
||||||
|
2. `buffered`: bytes are counted as they arrive and the request is rejected the moment the running
|
||||||
|
total passes the limit — so a lying or absent `Content-Length` is bounded by what actually
|
||||||
|
arrived rather than by what was claimed, which the previous implementation did not do. At most
|
||||||
|
the route's own declared allowance is ever held.
|
||||||
|
3. `streamed`: the body is wrapped in a counting transform and handed to the handler. The moment the
|
||||||
|
total passes the limit the stream errors, the source is cancelled, and the response is replaced
|
||||||
|
with the same sanitized `413` — **even if the handler caught the error and answered anyway**. A
|
||||||
|
limit a handler can swallow is not a limit.
|
||||||
|
|
||||||
|
A body no handler read is cancelled after the request, so an abandoned upload releases its socket
|
||||||
|
rather than draining. An aborted upload surfaces as an ordinary failed request, never a `413`:
|
||||||
|
nothing exceeded a limit.
|
||||||
|
|
||||||
|
Ordering is unchanged and remains deliberate: trusted-origin check, then the body contract, then
|
||||||
|
permission evaluation, then the handler. In `streamed` mode no body byte is read until the handler
|
||||||
|
reads it and the handler runs only after authorization succeeds, so an unauthorized caller can never
|
||||||
|
push an upload through the platform at all — strictly better than the buffered path.
|
||||||
|
|
||||||
|
Timeouts stay where they were: the server's own `headersTimeout`/`requestTimeout`. A slow upload is a
|
||||||
|
slow *request*, and the platform already has one answer for that rather than a second per-route one.
|
||||||
|
|
||||||
|
### What a module author must know
|
||||||
|
|
||||||
|
In `streamed` mode a `413` can arrive **after** the handler has started. A handler that persists as
|
||||||
|
it reads must be prepared for a partially consumed upload — write to a temporary key and commit only
|
||||||
|
after the read completes. The platform cannot do that for the module, because only the module knows
|
||||||
|
what a partial write means to its own data.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- An ordinary image upload works, through the route contract a module already had, with the platform
|
||||||
|
holding none of it.
|
||||||
|
- The buffered path now counts bytes it previously trusted a header for, closing a gap rather than
|
||||||
|
opening one.
|
||||||
|
- Every existing route and every existing test is unchanged: the default is the old behaviour, and
|
||||||
|
the `413` contract, ordering, and sanitization are identical.
|
||||||
|
- `apps/api` owns ~60 lines of stream plumbing it previously delegated. That is the price of a
|
||||||
|
limiter that neither buffers a large body nor can be swallowed by a handler, and it is enforced by
|
||||||
|
tests rather than by reading the dependency's source.
|
||||||
|
- Media larger than 32 MiB — a video — still has no path in. That is deliberate and named below.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Raise the single ceiling to 32 MiB.** Rejected: every existing route would silently gain the
|
||||||
|
ability to have 32 MiB buffered in memory on its behalf, and `await request.json()` in a module
|
||||||
|
handler would stop being a bounded operation.
|
||||||
|
- **Infer streaming from `maxBodyBytes`.** Rejected: it makes a size change silently change delivery
|
||||||
|
semantics and error timing for the author's own code.
|
||||||
|
- **A separate upload endpoint or second HTTP system.** Rejected: a second route table, a second
|
||||||
|
authorization path, a second CORS and origin policy, and a second failure vocabulary, to solve
|
||||||
|
something the existing route contract can express with one field.
|
||||||
|
- **Signed write URLs to object storage.** Deliberately still deferred (ADR 0031). A signed write URL
|
||||||
|
is a bearer capability to *create* an object, which needs quota, content-type, and lifecycle
|
||||||
|
answers this pass does not have — and a module that wants to validate, transform, or authorize per
|
||||||
|
byte cannot use one anyway.
|
||||||
|
- **Let the handler enforce its own limit.** Rejected: a limit enforced by the party being protected
|
||||||
|
against is not a limit, and every module would reimplement it slightly differently.
|
||||||
|
- **Trust `Content-Length` in streamed mode.** Rejected: the running byte count costs nothing and
|
||||||
|
removes the need to reason about framing at all.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Signed write URLs, resumable or chunked multi-request uploads, per-module or per-installation upload
|
||||||
|
quotas, content-type or magic-byte validation, virus scanning, image transformation, progress
|
||||||
|
reporting, and any body larger than the absolute ceiling — a video pipeline is its own design, not a
|
||||||
|
larger number here.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# 0043: The display caches its shell and declared code, and nothing else
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Supersedes: none
|
||||||
|
- Related: [ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||||
|
[ADR 0031](0031-direct-object-delivery.md),
|
||||||
|
[ADR 0032](0032-native-module-realtime-transport.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||||
|
[ADR 0041](0041-module-browser-contributions.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A display is the one surface in this platform that is expected to outlive its network. It is a
|
||||||
|
television on a wall in a building whose uplink is a consumer router, nobody is standing in front of
|
||||||
|
it, and the person who could press a button is not in the building on a Tuesday.
|
||||||
|
|
||||||
|
Today, when that network drops and the screen reboots — a power blip, a scheduled restart, a
|
||||||
|
janitor's foot — the browser shows **its own error page**. Not the display shell, not an Ekklesia
|
||||||
|
message, not a retry: a network failure with no Ekklesia in it at all, until someone notices and
|
||||||
|
does something about it. Nothing about that page recovers on its own, because there is no Ekklesia
|
||||||
|
code running on it to recover.
|
||||||
|
|
||||||
|
The temptation is to solve this by caching more: the descriptor, the principal, the module's data,
|
||||||
|
so a screen can keep showing content through an outage. That is exactly the wrong instinct. A cached
|
||||||
|
descriptor is a cached permission decision, and a screen replaying one is a revoked device still
|
||||||
|
showing content it is no longer entitled to.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Cache **the shell and the declared code**, and nothing else, in a service worker scoped to the
|
||||||
|
display.
|
||||||
|
|
||||||
|
### What is cached
|
||||||
|
|
||||||
|
- The display page (`/display/<module-id>`).
|
||||||
|
- The fixed display host script.
|
||||||
|
- The versioned browser artifacts the display actually loaded: its module's display artifact, and
|
||||||
|
every contributed renderer it mounted (ADR 0041).
|
||||||
|
|
||||||
|
All three are code. All three are already public bytes that confer no authority (ADR 0025, ADR 0041).
|
||||||
|
All three are addressed by a URL that carries an exact version, so a cache entry is unambiguous about
|
||||||
|
what it is.
|
||||||
|
|
||||||
|
### What is never cached, under any condition
|
||||||
|
|
||||||
|
The display descriptor, `/api/v1/principal`, every other platform and module API response, the
|
||||||
|
realtime stream, pairing requests and responses, and **every cross-origin request** — which is what a
|
||||||
|
signed object-delivery URL is (ADR 0031), a bearer capability with an expiry that a cache would
|
||||||
|
outlive.
|
||||||
|
|
||||||
|
This is an **allowlist**, not a deny list. A deny list has to be updated every time the platform gains
|
||||||
|
an endpoint, and the cost of forgetting is a stored permission decision. Here the cost of forgetting
|
||||||
|
is that a new kind of code is simply not cached, which is a bug rather than a vulnerability.
|
||||||
|
|
||||||
|
### The consequence, stated rather than papered over
|
||||||
|
|
||||||
|
**A display cannot render module content while its network is down.** Mounting requires a descriptor;
|
||||||
|
a descriptor is authorization-bearing; it is never served from a cache. A revoked device therefore
|
||||||
|
stays revoked, and cached code can never manufacture current authorization — the two properties this
|
||||||
|
pass must not trade away.
|
||||||
|
|
||||||
|
What the cache buys is real and worth having on its own:
|
||||||
|
|
||||||
|
- A kiosk that reboots during an outage boots into **Ekklesia's** honest offline state instead of the
|
||||||
|
browser's error page, and keeps retrying by itself.
|
||||||
|
- The moment connectivity returns it resolves and mounts with **nothing left to download** — the
|
||||||
|
600 KB artifact is already there.
|
||||||
|
- A momentary failure fetching an artifact, after a descriptor already resolved, falls back to the
|
||||||
|
identical bytes instead of failing the whole display. The descriptor is tiny and the artifact is
|
||||||
|
not, so this is the failure that actually happens.
|
||||||
|
|
||||||
|
Module-owned offline content — a module persisting its own state and media so a screen can keep
|
||||||
|
showing yesterday's slides — remains the module's responsibility. This is the code foundation it
|
||||||
|
would build on.
|
||||||
|
|
||||||
|
### Network first, always
|
||||||
|
|
||||||
|
Every cached kind is fetched from the network first and only falls back when that fetch fails. A
|
||||||
|
kiosk therefore picks up new code the moment the server returns and can never be trapped on an
|
||||||
|
obsolete build — which is precisely the failure mode a cache-first worker produces on an unattended
|
||||||
|
screen, and the one that requires physically visiting it to fix.
|
||||||
|
|
||||||
|
Only a complete, successful, same-origin `200` is ever stored. A `304`, a `404`, a redirect, an
|
||||||
|
opaque response, and a `503` from an API mid-restart are all things a screen must never later replay
|
||||||
|
as though they were the artifact.
|
||||||
|
|
||||||
|
### Versioned keys and pruning
|
||||||
|
|
||||||
|
Cache names carry a policy version; superseded generations are deleted on activation, so a policy
|
||||||
|
change cannot leave a screen serving entries from rules that no longer apply.
|
||||||
|
|
||||||
|
Artifact URLs already carry an exact module version, so a new build lands *beside* the old one. After
|
||||||
|
storing an artifact the worker removes every entry for the same module at a different version — and
|
||||||
|
deliberately keeps every entry at the current version, because a module's display artifact and each
|
||||||
|
contributed renderer are separate URLs under one version and dropping any of them would half-cache a
|
||||||
|
working display. Another module's artifacts are never touched.
|
||||||
|
|
||||||
|
The worker never caches **itself**, which is the other way a screen gets stuck: it is served
|
||||||
|
`no-cache` with a strong validator, exactly like the host script.
|
||||||
|
|
||||||
|
### Scope is the directory
|
||||||
|
|
||||||
|
The worker is served at `/display/sw.js`, chosen for its *directory*: a service worker's default
|
||||||
|
scope is where it was served from, so this one controls display pages and nothing else. The Dashboard
|
||||||
|
is never controlled by it and no Dashboard request is ever intercepted. Serving it beside the host
|
||||||
|
script under `/assets/` would scope it to a directory where no page lives; widening it with
|
||||||
|
`Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
|
||||||
|
|
||||||
|
A controlled page's requests are intercepted wherever they point, which is how an artifact under
|
||||||
|
`/api/v1/module-ui/` is cached without widening the scope to the whole origin.
|
||||||
|
|
||||||
|
### Reporting, not deciding
|
||||||
|
|
||||||
|
The page gets a small browser-safe signal built from two independent sources, because either alone
|
||||||
|
would lie: `navigator.onLine` (cheap, immediate, famously optimistic — a captive portal reports
|
||||||
|
`true`) and the worker's own report that it actually served from cache (ground truth about this
|
||||||
|
application). A display renders a quiet corner indicator, never a full-page failure: a screen whose
|
||||||
|
switch rebooted must not blank a lobby wall.
|
||||||
|
|
||||||
|
When the browser reports the network is back, the display resolves its descriptor again on its own.
|
||||||
|
Nobody presses Retry on a lobby television, and that fresh descriptor is also what stops a screen
|
||||||
|
sitting on cached code after a deployment — it names the current version, and the network-first worker
|
||||||
|
fetches it.
|
||||||
|
|
||||||
|
Registration is an **enhancement and never a prerequisite**: a browser without service workers, a page
|
||||||
|
served over plain HTTP in development, and a policy that refuses all leave a display that works
|
||||||
|
exactly as it did before, online only.
|
||||||
|
|
||||||
|
### Testable by construction
|
||||||
|
|
||||||
|
Every caching decision lives in a module that touches no global: the cache storage, `fetch`, and the
|
||||||
|
way a status reaches the page all arrive as an environment. The worker file is thin wiring that
|
||||||
|
supplies the real ones. That is what lets the behaviour be exercised directly — first load, offline
|
||||||
|
reload, a missing uncached artifact, contribution fallback, version upgrade and pruning, reconnect,
|
||||||
|
and the proof that nothing authorization-bearing is ever stored — rather than through a service
|
||||||
|
worker a test runner cannot host.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- An unattended screen degrades to an Ekklesia message and recovers by itself, instead of degrading
|
||||||
|
to a browser error page and staying there.
|
||||||
|
- The platform serves a third fixed display asset, read once before the listener binds like the other
|
||||||
|
two.
|
||||||
|
- A display page is now controlled by a service worker, which is a real operational surface: an
|
||||||
|
operator debugging a screen must know a worker is in front of it. It is `no-cache`, claims clients
|
||||||
|
immediately, and caches only code, so the ways it can go wrong are bounded.
|
||||||
|
- Offline *content* is still absent, and that is the honest state of this foundation rather than an
|
||||||
|
oversight.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Cache the descriptor so a display can mount offline.** Rejected outright. It is a permission
|
||||||
|
decision and a device identity; replaying one is a revoked screen still showing content.
|
||||||
|
- **Cache-first for speed.** Rejected: it is how an unattended screen ends up serving a build from
|
||||||
|
months ago with no way to notice, and fixing it means visiting the screen.
|
||||||
|
- **Precache everything at install.** Rejected: the worker would have to know which module a screen
|
||||||
|
runs and at which version — facts only the descriptor carries — and an install would fail whenever
|
||||||
|
an asset a future version needs did not exist yet.
|
||||||
|
- **Cache at the whole origin with `Service-Worker-Allowed`.** Rejected: the Dashboard gains a worker
|
||||||
|
it has no use for, for no benefit.
|
||||||
|
- **Use `navigator.onLine` alone.** Rejected: it reports `true` behind a captive portal and a dead
|
||||||
|
uplink, so a screen would insist it was fine while showing nothing.
|
||||||
|
- **Store artifacts in IndexedDB and import them from a blob URL.** Rejected: it costs a host its
|
||||||
|
`script-src` CSP, exactly as ADR 0024 rejected for the loader, and reimplements what a cache
|
||||||
|
already does correctly.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Module-owned offline content and media persistence, background sync, periodic background update,
|
||||||
|
push, a Dashboard service worker, offline pairing, precaching a display's artifact before its first
|
||||||
|
visit, and any cached response that carries identity or authorization.
|
||||||
@@ -47,3 +47,8 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
||||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||||
|
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||||
|
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
|
||||||
|
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
|
||||||
|
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
|
||||||
|
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
||||||
|
|||||||
@@ -228,7 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
|
|||||||
independent.
|
independent.
|
||||||
|
|
||||||
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
|
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
|
||||||
dependencies, consumed by module authors and hosts alike. Its host context is five members —
|
dependencies, consumed by module authors and hosts alike. Its host context was five members at that
|
||||||
|
checkpoint and is six today (ADR 0041) —
|
||||||
`moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
|
`moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
|
||||||
`api.request(path)` with a path relative to the module's own API root, never by constructing
|
`api.request(path)` with a path relative to the module's own API root, never by constructing
|
||||||
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
|
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
|
||||||
@@ -244,7 +245,8 @@ it, and hosts perform their own dynamic import.
|
|||||||
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
|
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
|
||||||
option in one `tooling` package so module authors write no build configuration. React is bundled
|
option in one `tooling` package so module authors write no build configuration. React is bundled
|
||||||
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
|
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
|
||||||
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
|
styling was inline style objects only at that checkpoint and is real compiled CSS today (ADR 0040),
|
||||||
|
and a stylesheet import then failed the build rather than emitting an
|
||||||
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
|
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
|
||||||
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
|
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
|
||||||
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
|
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
|
||||||
@@ -573,3 +575,80 @@ An authenticated result preserves the response, session, and mounted UI. Display
|
|||||||
remains deferred and the display bundle gains no Dashboard login. Public Website routing,
|
remains deferred and the display bundle gains no Dashboard login. Public Website routing,
|
||||||
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
|
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
|
||||||
and the missing production first-user creation path are also outside this pass.
|
and the missing production first-user creation path are also outside this pass.
|
||||||
|
|
||||||
|
**Phase 8 supplies the five platform prerequisites the first production feature module needs.** None
|
||||||
|
of it is Signage, and none of it names Signage: each capability is generic, and each is exercised by
|
||||||
|
something other than a feature module.
|
||||||
|
|
||||||
|
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md) adds the design layer the platform
|
||||||
|
never had. `@ekklesia/design-tokens` is one validated, enumerable registry of **semantic** tokens —
|
||||||
|
`background.raised`, `text.danger`, `state.hover-background` — where a token's category and
|
||||||
|
`--ekklesia-*` identifier are *derived* from its name and every value must be a bounded literal, so a
|
||||||
|
registry that constructs is one that can be emitted as CSS without escaping. That validation is
|
||||||
|
exactly what makes a future theme editor implementable rather than aspirational: the values it would
|
||||||
|
edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of
|
||||||
|
elements a real module dashboard cannot be built without, every value a token reference, with real
|
||||||
|
`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens
|
||||||
|
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
|
||||||
|
else. The Dashboard and display install the defaults at their document root; custom properties
|
||||||
|
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
|
||||||
|
run time and nothing for a host to forget to pass.
|
||||||
|
|
||||||
|
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) reverses ADR 0023's
|
||||||
|
inline-styles-only rule, and supplies the three things that ADR said were missing. A module imports
|
||||||
|
plain `.css`; the canonical builder compiles it **into** the declared artifact as text, so there is
|
||||||
|
still exactly one file per role and no sidecar can exist — the failure ADR 0023 was protecting
|
||||||
|
against. Two rules are enforced with their own codes: a module stylesheet may reference
|
||||||
|
`--ekklesia-*` and never declare one, and every `@import` must resolve locally. The generic loader
|
||||||
|
then mounts **every** browser artifact inside its own open Shadow DOM root with its own stylesheet
|
||||||
|
installed, unconditionally, because a conditional isolation boundary is one a module can fall out of
|
||||||
|
by editing a source file. That root is a correctness boundary for *styles* and is never described as
|
||||||
|
a sandbox: it is `open` precisely because module code is trusted, same-realm code that could reach
|
||||||
|
`document` anyway. The browser mount ABI is versioned (`MODULE_UI_ABI_VERSION`, stamped by
|
||||||
|
`defineModuleUi`) with no dual acceptance; migration is one `pnpm build`.
|
||||||
|
|
||||||
|
[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser
|
||||||
|
renderer to a capability another module provides — the browser half of the capability system Core has
|
||||||
|
had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target
|
||||||
|
capability, a compatible range, and one artifact. That is the complete list: there is no payload
|
||||||
|
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
|
||||||
|
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the
|
||||||
|
identical loop every other compiled entrypoint uses; publication requires that *another* eligible
|
||||||
|
module provides the capability within the declared range, and a contribution that fails is absent
|
||||||
|
rather than broken. A consumer discovers through `ModuleUiHostContext.contributions` and mounts by
|
||||||
|
id — never by building a publication URL — and can only see contributions aimed at a capability it
|
||||||
|
itself provides. The input is opaque JSON validated for serializability and nothing else, so the
|
||||||
|
domain contract stays entirely between the two modules. A contributed renderer receives its own id
|
||||||
|
and that input: no API client, realtime, principal, configuration, database, storage, or credential,
|
||||||
|
and loading one grants its module nothing.
|
||||||
|
|
||||||
|
[ADR 0042](decisions/0042-bounded-streaming-request-bodies.md) closes the write path ADR 0031 left
|
||||||
|
open. ADR 0030 gave modules a bucket and ADR 0031 took Ekklesia out of the *read* path, but declined
|
||||||
|
signed write URLs — so bytes reach a bucket only through a module's own route, which was capped at
|
||||||
|
1 MiB. A route now declares `bodyMode`: `buffered` (the default, and what every existing route
|
||||||
|
meant) keeps that ceiling and materializes the body before the handler runs; `streamed` raises it to
|
||||||
|
32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather
|
||||||
|
than inferred from the size, because inferring it would silently change how an author's own handler
|
||||||
|
receives its body. 32 MiB is also the platform's absolute ceiling in every mode, sized for one
|
||||||
|
25 MiB image plus bounded overhead, and the unit is deliberately one file per request. The platform's
|
||||||
|
own limiter replaces the buffering dependency: a declared oversize is refused before authorization,
|
||||||
|
the buffered path now counts what actually arrived rather than trusting a header, and a streamed
|
||||||
|
overflow errors the stream, cancels the source, and replaces the response with the same sanitized
|
||||||
|
`413` even if the handler swallowed the error.
|
||||||
|
|
||||||
|
[ADR 0043](decisions/0043-display-offline-code-fallback.md) gives the display host-owned offline
|
||||||
|
resilience, scoped precisely to **the shell and the declared code**. A service worker scoped to
|
||||||
|
`/display/` caches the page, the host script, the module's display artifact, and every contributed
|
||||||
|
renderer that display mounted — and never the descriptor, `/api/v1/principal`, any API response, the
|
||||||
|
realtime stream, a pairing exchange, or any cross-origin request, which is what a signed
|
||||||
|
object-delivery URL is. The consequence is stated rather than papered over: a display cannot render
|
||||||
|
module content while offline, because mounting needs an authorization-bearing descriptor that is
|
||||||
|
never cached, so a revoked device stays revoked and cached code can never manufacture current
|
||||||
|
authorization. What it buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline
|
||||||
|
state and recovers by itself, and that reconnecting mounts with nothing left to download. Caching is
|
||||||
|
network-first everywhere, so a screen can never be trapped on obsolete code.
|
||||||
|
|
||||||
|
Still deliberately absent after Phase 8: a theme editor, a brand builder, or any second theme; a
|
||||||
|
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
|
||||||
|
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
|
||||||
|
included. Nothing in this pass claims any of them exists.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
||||||
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
||||||
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries |
|
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, the approved design packages, public contracts, React, and external backend libraries |
|
||||||
| `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework |
|
| `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework |
|
||||||
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
||||||
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
||||||
@@ -20,10 +20,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
|
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
|
||||||
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
|
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
|
||||||
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
|
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
|
||||||
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
|
`@ekklesia/module-ui-sdk`. Since
|
||||||
|
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
|
||||||
|
`.css`, but **only** inside a browser-artifact source root the canonical builder compiles
|
||||||
|
(`src/ui/`, `src/display/`, `src/contributions/`); a stylesheet anywhere else in a module is a file
|
||||||
|
no build reads and no host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
|
||||||
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
||||||
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
|
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
|
||||||
packages. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
||||||
a role modules could already depend on — owns the entire module UI build
|
a role modules could already depend on — owns the entire module UI build
|
||||||
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
|
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
|
||||||
real target.
|
real target.
|
||||||
@@ -31,12 +35,47 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
import Core, an app, a surface, a builder, or another module. It also may not import
|
import Core, an app, a surface, a builder, or another module. It also may not import
|
||||||
`@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader
|
`@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader
|
||||||
would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)).
|
would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)).
|
||||||
|
- A module may consume exactly two shared **design** roles: `design-tokens` (`@ekklesia/design-tokens`)
|
||||||
|
and `ui` (`@ekklesia/ui`), added by
|
||||||
|
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md). This is a named allowlist, not a
|
||||||
|
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
|
||||||
|
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
|
||||||
|
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
|
||||||
|
it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
|
||||||
|
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
|
||||||
|
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
|
||||||
|
layer, an API/auth client, or the backend contracts barrel, because a design package that could
|
||||||
|
would put all of it inside every module artifact that renders a button.
|
||||||
|
- A module **references** registered global semantic tokens and may derive private,
|
||||||
|
differently-prefixed aliases from them inside its own artifact. It may never define an
|
||||||
|
`--ekklesia-*` custom property: that would invent a global theme variable outside the registry,
|
||||||
|
which a future theme editor could not enumerate and another module could unknowingly depend on.
|
||||||
- `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only
|
- `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only
|
||||||
on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it
|
on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it
|
||||||
Node's global declarations — into browser code. Core still owns the containing
|
Node's global declarations — into browser code. Core still owns the containing
|
||||||
application shell and all platform policy, and module UI reaches the host only through
|
application shell and all platform policy, and module UI reaches the host only through
|
||||||
`@ekklesia/module-ui-sdk`.
|
`@ekklesia/module-ui-sdk`.
|
||||||
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
- A module stylesheet is compiled **into** the one declared JavaScript artifact as text and installed
|
||||||
|
by the host at mount; there is no sidecar file, no second publication route, and no manifest field
|
||||||
|
for one. Every browser artifact is mounted inside its own Shadow DOM root the generic loader
|
||||||
|
creates, so one module's styles cannot reach the Dashboard, another module, or another renderer,
|
||||||
|
and the host's cannot reach in. That root is a correctness boundary for styles, never a sandbox:
|
||||||
|
module code remains trusted, same-realm code. The browser mount ABI is versioned
|
||||||
|
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
|
||||||
|
semantics.
|
||||||
|
- A module may declare **browser contributions** (`manifest.contributions`, v7): compiled renderers
|
||||||
|
it offers to capabilities *other* modules provide
|
||||||
|
([ADR 0041](decisions/0041-module-browser-contributions.md)). Core validates identity,
|
||||||
|
compatibility, and the artifact path through the identical resolution every other compiled
|
||||||
|
entrypoint uses, and knows nothing about what a contribution renders — there is no payload schema,
|
||||||
|
slot, ordering, or rendering hint anywhere in the platform. A contribution is published only when
|
||||||
|
another eligible module provides its target capability within the declared range; otherwise it is
|
||||||
|
absent rather than broken. A consuming module discovers contributions through
|
||||||
|
`ModuleUiHostContext.contributions` and mounts them by id; it never constructs a publication URL,
|
||||||
|
and it can only ever see contributions aimed at a capability it itself provides. A contributed
|
||||||
|
renderer receives its own id and the consumer's opaque JSON input, and no database, storage,
|
||||||
|
credential, session, or API client of its own.
|
||||||
|
- Manifest v7 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||||
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
||||||
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
||||||
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
|
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
|
||||||
@@ -80,7 +119,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
receiving only the host-neutral principal and permission hints already in the UI SDK.
|
receiving only the host-neutral principal and permission hints already in the UI SDK.
|
||||||
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
|
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
|
||||||
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
|
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
|
||||||
API. It contains no Dashboard chrome and does not move display rendering into the surface—the
|
API. It ships a third fixed asset, a service worker scoped to `/display/`, that caches **only** the
|
||||||
|
display shell and the declared browser artifacts it actually loaded
|
||||||
|
([ADR 0043](decisions/0043-display-offline-code-fallback.md)). It never caches the descriptor,
|
||||||
|
`/api/v1/principal`, any module or platform API response, the realtime stream, a pairing exchange,
|
||||||
|
or any cross-origin request — so a revoked device stays revoked and cached code can never
|
||||||
|
manufacture current authorization. Caching is network-first, so a screen cannot be trapped on
|
||||||
|
obsolete code. It contains no Dashboard chrome and does not move display rendering into the surface—the
|
||||||
module still owns that renderer.
|
module still owns that renderer.
|
||||||
- Separate surfaces do not import each other's internals.
|
- Separate surfaces do not import each other's internals.
|
||||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||||
@@ -96,6 +141,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
|
direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
|
||||||
columns, relations, schema entrypoint, or migration internals.
|
columns, relations, schema entrypoint, or migration internals.
|
||||||
|
|
||||||
|
The design packages are covered the same way: one fixture proves a module may depend on both, one
|
||||||
|
proves every other shared role stays rejected, and one proves neither design package may depend
|
||||||
|
upward.
|
||||||
|
|
||||||
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
|
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
|
||||||
rejects every currently machine-checkable repository rule even before production packages fill
|
rejects every currently machine-checkable repository rule even before production packages fill
|
||||||
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
|
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
|
||||||
|
|||||||
+32
-10
@@ -408,16 +408,28 @@ protection across restarts or replicas.
|
|||||||
|
|
||||||
## Request body limits
|
## Request body limits
|
||||||
|
|
||||||
Every public body-consuming route has an explicit, platform-enforced byte ceiling
|
Every public body-consuming route has an explicit, platform-enforced byte ceiling, enforced by the
|
||||||
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified
|
platform's own limiter (`apps/api/src/body-limits.ts`):
|
||||||
directly against its source to reject both an oversized declared `Content-Length` and an oversized
|
|
||||||
declared-length-free streamed body without ever buffering the whole payload first):
|
|
||||||
|
|
||||||
| Surface | Limit |
|
| Surface | Limit |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/api/auth/*` (every Better Auth route) | 8 KiB |
|
| `/api/auth/*` (every Better Auth route) | 8 KiB |
|
||||||
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
|
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
|
||||||
| A module route (`http.route(...)`'s own `maxBodyBytes`) | Up to 1 MiB, the platform ceiling (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
| A module route declaring `bodyMode: "buffered"` (the default) | Up to 1 MiB (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||||||
|
| A module route declaring `bodyMode: "streamed"` | Up to 32 MiB (`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING`), which is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES` |
|
||||||
|
|
||||||
|
A declared `Content-Length` over the limit is refused synchronously, before authorization and before
|
||||||
|
a single body byte is read. Beyond that the two module modes differ only in *when* the platform
|
||||||
|
counts ([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
|
||||||
|
|
||||||
|
- **`buffered`** materializes the body before the handler runs, counting as it arrives, so a lying or
|
||||||
|
absent `Content-Length` is bounded by what actually arrived. The handler is promised a fully
|
||||||
|
available body within its declared allowance, and the platform holds at most that allowance.
|
||||||
|
- **`streamed`** never materializes the body: it is counted as the handler consumes it, and the
|
||||||
|
moment the total passes the limit the stream errors, the source is cancelled, and the response
|
||||||
|
becomes the same `413` — even if the handler caught the error and answered anyway. A `413` can
|
||||||
|
therefore arrive *after* a streamed handler has started, which is the one consequence a module
|
||||||
|
author must design for.
|
||||||
|
|
||||||
An oversized request always receives the identical stable, sanitized contract —
|
An oversized request always receives the identical stable, sanitized contract —
|
||||||
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
|
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
|
||||||
@@ -425,10 +437,20 @@ Auth's business logic, a Core mutation, permission evaluation (where avoidable),
|
|||||||
or module database behavior. For a state-changing route the body limit is enforced after
|
or module database behavior. For a state-changing route the body limit is enforced after
|
||||||
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
|
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
|
||||||
is never read for size at all, while an oversized same-origin request still fails before any
|
is never read for size at all, while an oversized same-origin request still fails before any
|
||||||
authorization work runs. See [Module HTTP routes](#module-http-routes) for the module-route body
|
authorization work runs. In `streamed` mode no body byte is read until the handler reads it, and the
|
||||||
contract itself, and [Module development](../module-development/README.md#http-routes) for the
|
handler runs only after authorization succeeds, so an unauthorized caller can never push an upload
|
||||||
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module
|
through the platform at all.
|
||||||
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
|
|
||||||
|
Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout` below, not by a
|
||||||
|
second per-route timeout. A body no handler read is cancelled after the request, so an abandoned
|
||||||
|
upload releases its socket rather than draining; an aborted upload surfaces as an ordinary failed
|
||||||
|
request rather than a `413`, because nothing exceeded a limit.
|
||||||
|
|
||||||
|
See [Module HTTP routes](#module-http-routes) for the module-route body contract itself, and
|
||||||
|
[Module development](../module-development/README.md#http-routes) for the module-authoring side of
|
||||||
|
it. The absolute ceiling is one image-sized file per request: aggregate uploads and anything larger
|
||||||
|
than 32 MiB remain out of scope, and signed write URLs are still deliberately not offered
|
||||||
|
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)).
|
||||||
|
|
||||||
## Node request and header timeouts
|
## Node request and header timeouts
|
||||||
|
|
||||||
@@ -1210,7 +1232,7 @@ its role-specific URL only from the protected descriptor below.
|
|||||||
|
|
||||||
## Module display host
|
## Module display host
|
||||||
|
|
||||||
Manifest version 6 adds one independently optional display declaration
|
The manifest carries one independently optional display declaration
|
||||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
|
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ enforces those permissions while the module is disabled, because it mounts no ro
|
|||||||
|
|
||||||
## Module permission composition
|
## Module permission composition
|
||||||
|
|
||||||
A module manifest's `permissions` array (`manifestVersion: 4`,
|
A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict
|
||||||
|
manifest is version 7 — see
|
||||||
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
||||||
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
||||||
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
||||||
@@ -667,7 +668,7 @@ module database usage), and exactly one `pool.end()` call regardless of how many
|
|||||||
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
||||||
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
||||||
rejection, malformed permission id rejection.
|
rejection, malformed permission id rejection.
|
||||||
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 4` acceptance and version
|
- `packages/contracts/tests/module-manifest.test.ts` — exact-version acceptance and version
|
||||||
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
||||||
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
||||||
path, and default-empty-list isolation across separate parses.
|
path, and default-empty-list isolation across separate parses.
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ the root verification command discovers them automatically.
|
|||||||
|
|
||||||
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
|
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
|
||||||
missing or empty modules directory is valid and reports zero installed modules. Installed modules
|
missing or empty modules directory is valid and reports zero installed modules. Installed modules
|
||||||
must produce the compiled JavaScript entrypoints declared by their static manifests. Optional
|
must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
|
||||||
|
declared browser contributions are listed with their target capability and range, and marked
|
||||||
|
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
|
||||||
|
silent, since the manifest is valid and the artifact exists while nothing renders (ADR 0041). Optional
|
||||||
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
|
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
|
||||||
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
|
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
|
||||||
|
|
||||||
@@ -82,7 +85,8 @@ not a broken deployment).
|
|||||||
Doctor reports a fixed, deterministic set of checks:
|
Doctor reports a fixed, deterministic set of checks:
|
||||||
|
|
||||||
- The active Node.js version against the repository's declared engine range.
|
- The active Node.js version against the repository's declared engine range.
|
||||||
- Static module discovery and dependency-first ordering.
|
- Static module discovery and dependency-first ordering. A module that declares browser
|
||||||
|
contributions also reports how many of them have a compatible provider among the installed set.
|
||||||
- Schema discovery, including validated module and table counts. This imports compiled schema
|
- Schema discovery, including validated module and table counts. This imports compiled schema
|
||||||
definitions, which the module contract requires to perform no configuration reads, I/O, or
|
definitions, which the module contract requires to perform no configuration reads, I/O, or
|
||||||
database connection.
|
database connection.
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ separate deployment and threat-model decision.
|
|||||||
|
|
||||||
### A display page is authorized; its artifact bytes are not authority
|
### A display page is authorized; its artifact bytes are not authority
|
||||||
|
|
||||||
Manifest version 6 binds a display artifact to one permission declared by the same module
|
The manifest binds a display artifact to one permission declared by the same module
|
||||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
|
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
|
||||||
a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
|
a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
|
||||||
and public/device identity is not approximated with an anonymous exception. Both the stable
|
and public/device identity is not approximated with an anonymous exception. Both the stable
|
||||||
|
|||||||
@@ -69,6 +69,77 @@ protocol-relative, backslash-prefixed, and scheme-bearing values cannot become n
|
|||||||
Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out
|
Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out
|
||||||
of the separate display bundle.
|
of the separate display bundle.
|
||||||
|
|
||||||
|
Phase 8 adds evidence for the five platform prerequisites.
|
||||||
|
|
||||||
|
**Design tokens and shared elements** (ADR 0039). The token registry's tests prove it is
|
||||||
|
deterministic and enumerable, that every token derives a unique `--ekklesia-*` identifier from its
|
||||||
|
name and carries a label, description, and category, that each status family pairs a colour with a
|
||||||
|
non-colour symbol, and that a duplicate name, an unknown category, an invalid or single-segment name,
|
||||||
|
and a value carrying a declaration terminator, a rule block, a comment, an at-rule, `url(`,
|
||||||
|
`!important`, an escape, a control character, or markup are each rejected — with an error that never
|
||||||
|
echoes the rejected declaration. The elements' tests assert accessible structure (label/hint/error
|
||||||
|
association, icon-button naming, tab roles and arrow-key movement, announced error and loading
|
||||||
|
states), that status never depends on colour alone, that a dialog renders in place rather than
|
||||||
|
portalling out of an isolated root, and — mechanically — that the stylesheet and every component
|
||||||
|
source contain no literal colour, font stack, or theme value and reference only registered tokens.
|
||||||
|
Both packages compile module-shaped source under the exact browser project a module uses, where
|
||||||
|
`process.env` must fail to typecheck, so the browser boundary is measured rather than asserted.
|
||||||
|
|
||||||
|
**Module stylesheets and artifact isolation** (ADR 0040). The builder's tests prove a plain
|
||||||
|
stylesheet is compiled into the declared artifact with no `.css` emitted beside it, that a local
|
||||||
|
`@import` is inlined, and that an unresolved `@import`, a stylesheet declaring an `--ekklesia-*`
|
||||||
|
variable, and a non-CSS dialect are each rejected with their own sanitized code — while a private
|
||||||
|
alias derived from a registered token is accepted. The loader's tests prove every artifact mounts
|
||||||
|
inside an open shadow root, that the artifact receives that root rather than the host's element, that
|
||||||
|
exactly one `<style>` is installed per mount and never accumulates across remounts, that the host's
|
||||||
|
base rules precede the artifact's so a display can override `:host`, that unmount removes the whole
|
||||||
|
root even when the module's cleanup throws, that a failed mount leaves neither DOM nor styles behind,
|
||||||
|
that an environment without Shadow DOM fails closed rather than mounting unisolated, and that an
|
||||||
|
artifact built against the previous ABI is refused with `MODULE_UI_ABI_UNSUPPORTED`. The template's
|
||||||
|
suite builds real React artifacts through the canonical pipeline and asserts the compiled stylesheet
|
||||||
|
travels inside the one declared file.
|
||||||
|
|
||||||
|
**Browser contributions** (ADR 0041). Manifest tests cover namespacing, the single local segment,
|
||||||
|
duplicate ids and artifact paths, collision with an entrypoint, and a contribution targeting a
|
||||||
|
capability its own module provides. Discovery tests prove a contribution artifact inherits the exact
|
||||||
|
path, symlink, containment, extension, and file-kind protections every other compiled entrypoint has,
|
||||||
|
including two contributions linked to one file. Publication tests prove a contribution is published
|
||||||
|
only when another eligible module provides its capability *within the declared range*, that the
|
||||||
|
contributing module never sees its own contribution, that a lookup fails closed on an unknown,
|
||||||
|
mismatched, or non-string identity, and that the published record carries no domain vocabulary at
|
||||||
|
all. Route tests prove the artifact is served byte-for-byte at a URL that cannot select a neighbour,
|
||||||
|
and that an unknown id, another module's id, a traversal, a source map, a wrong version, an absent
|
||||||
|
provider, and an incompatible provider all collapse into one indistinguishable `404`. The loader's
|
||||||
|
tests cover mount, update-without-remount, a renderer that declares no `update`, deterministic and
|
||||||
|
idempotent unmount, a sanitized update failure, and input that is not genuinely JSON — a `Date`, a
|
||||||
|
function, `NaN`, `undefined`, and a cycle — refused *before* anything is imported and without ever
|
||||||
|
echoing the consumer's data. The flagship shell fixture drives the whole relationship: a `signage`
|
||||||
|
module discovers, mounts, updates, and unmounts an `announcements` React renderer with neither module
|
||||||
|
importing the other, the consumer seeing identity but never a URL, the host resolving publication,
|
||||||
|
and a contribution id the server withheld refused without a fetch.
|
||||||
|
|
||||||
|
**Bounded streaming uploads** (ADR 0042). Contract tests pin the two ceilings, that `maxBodyBytes` is
|
||||||
|
checked against the ceiling the declared mode selects, that 32 MiB holds a 25 MiB image and one byte
|
||||||
|
more is refused, and that no mode admits an unbounded body. HTTP tests cover exact-boundary success,
|
||||||
|
a declared `Content-Length` refused before the handler runs, a misleading smaller `Content-Length`
|
||||||
|
that cannot smuggle a megabyte past the limit, a chunked overflow, a `413` that still wins when the
|
||||||
|
handler swallowed the stream error, proof that the body is genuinely not buffered (a handler sees the
|
||||||
|
first chunk before the last is produced), an aborted upload surfacing as an ordinary failure rather
|
||||||
|
than a `413`, an unread body being released, authorization ordering in both directions, and a
|
||||||
|
rejection that names no limit, size, or body content. The live suite uploads a real 25 MiB body over
|
||||||
|
a real socket end to end and proves the server is healthy immediately afterwards.
|
||||||
|
|
||||||
|
**Display offline fallback** (ADR 0043). The worker's caching logic is exercised directly, because it
|
||||||
|
touches no global: first online load, offline reload after one successful visit, a missing uncached
|
||||||
|
artifact failing honestly, contributed-renderer fallback, version upgrade with pruning that keeps
|
||||||
|
every artifact of the current version and never touches another module's, superseded cache
|
||||||
|
generations removed while unrelated ones survive, network-first behaviour picking up rebuilt bytes,
|
||||||
|
and an explicit inventory proving the descriptor, principal, module API, realtime stream, pairing
|
||||||
|
exchange, auth routes, cross-origin signed object URLs, and the worker itself are **never** stored —
|
||||||
|
so a revoked device's refusal is what a reconnected display sees. Page-level tests cover the quiet
|
||||||
|
offline indicator, a cache fallback reported while the browser still claims to be online, automatic
|
||||||
|
re-resolution when the network returns, and a revoked device dropping back to pairing on reconnect.
|
||||||
|
|
||||||
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
|
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
|
||||||
database.
|
database.
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ without executing module code:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"manifestVersion": 6,
|
"manifestVersion": 7,
|
||||||
"id": "calendar",
|
"id": "calendar",
|
||||||
"name": "Calendar",
|
"name": "Calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -122,6 +122,16 @@ without executing module code:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"id": "calendar.signage-slide",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"capability": "signage.slide-renderer",
|
||||||
|
"capabilityRange": "^1.0.0",
|
||||||
|
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||||
|
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||||
|
}
|
||||||
|
],
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"server": "./dist/server/index.js",
|
"server": "./dist/server/index.js",
|
||||||
"schema": "./dist/schema/index.js",
|
"schema": "./dist/schema/index.js",
|
||||||
@@ -140,12 +150,13 @@ has its own exact semantic version because its public contract can evolve indepe
|
|||||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||||
may be absent; if it is installed, its capability version must be compatible.
|
may be absent; if it is installed, its capability version must be compatible.
|
||||||
|
|
||||||
`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
`manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||||
version 5 manifest, and any other version, fails validation outright rather than being silently
|
version 6 manifest, and any other version, fails validation outright rather than being silently
|
||||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||||
Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display
|
Version 7 adds the optional `contributions` array
|
||||||
changes only the version literal; `ui` remains independently optional
|
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
|
||||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
contributes nothing changes only the version literal; every browser artifact remains independently
|
||||||
|
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||||
@@ -214,6 +225,16 @@ are separate artifacts because a full-viewport display is not a Dashboard screen
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"id": "calendar.signage-slide",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"capability": "signage.slide-renderer",
|
||||||
|
"capabilityRange": "^1.0.0",
|
||||||
|
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||||
|
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||||
|
}
|
||||||
|
],
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"server": "./dist/server/index.js",
|
"server": "./dist/server/index.js",
|
||||||
"schema": "./dist/schema/index.js",
|
"schema": "./dist/schema/index.js",
|
||||||
@@ -271,7 +292,7 @@ artifact is the role selection.
|
|||||||
|
|
||||||
### What the host gives you
|
### What the host gives you
|
||||||
|
|
||||||
`mount` receives exactly five members, and nothing else is available:
|
`mount` receives exactly six members, and nothing else is available:
|
||||||
|
|
||||||
| Member | What it is |
|
| Member | What it is |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -280,10 +301,17 @@ artifact is the role selection.
|
|||||||
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
|
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
|
||||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
||||||
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
|
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
|
||||||
|
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
|
||||||
|
|
||||||
Navigation, host theme and design tokens, module configuration, organization context, and
|
Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
|
||||||
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a
|
contract. They need designs that do not exist, and a speculative field frozen into a public contract
|
||||||
speculative field frozen into a public contract is harder to remove than to add.
|
is harder to remove than to add.
|
||||||
|
|
||||||
|
The host **theme** is deliberately not a context member either, and that is not a gap: design tokens
|
||||||
|
reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import
|
||||||
|
`@ekklesia/design-tokens` and write `var(--ekklesia-…)`; the host installs the values at its own
|
||||||
|
root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that
|
||||||
|
can forget to pass one.
|
||||||
|
|
||||||
### Never build your own API URL
|
### Never build your own API URL
|
||||||
|
|
||||||
@@ -413,20 +441,157 @@ loads several React copies, and artifacts are large (the template's is roughly 5
|
|||||||
an operator can review it). That is accepted for now and can be optimized later **without changing
|
an operator can review it). That is accepted for now and can be optimized later **without changing
|
||||||
the contract**, because the contract is a mount function rather than a component.
|
the contract**, because the contract is a mount function rather than a component.
|
||||||
|
|
||||||
### Styling: inline style objects only, for now
|
### Design tokens and shared elements
|
||||||
|
|
||||||
Style with React's `style` prop:
|
Your UI may import two shared design packages, and only those two
|
||||||
|
([ADR 0039](../architecture/decisions/0039-shared-design-tokens-and-elements.md)):
|
||||||
|
|
||||||
|
| Package | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `@ekklesia/design-tokens` | The platform's global semantic theme — a validated, enumerable registry of named values and the `var(--ekklesia-…)` references for them |
|
||||||
|
| `@ekklesia/ui` | The foundational Ekklesia elements, every one of them built from those tokens |
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<section style={{ display: "grid", gap: "0.5rem" }}>…</section>
|
import { designToken } from "@ekklesia/design-tokens";
|
||||||
|
import { Badge, Button, EkklesiaUiStyles, Panel } from "@ekklesia/ui";
|
||||||
|
|
||||||
|
export function Slides({ context }: { readonly context: ModuleUiHostContext }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EkklesiaUiStyles />
|
||||||
|
<Panel actions={<Button variant="primary">Add slide</Button>} title="Slides">
|
||||||
|
<p style={{ color: designToken("text.secondary") }}>Nothing scheduled yet.</p>
|
||||||
|
<Badge tone="warning">Paused</Badge>
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
|
Render `<EkklesiaUiStyles />` once, near the top of your tree. It puts the element stylesheet into
|
||||||
an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no
|
whichever root your artifact is mounted in, so you never have to know whether that is a document or
|
||||||
manifest field declares it and no host loads it — so it would work in your tests and silently do
|
an isolated root.
|
||||||
nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and
|
|
||||||
a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for
|
**Use semantic names, not colours.** `designToken("text.danger")` says what the text *means*;
|
||||||
this phase.
|
`#9f1239` says what it looks like today. An installation that changes its theme changes the first
|
||||||
|
and not the second. `designToken` only accepts registered names, so a typo is a compile error.
|
||||||
|
|
||||||
|
**Never define an `--ekklesia-*` custom property.** Those are the platform's global theme variables,
|
||||||
|
and inventing one would create a global a future theme editor could not enumerate and another module
|
||||||
|
could unknowingly depend on. You may derive as many *private* aliases as you like inside your own
|
||||||
|
artifact, under your own prefix:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div style={{ "--calendar-row-gap": designToken("spacing.sm") } as CSSProperties}>
|
||||||
|
```
|
||||||
|
|
||||||
|
The host installs the default token values at its own root, and CSS custom properties inherit
|
||||||
|
through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at
|
||||||
|
run time and nothing for the host to hand you.
|
||||||
|
|
||||||
|
Every other shared package stays rejected inside a module: `@ekklesia/api-client`,
|
||||||
|
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
|
||||||
|
import, and `pnpm boundaries` says so.
|
||||||
|
|
||||||
|
### Styling: a stylesheet compiled into your artifact
|
||||||
|
|
||||||
|
Author plain `.css` beside your UI source, import it, and declare it
|
||||||
|
([ADR 0040](../architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)):
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/ui/index.tsx
|
||||||
|
src/ui/styles.css
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||||
|
import styles from "./styles.css";
|
||||||
|
|
||||||
|
export default defineModuleUi({
|
||||||
|
id: "announcements",
|
||||||
|
version: "0.1.0",
|
||||||
|
styles: `${EKKLESIA_UI_STYLESHEET}\n${styles}`,
|
||||||
|
mount(container, context) { /* … */ },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
TypeScript needs to know what a `.css` import is. Copy the template's `src/module-styles.d.ts` into
|
||||||
|
your module and include it in your UI TypeScript project:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
declare module "*.css" {
|
||||||
|
const stylesheet: string;
|
||||||
|
export default stylesheet;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**There is no sidecar file.** `@ekklesia/module-ui-build` compiles each stylesheet — resolving local
|
||||||
|
`@import`s — into your declared JavaScript artifact as text. One declared file still contains
|
||||||
|
everything, so there is nothing extra to publish, nothing for a host to fetch, and no way for your
|
||||||
|
CSS to become an asset nobody serves. That was the exact hazard that kept stylesheets out until now.
|
||||||
|
|
||||||
|
Two rules are enforced by the build, each with its own error:
|
||||||
|
|
||||||
|
- **Reference Ekklesia's global theme variables; never declare one.** `--ekklesia-*` belongs to the
|
||||||
|
registry a future theme editor enumerates (ADR 0039). Derive private aliases under your own prefix
|
||||||
|
freely: `--announcements-slide-gap: var(--ekklesia-spacing-sm)`.
|
||||||
|
- **Every `@import` must resolve to a local file.** A remote import would make your artifact fetch a
|
||||||
|
stylesheet at run time, and a declared artifact has to be self-contained.
|
||||||
|
|
||||||
|
Plain `.css` only. `.scss`, `.less`, and other dialects stay rejected — adding a preprocessor is a
|
||||||
|
toolchain decision that needs its own ADR. A CSS `url()` pointing at a local image or font also fails
|
||||||
|
the build: no asset loader is configured. Use a data URL, or serve real media from your module's
|
||||||
|
object storage.
|
||||||
|
|
||||||
|
`pnpm boundaries` allows `.css` inside `src/ui/` and `src/display/` only. A stylesheet anywhere else
|
||||||
|
in your module is a file no build reads and no host loads.
|
||||||
|
|
||||||
|
### Your artifact renders in its own isolated root
|
||||||
|
|
||||||
|
Every browser artifact — primary UI, display UI, and any renderer you contribute — is mounted inside
|
||||||
|
a Shadow DOM root the host creates, with your stylesheet installed in it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
host container
|
||||||
|
└─ <div data-ekklesia-module-ui>
|
||||||
|
#shadow-root (open)
|
||||||
|
├─ <style> host base rules, then your declared CSS
|
||||||
|
└─ <div> ← the container your mount() receives
|
||||||
|
```
|
||||||
|
|
||||||
|
What that buys you, and what it costs:
|
||||||
|
|
||||||
|
- **Your selectors cannot leak out**, and the Dashboard's cannot leak in. Write `.slide` without a
|
||||||
|
prefix and without worrying about another module's `.slide`.
|
||||||
|
- **Design tokens still reach you.** CSS custom properties inherit through the boundary, so
|
||||||
|
`var(--ekklesia-…)` resolves from the values the host installed at its root. Nothing to import at
|
||||||
|
run time, nothing for the host to pass.
|
||||||
|
- **Two artifacts of your own module are isolated from each other too.** Your management UI and your
|
||||||
|
display share styles only if they share a source file.
|
||||||
|
- **Your DOM is not in the host document's tree.** `document.querySelector` from outside will not
|
||||||
|
find your elements — including in your own tests, which should mount the artifact and query inside
|
||||||
|
the root the loader hands `mount`.
|
||||||
|
- **You get no box by default.** The host's two wrapper elements are `display: contents`, so you lay
|
||||||
|
out exactly where the host put you. A full-viewport display that needs a real box overrides it:
|
||||||
|
`:host { display: block }`, which works because the host installs its base rules *before* yours.
|
||||||
|
- **Mount and unmount are deterministic.** The host installs your stylesheet once per mount and
|
||||||
|
removes the whole root on unmount, so repeated mounts never accumulate styles and a cleanup that
|
||||||
|
throws still cannot leave your CSS behind. You never install or remove your own `<style>`.
|
||||||
|
|
||||||
|
**This is not a sandbox.** Your artifact is trusted first-party code in the host's own JavaScript
|
||||||
|
realm and can reach `document` if it wants to; the root is `open` for exactly that reason. It is a
|
||||||
|
correctness boundary for styles, and nothing more. See
|
||||||
|
[Security model and deployment trust boundaries](../development/security.md).
|
||||||
|
|
||||||
|
### The mount ABI is versioned
|
||||||
|
|
||||||
|
`defineModuleUi` stamps the browser mount ABI version onto your artifact — you never write it. A host
|
||||||
|
verifies it before mounting, and an artifact built against an older SDK fails with
|
||||||
|
`MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics.
|
||||||
|
|
||||||
|
Migrating from ABI 1 is one `pnpm build`. If you declare no stylesheet, no source change is needed at
|
||||||
|
all; if your own tests assert on module DOM, they need to look inside the isolated root.
|
||||||
|
|
||||||
### What you may and may not use today
|
### What you may and may not use today
|
||||||
|
|
||||||
@@ -435,9 +600,9 @@ the build pipeline** — adding the pipeline required no boundary relaxation at
|
|||||||
|
|
||||||
| Allowed in a module | Still rejected |
|
| Allowed in a module | Still rejected |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
| `.tsx` source, and plain `.css` inside `src/ui/` or `src/display/` | `.jsx` (strict TypeScript only), `.css` outside a browser source root, `.scss`/`.less`/`.styl`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
||||||
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
|
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
|
||||||
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build` | `@ekklesia/ui` and other shared UI-role packages |
|
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build`, `@ekklesia/design-tokens`, `@ekklesia/ui` | Every other shared role: `api-client`, `auth-client`, `surface-sdk`, `module-ui-host` |
|
||||||
|
|
||||||
You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because
|
You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because
|
||||||
`@ekklesia/module-ui-build` owns the bundler entirely.
|
`@ekklesia/module-ui-build` owns the bundler entirely.
|
||||||
@@ -523,8 +688,9 @@ Four things about it matter to you as a module author:
|
|||||||
- **Only the artifact that role declares is served.** The module id and version in either URL are
|
- **Only the artifact that role declares is served.** The module id and version in either URL are
|
||||||
lookup keys, never path components — there is no way to request a second file from your package.
|
lookup keys, never path components — there is no way to request a second file from your package.
|
||||||
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
|
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
|
||||||
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
|
an asset, it must be inside the bundle: that is exactly why your stylesheet is compiled into the
|
||||||
stylesheet import still fails the build.
|
artifact as text rather than published beside it (ADR 0040), and why a bundled image or font is
|
||||||
|
still not supported.
|
||||||
- **The version must match exactly.** A request for any other version is refused rather than served
|
- **The version must match exactly.** A request for any other version is refused rather than served
|
||||||
your installed artifact, so a stale host cannot mount something it did not ask for.
|
your installed artifact, so a stale host cannot mount something it did not ask for.
|
||||||
- **Your artifact bytes are public.** These two routes require no authentication — fetching code
|
- **Your artifact bytes are public.** These two routes require no authentication — fetching code
|
||||||
@@ -580,7 +746,7 @@ eligible, running, and declares `entrypoints.display`, Ekklesia registers exactl
|
|||||||
|
|
||||||
The page and descriptor both require the manifest's display permission through the ordinary Core
|
The page and descriptor both require the manifest's display permission through the ordinary Core
|
||||||
authorization evaluator and an authenticated cookie session. A `public` display permission is
|
authorization evaluator and an authenticated cookie session. A `public` display permission is
|
||||||
rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are
|
rejected; there is no anonymous exception or device token. Artifact bytes are
|
||||||
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
|
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
|
||||||
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
|
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
|
||||||
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
|
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
|
||||||
@@ -598,8 +764,31 @@ artifact remains compatible with a future standalone host.
|
|||||||
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
|
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
|
||||||
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
|
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
|
||||||
feature experience and cleanup inside its container. There is one display per module. Display
|
feature experience and cleanup inside its container. There is one display per module. Display
|
||||||
inventory/management, named variants, targeting, public/device identity, a standalone host, shared
|
inventory/management, named variants, targeting, public/device identity, a standalone host,
|
||||||
CSS, Announcements migration, and Companion integration are deferred.
|
Announcements migration, and Companion integration are deferred.
|
||||||
|
|
||||||
|
### Your display survives a network outage — as code, not as content
|
||||||
|
|
||||||
|
A display page is controlled by a service worker that caches **the shell and the declared code**: the
|
||||||
|
page, the host script, your display artifact, and every contributed renderer that display actually
|
||||||
|
mounted ([ADR 0043](../architecture/decisions/0043-display-offline-code-fallback.md)). Caching is
|
||||||
|
network-first, so a screen picks up a new build the moment the server returns and can never be
|
||||||
|
trapped on an obsolete one.
|
||||||
|
|
||||||
|
What that means for you, concretely:
|
||||||
|
|
||||||
|
- **Your artifact will be served from cache when a fetch fails.** The bytes are identical and the
|
||||||
|
loader still verifies the artifact's declared id and version, so a fallback can never mount
|
||||||
|
something the descriptor did not name.
|
||||||
|
- **Your display cannot render while the network is down.** Mounting requires the descriptor, the
|
||||||
|
descriptor is authorization-bearing, and it is never cached — so a device whose access was revoked
|
||||||
|
stays revoked, and cached code can never manufacture authorization it no longer has. The screen
|
||||||
|
shows Ekklesia's own quiet offline indicator and resolves again by itself when connectivity
|
||||||
|
returns.
|
||||||
|
- **Nothing of yours is cached but code.** Your module's API responses, your realtime stream, and
|
||||||
|
every signed object-delivery URL are deliberately never stored. If you want a screen to keep
|
||||||
|
showing content through an outage, persisting that content is *your module's* job — this is the
|
||||||
|
foundation it would build on, not a substitute for it.
|
||||||
|
|
||||||
### What a module UI will never own
|
### What a module UI will never own
|
||||||
|
|
||||||
@@ -626,6 +815,132 @@ A Dashboard may later group modules into folders (`Media` holding Video and Live
|
|||||||
configuration an administrator can reorganize freely. They are not dependencies, they are not
|
configuration an administrator can reorganize freely. They are not dependencies, they are not
|
||||||
declared in your manifest, and your module must never require one to work correctly.
|
declared in your manifest, and your module must never require one to work correctly.
|
||||||
|
|
||||||
|
## Browser contributions
|
||||||
|
|
||||||
|
Sometimes another module owns a screen, and *you* own what belongs on part of it. A signage module
|
||||||
|
owns a timeline; an announcements module knows what an announcement should look like. Neither can
|
||||||
|
import the other — modules are independently installed, independently versioned peers — and neither
|
||||||
|
should re-implement the other's job.
|
||||||
|
|
||||||
|
A **browser contribution** is the mechanism for exactly that
|
||||||
|
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)): a compiled renderer you
|
||||||
|
declare for a capability *another* module provides, which that module mounts inside a container it
|
||||||
|
owns.
|
||||||
|
|
||||||
|
```text
|
||||||
|
signage provides the capability signage.slide-renderer, and publishes its input contract
|
||||||
|
announcements contributes a React renderer for it
|
||||||
|
signage mounts that renderer in one timeline slot, updates it, and takes it down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contributing a renderer
|
||||||
|
|
||||||
|
Declare it in your manifest and put the source at the fixed path your contribution id implies:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"id": "announcements.signage-slide",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"capability": "signage.slide-renderer",
|
||||||
|
"capabilityRange": "^1.0.0",
|
||||||
|
"artifact": "./dist/contributions/signage-slide/index.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/contributions/signage-slide/index.tsx → the contribution's compiled artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import styles from "./styles.css";
|
||||||
|
import { Slide, type SlideInput } from "./Slide.js";
|
||||||
|
|
||||||
|
export default defineModuleContribution({
|
||||||
|
id: "announcements.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
styles,
|
||||||
|
mount(container, context) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<Slide input={context.input as SlideInput} />);
|
||||||
|
return {
|
||||||
|
update(input) {
|
||||||
|
root.render(<Slide input={input as SlideInput} />);
|
||||||
|
},
|
||||||
|
unmount() {
|
||||||
|
root.unmount();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The id is `<your-module-id>.<local-name>`, which is what makes it unique platform-wide without a
|
||||||
|
registry. Your contribution carries **its own version**, independent of your module's, and the
|
||||||
|
platform verifies the artifact claims it before mounting — a stale bundle is caught rather than
|
||||||
|
rendered. `capabilityRange` says which provider versions you work with: if no installed module
|
||||||
|
provides that capability inside your range, your contribution is simply not published. It is never
|
||||||
|
mounted and broken.
|
||||||
|
|
||||||
|
`pnpm build` compiles it alongside your other artifacts. You configure no paths and no bundler.
|
||||||
|
|
||||||
|
### What a contributed renderer receives
|
||||||
|
|
||||||
|
Two things: its own id, and the consumer's `input`.
|
||||||
|
|
||||||
|
That is the whole context. **No API client, no realtime subscription, no principal, no permissions,
|
||||||
|
no configuration, no database, no storage, and no credential.** Mounting your renderer grants your
|
||||||
|
module nothing — server-side authorization is untouched, and everything the renderer needs arrives
|
||||||
|
through the consumer's input, because the consumer is the party that was authorized to fetch it.
|
||||||
|
|
||||||
|
The input is **opaque JSON to the platform**. Ekklesia validates that it is genuinely
|
||||||
|
JSON-serializable and nothing else: not its shape, not its meaning, not its version. The consuming
|
||||||
|
module publishes that contract alongside its capability and versions it through its capability
|
||||||
|
version. Validate what you receive and render a sensible default when it is not what you expected —
|
||||||
|
you may be running against an older or newer consumer than the one you developed against.
|
||||||
|
|
||||||
|
`update` is what lets a long-running screen change content without restarting animations or reloading
|
||||||
|
media on every tick. Implement it if you can render new input in place; omit it if you genuinely
|
||||||
|
cannot, and the consumer will unmount and mount you again.
|
||||||
|
|
||||||
|
Your renderer mounts in its own isolated root with its own stylesheet, exactly like a module UI
|
||||||
|
artifact (ADR 0040): your rules cannot reach the consuming module's screen around you, and its rules
|
||||||
|
cannot reach in.
|
||||||
|
|
||||||
|
### Consuming contributions
|
||||||
|
|
||||||
|
If *your* module provides a capability other modules can render for, ask the host what was
|
||||||
|
contributed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const renderers = context.contributions.list("signage.slide-renderer");
|
||||||
|
|
||||||
|
const slide = await context.contributions.mount({
|
||||||
|
container: slotElement, // an element your module owns
|
||||||
|
contributionId: renderers[0].id,
|
||||||
|
input: { headline, seconds }, // your own published contract
|
||||||
|
});
|
||||||
|
|
||||||
|
await slide.update({ headline: next, seconds });
|
||||||
|
await slide.unmount();
|
||||||
|
```
|
||||||
|
|
||||||
|
You see identity, version, and capability — never a URL. Asking the host to mount by id is what keeps
|
||||||
|
your module hostable somewhere other than Ekklesia, exactly as `context.api.request("records")` does.
|
||||||
|
|
||||||
|
You will only ever see contributions aimed at a capability **your** module provides, and only ones
|
||||||
|
the server judged compatible. Handle an empty list: a contributor may be absent, disabled, or outside
|
||||||
|
your compatible range, and that is ordinary rather than exceptional.
|
||||||
|
|
||||||
|
You own the input contract completely. Publish it beside your capability, version it with your
|
||||||
|
capability version, and remember that a renderer written against an older version of it may still be
|
||||||
|
installed.
|
||||||
|
|
||||||
## Runtime server definition
|
## Runtime server definition
|
||||||
|
|
||||||
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
||||||
@@ -713,8 +1028,8 @@ export default defineModuleRuntime({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
A route definition has exactly five fields — `method`, `path`, `permission`, `maxBodyBytes`,
|
A route definition has exactly five required fields — `method`, `path`, `permission`,
|
||||||
`handler` — and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
|
`maxBodyBytes`, `handler` — plus one optional `bodyMode`, and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
|
||||||
middleware, audit metadata, authentication behavior, or another module's namespace) fails
|
middleware, audit metadata, authentication behavior, or another module's namespace) fails
|
||||||
registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`,
|
registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`,
|
||||||
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
|
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
|
||||||
@@ -779,15 +1094,61 @@ ones. The platform continues to own authentication, authorization, `Origin`, COR
|
|||||||
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
|
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
|
||||||
before your handler runs — an unauthorized caller can never reach your validation at all.
|
before your handler runs — an unauthorized caller can never reach your validation at all.
|
||||||
|
|
||||||
|
### Request bodies: `maxBodyBytes` and `bodyMode`
|
||||||
|
|
||||||
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
|
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
|
||||||
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
|
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
|
||||||
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
|
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
|
||||||
accepted. It must be a non-negative integer no greater than the platform ceiling,
|
accepted. A negative, non-integer, non-finite, or over-ceiling value fails registration with
|
||||||
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (`@ekklesia/contracts`, currently 1 MiB) — a negative,
|
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`.
|
||||||
non-integer, non-finite, or over-ceiling value fails registration with
|
|
||||||
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`. The platform enforces this limit itself, ahead of permission
|
`bodyMode` chooses how that body reaches you, and therefore which ceiling applies
|
||||||
evaluation and the handler, using the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}`
|
([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
|
||||||
contract every other body-consuming route in the platform uses — see
|
|
||||||
|
| `bodyMode` | Ceiling | What the platform does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `"buffered"` (default) | 1 MiB | Reads and counts the whole body **before** your handler runs |
|
||||||
|
| `"streamed"` | 32 MiB | Counts bytes **as you consume them**, and never materializes the body |
|
||||||
|
|
||||||
|
```ts
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/slides",
|
||||||
|
permission: "signage.slides.write",
|
||||||
|
maxBodyBytes: 33_554_432,
|
||||||
|
bodyMode: "streamed",
|
||||||
|
async handler(request, context) { /* read request.body as a stream */ },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `bodyMode` means `buffered`, which is what every route written before this existed meant.
|
||||||
|
|
||||||
|
**Memory.** In `buffered` mode the platform holds at most your declared allowance, which is why that
|
||||||
|
ceiling is small and why `await request.json()` is safe there. In `streamed` mode the platform holds
|
||||||
|
nothing: read `request.body` as a stream. Calling `request.arrayBuffer()` on a streamed route works
|
||||||
|
but throws away the entire point — you have just buffered 32 MiB yourself.
|
||||||
|
|
||||||
|
**Streaming.** A declared `Content-Length` over your limit is refused synchronously, before
|
||||||
|
authorization and before a byte is read. Otherwise the platform counts what actually arrives, so a
|
||||||
|
misleading or absent `Content-Length` bounds nothing.
|
||||||
|
|
||||||
|
**A `413` can arrive after your handler starts.** This is the one consequence of `streamed` mode you
|
||||||
|
must design for. When the running total passes your limit the stream errors, the source is cancelled,
|
||||||
|
and the platform replaces your response with the sanitized `413` — even if you caught the error and
|
||||||
|
answered anyway. If you persist as you read, write to a temporary key and commit only once the read
|
||||||
|
completes; the platform cannot do that for you, because only your module knows what a partial write
|
||||||
|
means to its own data.
|
||||||
|
|
||||||
|
**Timeouts.** Body reading is bounded by the server's own header and request timeouts, not by a
|
||||||
|
per-route setting. A slow upload is a slow request, and the platform has one answer for that.
|
||||||
|
|
||||||
|
**One file per request.** 32 MiB is the platform's absolute ceiling in every mode — there is no
|
||||||
|
unbounded option anywhere in the contract. A UI uploading twenty images issues twenty individually
|
||||||
|
bounded requests; nothing here can express a 500 MiB aggregate upload, deliberately, because one bad
|
||||||
|
byte in one would waste everything already sent.
|
||||||
|
|
||||||
|
Every rejection uses the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}` contract every
|
||||||
|
other body-consuming route in the platform uses — see
|
||||||
[API](../development/api.md#request-body-limits).
|
[API](../development/api.md#request-body-limits).
|
||||||
|
|
||||||
`path` is local to the module, never the path a caller actually sends. The platform alone owns the
|
`path` is local to the module, never the path a caller actually sends. The platform alone owns the
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from "./authorization.js";
|
|||||||
export * from "./identifiers.js";
|
export * from "./identifiers.js";
|
||||||
export * from "./identity.js";
|
export * from "./identity.js";
|
||||||
export * from "./module-configuration.js";
|
export * from "./module-configuration.js";
|
||||||
|
export * from "./module-contribution.js";
|
||||||
export * from "./module-database.js";
|
export * from "./module-database.js";
|
||||||
export * from "./module-http.js";
|
export * from "./module-http.js";
|
||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { capabilityIdSchema, packageRelativePathSchema } from "./identifiers.js";
|
||||||
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **browser contribution**: one module's compiled renderer, declared for a capability another
|
||||||
|
* module provides, and mounted by that other module inside a container it owns (ADR 0041).
|
||||||
|
*
|
||||||
|
* This is deliberately not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts
|
||||||
|
* *the platform* mounts, in surfaces the platform owns. A contribution is mounted by a **consumer
|
||||||
|
* module**, at a moment and in a place only that module understands — a slot in its own layout —
|
||||||
|
* and the platform never renders one on its own.
|
||||||
|
*
|
||||||
|
* ## What the platform knows, and the complete list of it
|
||||||
|
*
|
||||||
|
* Identity, version, target capability, compatible range, and one compiled artifact. That is all,
|
||||||
|
* and the omissions are the point: nothing here says what the renderer draws, what shape its input
|
||||||
|
* has, when it should be mounted, or what any of it means. Core could not learn what a "slide" or a
|
||||||
|
* "timeline slot" is from this declaration, because the declaration cannot express it — the
|
||||||
|
* domain contract belongs entirely to the consumer's own versioned input.
|
||||||
|
*
|
||||||
|
* Inert, like every other manifest declaration: reading one causes no import, no evaluation, and no
|
||||||
|
* network or database access.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `<module-id>.<local-name>` — exactly two segments. The prefix is checked against the declaring
|
||||||
|
* module in `moduleManifestSchema`, which is what makes a contribution id globally unique without a
|
||||||
|
* registry: module ids are already unique, so two modules cannot produce the same contribution id. */
|
||||||
|
export const contributionIdSchema = z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.max(160)
|
||||||
|
.regex(
|
||||||
|
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u,
|
||||||
|
"Must be <module-id>.<local-name>, both lowercase kebab-case.",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const moduleContributionDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
id: contributionIdSchema,
|
||||||
|
/** This contribution's own version, independent of the module's. A consumer sees it and a host
|
||||||
|
* verifies the artifact claims it, so a stale bundle is caught exactly as it is for a module
|
||||||
|
* UI artifact. */
|
||||||
|
version: semanticVersionSchema,
|
||||||
|
/** The capability this renderer plugs into — owned by whichever module provides it, never by
|
||||||
|
* the contributing module. */
|
||||||
|
capability: capabilityIdSchema,
|
||||||
|
/** The provider capability versions this renderer is compatible with. A provider outside this
|
||||||
|
* range makes the contribution unpublishable rather than mounted and broken. */
|
||||||
|
capabilityRange: semanticVersionRangeSchema,
|
||||||
|
/** The compiled browser artifact, resolved and validated during discovery exactly like every
|
||||||
|
* other declared entrypoint: package-relative, inside the package before and after symlink
|
||||||
|
* resolution, and a real `.js`/`.mjs` file. */
|
||||||
|
artifact: packageRelativePathSchema,
|
||||||
|
description: z.string().trim().min(1).max(240).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ModuleContributionDeclaration = z.output<typeof moduleContributionDeclarationSchema>;
|
||||||
|
|
||||||
|
/** The local segment of a contribution id — the part after the declaring module's own id. Used by
|
||||||
|
* the canonical builder to locate the fixed source directory, so a module author configures no
|
||||||
|
* paths and cannot point the build somewhere else. */
|
||||||
|
export function contributionLocalName(contributionId: string): string {
|
||||||
|
return contributionId.slice(contributionId.indexOf(".") + 1);
|
||||||
|
}
|
||||||
@@ -25,21 +25,72 @@ export function isModuleHttpMethod(value: unknown): value is ModuleHttpMethod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform-owned hard ceiling on any single module route's declared request-body allowance, in
|
* How a route's request body reaches its handler, and therefore which ceiling applies to it
|
||||||
* bytes. No module route may declare a `maxBodyBytes` above this value — validated at registration
|
* (ADR 0042).
|
||||||
* (`safeParseModuleRouteDefinition`, below), not merely documented. This is a security contract,
|
*
|
||||||
* not a manifest capability: it does not gate on `manifestVersion` and is enforced identically for
|
* - `buffered` (the default, and everything that existed before): the platform reads and counts the
|
||||||
* every installed module regardless of its declared compatibility range. Large media transfer
|
* whole body **before** the handler runs, so the handler is promised a fully available, bounded
|
||||||
* belongs to a future dedicated storage design, never to widening this ceiling.
|
* body and an oversized request is rejected before any authorization or handler work happens.
|
||||||
|
* Bounded by {@link MODULE_ROUTE_MAX_BODY_BYTES_CEILING}, deliberately small.
|
||||||
|
* - `streamed`: the platform counts bytes **as the handler consumes them** and never materializes
|
||||||
|
* the body. That is what makes an ordinary image upload possible without the platform holding it
|
||||||
|
* in memory, and it is the whole reason the larger ceiling exists.
|
||||||
|
*
|
||||||
|
* Selecting the mode is an explicit, conscious decision rather than something inferred from the size
|
||||||
|
* a route asked for. Inferring it would mean an author who raised a limit silently changed how their
|
||||||
|
* own handler receives its body — and when a `413` can arrive relative to their own code.
|
||||||
|
*/
|
||||||
|
export const MODULE_HTTP_BODY_MODES = ["buffered", "streamed"] as const;
|
||||||
|
|
||||||
|
export type ModuleRouteBodyMode = (typeof MODULE_HTTP_BODY_MODES)[number];
|
||||||
|
|
||||||
|
const moduleRouteBodyModes: ReadonlySet<string> = new Set(MODULE_HTTP_BODY_MODES);
|
||||||
|
|
||||||
|
export function isModuleRouteBodyMode(value: unknown): value is ModuleRouteBodyMode {
|
||||||
|
return typeof value === "string" && moduleRouteBodyModes.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform-owned hard ceiling on a **buffered** module route's declared request-body allowance,
|
||||||
|
* in bytes. No buffered route may declare a `maxBodyBytes` above this value — validated at
|
||||||
|
* registration (`safeParseModuleRouteDefinition`, below), not merely documented. This is a security
|
||||||
|
* contract, not a manifest capability: it does not gate on `manifestVersion` and is enforced
|
||||||
|
* identically for every installed module regardless of its declared compatibility range.
|
||||||
|
*
|
||||||
|
* It stays exactly where it was. A body the platform materializes before a handler runs is a body
|
||||||
|
* the platform is holding in memory, and 1 MiB is the most a JSON request has any business being.
|
||||||
*/
|
*/
|
||||||
export const MODULE_ROUTE_MAX_BODY_BYTES_CEILING = 1_048_576;
|
export const MODULE_ROUTE_MAX_BODY_BYTES_CEILING = 1_048_576;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform-owned hard ceiling on a **streamed** module route's declared allowance, in bytes,
|
||||||
|
* and the platform's absolute request-body ceiling: nothing in Ekklesia may declare or accept a
|
||||||
|
* larger single request body, and there is no unbounded option anywhere in the contract.
|
||||||
|
*
|
||||||
|
* 32 MiB, chosen to hold one 25 MiB image plus bounded request overhead — a multipart envelope, its
|
||||||
|
* boundaries and part headers, and a modest allowance for a client that pads. The unit is
|
||||||
|
* deliberately *one file*: a UI offering twenty images issues twenty individually bounded requests,
|
||||||
|
* each of which the platform can reject cheaply, rather than one 500 MiB request whose failure
|
||||||
|
* wastes everything already sent. Nothing in this contract can express an aggregate upload.
|
||||||
|
*/
|
||||||
|
export const MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING = 33_554_432;
|
||||||
|
|
||||||
|
/** The largest body any module route may declare, in any mode. */
|
||||||
|
export const MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES = MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING;
|
||||||
|
|
||||||
|
/** The ceiling that applies to one delivery mode. */
|
||||||
|
export function moduleRouteBodyCeiling(mode: ModuleRouteBodyMode): number {
|
||||||
|
return mode === "streamed"
|
||||||
|
? MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING
|
||||||
|
: MODULE_ROUTE_MAX_BODY_BYTES_CEILING;
|
||||||
|
}
|
||||||
|
|
||||||
const moduleRouteMaxBodyBytesSchema = z
|
const moduleRouteMaxBodyBytesSchema = z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.finite()
|
.finite()
|
||||||
.min(0)
|
.min(0)
|
||||||
.max(MODULE_ROUTE_MAX_BODY_BYTES_CEILING);
|
.max(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES);
|
||||||
|
|
||||||
const MODULE_ROUTE_PATH_MAX_LENGTH = 256;
|
const MODULE_ROUTE_PATH_MAX_LENGTH = 256;
|
||||||
const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||||
@@ -265,6 +316,12 @@ export interface ModuleRouteDefinitionInput {
|
|||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly permission: string;
|
readonly permission: string;
|
||||||
readonly maxBodyBytes: number;
|
readonly maxBodyBytes: number;
|
||||||
|
/**
|
||||||
|
* How the body reaches the handler, and therefore which ceiling `maxBodyBytes` is checked
|
||||||
|
* against (ADR 0042). Omitted means `buffered`, which is what every route written before this
|
||||||
|
* existed meant, so adding this field changed no existing route's behaviour.
|
||||||
|
*/
|
||||||
|
readonly bodyMode?: ModuleRouteBodyMode;
|
||||||
readonly handler: ModuleRouteHandler;
|
readonly handler: ModuleRouteHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +332,9 @@ export interface ModuleRouteDefinition {
|
|||||||
readonly path: ModuleRoutePath;
|
readonly path: ModuleRoutePath;
|
||||||
readonly permission: PermissionId;
|
readonly permission: PermissionId;
|
||||||
readonly maxBodyBytes: number;
|
readonly maxBodyBytes: number;
|
||||||
|
/** Always present in the canonical form, defaulted to `buffered`, so no consumer downstream has
|
||||||
|
* to re-apply the default and none of them can disagree about it. */
|
||||||
|
readonly bodyMode: ModuleRouteBodyMode;
|
||||||
readonly handler: ModuleRouteHandler;
|
readonly handler: ModuleRouteHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,10 +354,20 @@ export interface RegisteredModuleHttpRoute {
|
|||||||
readonly path: ModuleRoutePath;
|
readonly path: ModuleRoutePath;
|
||||||
readonly permission: PermissionId;
|
readonly permission: PermissionId;
|
||||||
readonly maxBodyBytes: number;
|
readonly maxBodyBytes: number;
|
||||||
|
readonly bodyMode: ModuleRouteBodyMode;
|
||||||
readonly handler: ModuleRouteHandler;
|
readonly handler: ModuleRouteHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleRouteDefinitionKeys = new Set([
|
const moduleRouteDefinitionKeys = new Set([
|
||||||
|
"method",
|
||||||
|
"path",
|
||||||
|
"permission",
|
||||||
|
"maxBodyBytes",
|
||||||
|
"bodyMode",
|
||||||
|
"handler",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const requiredModuleRouteDefinitionKeys = new Set([
|
||||||
"method",
|
"method",
|
||||||
"path",
|
"path",
|
||||||
"permission",
|
"permission",
|
||||||
@@ -314,15 +384,18 @@ function hasModuleRouteDefinitionShape(value: unknown): value is {
|
|||||||
path: unknown;
|
path: unknown;
|
||||||
permission: unknown;
|
permission: unknown;
|
||||||
maxBodyBytes: unknown;
|
maxBodyBytes: unknown;
|
||||||
|
bodyMode: unknown;
|
||||||
handler: unknown;
|
handler: unknown;
|
||||||
} {
|
} {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const keys = Object.keys(value);
|
const keys = Object.keys(value);
|
||||||
|
// Every required field present, `bodyMode` optional, and nothing else at all: a route definition
|
||||||
|
// still cannot carry a baseline, a mount prefix, middleware, or another module's namespace.
|
||||||
return (
|
return (
|
||||||
keys.length === moduleRouteDefinitionKeys.size &&
|
|
||||||
keys.every((key) => moduleRouteDefinitionKeys.has(key)) &&
|
keys.every((key) => moduleRouteDefinitionKeys.has(key)) &&
|
||||||
|
[...requiredModuleRouteDefinitionKeys].every((key) => keys.includes(key)) &&
|
||||||
typeof value.handler === "function"
|
typeof value.handler === "function"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -332,6 +405,7 @@ export type ModuleRouteDefinitionIssueCode =
|
|||||||
| "MODULE_ROUTE_METHOD_INVALID"
|
| "MODULE_ROUTE_METHOD_INVALID"
|
||||||
| "MODULE_ROUTE_PATH_INVALID"
|
| "MODULE_ROUTE_PATH_INVALID"
|
||||||
| "MODULE_ROUTE_PERMISSION_INVALID"
|
| "MODULE_ROUTE_PERMISSION_INVALID"
|
||||||
|
| "MODULE_ROUTE_BODY_MODE_INVALID"
|
||||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID";
|
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID";
|
||||||
|
|
||||||
export type ModuleRouteDefinitionParseResult =
|
export type ModuleRouteDefinitionParseResult =
|
||||||
@@ -360,13 +434,23 @@ export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefin
|
|||||||
if (!parsedPermission.success) {
|
if (!parsedPermission.success) {
|
||||||
return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" };
|
return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" };
|
||||||
}
|
}
|
||||||
|
if (value.bodyMode !== undefined && !isModuleRouteBodyMode(value.bodyMode)) {
|
||||||
|
return { success: false, code: "MODULE_ROUTE_BODY_MODE_INVALID" };
|
||||||
|
}
|
||||||
|
const bodyMode: ModuleRouteBodyMode = isModuleRouteBodyMode(value.bodyMode)
|
||||||
|
? value.bodyMode
|
||||||
|
: "buffered";
|
||||||
const parsedMaxBodyBytes = moduleRouteMaxBodyBytesSchema.safeParse(value.maxBodyBytes);
|
const parsedMaxBodyBytes = moduleRouteMaxBodyBytesSchema.safeParse(value.maxBodyBytes);
|
||||||
if (!parsedMaxBodyBytes.success) {
|
// The ceiling is chosen by the declared mode, so a route asking for more than a buffered body may
|
||||||
|
// hold has to say `streamed` — and thereby accept that its handler consumes a stream and that a
|
||||||
|
// `413` can arrive after its own code has started running.
|
||||||
|
if (!parsedMaxBodyBytes.success || parsedMaxBodyBytes.data > moduleRouteBodyCeiling(bodyMode)) {
|
||||||
return { success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" };
|
return { success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: Object.freeze({
|
data: Object.freeze({
|
||||||
|
bodyMode,
|
||||||
method: value.method,
|
method: value.method,
|
||||||
path: parsedPath.data,
|
path: parsedPath.data,
|
||||||
permission: parsedPermission.data,
|
permission: parsedPermission.data,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
type ModuleConfigurationDeclaration,
|
type ModuleConfigurationDeclaration,
|
||||||
moduleConfigurationDeclarationSchema,
|
moduleConfigurationDeclarationSchema,
|
||||||
} from "./module-configuration.js";
|
} from "./module-configuration.js";
|
||||||
|
import {
|
||||||
|
type ModuleContributionDeclaration,
|
||||||
|
moduleContributionDeclarationSchema,
|
||||||
|
} from "./module-contribution.js";
|
||||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||||
@@ -118,9 +122,9 @@ function reportDuplicates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one manifest shape Core accepts. Version 5's strict `entrypoints` object rejects `display`, so
|
* The one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`,
|
||||||
* the static display declaration is an incompatible shape change and the canonical manifest moves
|
* so the static browser-contribution declaration is an incompatible shape change and the canonical
|
||||||
* to `manifestVersion: 6` rather than silently reinterpreting version 5.
|
* manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6.
|
||||||
*
|
*
|
||||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||||
* executable module code, for one decisive reason: the platform validates a module's configuration
|
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||||
@@ -129,13 +133,14 @@ function reportDuplicates(
|
|||||||
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||||
* importing a single line of module code, exactly as they always have.
|
* importing a single line of module code, exactly as they always have.
|
||||||
*
|
*
|
||||||
* Version 5, and every other version, fails validation: there is no compatibility adapter, no
|
* Version 6, and every other version, fails validation: there is no compatibility adapter, no
|
||||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||||
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
|
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 5
|
||||||
* manifest document can therefore never be read as two different versions. Upgrading a version 5
|
* by version 6 (ADR 0033). One manifest document can therefore never be read as two different
|
||||||
* manifest is a one-character edit for a module that declares no display: the new field is optional.
|
* versions. Upgrading a version 6 manifest is a one-character edit for a module that contributes
|
||||||
|
* nothing: the new field defaults to empty.
|
||||||
*/
|
*/
|
||||||
export const MODULE_MANIFEST_VERSION = 6;
|
export const MODULE_MANIFEST_VERSION = 7;
|
||||||
|
|
||||||
export const moduleManifestSchema = z
|
export const moduleManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -158,6 +163,10 @@ export const moduleManifestSchema = z
|
|||||||
* module code during discovery. A module that needs nothing configured omits the field. */
|
* module code during discovery. A module that needs nothing configured omits the field. */
|
||||||
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
||||||
storage: moduleStorageSchema,
|
storage: moduleStorageSchema,
|
||||||
|
/** Compiled browser renderers this module offers to capabilities **other** modules provide
|
||||||
|
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
|
||||||
|
* never learns what a contribution renders. A module that contributes nothing omits the field. */
|
||||||
|
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
|
||||||
entrypoints: entrypointsSchema,
|
entrypoints: entrypointsSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -200,6 +209,58 @@ export const moduleManifestSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
reportDuplicates(
|
||||||
|
manifest.contributions.map((contribution) => contribution.id),
|
||||||
|
["contributions"],
|
||||||
|
context,
|
||||||
|
"id",
|
||||||
|
);
|
||||||
|
|
||||||
|
const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id));
|
||||||
|
const contributionArtifacts = new Set<string>();
|
||||||
|
manifest.contributions.forEach((contribution, index) => {
|
||||||
|
// Namespacing is what makes a contribution id globally unique without a registry: module ids
|
||||||
|
// are already unique, so two modules can never produce the same contribution id.
|
||||||
|
if (!contribution.id.startsWith(`${manifest.id}.`)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`,
|
||||||
|
path: ["contributions", index, "id"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A contribution extends *another* module's capability. Targeting one this module already
|
||||||
|
// provides would be a module contributing to itself, which needs no platform mechanism at all
|
||||||
|
// and would make the compatibility check meaningless.
|
||||||
|
if (ownedCapabilityIds.has(contribution.capability)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A browser contribution must target a capability another module provides.",
|
||||||
|
path: ["contributions", index, "capability"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Object.entries(manifest.entrypoints).some(([entrypointName, configured]) =>
|
||||||
|
entrypointName === "display"
|
||||||
|
? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact
|
||||||
|
: configured === contribution.artifact,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A contribution artifact must use a different path from every entrypoint.",
|
||||||
|
path: ["contributions", index, "artifact"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (contributionArtifacts.has(contribution.artifact)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Two contributions must not declare the same artifact path.",
|
||||||
|
path: ["contributions", index, "artifact"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
contributionArtifacts.add(contribution.artifact);
|
||||||
|
});
|
||||||
|
|
||||||
manifest.permissions.forEach((permission, index) => {
|
manifest.permissions.forEach((permission, index) => {
|
||||||
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
@@ -263,10 +324,9 @@ export const moduleManifestSchema = z
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownedCapabilities = new Set(manifest.capabilities.map((capability) => capability.id));
|
|
||||||
for (const dependencyKind of ["required", "optional"] as const) {
|
for (const dependencyKind of ["required", "optional"] as const) {
|
||||||
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
|
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
|
||||||
if (ownedCapabilities.has(capability)) {
|
if (ownedCapabilityIds.has(capability)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "A module does not need to depend on a capability it owns.",
|
message: "A module does not need to depend on a capability it owns.",
|
||||||
@@ -297,6 +357,9 @@ export function safeParseModuleManifest(input: unknown) {
|
|||||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||||
|
/** A module manifest's `contributions` array entry — the same `ModuleContributionDeclaration` shape
|
||||||
|
* Core's publication registry consumes (`packages/contracts/src/module-contribution.ts`). */
|
||||||
|
export type ModuleManifestContributionDeclaration = ModuleContributionDeclaration;
|
||||||
export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>;
|
export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>;
|
||||||
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
||||||
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
isModuleHttpMethod,
|
isModuleHttpMethod,
|
||||||
MODULE_HTTP_METHODS,
|
MODULE_HTTP_METHODS,
|
||||||
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||||
|
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||||
|
moduleRouteBodyCeiling,
|
||||||
type ModuleRouteDefinitionInput,
|
type ModuleRouteDefinitionInput,
|
||||||
moduleRoutePathSchema,
|
moduleRoutePathSchema,
|
||||||
moduleRouteShapeKey,
|
moduleRouteShapeKey,
|
||||||
@@ -153,6 +156,7 @@ describe("safeParseModuleRouteDefinition", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.data).toEqual({
|
expect(result.data).toEqual({
|
||||||
|
bodyMode: "buffered",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/events/:eventId",
|
path: "/events/:eventId",
|
||||||
permission: "calendar.events.read",
|
permission: "calendar.events.read",
|
||||||
@@ -258,6 +262,81 @@ describe("safeParseModuleRouteDefinition", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults an omitted bodyMode to buffered, so an existing route means what it always meant", () => {
|
||||||
|
const parsed = safeParseModuleRouteDefinition(validDefinition());
|
||||||
|
|
||||||
|
expect(parsed.success && parsed.data.bodyMode).toBe("buffered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts either declared body mode and carries it through", () => {
|
||||||
|
for (const bodyMode of ["buffered", "streamed"] as const) {
|
||||||
|
const parsed = safeParseModuleRouteDefinition({ ...validDefinition(), bodyMode });
|
||||||
|
|
||||||
|
expect(parsed.success && parsed.data.bodyMode).toBe(bodyMode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a body mode outside the closed set", () => {
|
||||||
|
for (const bodyMode of ["stream", "chunked", "", 1, null]) {
|
||||||
|
expect(safeParseModuleRouteDefinition({ ...validDefinition(), bodyMode })).toEqual({
|
||||||
|
success: false,
|
||||||
|
code: "MODULE_ROUTE_BODY_MODE_INVALID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks maxBodyBytes against the ceiling the declared mode selects", () => {
|
||||||
|
// A route asking for more than a buffered body may hold must say `streamed` — and thereby accept
|
||||||
|
// that its handler consumes a stream (ADR 0042).
|
||||||
|
expect(
|
||||||
|
safeParseModuleRouteDefinition(
|
||||||
|
validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1 }),
|
||||||
|
),
|
||||||
|
).toEqual({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" });
|
||||||
|
expect(
|
||||||
|
safeParseModuleRouteDefinition({
|
||||||
|
...validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1 }),
|
||||||
|
bodyMode: "streamed",
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds one 25 MiB image plus bounded overhead, and refuses one byte more", () => {
|
||||||
|
const image = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
expect(MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING).toBeGreaterThan(image);
|
||||||
|
expect(
|
||||||
|
safeParseModuleRouteDefinition({
|
||||||
|
...validDefinition({ maxBodyBytes: MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING }),
|
||||||
|
bodyMode: "streamed",
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
safeParseModuleRouteDefinition({
|
||||||
|
...validDefinition({ maxBodyBytes: MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING + 1 }),
|
||||||
|
bodyMode: "streamed",
|
||||||
|
}),
|
||||||
|
).toEqual({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has one absolute ceiling and no unbounded option in any mode", () => {
|
||||||
|
expect(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES).toBe(MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING);
|
||||||
|
expect(moduleRouteBodyCeiling("buffered")).toBe(MODULE_ROUTE_MAX_BODY_BYTES_CEILING);
|
||||||
|
expect(moduleRouteBodyCeiling("streamed")).toBe(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES);
|
||||||
|
for (const bodyMode of ["buffered", "streamed"] as const) {
|
||||||
|
for (const maxBodyBytes of [
|
||||||
|
Number.POSITIVE_INFINITY,
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES + 1,
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
safeParseModuleRouteDefinition({ ...validDefinition({ maxBodyBytes }), bodyMode })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
|
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
|
||||||
const source = { ...validDefinition() };
|
const source = { ...validDefinition() };
|
||||||
const result = safeParseModuleRouteDefinition(source);
|
const result = safeParseModuleRouteDefinition(source);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
|
|
||||||
function validManifest(): Record<string, unknown> {
|
function validManifest(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
name: "Calendar",
|
name: "Calendar",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -226,15 +226,21 @@ describe("module manifest contract", () => {
|
|||||||
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
|
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a version 5 manifest rather than silently interpreting it as version 6", () => {
|
it("rejects a version 5 manifest rather than silently interpreting it as a later version", () => {
|
||||||
const version5Manifest = { ...validManifest(), manifestVersion: 5 };
|
const version5Manifest = { ...validManifest(), manifestVersion: 5 };
|
||||||
|
|
||||||
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
|
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a version 6 manifest rather than silently interpreting it as version 7", () => {
|
||||||
|
const version6Manifest = { ...validManifest(), manifestVersion: 6 };
|
||||||
|
|
||||||
|
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||||
expect(MODULE_MANIFEST_VERSION).toBe(6);
|
expect(MODULE_MANIFEST_VERSION).toBe(7);
|
||||||
for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) {
|
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 8, "7", 7.5, null]) {
|
||||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
@@ -243,6 +249,124 @@ describe("module manifest contract", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults contributions to an empty list for a module that contributes nothing", () => {
|
||||||
|
const parsed = safeParseModuleManifest(validManifest());
|
||||||
|
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
expect(parsed.success && parsed.data.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a well-formed browser contribution", () => {
|
||||||
|
const parsed = safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
id: "calendar.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
artifact: "./dist/contributions/signage-slide/index.js",
|
||||||
|
description: "Renders an upcoming-events slide.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
expect(parsed.success && parsed.data.contributions[0]?.capability).toBe(
|
||||||
|
"signage.slide-renderer",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<[string, Record<string, unknown>]>([
|
||||||
|
["an id outside this module's namespace", { id: "signage.slide" }],
|
||||||
|
["an id with no local segment", { id: "calendar" }],
|
||||||
|
["an id with more than one local segment", { id: "calendar.signage.slide" }],
|
||||||
|
["an unknown property", { slot: "primary" }],
|
||||||
|
["a non-semver version", { version: "1" }],
|
||||||
|
["an invalid capability range", { capabilityRange: "not a range" }],
|
||||||
|
["an artifact escaping the package", { artifact: "../elsewhere/index.js" }],
|
||||||
|
["an absolute artifact path", { artifact: "/etc/passwd" }],
|
||||||
|
])("rejects a contribution with %s", (_label, override) => {
|
||||||
|
const contribution = {
|
||||||
|
id: "calendar.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
artifact: "./dist/contributions/signage-slide/index.js",
|
||||||
|
...override,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
safeParseModuleManifest({ ...validManifest(), contributions: [contribution] }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a contribution targeting a capability this module already provides", () => {
|
||||||
|
// A module contributing to itself needs no platform mechanism, and would make the compatibility
|
||||||
|
// check meaningless: it would always be comparing a module against itself.
|
||||||
|
expect(
|
||||||
|
safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
id: "calendar.self",
|
||||||
|
version: "1.0.0",
|
||||||
|
capability: "calendar.events",
|
||||||
|
capabilityRange: "^2.0.0",
|
||||||
|
artifact: "./dist/contributions/self/index.js",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects two contributions sharing an id or an artifact path", () => {
|
||||||
|
const base = {
|
||||||
|
version: "1.0.0",
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
contributions: [
|
||||||
|
{ ...base, id: "calendar.a", artifact: "./dist/contributions/a/index.js" },
|
||||||
|
{ ...base, id: "calendar.a", artifact: "./dist/contributions/b/index.js" },
|
||||||
|
],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
contributions: [
|
||||||
|
{ ...base, id: "calendar.a", artifact: "./dist/contributions/a/index.js" },
|
||||||
|
{ ...base, id: "calendar.b", artifact: "./dist/contributions/a/index.js" },
|
||||||
|
],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a contribution artifact that collides with a declared entrypoint", () => {
|
||||||
|
const manifest = validManifest();
|
||||||
|
const entrypoints = manifest.entrypoints as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
safeParseModuleManifest({
|
||||||
|
...manifest,
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
id: "calendar.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange: "^1.0.0",
|
||||||
|
artifact: entrypoints.server,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a bare permission-id string array instead of complete declarations", () => {
|
it("rejects a bare permission-id string array instead of complete declarations", () => {
|
||||||
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
|
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/design-tokens",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/design-tokens",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"happy-dom": "20.0.11",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "design-tokens"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./install.js";
|
||||||
|
export * from "./registry.js";
|
||||||
|
export * from "./token.js";
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { ekklesiaDesignTokenStylesheet } from "./registry.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attribute that marks a token stylesheet this package installed. Used to make installation
|
||||||
|
* idempotent, and to let a host remove exactly what it added.
|
||||||
|
*/
|
||||||
|
export const DESIGN_TOKEN_STYLE_ATTRIBUTE = "data-ekklesia-design-tokens";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A root that can own a `<style>` element: a whole document, or one artifact's shadow root.
|
||||||
|
*
|
||||||
|
* Structural rather than nominal (`Document | ShadowRoot`) on purpose. This package must not
|
||||||
|
* reference DOM interfaces it does not need, and the two things a caller actually passes differ in
|
||||||
|
* everything except these three members.
|
||||||
|
*/
|
||||||
|
export interface DesignTokenStyleRoot {
|
||||||
|
querySelector(selectors: string): Element | null;
|
||||||
|
appendChild<Node_ extends Node>(node: Node_): Node_;
|
||||||
|
readonly ownerDocument?: Document | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentFor(root: DesignTokenStyleRoot): Document | undefined {
|
||||||
|
if (typeof (root as Partial<Document>).createElement === "function") {
|
||||||
|
return root as Document;
|
||||||
|
}
|
||||||
|
return root.ownerDocument ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs Ekklesia's default token values at a host root, once.
|
||||||
|
*
|
||||||
|
* Idempotent by construction: a second call finds the element it inserted the first time and
|
||||||
|
* updates nothing, so a re-render, a re-mount, or two independent callers can never leave two
|
||||||
|
* competing copies of the theme in one root.
|
||||||
|
*
|
||||||
|
* CSS custom properties **inherit through shadow boundaries**, so installing this at the document
|
||||||
|
* root is what makes the same tokens available inside every isolated module artifact without the
|
||||||
|
* artifact importing anything or the host reaching into it. A caller that genuinely owns an isolated
|
||||||
|
* root with no inheriting ancestor — a standalone display, a test — installs with `scope: "host"`
|
||||||
|
* into that root instead.
|
||||||
|
*
|
||||||
|
* Returns a function that removes exactly the element this call inserted, and nothing else; calling
|
||||||
|
* it twice is safe. Returns a no-op when the tokens were already present, so a caller can never
|
||||||
|
* uninstall a theme it did not install.
|
||||||
|
*/
|
||||||
|
export function installEkklesiaDesignTokens(
|
||||||
|
root: DesignTokenStyleRoot,
|
||||||
|
options: { readonly scope?: "root" | "host" } = {},
|
||||||
|
): () => void {
|
||||||
|
if (root.querySelector(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`)) {
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
const ownerDocument = documentFor(root);
|
||||||
|
if (!ownerDocument) {
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
const style = ownerDocument.createElement("style");
|
||||||
|
style.setAttribute(DESIGN_TOKEN_STYLE_ATTRIBUTE, "");
|
||||||
|
style.textContent = ekklesiaDesignTokenStylesheet(options.scope ?? "root");
|
||||||
|
const target =
|
||||||
|
(ownerDocument === (root as unknown as Document) ? ownerDocument.head : undefined) ?? root;
|
||||||
|
target.appendChild(style);
|
||||||
|
|
||||||
|
let removed = false;
|
||||||
|
return () => {
|
||||||
|
if (removed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removed = true;
|
||||||
|
style.remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
import {
|
||||||
|
createDesignTokenRegistry,
|
||||||
|
type DesignTokenInput,
|
||||||
|
type DesignTokenScope,
|
||||||
|
} from "./token.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ekklesia's global semantic theme.
|
||||||
|
*
|
||||||
|
* These are **semantic** tokens, not a palette. Nothing here is named for the colour it currently
|
||||||
|
* happens to be, because the whole point is that a future theme editor can change the value without
|
||||||
|
* every consumer's meaning changing with it: `background.raised` stays "the surface that sits above
|
||||||
|
* an ordinary surface" whatever a church's brand makes it.
|
||||||
|
*
|
||||||
|
* The default values are a neutral, high-contrast light theme. They are defaults, not a brand —
|
||||||
|
* a deployment overrides them by installing different values at its host root, and this pass
|
||||||
|
* deliberately ships no theme editor, brand builder, or dark-mode variant to do that with.
|
||||||
|
*
|
||||||
|
* ## Status is never colour alone
|
||||||
|
*
|
||||||
|
* Each status family carries a `symbol` token alongside its colours. A status that is only red is
|
||||||
|
* invisible to a person who cannot distinguish it, and unreadable on a projector or a washed-out
|
||||||
|
* lobby screen. Component code renders the symbol next to the label, so the meaning survives losing
|
||||||
|
* the colour entirely (WCAG 2.2 §1.4.1).
|
||||||
|
*/
|
||||||
|
const declarations = [
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Backgrounds — the elevation ladder, from the page behind everything to the overlay above it.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "The furthest-back background of a whole page or a full-viewport display.",
|
||||||
|
label: "Page background",
|
||||||
|
name: "background.page",
|
||||||
|
value: "#f6f6f5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"A large working area inside a page — a scrolling list region, a preview area, an editor body.",
|
||||||
|
label: "Canvas background",
|
||||||
|
name: "background.canvas",
|
||||||
|
value: "#fbfbfa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "An ordinary panel, card, table, or dialog body sitting on the page.",
|
||||||
|
label: "Surface background",
|
||||||
|
name: "background.surface",
|
||||||
|
value: "#ffffff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "A surface deliberately lifted above an ordinary surface, such as a popover.",
|
||||||
|
label: "Raised surface background",
|
||||||
|
name: "background.raised",
|
||||||
|
value: "#ffffff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The scrim drawn behind a modal dialog to separate it from the page beneath.",
|
||||||
|
label: "Overlay background",
|
||||||
|
name: "background.overlay",
|
||||||
|
value: "rgba(23, 23, 21, 0.55)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "A recessed area such as a text input, a code block, or a well.",
|
||||||
|
label: "Inset background",
|
||||||
|
name: "background.inset",
|
||||||
|
value: "#f2f2f0",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Text.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "Body copy and headings — the default foreground for readable content.",
|
||||||
|
label: "Primary text",
|
||||||
|
name: "text.primary",
|
||||||
|
value: "#1c1917",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Supporting text that is still content: captions, secondary columns, help text.",
|
||||||
|
label: "Secondary text",
|
||||||
|
name: "text.secondary",
|
||||||
|
value: "#57534e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
|
||||||
|
label: "Muted text",
|
||||||
|
name: "text.muted",
|
||||||
|
value: "#79716b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Text drawn on a strongly coloured background, such as a primary button.",
|
||||||
|
label: "Inverse text",
|
||||||
|
name: "text.inverse",
|
||||||
|
value: "#ffffff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Text reporting a successful outcome, paired with the success symbol.",
|
||||||
|
label: "Success text",
|
||||||
|
name: "text.success",
|
||||||
|
value: "#166534",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Text reporting a caution or a degraded state, paired with the warning symbol.",
|
||||||
|
label: "Warning text",
|
||||||
|
name: "text.warning",
|
||||||
|
value: "#854d0e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Text reporting a failure or a destructive consequence, paired with its symbol.",
|
||||||
|
label: "Danger text",
|
||||||
|
name: "text.danger",
|
||||||
|
value: "#9f1239",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Accent and interactive states.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "The primary action colour: primary buttons, selected tabs, active links.",
|
||||||
|
label: "Accent",
|
||||||
|
name: "accent.base",
|
||||||
|
value: "#1d4ed8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
|
||||||
|
label: "Strong accent",
|
||||||
|
name: "accent.strong",
|
||||||
|
value: "#1e3a8a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "A faint accent wash for selected rows and subtle accent backgrounds.",
|
||||||
|
label: "Subtle accent",
|
||||||
|
name: "accent.subtle",
|
||||||
|
value: "#e8effc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Foreground drawn on top of the accent background.",
|
||||||
|
label: "Accent foreground",
|
||||||
|
name: "accent.foreground",
|
||||||
|
value: "#ffffff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The focus ring colour. Never the only signal that something has focus.",
|
||||||
|
label: "Focus ring",
|
||||||
|
name: "accent.focus",
|
||||||
|
value: "#1d4ed8",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Borders.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "The default border around panels, inputs, and table cells.",
|
||||||
|
label: "Border",
|
||||||
|
name: "border.base",
|
||||||
|
value: "#d7d3d0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "A heavier border for emphasis, active inputs, and selected outlines.",
|
||||||
|
label: "Strong border",
|
||||||
|
name: "border.strong",
|
||||||
|
value: "#a8a29e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "A hairline rule separating rows or sections inside one surface.",
|
||||||
|
label: "Divider",
|
||||||
|
name: "border.divider",
|
||||||
|
value: "#e7e5e4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The border width used by controls and panels.",
|
||||||
|
label: "Border width",
|
||||||
|
name: "border.width",
|
||||||
|
value: "1px",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Status families. Each carries a symbol so meaning never depends on colour.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "Background of a success badge, banner, or row highlight.",
|
||||||
|
label: "Success background",
|
||||||
|
name: "status.success-background",
|
||||||
|
value: "#dcfce7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Border of a success badge or banner.",
|
||||||
|
label: "Success border",
|
||||||
|
name: "status.success-border",
|
||||||
|
value: "#86efac",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The non-colour signal for success, rendered beside the label.",
|
||||||
|
label: "Success symbol",
|
||||||
|
name: "status.success-symbol",
|
||||||
|
value: '"✓"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Background of a warning badge, banner, or row highlight.",
|
||||||
|
label: "Warning background",
|
||||||
|
name: "status.warning-background",
|
||||||
|
value: "#fef3c7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Border of a warning badge or banner.",
|
||||||
|
label: "Warning border",
|
||||||
|
name: "status.warning-border",
|
||||||
|
value: "#fcd34d",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The non-colour signal for a warning, rendered beside the label.",
|
||||||
|
label: "Warning symbol",
|
||||||
|
name: "status.warning-symbol",
|
||||||
|
value: '"!"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Background of a danger badge, banner, or row highlight.",
|
||||||
|
label: "Danger background",
|
||||||
|
name: "status.danger-background",
|
||||||
|
value: "#ffe4e6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Border of a danger badge or banner.",
|
||||||
|
label: "Danger border",
|
||||||
|
name: "status.danger-border",
|
||||||
|
value: "#fda4af",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The non-colour signal for danger or failure, rendered beside the label.",
|
||||||
|
label: "Danger symbol",
|
||||||
|
name: "status.danger-symbol",
|
||||||
|
value: '"✕"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Background of a neutral informational badge or banner.",
|
||||||
|
label: "Info background",
|
||||||
|
name: "status.info-background",
|
||||||
|
value: "#e8effc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Border of a neutral informational badge or banner.",
|
||||||
|
label: "Info border",
|
||||||
|
name: "status.info-border",
|
||||||
|
value: "#bfd3f7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The non-colour signal for information, rendered beside the label.",
|
||||||
|
label: "Info symbol",
|
||||||
|
name: "status.info-symbol",
|
||||||
|
value: '"i"',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Interaction states.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "Background wash under a hovered control or row.",
|
||||||
|
label: "Hover background",
|
||||||
|
name: "state.hover-background",
|
||||||
|
value: "#f2f2f0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Background wash under a control being pressed.",
|
||||||
|
label: "Pressed background",
|
||||||
|
name: "state.pressed-background",
|
||||||
|
value: "#e7e5e4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Background of a selected row, tab, or list item.",
|
||||||
|
label: "Selected background",
|
||||||
|
name: "state.selected-background",
|
||||||
|
value: "#e8effc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Foreground of a selected row, tab, or list item.",
|
||||||
|
label: "Selected foreground",
|
||||||
|
name: "state.selected-foreground",
|
||||||
|
value: "#1e3a8a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Opacity applied to a disabled control. Disabled state is never colour alone.",
|
||||||
|
label: "Disabled opacity",
|
||||||
|
name: "state.disabled-opacity",
|
||||||
|
value: "0.55",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Thickness of the keyboard focus ring.",
|
||||||
|
label: "Focus ring width",
|
||||||
|
name: "state.focus-visible-width",
|
||||||
|
value: "2px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Gap between a focused control and its focus ring.",
|
||||||
|
label: "Focus ring offset",
|
||||||
|
name: "state.focus-visible-offset",
|
||||||
|
value: "2px",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Typography.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "The interface font stack for all ordinary text.",
|
||||||
|
label: "Interface font family",
|
||||||
|
name: "typography.family-sans",
|
||||||
|
value: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The monospace stack for identifiers, keys, and code.",
|
||||||
|
label: "Monospace font family",
|
||||||
|
name: "typography.family-mono",
|
||||||
|
value: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Smallest supported text size, for dense metadata only.",
|
||||||
|
label: "Extra small font size",
|
||||||
|
name: "typography.size-xs",
|
||||||
|
value: "0.75rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Small text: captions, table metadata, help text.",
|
||||||
|
label: "Small font size",
|
||||||
|
name: "typography.size-sm",
|
||||||
|
value: "0.875rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The default body text size.",
|
||||||
|
label: "Body font size",
|
||||||
|
name: "typography.size-md",
|
||||||
|
value: "1rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Large body text and small headings.",
|
||||||
|
label: "Large font size",
|
||||||
|
name: "typography.size-lg",
|
||||||
|
value: "1.125rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Third-level heading size.",
|
||||||
|
label: "Heading 3 font size",
|
||||||
|
name: "typography.size-heading-3",
|
||||||
|
value: "1.25rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Second-level heading size.",
|
||||||
|
label: "Heading 2 font size",
|
||||||
|
name: "typography.size-heading-2",
|
||||||
|
value: "1.5rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "First-level heading size — one per screen.",
|
||||||
|
label: "Heading 1 font size",
|
||||||
|
name: "typography.size-heading-1",
|
||||||
|
value: "1.875rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Normal text weight.",
|
||||||
|
label: "Regular font weight",
|
||||||
|
name: "typography.weight-regular",
|
||||||
|
value: "400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Weight for labels, table headers, and emphasised body text.",
|
||||||
|
label: "Medium font weight",
|
||||||
|
name: "typography.weight-medium",
|
||||||
|
value: "500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Weight for headings and primary actions.",
|
||||||
|
label: "Bold font weight",
|
||||||
|
name: "typography.weight-bold",
|
||||||
|
value: "600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Tight line height for headings and single-line controls.",
|
||||||
|
label: "Tight line height",
|
||||||
|
name: "typography.line-height-tight",
|
||||||
|
value: "1.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Default line height for body copy.",
|
||||||
|
label: "Normal line height",
|
||||||
|
name: "typography.line-height-normal",
|
||||||
|
value: "1.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Relaxed line height for long-form reading.",
|
||||||
|
label: "Relaxed line height",
|
||||||
|
name: "typography.line-height-relaxed",
|
||||||
|
value: "1.7",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Spacing scale.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "No space. Named so a component never hardcodes a bare zero.",
|
||||||
|
label: "No spacing",
|
||||||
|
name: "spacing.none",
|
||||||
|
value: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Hairline spacing between tightly related inline elements.",
|
||||||
|
label: "Extra extra small spacing",
|
||||||
|
name: "spacing.2xs",
|
||||||
|
value: "0.125rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Spacing inside a compact control.",
|
||||||
|
label: "Extra small spacing",
|
||||||
|
name: "spacing.xs",
|
||||||
|
value: "0.25rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Spacing between a label and its field.",
|
||||||
|
label: "Small spacing",
|
||||||
|
name: "spacing.sm",
|
||||||
|
value: "0.5rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "The default gap between related elements.",
|
||||||
|
label: "Medium spacing",
|
||||||
|
name: "spacing.md",
|
||||||
|
value: "0.75rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Padding inside a panel or dialog.",
|
||||||
|
label: "Large spacing",
|
||||||
|
name: "spacing.lg",
|
||||||
|
value: "1rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Spacing between sections of one screen.",
|
||||||
|
label: "Extra large spacing",
|
||||||
|
name: "spacing.xl",
|
||||||
|
value: "1.5rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Spacing between major regions of a page.",
|
||||||
|
label: "Extra extra large spacing",
|
||||||
|
name: "spacing.2xl",
|
||||||
|
value: "2.5rem",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Radii and shadows.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "Square corners.",
|
||||||
|
label: "No radius",
|
||||||
|
name: "radius.none",
|
||||||
|
value: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Corner radius for badges and small inline elements.",
|
||||||
|
label: "Small radius",
|
||||||
|
name: "radius.sm",
|
||||||
|
value: "0.25rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Corner radius for controls, inputs, and buttons.",
|
||||||
|
label: "Medium radius",
|
||||||
|
name: "radius.md",
|
||||||
|
value: "0.375rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Corner radius for panels and dialogs.",
|
||||||
|
label: "Large radius",
|
||||||
|
name: "radius.lg",
|
||||||
|
value: "0.75rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Fully rounded ends, for pills and status dots.",
|
||||||
|
label: "Pill radius",
|
||||||
|
name: "radius.pill",
|
||||||
|
value: "9999px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "No shadow.",
|
||||||
|
label: "No shadow",
|
||||||
|
name: "shadow.none",
|
||||||
|
value: "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Subtle lift for a resting panel.",
|
||||||
|
label: "Small shadow",
|
||||||
|
name: "shadow.sm",
|
||||||
|
value: "0 1px 2px rgba(23, 23, 21, 0.08)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Shadow for a popover or a raised surface.",
|
||||||
|
label: "Medium shadow",
|
||||||
|
name: "shadow.md",
|
||||||
|
value: "0 4px 12px rgba(23, 23, 21, 0.12)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Shadow for a modal dialog.",
|
||||||
|
label: "Large shadow",
|
||||||
|
name: "shadow.lg",
|
||||||
|
value: "0 16px 40px rgba(23, 23, 21, 0.22)",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Control sizing.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
description: "Height of a compact control.",
|
||||||
|
label: "Small control height",
|
||||||
|
name: "control.height-sm",
|
||||||
|
value: "1.75rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Height of an ordinary button, input, or select.",
|
||||||
|
label: "Medium control height",
|
||||||
|
name: "control.height-md",
|
||||||
|
value: "2.25rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Height of a prominent primary control.",
|
||||||
|
label: "Large control height",
|
||||||
|
name: "control.height-lg",
|
||||||
|
value: "2.75rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Horizontal padding inside a control.",
|
||||||
|
label: "Control padding",
|
||||||
|
name: "control.padding-inline",
|
||||||
|
value: "0.75rem",
|
||||||
|
},
|
||||||
|
] as const satisfies readonly DesignTokenInput[];
|
||||||
|
|
||||||
|
/** Every registered token name, as a union — the compile-time half of "modules consume registered
|
||||||
|
* tokens and never invent new global theme variables". */
|
||||||
|
export type EkklesiaDesignTokenName = (typeof declarations)[number]["name"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one global semantic token registry. Built (and therefore validated) at module evaluation, so
|
||||||
|
* an invalid declaration fails where it was written rather than in a browser.
|
||||||
|
*/
|
||||||
|
export const ekklesiaDesignTokens = createDesignTokenRegistry(declarations);
|
||||||
|
|
||||||
|
/** `var(--ekklesia-…)` for a registered token. The typed reference component and module code use;
|
||||||
|
* an unregistered name is a compile error, and also a runtime `DesignTokenError`. */
|
||||||
|
export function designToken(name: EkklesiaDesignTokenName): string {
|
||||||
|
return ekklesiaDesignTokens.reference(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The complete default theme as one installable CSS rule. */
|
||||||
|
export function ekklesiaDesignTokenStylesheet(scope: DesignTokenScope = "root"): string {
|
||||||
|
return ekklesiaDesignTokens.stylesheet(scope);
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* The design-token contract: what a semantic token is, what makes one valid, and how a validated
|
||||||
|
* registry is built from a declaration list.
|
||||||
|
*
|
||||||
|
* This package has **no dependencies at all**, exactly like `@ekklesia/module-ui-sdk` and for the
|
||||||
|
* same reason (ADR 0024): it is imported by browser programs — the Dashboard shell, a module's own
|
||||||
|
* UI artifact, a contributed renderer — and a design contract that dragged a backend type graph
|
||||||
|
* behind it would put Node's global declarations back into every one of them. Nothing here reads
|
||||||
|
* `process`, touches a filesystem, or imports a platform contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The closed set of token families. A token's category is **derived from the first segment of its
|
||||||
|
* name** rather than declared separately, so a token can never claim one category while being named
|
||||||
|
* for another, and a future theme editor can group the registry without a second source of truth.
|
||||||
|
*/
|
||||||
|
export const DESIGN_TOKEN_CATEGORIES = [
|
||||||
|
"background",
|
||||||
|
"text",
|
||||||
|
"accent",
|
||||||
|
"border",
|
||||||
|
"status",
|
||||||
|
"state",
|
||||||
|
"typography",
|
||||||
|
"spacing",
|
||||||
|
"radius",
|
||||||
|
"shadow",
|
||||||
|
"control",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DesignTokenCategory = (typeof DESIGN_TOKEN_CATEGORIES)[number];
|
||||||
|
|
||||||
|
const categories: ReadonlySet<string> = new Set(DESIGN_TOKEN_CATEGORIES);
|
||||||
|
|
||||||
|
/** The one prefix every Ekklesia global theme variable uses. A module may reference these and may
|
||||||
|
* define its own differently-prefixed private aliases; it may never define one of these. */
|
||||||
|
export const DESIGN_TOKEN_VARIABLE_PREFIX = "--ekklesia-";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dotted, lowercase, kebab-cased segments with at least two segments — `background.page`, not
|
||||||
|
* `page`. The first segment is the category, so a one-segment name has no family to belong to.
|
||||||
|
*/
|
||||||
|
const TOKEN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/u;
|
||||||
|
|
||||||
|
/** The CSS custom-property identifier grammar this package will emit, checked against the derived
|
||||||
|
* name rather than trusted. A value that cannot pass this can never reach a stylesheet. */
|
||||||
|
const CSS_VARIABLE_PATTERN = /^--ekklesia-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bound on a token's default value.
|
||||||
|
*
|
||||||
|
* A design token is a small, declarative constant — a color, a length, a font stack, a shadow — and
|
||||||
|
* every fragment rejected below is either an injection vector or a sign that arbitrary CSS is being
|
||||||
|
* smuggled through the token registry into a `<style>` element. Rejecting them is what "no unbounded
|
||||||
|
* arbitrary values" means concretely: the registry stays a value table rather than a second,
|
||||||
|
* unreviewed stylesheet.
|
||||||
|
*/
|
||||||
|
export const DESIGN_TOKEN_VALUE_MAX_LENGTH = 160;
|
||||||
|
|
||||||
|
const FORBIDDEN_VALUE_FRAGMENTS = [
|
||||||
|
";",
|
||||||
|
"{",
|
||||||
|
"}",
|
||||||
|
"<",
|
||||||
|
">",
|
||||||
|
"/*",
|
||||||
|
"*/",
|
||||||
|
"@",
|
||||||
|
"url(",
|
||||||
|
"expression(",
|
||||||
|
"!important",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DESIGN_TOKEN_LABEL_MAX_LENGTH = 80;
|
||||||
|
export const DESIGN_TOKEN_DESCRIPTION_MAX_LENGTH = 240;
|
||||||
|
|
||||||
|
/** What a token declaration says. Everything else about a token is derived. */
|
||||||
|
export interface DesignTokenInput {
|
||||||
|
/** `category.rest-of-name`, lowercase kebab-case segments. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Short human-readable name, for a future theme editor's field label. */
|
||||||
|
readonly label: string;
|
||||||
|
/** What the token means and when to reach for it. Enumerable, never rendered by the platform. */
|
||||||
|
readonly description: string;
|
||||||
|
/** The default CSS value installed at a host root. */
|
||||||
|
readonly value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A validated token: its declaration plus the two derived facts callers actually consume. */
|
||||||
|
export interface DesignToken extends DesignTokenInput {
|
||||||
|
readonly category: DesignTokenCategory;
|
||||||
|
/** `--ekklesia-<name with dots replaced by dashes>`. */
|
||||||
|
readonly cssVariable: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesignTokenErrorCode =
|
||||||
|
| "DESIGN_TOKEN_NAME_INVALID"
|
||||||
|
| "DESIGN_TOKEN_CATEGORY_UNKNOWN"
|
||||||
|
| "DESIGN_TOKEN_VARIABLE_INVALID"
|
||||||
|
| "DESIGN_TOKEN_LABEL_INVALID"
|
||||||
|
| "DESIGN_TOKEN_DESCRIPTION_INVALID"
|
||||||
|
| "DESIGN_TOKEN_VALUE_INVALID"
|
||||||
|
| "DESIGN_TOKEN_DUPLICATE"
|
||||||
|
| "DESIGN_TOKEN_UNKNOWN"
|
||||||
|
| "DESIGN_TOKEN_SCOPE_INVALID";
|
||||||
|
|
||||||
|
const errorMessages: Readonly<Record<DesignTokenErrorCode, string>> = Object.freeze({
|
||||||
|
DESIGN_TOKEN_NAME_INVALID:
|
||||||
|
"A design token name must be at least two lowercase kebab-case segments separated by dots.",
|
||||||
|
DESIGN_TOKEN_CATEGORY_UNKNOWN: "A design token's first name segment must be a known category.",
|
||||||
|
DESIGN_TOKEN_VARIABLE_INVALID:
|
||||||
|
"A design token must derive a valid --ekklesia- CSS custom property identifier.",
|
||||||
|
DESIGN_TOKEN_LABEL_INVALID: "A design token must declare a bounded, non-empty label.",
|
||||||
|
DESIGN_TOKEN_DESCRIPTION_INVALID: "A design token must declare a bounded, non-empty description.",
|
||||||
|
DESIGN_TOKEN_VALUE_INVALID:
|
||||||
|
"A design token value must be a bounded literal CSS value with no declaration, comment, at-rule, url(), or !important syntax.",
|
||||||
|
DESIGN_TOKEN_DUPLICATE: "A design token name is declared more than once.",
|
||||||
|
DESIGN_TOKEN_UNKNOWN: "That design token is not registered.",
|
||||||
|
DESIGN_TOKEN_SCOPE_INVALID: 'A design token stylesheet scope must be "root" or "host".',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries a stable code and a fixed message, never the offending token name or value — the same
|
||||||
|
* sanitization rule every other Ekklesia error boundary follows. The rejected declaration is
|
||||||
|
* visible to the developer who wrote it, in the source file that failed to load.
|
||||||
|
*/
|
||||||
|
export class DesignTokenError extends Error {
|
||||||
|
readonly code: DesignTokenErrorCode;
|
||||||
|
|
||||||
|
constructor(code: DesignTokenErrorCode) {
|
||||||
|
super(errorMessages[code]);
|
||||||
|
this.name = "DesignTokenError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoundedText(value: unknown, maxLength: number): value is string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Written as a code-point scan rather than a character-class regex: a regex spelling of this range
|
||||||
|
* has to contain the control characters themselves, which is both unreadable and exactly the kind of
|
||||||
|
* literal the linter refuses. */
|
||||||
|
function containsControlCharacter(value: string): boolean {
|
||||||
|
for (const character of value) {
|
||||||
|
const codePoint = character.codePointAt(0) ?? 0;
|
||||||
|
if (codePoint < 0x20 || codePoint === 0x7f) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTokenValue(value: unknown): value is string {
|
||||||
|
if (!isBoundedText(value, DESIGN_TOKEN_VALUE_MAX_LENGTH)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value !== value.trim() || containsControlCharacter(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value.includes("\\")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !FORBIDDEN_VALUE_FRAGMENTS.some((fragment) => value.includes(fragment));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function designTokenVariableName(name: string): string {
|
||||||
|
return `${DESIGN_TOKEN_VARIABLE_PREFIX}${name.split(".").join("-")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateToken(input: DesignTokenInput): DesignToken {
|
||||||
|
if (typeof input.name !== "string" || !TOKEN_NAME_PATTERN.test(input.name)) {
|
||||||
|
throw new DesignTokenError("DESIGN_TOKEN_NAME_INVALID");
|
||||||
|
}
|
||||||
|
const category = input.name.split(".")[0] as string;
|
||||||
|
if (!categories.has(category)) {
|
||||||
|
throw new DesignTokenError("DESIGN_TOKEN_CATEGORY_UNKNOWN");
|
||||||
|
}
|
||||||
|
const cssVariable = designTokenVariableName(input.name);
|
||||||
|
if (!CSS_VARIABLE_PATTERN.test(cssVariable)) {
|
||||||
|
throw new DesignTokenError("DESIGN_TOKEN_VARIABLE_INVALID");
|
||||||
|
}
|
||||||
|
if (!isBoundedText(input.label, DESIGN_TOKEN_LABEL_MAX_LENGTH)) {
|
||||||
|
throw new DesignTokenError("DESIGN_TOKEN_LABEL_INVALID");
|
||||||
|
}
|
||||||
|
if (!isBoundedText(input.description, DESIGN_TOKEN_DESCRIPTION_MAX_LENGTH)) {
|
||||||
|
throw new DesignTokenError("DESIGN_TOKEN_DESCRIPTION_INVALID");
|
||||||
|
}
|
||||||
|
if (!isTokenValue(input.value)) {
|
||||||
|
throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
category: category as DesignTokenCategory,
|
||||||
|
cssVariable,
|
||||||
|
description: input.description,
|
||||||
|
label: input.label,
|
||||||
|
name: input.name,
|
||||||
|
value: input.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a generated token stylesheet installs its declarations. `root` targets a document, `host`
|
||||||
|
* targets the element a shadow root is attached to. There is deliberately no caller-chosen selector:
|
||||||
|
* an arbitrary selector string is exactly the unbounded value this package refuses everywhere else. */
|
||||||
|
export type DesignTokenScope = "root" | "host";
|
||||||
|
|
||||||
|
export interface DesignTokenRegistry<Name extends string = string> {
|
||||||
|
/** Every token, in declaration order. Stable, frozen, and the enumeration a theme editor reads. */
|
||||||
|
list(): readonly DesignToken[];
|
||||||
|
/** Every token in one category, in declaration order. */
|
||||||
|
listByCategory(category: DesignTokenCategory): readonly DesignToken[];
|
||||||
|
has(name: unknown): boolean;
|
||||||
|
/** The validated token, or `undefined`. Takes `unknown` so a lookup from untyped data fails
|
||||||
|
* closed rather than assuming validation already happened. */
|
||||||
|
get(name: unknown): DesignToken | undefined;
|
||||||
|
/** `--ekklesia-…` for a registered token. Throws `DesignTokenError` for anything else. */
|
||||||
|
variable(name: Name): string;
|
||||||
|
/** `var(--ekklesia-…)` for a registered token — what component code actually writes. */
|
||||||
|
reference(name: Name): string;
|
||||||
|
/** The complete default theme as one CSS rule, ready to install at a host root. */
|
||||||
|
stylesheet(scope?: DesignTokenScope): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a declaration list and returns the immutable registry built from it.
|
||||||
|
*
|
||||||
|
* Every rule is enforced here rather than at review time: an invalid name, an unknown category, a
|
||||||
|
* malformed derived CSS identifier, an empty or overlong label or description, a value that is not
|
||||||
|
* a bounded literal, and a duplicate name each throw. A registry that constructs is therefore a
|
||||||
|
* registry that can be emitted as CSS without escaping, because nothing that needs escaping can be
|
||||||
|
* in it.
|
||||||
|
*/
|
||||||
|
export function createDesignTokenRegistry<const Tokens extends readonly DesignTokenInput[]>(
|
||||||
|
declarations: Tokens,
|
||||||
|
): DesignTokenRegistry<Tokens[number]["name"]> {
|
||||||
|
const byName = new Map<string, DesignToken>();
|
||||||
|
for (const declaration of declarations) {
|
||||||
|
const token = validateToken(declaration);
|
||||||
|
if (byName.has(token.name)) {
|
||||||
|
throw new DesignTokenError("DESIGN_TOKEN_DUPLICATE");
|
||||||
|
}
|
||||||
|
byName.set(token.name, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = Object.freeze([...byName.values()]);
|
||||||
|
const byCategory = new Map<DesignTokenCategory, readonly DesignToken[]>(
|
||||||
|
DESIGN_TOKEN_CATEGORIES.map((category) => [
|
||||||
|
category,
|
||||||
|
Object.freeze(tokens.filter((token) => token.category === category)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function requireToken(name: unknown): DesignToken {
|
||||||
|
const token = typeof name === "string" ? byName.get(name) : undefined;
|
||||||
|
if (!token) {
|
||||||
|
throw new DesignTokenError("DESIGN_TOKEN_UNKNOWN");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
get: (name: unknown): DesignToken | undefined =>
|
||||||
|
typeof name === "string" ? byName.get(name) : undefined,
|
||||||
|
has: (name: unknown): boolean => typeof name === "string" && byName.has(name),
|
||||||
|
list: (): readonly DesignToken[] => tokens,
|
||||||
|
listByCategory: (category: DesignTokenCategory): readonly DesignToken[] =>
|
||||||
|
byCategory.get(category) ?? Object.freeze([]),
|
||||||
|
reference: (name: string): string => `var(${requireToken(name).cssVariable})`,
|
||||||
|
stylesheet: (scope: DesignTokenScope = "root"): string => {
|
||||||
|
if (scope !== "root" && scope !== "host") {
|
||||||
|
throw new DesignTokenError("DESIGN_TOKEN_SCOPE_INVALID");
|
||||||
|
}
|
||||||
|
const declarationText = tokens
|
||||||
|
.map((token) => ` ${token.cssVariable}: ${token.value};`)
|
||||||
|
.join("\n");
|
||||||
|
return `${scope === "root" ? ":root" : ":host"} {\n${declarationText}\n}\n`;
|
||||||
|
},
|
||||||
|
variable: (name: string): string => requireToken(name).cssVariable,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||||
|
|
||||||
|
async function sourceFiles(): Promise<readonly string[]> {
|
||||||
|
const directory = path.join(packageDirectory, "src");
|
||||||
|
const entries = await readdir(directory);
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.endsWith(".ts"))
|
||||||
|
.map((entry) => path.join(directory, entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The design contract is imported by the Dashboard shell, by module UI artifacts, and by contributed
|
||||||
|
* renderers. It inherits exactly the rule ADR 0024 established for `@ekklesia/module-ui-sdk`: it
|
||||||
|
* must not be able to pull a backend type graph — and with it Node's global declarations — into a
|
||||||
|
* browser program.
|
||||||
|
*/
|
||||||
|
describe("the design token package stays browser-safe", () => {
|
||||||
|
it("declares no runtime dependency at all", async () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||||
|
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
|
||||||
|
|
||||||
|
expect(manifest.dependencies).toBeUndefined();
|
||||||
|
expect(manifest.peerDependencies).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports nothing outside its own source", async () => {
|
||||||
|
for (const file of await sourceFiles()) {
|
||||||
|
const source = await readFile(file, "utf8");
|
||||||
|
const specifiers = [...source.matchAll(/from\s+"([^"]+)"/gmu)].map(([, specifier]) =>
|
||||||
|
String(specifier),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(specifiers.every((specifier) => specifier.startsWith("."))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never names a Node built-in or reads the environment", async () => {
|
||||||
|
for (const file of await sourceFiles()) {
|
||||||
|
const source = await readFile(file, "utf8");
|
||||||
|
|
||||||
|
expect(source).not.toContain('"node:');
|
||||||
|
expect(source).not.toContain("process.env");
|
||||||
|
expect(source).not.toContain("globalThis.process");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DESIGN_TOKEN_CATEGORIES,
|
||||||
|
DESIGN_TOKEN_STYLE_ATTRIBUTE,
|
||||||
|
DESIGN_TOKEN_VARIABLE_PREFIX,
|
||||||
|
designToken,
|
||||||
|
ekklesiaDesignTokens,
|
||||||
|
ekklesiaDesignTokenStylesheet,
|
||||||
|
installEkklesiaDesignTokens,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
describe("the Ekklesia design token registry", () => {
|
||||||
|
it("is enumerable and deterministic", () => {
|
||||||
|
const first = ekklesiaDesignTokens.list();
|
||||||
|
const second = ekklesiaDesignTokens.list();
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(Object.isFrozen(first)).toBe(true);
|
||||||
|
expect(first.length).toBeGreaterThan(0);
|
||||||
|
expect(first.map((token) => token.name)).toEqual(second.map((token) => token.name));
|
||||||
|
expect(ekklesiaDesignTokenStylesheet()).toBe(ekklesiaDesignTokenStylesheet());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares a unique name and CSS variable for every token", () => {
|
||||||
|
const names = ekklesiaDesignTokens.list().map((token) => token.name);
|
||||||
|
const variables = ekklesiaDesignTokens.list().map((token) => token.cssVariable);
|
||||||
|
|
||||||
|
expect(new Set(names).size).toBe(names.length);
|
||||||
|
expect(new Set(variables).size).toBe(variables.length);
|
||||||
|
expect(variables.every((variable) => variable.startsWith(DESIGN_TOKEN_VARIABLE_PREFIX))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every token a human-readable label, description, and derived category", () => {
|
||||||
|
for (const token of ekklesiaDesignTokens.list()) {
|
||||||
|
expect(token.label.trim().length).toBeGreaterThan(0);
|
||||||
|
expect(token.description.trim().length).toBeGreaterThan(0);
|
||||||
|
expect(DESIGN_TOKEN_CATEGORIES).toContain(token.category);
|
||||||
|
expect(token.name.startsWith(`${token.category}.`)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the semantic families the platform promises", () => {
|
||||||
|
const names = new Set(ekklesiaDesignTokens.list().map((token) => token.name));
|
||||||
|
|
||||||
|
for (const required of [
|
||||||
|
"background.page",
|
||||||
|
"background.canvas",
|
||||||
|
"background.surface",
|
||||||
|
"background.raised",
|
||||||
|
"background.overlay",
|
||||||
|
"background.inset",
|
||||||
|
"text.primary",
|
||||||
|
"text.secondary",
|
||||||
|
"text.muted",
|
||||||
|
"text.inverse",
|
||||||
|
"text.success",
|
||||||
|
"text.warning",
|
||||||
|
"text.danger",
|
||||||
|
"accent.base",
|
||||||
|
"accent.focus",
|
||||||
|
"border.base",
|
||||||
|
"border.divider",
|
||||||
|
"state.hover-background",
|
||||||
|
"state.pressed-background",
|
||||||
|
"state.selected-background",
|
||||||
|
"state.disabled-opacity",
|
||||||
|
"state.focus-visible-width",
|
||||||
|
"typography.family-sans",
|
||||||
|
"typography.size-md",
|
||||||
|
"typography.weight-bold",
|
||||||
|
"typography.line-height-normal",
|
||||||
|
"typography.size-heading-1",
|
||||||
|
"spacing.md",
|
||||||
|
"radius.md",
|
||||||
|
"shadow.md",
|
||||||
|
"control.height-md",
|
||||||
|
]) {
|
||||||
|
expect(names).toContain(required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pairs every status colour with a non-colour symbol", () => {
|
||||||
|
for (const status of ["success", "warning", "danger", "info"]) {
|
||||||
|
expect(ekklesiaDesignTokens.has(`status.${status}-background`)).toBe(true);
|
||||||
|
expect(ekklesiaDesignTokens.has(`status.${status}-border`)).toBe(true);
|
||||||
|
const symbol = ekklesiaDesignTokens.get(`status.${status}-symbol`);
|
||||||
|
expect(symbol?.value.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands component code a var() reference rather than a raw colour", () => {
|
||||||
|
expect(designToken("text.primary")).toBe("var(--ekklesia-text-primary)");
|
||||||
|
expect(ekklesiaDesignTokens.variable("text.primary")).toBe("--ekklesia-text-primary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a stylesheet declaring every registered token exactly once", () => {
|
||||||
|
const stylesheet = ekklesiaDesignTokenStylesheet();
|
||||||
|
|
||||||
|
for (const token of ekklesiaDesignTokens.list()) {
|
||||||
|
expect(stylesheet.split(`${token.cssVariable}:`).length).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs the default theme at a document root exactly once and removes what it added", () => {
|
||||||
|
const remove = installEkklesiaDesignTokens(document);
|
||||||
|
const installedAgain = installEkklesiaDesignTokens(document);
|
||||||
|
|
||||||
|
expect(document.querySelectorAll(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`).length).toBe(1);
|
||||||
|
expect(
|
||||||
|
document
|
||||||
|
.querySelector(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`)
|
||||||
|
?.textContent?.includes("--ekklesia-background-page"),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
installedAgain();
|
||||||
|
expect(document.querySelectorAll(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`).length).toBe(1);
|
||||||
|
|
||||||
|
remove();
|
||||||
|
remove();
|
||||||
|
expect(document.querySelectorAll(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs host-scoped values into an isolated shadow root", () => {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const shadow = host.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
|
installEkklesiaDesignTokens(shadow, { scope: "host" });
|
||||||
|
|
||||||
|
const style = shadow.querySelector(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`);
|
||||||
|
expect(style?.textContent?.startsWith(":host {")).toBe(true);
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createDesignTokenRegistry,
|
||||||
|
DESIGN_TOKEN_CATEGORIES,
|
||||||
|
DESIGN_TOKEN_VALUE_MAX_LENGTH,
|
||||||
|
DesignTokenError,
|
||||||
|
type DesignTokenInput,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
function tokens(...overrides: readonly Partial<DesignTokenInput>[]): readonly DesignTokenInput[] {
|
||||||
|
return overrides.map((override) => ({
|
||||||
|
description: "A token used by this test.",
|
||||||
|
label: "Test token",
|
||||||
|
name: "background.page",
|
||||||
|
value: "#ffffff",
|
||||||
|
...override,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("design token validation", () => {
|
||||||
|
it("accepts a minimal well-formed declaration and derives category and CSS variable", () => {
|
||||||
|
const registry = createDesignTokenRegistry(tokens({ name: "spacing.2xs", value: "0.125rem" }));
|
||||||
|
const token = registry.get("spacing.2xs");
|
||||||
|
|
||||||
|
expect(token).toEqual({
|
||||||
|
category: "spacing",
|
||||||
|
cssVariable: "--ekklesia-spacing-2xs",
|
||||||
|
description: "A token used by this test.",
|
||||||
|
label: "Test token",
|
||||||
|
name: "spacing.2xs",
|
||||||
|
value: "0.125rem",
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate token name", () => {
|
||||||
|
expect(() => createDesignTokenRegistry(tokens({}, {}))).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_DUPLICATE" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a single segment", "page"],
|
||||||
|
["an empty name", ""],
|
||||||
|
["an uppercase segment", "background.Page"],
|
||||||
|
["an underscore", "background.page_alt"],
|
||||||
|
["a trailing dot", "background.page."],
|
||||||
|
["a space", "background.page alt"],
|
||||||
|
])("rejects %s as a token name", (_label, name) => {
|
||||||
|
expect(() => createDesignTokenRegistry(tokens({ name }))).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_NAME_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a name whose first segment is not a known category", () => {
|
||||||
|
expect(() => createDesignTokenRegistry(tokens({ name: "brand.primary" }))).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_CATEGORY_UNKNOWN" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a declaration terminator", "#fff; color: red"],
|
||||||
|
["a rule block", "#fff } body { display: none"],
|
||||||
|
["a comment", "#fff /* hidden */"],
|
||||||
|
["an at-rule", "@import 'evil.css'"],
|
||||||
|
["a url reference", "url(https://example.invalid/x.png)"],
|
||||||
|
["an override", "#fff !important"],
|
||||||
|
["a markup fragment", "</style><script>"],
|
||||||
|
["an escape sequence", "\\0041"],
|
||||||
|
["an empty value", " "],
|
||||||
|
])("rejects %s as a token value", (_label, value) => {
|
||||||
|
expect(() => createDesignTokenRegistry(tokens({ value }))).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unbounded value", () => {
|
||||||
|
expect(() =>
|
||||||
|
createDesignTokenRegistry(tokens({ value: "a".repeat(DESIGN_TOKEN_VALUE_MAX_LENGTH + 1) })),
|
||||||
|
).toThrowError(expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a control character hidden inside a value", () => {
|
||||||
|
expect(() => createDesignTokenRegistry(tokens({ value: "#fff\u0007" }))).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }),
|
||||||
|
);
|
||||||
|
expect(() => createDesignTokenRegistry(tokens({ value: "#fff\nred" }))).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty or overlong label and description", () => {
|
||||||
|
expect(() => createDesignTokenRegistry(tokens({ label: " " }))).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_LABEL_INVALID" }),
|
||||||
|
);
|
||||||
|
expect(() => createDesignTokenRegistry(tokens({ description: "" }))).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_DESCRIPTION_INVALID" }),
|
||||||
|
);
|
||||||
|
expect(() => createDesignTokenRegistry(tokens({ description: "d".repeat(241) }))).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_DESCRIPTION_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never echoes the rejected declaration in the error", () => {
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
createDesignTokenRegistry(tokens({ value: "#fff; content: 'leak-me'" }));
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(DesignTokenError);
|
||||||
|
expect(String((caught as Error).message)).not.toContain("leak-me");
|
||||||
|
expect(String((caught as Error).stack)).not.toContain("leak-me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a lookup closed for an unregistered or non-string name", () => {
|
||||||
|
const registry = createDesignTokenRegistry(tokens({}));
|
||||||
|
|
||||||
|
expect(registry.get("nope.nope")).toBeUndefined();
|
||||||
|
expect(registry.get(42)).toBeUndefined();
|
||||||
|
expect(registry.has(undefined)).toBe(false);
|
||||||
|
expect(() => registry.variable("nope.nope")).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_UNKNOWN" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stylesheet scope outside the closed set", () => {
|
||||||
|
const registry = createDesignTokenRegistry(tokens({}));
|
||||||
|
|
||||||
|
expect(() => registry.stylesheet("body" as "root")).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DESIGN_TOKEN_SCOPE_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits one rule per scope containing every declared token", () => {
|
||||||
|
const registry = createDesignTokenRegistry(
|
||||||
|
tokens({ name: "background.page", value: "#fff" }, { name: "text.primary", value: "#000" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registry.stylesheet()).toBe(
|
||||||
|
":root {\n --ekklesia-background-page: #fff;\n --ekklesia-text-primary: #000;\n}\n",
|
||||||
|
);
|
||||||
|
expect(registry.stylesheet("host").startsWith(":host {")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups tokens by category without a second source of truth", () => {
|
||||||
|
const registry = createDesignTokenRegistry(
|
||||||
|
tokens({ name: "background.page" }, { name: "text.primary", value: "#000" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registry.listByCategory("background").map((token) => token.name)).toEqual([
|
||||||
|
"background.page",
|
||||||
|
]);
|
||||||
|
expect(registry.listByCategory("shadow")).toEqual([]);
|
||||||
|
expect(DESIGN_TOKEN_CATEGORIES).toContain("background");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "tests"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@ekklesia/typescript-config/browser.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@ekklesia/typescript-config/browser.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { moduleDisplayEntrypointSchema, packageRelativePathSchema } from "@ekklesia/contracts";
|
import {
|
||||||
|
contributionLocalName,
|
||||||
|
moduleContributionDeclarationSchema,
|
||||||
|
moduleDisplayEntrypointSchema,
|
||||||
|
packageRelativePathSchema,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
import { build, type Plugin } from "esbuild";
|
import { build, type Plugin } from "esbuild";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +18,14 @@ import { build, type Plugin } from "esbuild";
|
|||||||
*/
|
*/
|
||||||
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
|
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
|
||||||
export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx";
|
export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx";
|
||||||
|
/** Where a declared browser contribution's source lives: `src/contributions/<local-name>/index.tsx`,
|
||||||
|
* where the local name is the part of the contribution id after the module's own id. Derived rather
|
||||||
|
* than configured, exactly like the two role entrypoints, so an author points the build nowhere. */
|
||||||
|
export const MODULE_CONTRIBUTION_SOURCE_DIRECTORY = "src/contributions";
|
||||||
|
|
||||||
|
export function moduleContributionSourceEntrypoint(contributionId: string): string {
|
||||||
|
return `${MODULE_CONTRIBUTION_SOURCE_DIRECTORY}/${contributionLocalName(contributionId)}/index.tsx`;
|
||||||
|
}
|
||||||
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||||
|
|
||||||
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
|
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
|
||||||
@@ -23,9 +36,11 @@ export type ModuleUiBuildErrorCode =
|
|||||||
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
||||||
| "MODULE_UI_ENTRYPOINT_COLLISION"
|
| "MODULE_UI_ENTRYPOINT_COLLISION"
|
||||||
| "MODULE_UI_SOURCE_MISSING"
|
| "MODULE_UI_SOURCE_MISSING"
|
||||||
|
| "MODULE_UI_STYLESHEET_INVALID"
|
||||||
|
| "MODULE_UI_STYLESHEET_UNDECLARED"
|
||||||
| "MODULE_UI_BUILD_FAILED";
|
| "MODULE_UI_BUILD_FAILED";
|
||||||
|
|
||||||
export type ModuleUiArtifactKind = "ui" | "display";
|
export type ModuleUiArtifactKind = "ui" | "display" | "contribution";
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
||||||
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
||||||
@@ -34,6 +49,10 @@ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.f
|
|||||||
MODULE_UI_ENTRYPOINT_COLLISION:
|
MODULE_UI_ENTRYPOINT_COLLISION:
|
||||||
"A display UI entrypoint must not overwrite another declared artifact.",
|
"A display UI entrypoint must not overwrite another declared artifact.",
|
||||||
MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.",
|
MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.",
|
||||||
|
MODULE_UI_STYLESHEET_INVALID:
|
||||||
|
"A module stylesheet may not define a global Ekklesia theme variable or keep an unresolved @import.",
|
||||||
|
MODULE_UI_STYLESHEET_UNDECLARED:
|
||||||
|
"A module UI build produced a stylesheet beside its artifact. Every style must be compiled into the declared artifact.",
|
||||||
MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.",
|
MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,32 +77,97 @@ export class ModuleUiBuildError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rejects a stylesheet import instead of quietly emitting one.
|
* A CSS custom property in Ekklesia's own global theme namespace, being **declared** rather than
|
||||||
*
|
* referenced. `var(--ekklesia-x)` and `var(--ekklesia-x, fallback)` are reads and are fine; only a
|
||||||
* esbuild would happily bundle `import "./styles.css"` into a sidecar `index.css` next to the
|
* `--ekklesia-x:` declaration matches, because that is what would invent a global theme variable
|
||||||
* artifact. Nothing declares that file, no host loads it, and the manifest has no field for it — so
|
* outside the registry a future theme editor enumerates (ADR 0039).
|
||||||
* it would work in a local test and silently do nothing in a real host. Module styling has no
|
|
||||||
* public loading contract yet (ADR 0023), so the build fails loudly rather than producing an
|
|
||||||
* undeclared asset. `scripts/check-boundaries.ts` rejects a `.css` file authored *inside* a module;
|
|
||||||
* this additionally covers a stylesheet reached through a dependency.
|
|
||||||
*/
|
*/
|
||||||
function rejectStylesheetImports(): Plugin {
|
const GLOBAL_THEME_DECLARATION = /--ekklesia-[a-z0-9-]*\s*:/iu;
|
||||||
|
|
||||||
|
/** An `@import` that survived compilation — a remote or otherwise unresolvable one. A local import
|
||||||
|
* is inlined by the nested bundle below and leaves nothing behind, so anything still here would make
|
||||||
|
* the artifact fetch a stylesheet at run time, which is exactly what "self-contained" excludes. */
|
||||||
|
const SURVIVING_IMPORT = /@import\s/iu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles each imported stylesheet into the artifact instead of emitting a sidecar.
|
||||||
|
*
|
||||||
|
* esbuild's default for `import "./styles.css"` from JavaScript is to collect every such stylesheet
|
||||||
|
* into an `index.css` output beside the artifact. Nothing declares that file, no host fetches it,
|
||||||
|
* and the manifest has no field for it — it would work in a local test and silently do nothing in a
|
||||||
|
* real host. ADR 0023 therefore rejected the import outright; ADR 0040 compiles it instead.
|
||||||
|
*
|
||||||
|
* Each `.css` import becomes a JavaScript module whose default export is that stylesheet's compiled
|
||||||
|
* text, produced by a nested esbuild run so local `@import`s are resolved and the result is one
|
||||||
|
* self-contained string. The author passes that string to `defineModuleUi({ styles })` and the host
|
||||||
|
* installs it into the artifact's isolated root. The declared `.js` artifact therefore remains the
|
||||||
|
* only file the platform publishes, exactly as before stylesheets existed.
|
||||||
|
*
|
||||||
|
* Other stylesheet dialects stay rejected. Adding one is a language and toolchain decision, not a
|
||||||
|
* detail of this pass, and would need its own ADR.
|
||||||
|
*/
|
||||||
|
const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports";
|
||||||
|
|
||||||
|
function compileStylesheetImports(): Plugin {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
return {
|
return {
|
||||||
name: "ekklesia-reject-stylesheet-imports",
|
name: STYLESHEET_PLUGIN_NAME,
|
||||||
setup(pluginBuild) {
|
setup(pluginBuild) {
|
||||||
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
|
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
|
||||||
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). This pattern is
|
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). These patterns are
|
||||||
// pure ASCII, so the flag would change nothing anyway.
|
// pure ASCII, so the flag would change nothing anyway.
|
||||||
pluginBuild.onResolve({ filter: /\.(css|s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
|
pluginBuild.onResolve({ filter: /\.(s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
text:
|
text:
|
||||||
"Module UI cannot import a stylesheet yet: there is no declared way for a host to " +
|
"Module UI supports plain .css only. Other stylesheet dialects have no compilation " +
|
||||||
"load one. Use React inline style objects for now (ADR 0023).",
|
"contract in this platform (ADR 0040).",
|
||||||
location: { file: args.importer },
|
location: { file: args.importer },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => {
|
||||||
|
const compiled = await build({
|
||||||
|
bundle: true,
|
||||||
|
entryPoints: [args.path],
|
||||||
|
logLevel: "silent",
|
||||||
|
minify: false,
|
||||||
|
target: MODULE_UI_BUILD_TARGET,
|
||||||
|
write: false,
|
||||||
|
});
|
||||||
|
const stylesheet = compiled.outputFiles
|
||||||
|
.map((outputFile) => decoder.decode(outputFile.contents))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if (GLOBAL_THEME_DECLARATION.test(stylesheet)) {
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
"A module stylesheet may reference Ekklesia's global theme variables but must " +
|
||||||
|
"never declare one. Derive a private alias under your own prefix instead " +
|
||||||
|
"(ADR 0039).",
|
||||||
|
location: { file: args.path },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (SURVIVING_IMPORT.test(stylesheet)) {
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
"A module stylesheet left an unresolved @import. A declared artifact must be " +
|
||||||
|
"self-contained, so every import has to resolve to a local file (ADR 0040).",
|
||||||
|
location: { file: args.path },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { contents: `export default ${JSON.stringify(stylesheet)};`, loader: "js" };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -94,13 +178,17 @@ export interface BuildModuleUiOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleUiBuildResult {
|
export interface ModuleUiBuildResult {
|
||||||
/** Built artifacts in stable role order: primary UI first, then display. Empty for backend-only
|
/** Built artifacts in stable role order: primary UI, then display, then each declared contribution
|
||||||
* modules, which makes the canonical command usable unchanged by every module package. */
|
* in manifest order. Empty for backend-only modules, which makes the canonical command usable
|
||||||
|
* unchanged by every module package. */
|
||||||
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
|
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleUiBuildArtifactResult {
|
export interface ModuleUiBuildArtifactResult {
|
||||||
readonly kind: ModuleUiArtifactKind;
|
readonly kind: ModuleUiArtifactKind;
|
||||||
|
/** For a contribution, the declared contribution id — so a multi-contribution build reports which
|
||||||
|
* artifact it produced. Absent for the two role entrypoints. */
|
||||||
|
readonly contributionId?: string;
|
||||||
/** Absolute path of the artifact actually written, exactly as the manifest declares. */
|
/** Absolute path of the artifact actually written, exactly as the manifest declares. */
|
||||||
readonly outputFile: string;
|
readonly outputFile: string;
|
||||||
/** Absolute path of the fixed source entrypoint that produced it. */
|
/** Absolute path of the fixed source entrypoint that produced it. */
|
||||||
@@ -132,7 +220,15 @@ async function resolveDeclaredArtifacts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const display = entrypoints.display;
|
const display = entrypoints.display;
|
||||||
|
const manifestContributions = isRecord(manifest) ? manifest.contributions : undefined;
|
||||||
|
const declaredContributions = Array.isArray(manifestContributions) ? manifestContributions : [];
|
||||||
|
for (const contribution of declaredContributions) {
|
||||||
|
if (!moduleContributionDeclarationSchema.safeParse(contribution).success) {
|
||||||
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "contribution");
|
||||||
|
}
|
||||||
|
}
|
||||||
const declarations: readonly {
|
const declarations: readonly {
|
||||||
|
readonly contributionId?: string;
|
||||||
readonly declared: unknown;
|
readonly declared: unknown;
|
||||||
readonly kind: ModuleUiArtifactKind;
|
readonly kind: ModuleUiArtifactKind;
|
||||||
readonly present: boolean;
|
readonly present: boolean;
|
||||||
@@ -150,6 +246,13 @@ async function resolveDeclaredArtifacts(
|
|||||||
present: display !== undefined,
|
present: display !== undefined,
|
||||||
source: MODULE_DISPLAY_SOURCE_ENTRYPOINT,
|
source: MODULE_DISPLAY_SOURCE_ENTRYPOINT,
|
||||||
},
|
},
|
||||||
|
...(declaredContributions as { artifact: string; id: string }[]).map((contribution) => ({
|
||||||
|
contributionId: contribution.id,
|
||||||
|
declared: contribution.artifact,
|
||||||
|
kind: "contribution" as const,
|
||||||
|
present: true,
|
||||||
|
source: moduleContributionSourceEntrypoint(contribution.id),
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
const artifacts: DeclaredModuleUiArtifact[] = [];
|
const artifacts: DeclaredModuleUiArtifact[] = [];
|
||||||
|
|
||||||
@@ -168,6 +271,9 @@ async function resolveDeclaredArtifacts(
|
|||||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
|
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
|
||||||
}
|
}
|
||||||
artifacts.push({
|
artifacts.push({
|
||||||
|
...(declaration.contributionId === undefined
|
||||||
|
? {}
|
||||||
|
: { contributionId: declaration.contributionId }),
|
||||||
kind: declaration.kind,
|
kind: declaration.kind,
|
||||||
outputFile: path.resolve(moduleDirectory, declared),
|
outputFile: path.resolve(moduleDirectory, declared),
|
||||||
sourceFile: path.join(moduleDirectory, declaration.source),
|
sourceFile: path.join(moduleDirectory, declaration.source),
|
||||||
@@ -178,17 +284,24 @@ async function resolveDeclaredArtifacts(
|
|||||||
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayArtifact = artifacts.find((artifact) => artifact.kind === "display");
|
// No declared artifact may overwrite another declared file — an entrypoint this build does not
|
||||||
if (displayArtifact !== undefined) {
|
// produce (`server`, `schema`, ...), or another artifact it does. Checked before esbuild runs, so
|
||||||
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
|
// a collision can never destroy output that was already correct.
|
||||||
const configured = entrypoints[entrypointName];
|
const backendEntrypointPaths = new Set(
|
||||||
|
(["server", "schema", "migrations", "jobs"] as const)
|
||||||
|
.map((entrypointName) => entrypoints[entrypointName])
|
||||||
|
.filter((configured): configured is string => typeof configured === "string")
|
||||||
|
.map((configured) => path.resolve(moduleDirectory, configured)),
|
||||||
|
);
|
||||||
|
const claimedOutputs = new Set<string>();
|
||||||
|
for (const artifact of artifacts) {
|
||||||
if (
|
if (
|
||||||
typeof configured === "string" &&
|
backendEntrypointPaths.has(artifact.outputFile) ||
|
||||||
path.resolve(moduleDirectory, configured) === displayArtifact.outputFile
|
claimedOutputs.has(artifact.outputFile)
|
||||||
) {
|
) {
|
||||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", "display");
|
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", artifact.kind);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
claimedOutputs.add(artifact.outputFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete preflight before esbuild starts: a missing second source must not leave the first role's
|
// Complete preflight before esbuild starts: a missing second source must not leave the first role's
|
||||||
@@ -237,7 +350,7 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
|
|||||||
minify: false,
|
minify: false,
|
||||||
outfile: artifact.outputFile,
|
outfile: artifact.outputFile,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
plugins: [rejectStylesheetImports()],
|
plugins: [compileStylesheetImports()],
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
target: MODULE_UI_BUILD_TARGET,
|
target: MODULE_UI_BUILD_TARGET,
|
||||||
// Fail rather than silently shipping a partial bundle.
|
// Fail rather than silently shipping a partial bundle.
|
||||||
@@ -246,6 +359,17 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
|
|||||||
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
|
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
|
||||||
write: false,
|
write: false,
|
||||||
});
|
});
|
||||||
|
// Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into
|
||||||
|
// JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or
|
||||||
|
// loader change could reintroduce a sidecar, and a sidecar is precisely the failure this
|
||||||
|
// design exists to make impossible: a file nothing declares, no host fetches, and every local
|
||||||
|
// test silently passes without. Producing one fails the build rather than shipping it.
|
||||||
|
const sidecar = result.outputFiles.find(
|
||||||
|
(outputFile) => path.extname(outputFile.path).toLowerCase() === ".css",
|
||||||
|
);
|
||||||
|
if (sidecar) {
|
||||||
|
throw new ModuleUiBuildError("MODULE_UI_STYLESHEET_UNDECLARED", artifact.kind);
|
||||||
|
}
|
||||||
pendingWrites.push(
|
pendingWrites.push(
|
||||||
result.outputFiles.map((outputFile) => ({
|
result.outputFiles.map((outputFile) => ({
|
||||||
contents: outputFile.contents,
|
contents: outputFile.contents,
|
||||||
@@ -253,10 +377,21 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof ModuleUiBuildError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
|
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
|
||||||
// programmatic caller never receives raw paths or internals.
|
// programmatic caller never receives raw paths or internals.
|
||||||
if (isRecord(error) && Array.isArray(error.errors)) {
|
if (isRecord(error) && Array.isArray(error.errors)) {
|
||||||
throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind), {
|
// A stylesheet the module authored broke one of this platform's own rules, rather than
|
||||||
|
// failing to compile. Reported with its own code so a caller can tell "your CSS is not
|
||||||
|
// allowed to do that" from "your TSX does not compile".
|
||||||
|
const code = error.errors.some(
|
||||||
|
(diagnostic) => isRecord(diagnostic) && diagnostic.pluginName === STYLESHEET_PLUGIN_NAME,
|
||||||
|
)
|
||||||
|
? "MODULE_UI_STYLESHEET_INVALID"
|
||||||
|
: "MODULE_UI_BUILD_FAILED";
|
||||||
|
throw Object.assign(new ModuleUiBuildError(code, artifact.kind), {
|
||||||
diagnostics: error.errors,
|
diagnostics: error.errors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async function main(): Promise<number> {
|
|||||||
}
|
}
|
||||||
for (const artifact of result.artifacts) {
|
for (const artifact of result.artifacts) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`Built module UI (${artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
|
`Built module UI (${artifact.contributionId ?? artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
|
|||||||
const manifest =
|
const manifest =
|
||||||
options.manifest ??
|
options.manifest ??
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "example",
|
id: "example",
|
||||||
name: "Example",
|
name: "Example",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -342,10 +342,10 @@ describe("module UI build", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
"styles.css",
|
|
||||||
"theme.scss",
|
"theme.scss",
|
||||||
"layout.less",
|
"layout.less",
|
||||||
])("rejects a %s import rather than emitting an undeclared sidecar asset", async (stylesheet) => {
|
"tokens.styl",
|
||||||
|
])("rejects a %s import: only plain CSS has a compilation contract", async (stylesheet) => {
|
||||||
const directory = await createModule({
|
const directory = await createModule({
|
||||||
ui: "./dist/ui/index.js",
|
ui: "./dist/ui/index.js",
|
||||||
files: {
|
files: {
|
||||||
@@ -358,47 +358,131 @@ describe("module UI build", () => {
|
|||||||
await buildModuleUi({ moduleDirectory: directory });
|
await buildModuleUi({ moduleDirectory: directory });
|
||||||
expect.unreachable("expected the stylesheet import to be rejected");
|
expect.unreachable("expected the stylesheet import to be rejected");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
|
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
|
||||||
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
|
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
|
||||||
expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe(
|
expect(diagnostics.some((entry) => entry.text?.includes("plain .css only"))).toBe(true);
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never leaves a stylesheet sidecar next to the artifact", async () => {
|
it("compiles a plain stylesheet into the artifact instead of emitting a sidecar", async () => {
|
||||||
const directory = await createModule({
|
const directory = await createModule({
|
||||||
ui: "./dist/ui/index.js",
|
ui: "./dist/ui/index.js",
|
||||||
files: {
|
files: {
|
||||||
"src/ui/index.tsx": 'import "./styles.css";\nexport default {};',
|
"src/ui/index.tsx": `
|
||||||
"src/ui/styles.css": ".example { color: red; }",
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
"src/ui/styles.css": ".example { color: var(--ekklesia-text-primary); }",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toBeInstanceOf(
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
ModuleUiBuildError,
|
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
|
||||||
);
|
|
||||||
|
// The whole stylesheet travels inside the one declared artifact.
|
||||||
|
expect(artifact).toContain(".example");
|
||||||
|
expect(artifact).toContain("--ekklesia-text-primary");
|
||||||
|
// And nothing was emitted beside it. A sidecar is the exact failure this design prevents: a
|
||||||
|
// file nothing declares, no host fetches, and every local test silently passes without.
|
||||||
await expect(
|
await expect(
|
||||||
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
|
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enforces the stylesheet boundary for display UI too", async () => {
|
it("resolves a local @import so the compiled stylesheet is self-contained", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
"src/ui/styles.css": '@import "./base.css";\n.example { color: red; }',
|
||||||
|
"src/ui/base.css": ".base { display: grid; }",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
|
||||||
|
|
||||||
|
expect(artifact).toContain(".base");
|
||||||
|
expect(artifact).toContain(".example");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stylesheet that keeps an unresolved @import, so the artifact stays self-contained", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
"src/ui/styles.css": '@import url("https://fonts.example.invalid/family.css");',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_STYLESHEET_INVALID",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stylesheet that declares a global Ekklesia theme variable", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
// Referencing a token is the contract; *declaring* one would invent a global theme variable
|
||||||
|
// outside the registry a future theme editor enumerates (ADR 0039).
|
||||||
|
"src/ui/styles.css": ":host { --ekklesia-text-primary: hotpink; }",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
expect.unreachable("expected the stylesheet to be rejected");
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
|
||||||
|
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
|
||||||
|
expect(diagnostics.some((entry) => entry.text?.includes("never declare one"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a private alias derived from a registered token", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
|
"src/ui/styles.css":
|
||||||
|
":host { --example-gap: var(--ekklesia-spacing-sm); }\n.example { gap: var(--example-gap); }",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const artifact = await readFile(
|
||||||
|
(await buildModuleUi({ moduleDirectory: directory })).artifacts[0]?.outputFile ?? "",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(artifact).toContain("--example-gap");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compiles a display artifact's stylesheet the same way", async () => {
|
||||||
const directory = await createModule({
|
const directory = await createModule({
|
||||||
display: {
|
display: {
|
||||||
artifact: "./dist/display/index.js",
|
artifact: "./dist/display/index.js",
|
||||||
permission: "example.display.view",
|
permission: "example.display.view",
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
"src/display/index.tsx": 'import "./styles.css";\nexport default {};',
|
"src/display/index.tsx": `
|
||||||
|
import styles from "./styles.css";
|
||||||
|
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
|
||||||
"src/display/styles.css": ".display { position: fixed; }",
|
"src/display/styles.css": ".display { position: fixed; }",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
code: "MODULE_UI_BUILD_FAILED",
|
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
|
||||||
kind: "display",
|
|
||||||
});
|
expect(artifact).toContain(".display");
|
||||||
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
|
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
export * from "./isolated-root.js";
|
||||||
|
export * from "./load-module-contribution.js";
|
||||||
export * from "./load-module-ui.js";
|
export * from "./load-module-ui.js";
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* The isolated root a host mounts a browser artifact into (ADR 0040).
|
||||||
|
*
|
||||||
|
* Every declared browser artifact — a module's primary UI, its display UI, and a contributed
|
||||||
|
* renderer — is mounted the same way, into its own Shadow DOM root the loader creates and owns:
|
||||||
|
*
|
||||||
|
* ```text
|
||||||
|
* host container ← the host's element; the loader never mutates it beyond this child
|
||||||
|
* └─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
|
||||||
|
* #shadow-root (open)
|
||||||
|
* ├─ <style data-ekklesia-module-ui-styles> base rules + the artifact's declared CSS
|
||||||
|
* └─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Shadow DOM is chosen because it is the one mechanism in a browser that actually **enforces** the
|
||||||
|
* rule rather than describing it: rules inside a shadow root do not apply outside it, and document
|
||||||
|
* rules do not apply inside it. A class-name convention, a build-time prefix, or a "please scope
|
||||||
|
* your selectors" instruction are all conventions a single mistake defeats, and the failure mode is
|
||||||
|
* a module silently restyling the Dashboard or another module's renderer.
|
||||||
|
*
|
||||||
|
* It is **not** a security boundary, and nothing here should be read as one. `mode: "open"` is
|
||||||
|
* deliberate: an installed module is trusted first-party code running in the host's own realm
|
||||||
|
* (ADR 0022, ADR 0024), it could reach `document` directly if it wanted to, and `mode: "closed"`
|
||||||
|
* would buy no protection while making the artifact's DOM unreachable to the host's own tests and
|
||||||
|
* diagnostics. This is a correctness boundary for *styles*, not a sandbox.
|
||||||
|
*
|
||||||
|
* Custom properties inherit through a shadow boundary, so the design tokens a host installed at its
|
||||||
|
* root (ADR 0039) are available inside every artifact with nothing passed and nothing imported.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MODULE_UI_ISOLATION_ATTRIBUTE = "data-ekklesia-module-ui";
|
||||||
|
export const MODULE_UI_ISOLATION_STYLE_ATTRIBUTE = "data-ekklesia-module-ui-styles";
|
||||||
|
export const MODULE_UI_ISOLATION_ROOT_ATTRIBUTE = "data-ekklesia-module-ui-root";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rules the host always installs, before any artifact CSS.
|
||||||
|
*
|
||||||
|
* Both wrappers are `display: contents`, so the two elements the loader adds contribute no boxes of
|
||||||
|
* their own and an artifact's own top-level elements lay out inside the host's container exactly as
|
||||||
|
* they did before isolation existed. They are declared as CSS rather than inline styles precisely so
|
||||||
|
* an artifact's own stylesheet — which the host appends *after* these — can override them when it
|
||||||
|
* genuinely needs a box to size.
|
||||||
|
*/
|
||||||
|
const BASE_STYLESHEET = `:host{display:contents}\n[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]{display:contents}`;
|
||||||
|
|
||||||
|
export interface IsolatedArtifactRoot {
|
||||||
|
/** The element handed to the artifact's `mount`. */
|
||||||
|
readonly mountTarget: Element;
|
||||||
|
/** The open shadow root, for a host's own tests and diagnostics. */
|
||||||
|
readonly shadowRoot: ShadowRoot;
|
||||||
|
/** Removes the whole isolated root — content and styles together. Idempotent. */
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one isolated root inside `container` and returns it.
|
||||||
|
*
|
||||||
|
* Nothing is attached to `container` until the shadow root, its stylesheet, and its mount target
|
||||||
|
* have all been created, so a host whose environment cannot provide Shadow DOM gets a thrown error
|
||||||
|
* and an untouched container rather than a half-built, unisolated mount.
|
||||||
|
*
|
||||||
|
* Each call creates a **fresh** root, and `dispose` removes it whole. That is what makes styles
|
||||||
|
* deterministic in both directions: a mount installs exactly one `<style>`, an unmount removes it
|
||||||
|
* with the element that owns it, and remounting cannot accumulate a second copy because there is
|
||||||
|
* nothing left to accumulate onto.
|
||||||
|
*/
|
||||||
|
export function createIsolatedArtifactRoot(
|
||||||
|
container: Element,
|
||||||
|
styles: string | undefined,
|
||||||
|
): IsolatedArtifactRoot {
|
||||||
|
const ownerDocument = container.ownerDocument;
|
||||||
|
if (!ownerDocument) {
|
||||||
|
throw new TypeError("A module UI container must belong to a document.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const shadowHost = ownerDocument.createElement("div");
|
||||||
|
shadowHost.setAttribute(MODULE_UI_ISOLATION_ATTRIBUTE, "");
|
||||||
|
if (typeof shadowHost.attachShadow !== "function") {
|
||||||
|
throw new TypeError("This environment does not support Shadow DOM.");
|
||||||
|
}
|
||||||
|
const shadowRoot = shadowHost.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
|
const style = ownerDocument.createElement("style");
|
||||||
|
style.setAttribute(MODULE_UI_ISOLATION_STYLE_ATTRIBUTE, "");
|
||||||
|
// The artifact's own CSS comes last, so it wins over the base rules above by ordinary cascade
|
||||||
|
// order rather than by anything the host has to special-case.
|
||||||
|
style.textContent = styles === undefined ? BASE_STYLESHEET : `${BASE_STYLESHEET}\n${styles}`;
|
||||||
|
shadowRoot.appendChild(style);
|
||||||
|
|
||||||
|
const mountTarget = ownerDocument.createElement("div");
|
||||||
|
mountTarget.setAttribute(MODULE_UI_ISOLATION_ROOT_ATTRIBUTE, "");
|
||||||
|
shadowRoot.appendChild(mountTarget);
|
||||||
|
|
||||||
|
container.appendChild(shadowHost);
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
return {
|
||||||
|
dispose: () => {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
disposed = true;
|
||||||
|
shadowHost.remove();
|
||||||
|
},
|
||||||
|
mountTarget,
|
||||||
|
shadowRoot,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
isModuleContributionInput,
|
||||||
|
type ModuleContributionDefinition,
|
||||||
|
ModuleContributionError,
|
||||||
|
type ModuleContributionInput,
|
||||||
|
type ModuleContributionInstance,
|
||||||
|
type MountedModuleContribution,
|
||||||
|
parseModuleContributionExport,
|
||||||
|
} from "@ekklesia/module-ui-sdk";
|
||||||
|
import { createIsolatedArtifactRoot, type IsolatedArtifactRoot } from "./isolated-root.js";
|
||||||
|
import { type ModuleUiImporter, ModuleUiHostError } from "./load-module-ui.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a host obtains one contribution artifact.
|
||||||
|
*
|
||||||
|
* `url` is a **browser-loadable module URL** the host produced, never a path a module composed. The
|
||||||
|
* consuming module names a contribution id; turning that into a URL is asset publication, which this
|
||||||
|
* generic package deliberately does not own — exactly as it does not own it for a module UI artifact
|
||||||
|
* (ADR 0024, ADR 0025). Ekklesia implements it at its API edge (ADR 0041).
|
||||||
|
*
|
||||||
|
* `contributionId` and `version` are the identity the host already read from the contributing
|
||||||
|
* module's validated manifest, and are checked against the artifact before anything is mounted.
|
||||||
|
*/
|
||||||
|
export interface ModuleContributionSource {
|
||||||
|
readonly contributionId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadModuleContributionOptions {
|
||||||
|
readonly source: ModuleContributionSource;
|
||||||
|
/** A container the **consuming module** owns. The loader builds the contribution's isolated root
|
||||||
|
* inside it and never reaches outside it. */
|
||||||
|
readonly container: Element;
|
||||||
|
/** The consumer's own versioned input, validated as JSON before it crosses the boundary. */
|
||||||
|
readonly input: ModuleContributionInput;
|
||||||
|
readonly importModule?: ModuleUiImporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultImporter: ModuleUiImporter = (url) => import(url);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports a published contribution artifact, verifies it is the contribution the host expected, and
|
||||||
|
* mounts it into an isolated root inside the consumer's container (ADR 0040, ADR 0041).
|
||||||
|
*
|
||||||
|
* **This executes trusted first-party code in the host's own page**, exactly like a module UI
|
||||||
|
* artifact. The isolated root keeps the renderer's *styles* to itself; it is not a sandbox, and
|
||||||
|
* mounting a contribution grants its module no authority whatsoever. Server-side authorization is
|
||||||
|
* untouched: the consumer was already authorized to obtain the data it passes as input, and the
|
||||||
|
* contribution receives nothing else.
|
||||||
|
*
|
||||||
|
* The consumer's `input` is validated as genuinely JSON-serializable **before** the artifact is even
|
||||||
|
* imported. A `Date`, a function, a cyclic object, or `undefined` would either vanish or throw
|
||||||
|
* somewhere far from the mistake, so it fails here, at the boundary, with a sanitized code that
|
||||||
|
* never echoes the domain data itself.
|
||||||
|
*/
|
||||||
|
export async function loadModuleContribution(
|
||||||
|
options: LoadModuleContributionOptions,
|
||||||
|
): Promise<MountedModuleContribution> {
|
||||||
|
const { container, input, source } = options;
|
||||||
|
if (!isModuleContributionInput(input)) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_INPUT_INVALID");
|
||||||
|
}
|
||||||
|
const importModule = options.importModule ?? defaultImporter;
|
||||||
|
|
||||||
|
let namespace: unknown;
|
||||||
|
try {
|
||||||
|
namespace = await importModule(source.url);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ModuleUiHostError("MODULE_UI_IMPORT_FAILED", { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one contract parser, reused rather than reimplemented: it validates the default export's
|
||||||
|
// shape, its ABI marker, and that its id/version match what the host expected. A mismatched or
|
||||||
|
// stale artifact fails before it can render.
|
||||||
|
const definition: ModuleContributionDefinition = parseModuleContributionExport(namespace, {
|
||||||
|
id: source.contributionId,
|
||||||
|
version: source.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
let isolated: IsolatedArtifactRoot;
|
||||||
|
try {
|
||||||
|
isolated = createIsolatedArtifactRoot(container, definition.styles);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ModuleUiHostError("MODULE_UI_ISOLATION_UNAVAILABLE", { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance: ModuleContributionInstance | undefined;
|
||||||
|
try {
|
||||||
|
const result = await definition.mount(isolated.mountTarget, {
|
||||||
|
contributionId: definition.id,
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
if (result && typeof result === "object") {
|
||||||
|
instance = result;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
isolated.dispose();
|
||||||
|
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
return {
|
||||||
|
contributionId: definition.id,
|
||||||
|
version: definition.version,
|
||||||
|
get mounted() {
|
||||||
|
return mounted;
|
||||||
|
},
|
||||||
|
async update(next: ModuleContributionInput): Promise<void> {
|
||||||
|
if (!mounted) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN");
|
||||||
|
}
|
||||||
|
if (!isModuleContributionInput(next)) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_INPUT_INVALID");
|
||||||
|
}
|
||||||
|
// A renderer that cannot update in place says so by returning no `update`, and the consumer is
|
||||||
|
// told to remount rather than left rendering stale content behind a silently ignored call.
|
||||||
|
if (!instance?.update) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_NOT_UPDATABLE");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await instance.update(next);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ModuleUiHostError("MODULE_CONTRIBUTION_UPDATE_FAILED", { cause: error });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async unmount(): Promise<void> {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Marked unmounted before cleanup runs, so a throwing cleanup surfaces once and a second call
|
||||||
|
// never re-runs it. The isolated root goes in a `finally`, so a renderer that fails to clean
|
||||||
|
// up still has its styles taken out of the consumer's page.
|
||||||
|
mounted = false;
|
||||||
|
try {
|
||||||
|
await instance?.unmount?.();
|
||||||
|
} finally {
|
||||||
|
isolated.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type ModuleUiUnmount,
|
type ModuleUiUnmount,
|
||||||
parseModuleUiExport,
|
parseModuleUiExport,
|
||||||
} from "@ekklesia/module-ui-sdk";
|
} from "@ekklesia/module-ui-sdk";
|
||||||
|
import { createIsolatedArtifactRoot, type IsolatedArtifactRoot } from "./isolated-root.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a host obtains a module's UI artifact.
|
* How a host obtains a module's UI artifact.
|
||||||
@@ -45,7 +46,11 @@ const defaultImporter: ModuleUiImporter = (url) => import(url);
|
|||||||
|
|
||||||
export interface LoadModuleUiOptions {
|
export interface LoadModuleUiOptions {
|
||||||
readonly source: ModuleUiSource;
|
readonly source: ModuleUiSource;
|
||||||
/** The element the module renders into. The host owns it; the module owns its contents. */
|
/**
|
||||||
|
* The element the loader builds this artifact's isolated root inside. The host owns it, the
|
||||||
|
* module owns its own root's contents, and the loader adds and removes exactly one child of its
|
||||||
|
* own (ADR 0040). The artifact never receives this element.
|
||||||
|
*/
|
||||||
readonly container: Element;
|
readonly container: Element;
|
||||||
/** Supplied whole by the host. This package forwards it and never adds to it. */
|
/** Supplied whole by the host. This package forwards it and never adds to it. */
|
||||||
readonly context: ModuleUiHostContext;
|
readonly context: ModuleUiHostContext;
|
||||||
@@ -61,17 +66,31 @@ export interface MountedModuleUi {
|
|||||||
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
|
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
|
||||||
readonly mounted: boolean;
|
readonly mounted: boolean;
|
||||||
/**
|
/**
|
||||||
* Runs the module's cleanup, if it returned one. Idempotent: the first call cleans up, later
|
* This artifact's open shadow root. Exposed so a host's own tests and diagnostics can see what an
|
||||||
|
* artifact actually rendered; a host has no reason to write to it, and doing so would be reaching
|
||||||
|
* into content the module owns.
|
||||||
|
*/
|
||||||
|
readonly shadowRoot: ShadowRoot;
|
||||||
|
/**
|
||||||
|
* Runs the module's cleanup, if it returned one, then removes the isolated root the loader
|
||||||
|
* created — content and installed styles together. Idempotent: the first call cleans up, later
|
||||||
* calls do nothing. Safe when the module returned no cleanup function.
|
* calls do nothing. Safe when the module returned no cleanup function.
|
||||||
*/
|
*/
|
||||||
unmount(): Promise<void>;
|
unmount(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModuleUiHostErrorCode = "MODULE_UI_IMPORT_FAILED" | "MODULE_UI_MOUNT_FAILED";
|
export type ModuleUiHostErrorCode =
|
||||||
|
| "MODULE_UI_IMPORT_FAILED"
|
||||||
|
| "MODULE_UI_ISOLATION_UNAVAILABLE"
|
||||||
|
| "MODULE_UI_MOUNT_FAILED"
|
||||||
|
| "MODULE_CONTRIBUTION_UPDATE_FAILED";
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
|
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
|
||||||
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
|
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
|
||||||
|
MODULE_UI_ISOLATION_UNAVAILABLE:
|
||||||
|
"A module UI artifact could not be given an isolated root in this environment.",
|
||||||
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
|
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
|
||||||
|
MODULE_CONTRIBUTION_UPDATE_FAILED: "A contributed renderer failed while applying new input.",
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +122,12 @@ export class ModuleUiHostError extends Error {
|
|||||||
*
|
*
|
||||||
* Nothing is mounted until the artifact's declared identity matches the manifest identity the host
|
* Nothing is mounted until the artifact's declared identity matches the manifest identity the host
|
||||||
* passed in, so a stale or mismatched bundle fails before it can render or run `mount`.
|
* passed in, so a stale or mismatched bundle fails before it can render or run `mount`.
|
||||||
|
*
|
||||||
|
* Every artifact is mounted into its own isolated root the loader creates inside `container`
|
||||||
|
* (ADR 0040), and the artifact's declared `styles` are installed there. That is what keeps one
|
||||||
|
* module's stylesheet out of the Dashboard, out of another module, and out of another contributed
|
||||||
|
* renderer — and it applies identically to every role, so there is one mounting path rather than a
|
||||||
|
* styled one and an unstyled one.
|
||||||
*/
|
*/
|
||||||
export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> {
|
export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> {
|
||||||
const { container, context, source } = options;
|
const { container, context, source } = options;
|
||||||
@@ -124,16 +149,26 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
|
|||||||
version: source.version,
|
version: source.version,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Built before `mount` runs and torn down again if `mount` throws, so a failed artifact leaves
|
||||||
|
// neither DOM nor styles behind.
|
||||||
|
let isolated: IsolatedArtifactRoot;
|
||||||
|
try {
|
||||||
|
isolated = createIsolatedArtifactRoot(container, definition.styles);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ModuleUiHostError("MODULE_UI_ISOLATION_UNAVAILABLE", { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
let cleanup: ModuleUiUnmount | undefined;
|
let cleanup: ModuleUiUnmount | undefined;
|
||||||
try {
|
try {
|
||||||
const result = await definition.mount(container, context);
|
const result = await definition.mount(isolated.mountTarget, context);
|
||||||
if (typeof result === "function") {
|
if (typeof result === "function") {
|
||||||
cleanup = result;
|
cleanup = result;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// No cleanup is registered for a mount that never completed, so the returned handle can never
|
// No cleanup is registered for a mount that never completed, so the returned handle can never
|
||||||
// claim a failed module is mounted. The container is deliberately left untouched: the host owns
|
// claim a failed module is mounted. The host's own container is deliberately left as it was:
|
||||||
// it and decides what a failed mount should look like.
|
// the host owns it and decides what a failed mount should look like.
|
||||||
|
isolated.dispose();
|
||||||
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
|
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,14 +179,20 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
|
|||||||
get mounted() {
|
get mounted() {
|
||||||
return mounted;
|
return mounted;
|
||||||
},
|
},
|
||||||
|
shadowRoot: isolated.shadowRoot,
|
||||||
async unmount() {
|
async unmount() {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once
|
// Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once
|
||||||
// and a second call never re-runs it.
|
// and a second call never re-runs it. The isolated root is removed in a `finally`, so a
|
||||||
|
// module whose cleanup throws still has its styles taken out of the page.
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
try {
|
||||||
await cleanup?.();
|
await cleanup?.();
|
||||||
|
} finally {
|
||||||
|
isolated.dispose();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 6,
|
"manifestVersion": 7,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example",
|
"name": "Example",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.css" {
|
||||||
|
const stylesheet: string;
|
||||||
|
export default stylesheet;
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||||
|
import styles from "./styles.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
|
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
|
||||||
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
|
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
|
||||||
*
|
*
|
||||||
* React is deliberately absent — the module template already proves the React path end to end, and
|
* React is deliberately absent — the module template already proves the React path end to end, and
|
||||||
* omitting it keeps this artifact small enough to serve as a `data:` URL.
|
* omitting it keeps this artifact small enough to serve as a `data:` URL. It does import a local
|
||||||
|
* stylesheet, so the built artifact also proves the CSS half of that loop: `.css` source -> compiled
|
||||||
|
* text inside the declared artifact -> installed in the isolated root at mount (ADR 0040).
|
||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "example",
|
id: "example",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
styles,
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
const response = await context.api.request("records");
|
const response = await context.api.request("records");
|
||||||
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
|
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* Compiled into the artifact by the canonical builder and installed into its isolated root by the
|
||||||
|
* loader (ADR 0040). It references a global token and declares none of its own. */
|
||||||
|
.artifact-label {
|
||||||
|
color: var(--ekklesia-text-primary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { MODULE_CONTRIBUTION_ABI_VERSION, ModuleContributionError } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
loadModuleContribution,
|
||||||
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_STYLE_ATTRIBUTE,
|
||||||
|
ModuleUiHostError,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
function moduleUrl(source: string): string {
|
||||||
|
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contributionArtifact = (
|
||||||
|
options: {
|
||||||
|
id?: string;
|
||||||
|
version?: string;
|
||||||
|
styles?: string;
|
||||||
|
updatable?: boolean;
|
||||||
|
cleanup?: boolean;
|
||||||
|
mountThrows?: boolean;
|
||||||
|
updateThrows?: boolean;
|
||||||
|
} = {},
|
||||||
|
) => `
|
||||||
|
export default {
|
||||||
|
abi: ${MODULE_CONTRIBUTION_ABI_VERSION},
|
||||||
|
id: ${JSON.stringify(options.id ?? "announcements.signage-slide")},
|
||||||
|
version: ${JSON.stringify(options.version ?? "1.0.0")},
|
||||||
|
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
|
||||||
|
mount(container, context) {
|
||||||
|
${options.mountThrows ? 'throw new Error("mount exploded");' : ""}
|
||||||
|
globalThis.__contributionMounts ??= [];
|
||||||
|
globalThis.__contributionMounts.push({ container, contributionId: context.contributionId });
|
||||||
|
container.textContent = "slide:" + JSON.stringify(context.input);
|
||||||
|
return {
|
||||||
|
${
|
||||||
|
options.updatable === false
|
||||||
|
? ""
|
||||||
|
: `update(input) {
|
||||||
|
${options.updateThrows ? 'throw new Error("update exploded");' : ""}
|
||||||
|
container.textContent = "slide:" + JSON.stringify(input);
|
||||||
|
},`
|
||||||
|
}
|
||||||
|
${
|
||||||
|
options.cleanup === false
|
||||||
|
? ""
|
||||||
|
: `unmount() {
|
||||||
|
globalThis.__contributionUnmounts = (globalThis.__contributionUnmounts ?? 0) + 1;
|
||||||
|
container.textContent = "";
|
||||||
|
},`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
function container(): Element {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
document.body.append(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderedText(element: Element): string {
|
||||||
|
const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = { contributionId: "announcements.signage-slide", version: "1.0.0" };
|
||||||
|
|
||||||
|
describe("generic contribution loader", () => {
|
||||||
|
it("mounts a contribution with the consumer's input, in its own isolated root", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: { slide: 1 },
|
||||||
|
source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{color:red}" })) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mounted.contributionId).toBe("announcements.signage-slide");
|
||||||
|
expect(mounted.version).toBe("1.0.0");
|
||||||
|
expect(mounted.mounted).toBe(true);
|
||||||
|
expect(renderedText(element)).toBe('slide:{"slide":1}');
|
||||||
|
// Same isolation as every other browser artifact (ADR 0040): the renderer's styles cannot reach
|
||||||
|
// the consuming module's own screen around it.
|
||||||
|
const shadowRoot = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`)?.shadowRoot;
|
||||||
|
expect(
|
||||||
|
shadowRoot?.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)?.textContent,
|
||||||
|
).toContain(".slide{color:red}");
|
||||||
|
expect(element.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the renderer its own id and nothing else from the platform", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: null,
|
||||||
|
source: { ...source, url: moduleUrl(contributionArtifact()) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mounts = (
|
||||||
|
globalThis as { __contributionMounts?: { contributionId: string; container: Element }[] }
|
||||||
|
).__contributionMounts;
|
||||||
|
expect(mounts?.at(-1)?.contributionId).toBe("announcements.signage-slide");
|
||||||
|
// The isolated root, never the consumer's own element.
|
||||||
|
expect(mounts?.at(-1)?.container).not.toBe(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates in place without remounting", async () => {
|
||||||
|
const element = container();
|
||||||
|
const before = (globalThis as { __contributionMounts?: unknown[] }).__contributionMounts
|
||||||
|
?.length;
|
||||||
|
|
||||||
|
const mounted = await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: { slide: 1 },
|
||||||
|
source: { ...source, url: moduleUrl(contributionArtifact()) },
|
||||||
|
});
|
||||||
|
await mounted.update({ slide: 2 });
|
||||||
|
|
||||||
|
expect(renderedText(element)).toBe('slide:{"slide":2}');
|
||||||
|
// One mount, two renders: exactly the property a long-running screen needs.
|
||||||
|
expect(
|
||||||
|
((globalThis as { __contributionMounts?: unknown[] }).__contributionMounts?.length ?? 0) -
|
||||||
|
(before ?? 0),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells a consumer to remount when the renderer cannot update in place", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: { slide: 1 },
|
||||||
|
source: { ...source, url: moduleUrl(contributionArtifact({ updatable: false })) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_CONTRIBUTION_NOT_UPDATABLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a throwing update as a sanitized failure", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: { slide: 1 },
|
||||||
|
source: { ...source, url: moduleUrl(contributionArtifact({ updateThrows: true })) },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mounted.update({ slide: 2 });
|
||||||
|
expect.unreachable("expected the update to fail");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModuleUiHostError);
|
||||||
|
expect((error as ModuleUiHostError).code).toBe("MODULE_CONTRIBUTION_UPDATE_FAILED");
|
||||||
|
expect((error as ModuleUiHostError).message).not.toContain("exploded");
|
||||||
|
expect((error as { cause?: Error }).cause?.message).toBe("update exploded");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmounts deterministically, idempotently, and removes the isolated root", async () => {
|
||||||
|
const element = container();
|
||||||
|
const before = (globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0;
|
||||||
|
|
||||||
|
const mounted = await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: { slide: 1 },
|
||||||
|
source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{}" })) },
|
||||||
|
});
|
||||||
|
await mounted.unmount();
|
||||||
|
await mounted.unmount();
|
||||||
|
|
||||||
|
expect(mounted.mounted).toBe(false);
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
((globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0) - before,
|
||||||
|
).toBe(1);
|
||||||
|
await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_CONTRIBUTION_UNKNOWN",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmounts safely when the renderer registered no cleanup", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: null,
|
||||||
|
source: { ...source, url: moduleUrl(contributionArtifact({ cleanup: false })) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(mounted.unmount()).resolves.toBeUndefined();
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses input that is not genuinely JSON, before importing anything", async () => {
|
||||||
|
const element = container();
|
||||||
|
let imported = false;
|
||||||
|
|
||||||
|
for (const input of [
|
||||||
|
new Date() as unknown as null,
|
||||||
|
(() => undefined) as unknown as null,
|
||||||
|
Number.NaN as unknown as null,
|
||||||
|
{ nested: undefined } as unknown as null,
|
||||||
|
]) {
|
||||||
|
await expect(
|
||||||
|
loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input,
|
||||||
|
importModule: async () => {
|
||||||
|
imported = true;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
source: { ...source, url: "unused" },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cyclic: Record<string, unknown> = {};
|
||||||
|
cyclic.self = cyclic;
|
||||||
|
await expect(
|
||||||
|
loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: cyclic as never,
|
||||||
|
source: { ...source, url: "unused" },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" });
|
||||||
|
|
||||||
|
expect(imported).toBe(false);
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never echoes the consumer's domain data in a failure", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: { secretHeading: new Date() } as unknown as null,
|
||||||
|
source: { ...source, url: "unused" },
|
||||||
|
});
|
||||||
|
expect.unreachable("expected the input to be refused");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModuleContributionError);
|
||||||
|
expect(String((error as Error).message)).not.toContain("secretHeading");
|
||||||
|
expect(String((error as Error).stack)).not.toContain("secretHeading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"a different contribution id",
|
||||||
|
contributionArtifact({ id: "calendar.week" }),
|
||||||
|
"MODULE_CONTRIBUTION_IDENTITY_MISMATCH",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a different version",
|
||||||
|
contributionArtifact({ version: "2.0.0" }),
|
||||||
|
"MODULE_CONTRIBUTION_IDENTITY_MISMATCH",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a different contribution ABI",
|
||||||
|
'export default { abi: 99, id: "announcements.signage-slide", version: "1.0.0", mount() {} };',
|
||||||
|
"MODULE_CONTRIBUTION_ABI_UNSUPPORTED",
|
||||||
|
],
|
||||||
|
["no default export", "export const contribution = {};", "MODULE_CONTRIBUTION_EXPORT_MISSING"],
|
||||||
|
[
|
||||||
|
"a malformed definition",
|
||||||
|
`export default { abi: ${MODULE_CONTRIBUTION_ABI_VERSION}, id: "announcements.signage-slide" };`,
|
||||||
|
"MODULE_CONTRIBUTION_DEFINITION_INVALID",
|
||||||
|
],
|
||||||
|
])("refuses %s before mounting", async (_label, artifact, code) => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: null,
|
||||||
|
source: { ...source, url: moduleUrl(artifact) },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code });
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a throwing mount as a sanitized failure and leaves nothing behind", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: null,
|
||||||
|
source: { ...source, url: moduleUrl(contributionArtifact({ mountThrows: true })) },
|
||||||
|
});
|
||||||
|
expect.unreachable("expected the mount to fail");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModuleUiHostError);
|
||||||
|
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_MOUNT_FAILED");
|
||||||
|
expect((error as ModuleUiHostError).message).not.toContain("exploded");
|
||||||
|
}
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unreachable artifact as an import failure with no URL in the message", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadModuleContribution({
|
||||||
|
container: element,
|
||||||
|
input: null,
|
||||||
|
source: { ...source, url: "data:text/javascript,this is not(valid javascript" },
|
||||||
|
});
|
||||||
|
expect.unreachable("expected the import to fail");
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED");
|
||||||
|
expect((error as ModuleUiHostError).message).not.toContain("data:");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps two mounted contributions independent", async () => {
|
||||||
|
const first = container();
|
||||||
|
const second = container();
|
||||||
|
const url = moduleUrl(contributionArtifact());
|
||||||
|
|
||||||
|
const a = await loadModuleContribution({
|
||||||
|
container: first,
|
||||||
|
input: { slide: "a" },
|
||||||
|
source: { ...source, url },
|
||||||
|
});
|
||||||
|
const b = await loadModuleContribution({
|
||||||
|
container: second,
|
||||||
|
input: { slide: "b" },
|
||||||
|
source: { ...source, url },
|
||||||
|
});
|
||||||
|
|
||||||
|
await a.unmount();
|
||||||
|
|
||||||
|
expect(a.mounted).toBe(false);
|
||||||
|
expect(b.mounted).toBe(true);
|
||||||
|
expect(first.children).toHaveLength(0);
|
||||||
|
expect(renderedText(second)).toBe('slide:{"slide":"b"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { ModuleUiContractError, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import {
|
||||||
|
MODULE_UI_ABI_VERSION,
|
||||||
|
ModuleUiContractError,
|
||||||
|
type ModuleUiHostContext,
|
||||||
|
NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
} from "@ekklesia/module-ui-sdk";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { loadModuleUi, ModuleUiHostError, type ModuleUiImporter } from "../src/index.js";
|
import {
|
||||||
|
loadModuleUi,
|
||||||
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_STYLE_ATTRIBUTE,
|
||||||
|
ModuleUiHostError,
|
||||||
|
type ModuleUiImporter,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A browser-loadable URL for a module built from source text.
|
* A browser-loadable URL for a module built from source text.
|
||||||
@@ -15,10 +27,14 @@ function moduleUrl(source: string): string {
|
|||||||
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
|
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validArtifact = (options: { id?: string; version?: string; cleanup?: boolean } = {}) => `
|
const validArtifact = (
|
||||||
|
options: { id?: string; version?: string; cleanup?: boolean; styles?: string } = {},
|
||||||
|
) => `
|
||||||
export default {
|
export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(options.id ?? "announcements")},
|
id: ${JSON.stringify(options.id ?? "announcements")},
|
||||||
version: ${JSON.stringify(options.version ?? "1.2.0")},
|
version: ${JSON.stringify(options.version ?? "1.2.0")},
|
||||||
|
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
globalThis.__mounts ??= [];
|
globalThis.__mounts ??= [];
|
||||||
globalThis.__mounts.push({ moduleId: context.moduleId, container });
|
globalThis.__mounts.push({ moduleId: context.moduleId, container });
|
||||||
@@ -35,6 +51,7 @@ export default {
|
|||||||
|
|
||||||
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
|
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
|
||||||
return {
|
return {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId,
|
moduleId,
|
||||||
api: {
|
api: {
|
||||||
async request(path) {
|
async request(path) {
|
||||||
@@ -54,6 +71,20 @@ function container(): Element {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the artifact actually rendered, reached the way a host's own diagnostics would: through the
|
||||||
|
* open shadow root the loader created. The host's container itself holds only the loader's one
|
||||||
|
* wrapper element, which is exactly the isolation ADR 0040 introduced.
|
||||||
|
*/
|
||||||
|
function artifactRoot(element: Element): Element | null {
|
||||||
|
const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||||
|
return shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactText(element: Element): string {
|
||||||
|
return artifactRoot(element)?.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
const source = { moduleId: "announcements", version: "1.2.0" };
|
const source = { moduleId: "announcements", version: "1.2.0" };
|
||||||
|
|
||||||
describe("generic module UI host loader", () => {
|
describe("generic module UI host loader", () => {
|
||||||
@@ -70,10 +101,99 @@ describe("generic module UI host loader", () => {
|
|||||||
expect(mounted.moduleId).toBe("announcements");
|
expect(mounted.moduleId).toBe("announcements");
|
||||||
expect(mounted.version).toBe("1.2.0");
|
expect(mounted.version).toBe("1.2.0");
|
||||||
expect(mounted.mounted).toBe(true);
|
expect(mounted.mounted).toBe(true);
|
||||||
expect(element.textContent).toBe("mounted:announcements");
|
expect(artifactText(element)).toBe("mounted:announcements");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the host's own container and context straight through", async () => {
|
it("mounts the artifact into an isolated root rather than the host's own container", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The host's container gained exactly one child: the loader's shadow host.
|
||||||
|
expect(element.children).toHaveLength(1);
|
||||||
|
const shadowHost = element.firstElementChild as Element;
|
||||||
|
expect(shadowHost.hasAttribute(MODULE_UI_ISOLATION_ATTRIBUTE)).toBe(true);
|
||||||
|
expect(shadowHost.shadowRoot).toBe(mounted.shadowRoot);
|
||||||
|
// Open, deliberately: this is a correctness boundary for styles, not a sandbox — an installed
|
||||||
|
// module is trusted code in the host's own realm and could reach `document` regardless.
|
||||||
|
expect(mounted.shadowRoot.mode).toBe("open");
|
||||||
|
// The artifact's DOM is not reachable from the host's own document tree.
|
||||||
|
expect(element.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs a declared stylesheet inside the isolated root, exactly once", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact({ styles: ".posts{color:red}" })) },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = mounted.shadowRoot.querySelectorAll(
|
||||||
|
`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`,
|
||||||
|
);
|
||||||
|
expect(styles).toHaveLength(1);
|
||||||
|
expect(styles[0]?.textContent).toContain(".posts{color:red}");
|
||||||
|
// The host document never receives the artifact's rules.
|
||||||
|
expect(document.head.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs the host's base rules before the artifact's own, so an artifact can override them", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact({ styles: ":host{display:block}" })) },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stylesheet =
|
||||||
|
mounted.shadowRoot.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)
|
||||||
|
?.textContent ?? "";
|
||||||
|
expect(stylesheet.indexOf(":host{display:contents}")).toBeLessThan(
|
||||||
|
stylesheet.indexOf(":host{display:block}"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still mounts an artifact that declares no stylesheet", async () => {
|
||||||
|
const element = container();
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(artifactText(element)).toBe("mounted:announcements");
|
||||||
|
expect(
|
||||||
|
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never accumulates styles across repeated mounts of the same container", async () => {
|
||||||
|
const element = container();
|
||||||
|
const url = moduleUrl(validArtifact({ styles: ".posts{color:red}" }));
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { ...source, url },
|
||||||
|
container: element,
|
||||||
|
context: hostContext([]),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
|
||||||
|
).toHaveLength(1);
|
||||||
|
await mounted.unmount();
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the isolated mount target and the host's context straight through", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const requested: string[] = [];
|
const requested: string[] = [];
|
||||||
const context = hostContext(requested);
|
const context = hostContext(requested);
|
||||||
@@ -86,7 +206,9 @@ describe("generic module UI host loader", () => {
|
|||||||
|
|
||||||
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
|
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
|
||||||
.__mounts;
|
.__mounts;
|
||||||
expect(mounts?.at(-1)?.container).toBe(element);
|
// The artifact receives the loader's isolated root, never the host's own element.
|
||||||
|
expect(mounts?.at(-1)?.container).toBe(artifactRoot(element));
|
||||||
|
expect(mounts?.at(-1)?.container).not.toBe(element);
|
||||||
expect(mounts?.at(-1)?.moduleId).toBe("announcements");
|
expect(mounts?.at(-1)?.moduleId).toBe("announcements");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,7 +226,7 @@ describe("generic module UI host loader", () => {
|
|||||||
expect(requested.join(" ")).not.toContain("/api/v1/modules");
|
expect(requested.join(" ")).not.toContain("/api/v1/modules");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs the module's cleanup on unmount without touching the host container", async () => {
|
it("runs the module's cleanup and removes the isolated root without touching the host container", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const parent = element.parentElement;
|
const parent = element.parentElement;
|
||||||
|
|
||||||
@@ -116,7 +238,8 @@ describe("generic module UI host loader", () => {
|
|||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
|
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
expect(element.textContent).toBe("");
|
// The artifact's root, and with it its styles, are gone.
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
// The host owns the container; unmounting a module never removes it.
|
// The host owns the container; unmounting a module never removes it.
|
||||||
expect(element.parentElement).toBe(parent);
|
expect(element.parentElement).toBe(parent);
|
||||||
expect(document.body.contains(element)).toBe(true);
|
expect(document.body.contains(element)).toBe(true);
|
||||||
@@ -151,13 +274,15 @@ describe("generic module UI host loader", () => {
|
|||||||
|
|
||||||
await expect(mounted.unmount()).resolves.toBeUndefined();
|
await expect(mounted.unmount()).resolves.toBeUndefined();
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
|
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const artifact = `
|
const artifact = `
|
||||||
export default {
|
export default {
|
||||||
id: "announcements", version: "1.2.0",
|
abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
|
||||||
|
styles: ".x{color:red}",
|
||||||
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
|
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
|
||||||
};`;
|
};`;
|
||||||
|
|
||||||
@@ -169,6 +294,8 @@ describe("generic module UI host loader", () => {
|
|||||||
|
|
||||||
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
|
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
|
// A module that fails to clean up must not leave its stylesheet installed in the page.
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
await expect(mounted.unmount()).resolves.toBeUndefined();
|
await expect(mounted.unmount()).resolves.toBeUndefined();
|
||||||
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
|
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -189,16 +316,17 @@ describe("generic module UI host loader", () => {
|
|||||||
context: hostContext([]),
|
context: hostContext([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(first.textContent).toBe("mounted:announcements");
|
expect(artifactText(first)).toBe("mounted:announcements");
|
||||||
expect(second.textContent).toBe("mounted:announcements");
|
expect(artifactText(second)).toBe("mounted:announcements");
|
||||||
|
expect(a.shadowRoot).not.toBe(b.shadowRoot);
|
||||||
|
|
||||||
await a.unmount();
|
await a.unmount();
|
||||||
|
|
||||||
// Each handle owns its own cleanup state; unmounting one leaves the other alone.
|
// Each handle owns its own cleanup state; unmounting one leaves the other alone.
|
||||||
expect(a.mounted).toBe(false);
|
expect(a.mounted).toBe(false);
|
||||||
expect(b.mounted).toBe(true);
|
expect(b.mounted).toBe(true);
|
||||||
expect(first.textContent).toBe("");
|
expect(first.children).toHaveLength(0);
|
||||||
expect(second.textContent).toBe("mounted:announcements");
|
expect(artifactText(second)).toBe("mounted:announcements");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("rejects an artifact before mounting it", () => {
|
describe("rejects an artifact before mounting it", () => {
|
||||||
@@ -209,7 +337,7 @@ describe("generic module UI host loader", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||||
expect(element.textContent).toBe("");
|
expect(element.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("when the version does not match the manifest", async () => {
|
it("when the version does not match the manifest", async () => {
|
||||||
@@ -219,7 +347,19 @@ describe("generic module UI host loader", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||||
expect(element.textContent).toBe("");
|
expect(element.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when it was built against the previous mount ABI", async () => {
|
||||||
|
const element = container();
|
||||||
|
const url = moduleUrl(
|
||||||
|
'export default { id: "announcements", version: "1.2.0", mount() {} };',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_UI_ABI_UNSUPPORTED" });
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("when there is no default export", async () => {
|
it("when there is no default export", async () => {
|
||||||
@@ -233,7 +373,9 @@ describe("generic module UI host loader", () => {
|
|||||||
|
|
||||||
it("when the default export is not a module UI definition", async () => {
|
it("when the default export is not a module UI definition", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const url = moduleUrl('export default { id: "announcements", version: "1.2.0" };');
|
const url = moduleUrl(
|
||||||
|
`export default { abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0" };`,
|
||||||
|
);
|
||||||
|
|
||||||
const failure = loadModuleUi({
|
const failure = loadModuleUi({
|
||||||
source: { ...source, url },
|
source: { ...source, url },
|
||||||
@@ -263,11 +405,12 @@ describe("generic module UI host loader", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a throwing mount as a mount failure and registers no cleanup", async () => {
|
it("reports a throwing mount as a mount failure, registers no cleanup, and removes the root", async () => {
|
||||||
const element = container();
|
const element = container();
|
||||||
const url = moduleUrl(`
|
const url = moduleUrl(`
|
||||||
export default {
|
export default {
|
||||||
id: "announcements", version: "1.2.0",
|
abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
|
||||||
|
styles: ".x{color:red}",
|
||||||
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
|
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
|
||||||
};`);
|
};`);
|
||||||
|
|
||||||
@@ -284,9 +427,38 @@ describe("generic module UI host loader", () => {
|
|||||||
expect((error as ModuleUiHostError).message).not.toContain("exploded");
|
expect((error as ModuleUiHostError).message).not.toContain("exploded");
|
||||||
expect((error as { cause?: Error }).cause?.message).toBe("mount exploded");
|
expect((error as { cause?: Error }).cause?.message).toBe("mount exploded");
|
||||||
}
|
}
|
||||||
// No handle exists, so nothing can claim the module is mounted. The container is left exactly
|
// No handle exists, so nothing can claim the module is mounted — and a failed mount leaves
|
||||||
// as the module left it: the host owns it and decides what a failed mount looks like.
|
// neither partial DOM nor an installed stylesheet behind in the host's container.
|
||||||
expect(element.textContent).toBe("partial");
|
expect(element.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an environment with no Shadow DOM as an isolation failure, never as an unisolated mount", async () => {
|
||||||
|
const element = container();
|
||||||
|
const detached = {
|
||||||
|
ownerDocument: document,
|
||||||
|
appendChild: element.appendChild.bind(element),
|
||||||
|
} as unknown as Element;
|
||||||
|
// A container whose created element cannot take a shadow root: the loader must refuse rather
|
||||||
|
// than fall back to mounting the artifact — and its styles — straight into the host's page.
|
||||||
|
const noShadow = new Proxy(detached, {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
if (property === "ownerDocument") {
|
||||||
|
return {
|
||||||
|
createElement: () => ({ setAttribute: () => undefined }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Reflect.get(target, property, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadModuleUi({
|
||||||
|
source: { ...source, url: moduleUrl(validArtifact()) },
|
||||||
|
container: noShadow,
|
||||||
|
context: hostContext([]),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_UI_ISOLATION_UNAVAILABLE" });
|
||||||
|
expect(element.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
|
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
|
||||||
@@ -294,7 +466,14 @@ describe("generic module UI host loader", () => {
|
|||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
const importModule: ModuleUiImporter = async (url) => {
|
const importModule: ModuleUiImporter = async (url) => {
|
||||||
seen.push(url);
|
seen.push(url);
|
||||||
return { default: { id: "announcements", version: "1.2.0", mount: () => undefined } };
|
return {
|
||||||
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
|
id: "announcements",
|
||||||
|
version: "1.2.0",
|
||||||
|
mount: () => undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mounted = await loadModuleUi({
|
const mounted = await loadModuleUi({
|
||||||
|
|||||||
@@ -3,9 +3,22 @@ import { readFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import { type ModuleUiHostContext, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import { loadModuleUi } from "../src/index.js";
|
import {
|
||||||
|
loadModuleUi,
|
||||||
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
/** What the artifact rendered, reached through the open shadow root the loader created (ADR 0040). */
|
||||||
|
function artifactText(container: Element): string {
|
||||||
|
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const fixtureDirectory = path.join(
|
const fixtureDirectory = path.join(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
@@ -19,6 +32,7 @@ let artifactSource: string;
|
|||||||
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
|
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
|
||||||
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
||||||
return {
|
return {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
api: {
|
api: {
|
||||||
async request(pathname) {
|
async request(pathname) {
|
||||||
@@ -35,6 +49,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
|||||||
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
|
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
|
||||||
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
||||||
return {
|
return {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
api: {
|
api: {
|
||||||
async request(pathname) {
|
async request(pathname) {
|
||||||
@@ -80,12 +95,32 @@ describe("loading a real compiled artifact", () => {
|
|||||||
context: ekklesiaContext(requested),
|
context: ekklesiaContext(requested),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toBe("example:200:user-123");
|
expect(artifactText(container)).toBe("example:200:user-123");
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(container.children).toHaveLength(0);
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("compiles the fixture's stylesheet into the artifact and installs it in the isolated root", async () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
|
||||||
|
// No sidecar: the whole stylesheet travels inside the one declared JavaScript artifact.
|
||||||
|
expect(artifactSource).toContain(".artifact-label");
|
||||||
|
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
|
||||||
|
container,
|
||||||
|
context: ekklesiaContext([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stylesheet = mounted.shadowRoot.querySelector("style")?.textContent ?? "";
|
||||||
|
expect(stylesheet).toContain(".artifact-label");
|
||||||
|
expect(stylesheet).toContain("var(--ekklesia-text-primary)");
|
||||||
|
expect(document.head.textContent ?? "").not.toContain(".artifact-label");
|
||||||
|
|
||||||
|
await mounted.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it("still rejects the real artifact when the host expects a different version", async () => {
|
it("still rejects the real artifact when the host expects a different version", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
loadModuleUi({
|
loadModuleUi({
|
||||||
@@ -118,8 +153,8 @@ describe("loading a real compiled artifact", () => {
|
|||||||
// the module asked for "records" both times and resolved nothing itself.
|
// the module asked for "records" both times and resolved nothing itself.
|
||||||
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
|
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
|
||||||
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
|
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
|
||||||
expect(ekklesiaContainer.textContent).toBe("example:200:user-123");
|
expect(artifactText(ekklesiaContainer)).toBe("example:200:user-123");
|
||||||
expect(standaloneContainer.textContent).toBe("example:200:guest");
|
expect(artifactText(standaloneContainer)).toBe("example:200:guest");
|
||||||
|
|
||||||
await inEkklesia.unmount();
|
await inEkklesia.unmount();
|
||||||
await inStandalone.unmount();
|
await inStandalone.unmount();
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* A value or a promise of one.
|
||||||
|
*
|
||||||
|
* In its own file rather than beside either contract that uses it: `module-ui.ts` and
|
||||||
|
* `contribution.ts` both need it, and `module-ui.ts` reaches `host-context.ts`, which reaches
|
||||||
|
* `contribution.ts` — so declaring it in either would close a source cycle that
|
||||||
|
* `scripts/check-boundaries.ts` rejects, including one formed only by `import type`.
|
||||||
|
*/
|
||||||
|
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import type { Awaitable } from "./awaitable.js";
|
||||||
|
import type { ModuleUiJsonValue } from "./json.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser **contribution** contract: what a module's contributed renderer exports, and what a
|
||||||
|
* consuming module can do with one (ADR 0041).
|
||||||
|
*
|
||||||
|
* A contribution is not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts a
|
||||||
|
* *host* mounts, in surfaces the host owns. A contribution is mounted by a **consumer module**,
|
||||||
|
* inside a container that consumer owns, at a moment only that consumer understands — a slot in its
|
||||||
|
* own layout. The platform loads the code and validates its identity; it never decides that a
|
||||||
|
* contribution should be on screen.
|
||||||
|
*
|
||||||
|
* ## The consumer owns the domain contract, entirely
|
||||||
|
*
|
||||||
|
* `input` is opaque JSON. The platform validates that it is JSON and nothing else: not its shape,
|
||||||
|
* not its meaning, not its version. A consumer that provides a "slide renderer" capability publishes
|
||||||
|
* *its own* input contract, versions it through its own capability version, and every contributor
|
||||||
|
* writes against that. Core cannot learn what a slide is from this file, which is the point.
|
||||||
|
*
|
||||||
|
* ## What a contribution does not receive
|
||||||
|
*
|
||||||
|
* No database, no object storage, no credential, no session, no API client, and no reach into any
|
||||||
|
* module — including the one that contributed it. Loading a contribution grants its module nothing:
|
||||||
|
* server-side authorization is unchanged and remains the only authority. Everything a contributed
|
||||||
|
* renderer needs arrives through the consumer's input, because the consumer is the party that was
|
||||||
|
* authorized to obtain it.
|
||||||
|
*
|
||||||
|
* ## Still trusted code
|
||||||
|
*
|
||||||
|
* A contribution artifact is installed first-party JavaScript running in the host's realm, exactly
|
||||||
|
* like every other module artifact (ADR 0022, ADR 0024). Its isolated root (ADR 0040) is a
|
||||||
|
* correctness boundary for styles, not a sandbox, and this contract must never be described as one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version of the contribution ABI this package defines, and the only one a host mounts.
|
||||||
|
*
|
||||||
|
* Separate from `MODULE_UI_ABI_VERSION` on purpose: a contributed renderer and a mounted module UI
|
||||||
|
* are different contracts with different lifecycles — one is handed a host context, the other is
|
||||||
|
* handed a consumer's input and can be updated in place — and versioning them together would force
|
||||||
|
* every module to rebuild whenever either moved.
|
||||||
|
*
|
||||||
|
* `defineModuleContribution` stamps it. An author never writes it.
|
||||||
|
*/
|
||||||
|
export const MODULE_CONTRIBUTION_ABI_VERSION = 1;
|
||||||
|
|
||||||
|
/** The consumer's own versioned input. Opaque to the platform. */
|
||||||
|
export type ModuleContributionInput = ModuleUiJsonValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a contributed renderer is given, and the complete list of it.
|
||||||
|
*
|
||||||
|
* Deliberately two members. Anything else — a principal, an API client, a realtime subscription,
|
||||||
|
* configuration — would either be authority the contribution has not earned or state the consumer
|
||||||
|
* should be passing in its own input, where it can version it.
|
||||||
|
*/
|
||||||
|
export interface ModuleContributionContext {
|
||||||
|
/** This contribution's own id, so it never re-derives its identity from anything. */
|
||||||
|
readonly contributionId: string;
|
||||||
|
/** The consumer's input for this mount. */
|
||||||
|
readonly input: ModuleContributionInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a contribution's `mount` returns: how to give it new input, and how to release it.
|
||||||
|
*
|
||||||
|
* `update` exists so a consumer can drive a renderer through changing state — a new slide, a new
|
||||||
|
* selection — **without** remounting. Remounting for every change would destroy DOM, restart
|
||||||
|
* animations, and reload media on every tick, which is exactly wrong for the long-running screens
|
||||||
|
* this contract is for. A contribution that genuinely cannot update in place omits it, and the
|
||||||
|
* consumer remounts.
|
||||||
|
*
|
||||||
|
* Returning nothing at all declares that the mount acquired nothing and cannot be updated.
|
||||||
|
*/
|
||||||
|
export interface ModuleContributionInstance {
|
||||||
|
readonly update?: (input: ModuleContributionInput) => Awaitable<void>;
|
||||||
|
readonly unmount?: () => Awaitable<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a module author passes to {@link defineModuleContribution}. */
|
||||||
|
export interface ModuleContributionDefinitionInput {
|
||||||
|
/** `<module-id>.<local-name>`, matching the manifest declaration exactly. */
|
||||||
|
readonly id: string;
|
||||||
|
/** This contribution's own version, matching the manifest declaration exactly. */
|
||||||
|
readonly version: string;
|
||||||
|
/** The artifact's compiled CSS text, installed by the host into this artifact's isolated root
|
||||||
|
* (ADR 0040). Exactly the same mechanism a module UI artifact uses. */
|
||||||
|
readonly styles?: string;
|
||||||
|
/**
|
||||||
|
* Asynchronous by contract. A renderer that has to decode media, measure a layout, or wait for a
|
||||||
|
* font before it is honestly ready returns a promise, and the consumer's `mount` resolves only
|
||||||
|
* once it has settled — so a consumer can sequence a transition instead of guessing.
|
||||||
|
*/
|
||||||
|
readonly mount: (
|
||||||
|
container: Element,
|
||||||
|
context: ModuleContributionContext,
|
||||||
|
) => Awaitable<ModuleContributionInstance | void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a compiled contribution artifact default-exports: the author's declaration plus the ABI
|
||||||
|
* marker `defineModuleContribution` stamped on it. */
|
||||||
|
export interface ModuleContributionDefinition extends ModuleContributionDefinitionInput {
|
||||||
|
readonly abi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModuleContributionErrorCode =
|
||||||
|
| "MODULE_CONTRIBUTION_EXPORT_MISSING"
|
||||||
|
| "MODULE_CONTRIBUTION_DEFINITION_INVALID"
|
||||||
|
| "MODULE_CONTRIBUTION_ABI_UNSUPPORTED"
|
||||||
|
| "MODULE_CONTRIBUTION_STYLES_INVALID"
|
||||||
|
| "MODULE_CONTRIBUTION_IDENTITY_MISMATCH"
|
||||||
|
| "MODULE_CONTRIBUTION_INPUT_INVALID"
|
||||||
|
| "MODULE_CONTRIBUTION_UNKNOWN"
|
||||||
|
| "MODULE_CONTRIBUTION_NOT_UPDATABLE";
|
||||||
|
|
||||||
|
const errorMessages: Readonly<Record<ModuleContributionErrorCode, string>> = Object.freeze({
|
||||||
|
MODULE_CONTRIBUTION_EXPORT_MISSING:
|
||||||
|
"A module contribution artifact must have a default export produced by defineModuleContribution.",
|
||||||
|
MODULE_CONTRIBUTION_DEFINITION_INVALID:
|
||||||
|
"A module contribution definition must declare a non-empty id and version and a mount function, and nothing else.",
|
||||||
|
MODULE_CONTRIBUTION_ABI_UNSUPPORTED:
|
||||||
|
"This contribution artifact was built for a different contribution ABI. Rebuild the module against the current module UI SDK.",
|
||||||
|
MODULE_CONTRIBUTION_STYLES_INVALID:
|
||||||
|
"A module contribution's styles must be the artifact's compiled CSS text, as a non-empty string.",
|
||||||
|
MODULE_CONTRIBUTION_IDENTITY_MISMATCH:
|
||||||
|
"The contribution artifact's id and version do not match the contribution it was loaded for.",
|
||||||
|
MODULE_CONTRIBUTION_INPUT_INVALID:
|
||||||
|
"A contribution's input must be a JSON-serializable value supplied by the consuming module.",
|
||||||
|
MODULE_CONTRIBUTION_UNKNOWN: "No contribution with that id is available to this module.",
|
||||||
|
MODULE_CONTRIBUTION_NOT_UPDATABLE:
|
||||||
|
"This contribution does not support updating in place. Unmount it and mount it again.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Carries a stable code and a fixed message, never the offending value, the artifact URL, or the
|
||||||
|
* consumer's input — which is domain data and never belongs in a platform error. */
|
||||||
|
export class ModuleContributionError extends Error {
|
||||||
|
readonly code: ModuleContributionErrorCode;
|
||||||
|
|
||||||
|
constructor(code: ModuleContributionErrorCode) {
|
||||||
|
super(errorMessages[code]);
|
||||||
|
this.name = "ModuleContributionError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const definitionKeys = new Set(["abi", "id", "mount", "styles", "version"]);
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a value is genuinely JSON-serializable, checked structurally rather than by attempting
|
||||||
|
* `JSON.stringify` and hoping.
|
||||||
|
*
|
||||||
|
* A consumer's input crosses from one module's code to another's. `undefined`, a function, a
|
||||||
|
* `Symbol`, a `Date`, a cyclic object, and a class instance all either vanish or throw when
|
||||||
|
* serialized, and a contribution that received one would fail somewhere far from the mistake. This
|
||||||
|
* is a bounded structural walk, so a cycle is caught rather than followed.
|
||||||
|
*/
|
||||||
|
export function isModuleContributionInput(value: unknown, seen = new Set<object>()): boolean {
|
||||||
|
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value);
|
||||||
|
}
|
||||||
|
if (typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const entries = Array.isArray(value) ? value : Object.values(value);
|
||||||
|
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const valid = entries.every((entry) => isModuleContributionInput(entry, seen));
|
||||||
|
seen.delete(value);
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModuleContributionDefinition(
|
||||||
|
value: unknown,
|
||||||
|
): value is ModuleContributionDefinition {
|
||||||
|
if (!isRecord(value) || value.abi !== MODULE_CONTRIBUTION_ABI_VERSION) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
isNonEmptyString(value.id) &&
|
||||||
|
isNonEmptyString(value.version) &&
|
||||||
|
typeof value.mount === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a contribution artifact's default export. The ABI marker is checked first and on its own,
|
||||||
|
* so an artifact built against a different contribution ABI is reported as exactly that. */
|
||||||
|
export function parseModuleContributionDefinition(value: unknown): ModuleContributionDefinition {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
|
||||||
|
}
|
||||||
|
if (value.abi !== MODULE_CONTRIBUTION_ABI_VERSION) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_ABI_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_STYLES_INVALID");
|
||||||
|
}
|
||||||
|
if (!isModuleContributionDefinition(value)) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
abi: MODULE_CONTRIBUTION_ABI_VERSION,
|
||||||
|
id: value.id,
|
||||||
|
mount: value.mount,
|
||||||
|
...(value.styles === undefined ? {} : { styles: value.styles }),
|
||||||
|
version: value.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The identity a host already knows from the contributing module's validated manifest. */
|
||||||
|
export interface ModuleContributionIdentity {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one entry point a host uses after importing a contribution artifact. Imports nothing itself,
|
||||||
|
* exactly like `parseModuleUiExport`, so this package stays free of any loader or URL assumption. */
|
||||||
|
export function parseModuleContributionExport(
|
||||||
|
namespace: unknown,
|
||||||
|
expected: ModuleContributionIdentity,
|
||||||
|
): ModuleContributionDefinition {
|
||||||
|
if (!isRecord(namespace) || !("default" in namespace)) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_EXPORT_MISSING");
|
||||||
|
}
|
||||||
|
const definition = parseModuleContributionDefinition(namespace.default);
|
||||||
|
if (definition.id !== expected.id || definition.version !== expected.version) {
|
||||||
|
throw new ModuleContributionError("MODULE_CONTRIBUTION_IDENTITY_MISMATCH");
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contributing module author's entry point, mirroring `defineModuleUi`: validate at authoring
|
||||||
|
* time, freeze, and stamp the contribution ABI this artifact was built against.
|
||||||
|
*/
|
||||||
|
export function defineModuleContribution(
|
||||||
|
definition: ModuleContributionDefinitionInput,
|
||||||
|
): ModuleContributionDefinition {
|
||||||
|
return parseModuleContributionDefinition({
|
||||||
|
...definition,
|
||||||
|
abi: MODULE_CONTRIBUTION_ABI_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a **consumer** module sees of the contributions aimed at a capability it provides.
|
||||||
|
*
|
||||||
|
* Identity and version only. There is deliberately no artifact URL: a consumer names a contribution
|
||||||
|
* and the host resolves it, exactly as a module names a relative API path and the host resolves that
|
||||||
|
* (ADR 0022). A module that built `/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would
|
||||||
|
* be welded to Ekklesia's private deployment detail and unhostable anywhere else.
|
||||||
|
*/
|
||||||
|
export interface ModuleContributionDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
/** The capability this renderer was contributed to — one the consuming module provides. */
|
||||||
|
readonly capability: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One mounted contribution, owned by the consumer that mounted it. */
|
||||||
|
export interface MountedModuleContribution {
|
||||||
|
readonly contributionId: string;
|
||||||
|
readonly version: string;
|
||||||
|
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
|
||||||
|
readonly mounted: boolean;
|
||||||
|
/**
|
||||||
|
* Hands the renderer new input without remounting it.
|
||||||
|
*
|
||||||
|
* Rejects if the contribution declared no `update`, so a consumer learns that it must remount
|
||||||
|
* rather than silently rendering stale content.
|
||||||
|
*/
|
||||||
|
update(input: ModuleContributionInput): Promise<void>;
|
||||||
|
/** Runs the renderer's cleanup and removes its isolated root. Idempotent. */
|
||||||
|
unmount(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MountModuleContributionOptions {
|
||||||
|
/** The exact contribution id from a {@link ModuleContributionDescriptor}. */
|
||||||
|
readonly contributionId: string;
|
||||||
|
/** A container the **consumer** owns — its own slot. The host builds the renderer's isolated root
|
||||||
|
* inside it and never reaches outside it. */
|
||||||
|
readonly container: Element;
|
||||||
|
/** The consumer's own versioned input. */
|
||||||
|
readonly input: ModuleContributionInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A consuming module's access to the renderers contributed to its own capabilities.
|
||||||
|
*
|
||||||
|
* The host supplies this; a module never constructs one. It can only ever see contributions aimed at
|
||||||
|
* a capability this module actually provides, because that is the only thing the server put in its
|
||||||
|
* descriptor — a module cannot enumerate the platform's contributions, only its own extension
|
||||||
|
* points.
|
||||||
|
*/
|
||||||
|
export interface ModuleUiContributions {
|
||||||
|
/** Contributions declared for one capability this module provides, in a stable order. Returns an
|
||||||
|
* empty list for a capability this module does not provide, rather than throwing: a consumer
|
||||||
|
* asking about an extension point nobody filled is the ordinary case. */
|
||||||
|
list(capabilityId: string): readonly ModuleContributionDescriptor[];
|
||||||
|
/** Loads, verifies, and mounts one contribution into a consumer-owned container. */
|
||||||
|
mount(options: MountModuleContributionOptions): Promise<MountedModuleContribution>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a host with no contribution mechanism supplies.
|
||||||
|
*
|
||||||
|
* A standalone host running exactly one module has no other module to contribute anything, so an
|
||||||
|
* empty implementation is the *correct* one rather than a stub — which is precisely why this member
|
||||||
|
* is always present on the context: a module writes the same code either way, and finds nothing.
|
||||||
|
*/
|
||||||
|
export const NO_MODULE_UI_CONTRIBUTIONS: ModuleUiContributions = Object.freeze({
|
||||||
|
list: (): readonly ModuleContributionDescriptor[] => Object.freeze([]),
|
||||||
|
mount: (): Promise<MountedModuleContribution> =>
|
||||||
|
Promise.reject(new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN")),
|
||||||
|
});
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { type ModuleUiDefinition, parseModuleUiDefinition } from "./module-ui.js";
|
import {
|
||||||
|
MODULE_UI_ABI_VERSION,
|
||||||
|
type ModuleUiDefinition,
|
||||||
|
type ModuleUiDefinitionInput,
|
||||||
|
parseModuleUiDefinition,
|
||||||
|
} from "./module-ui.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The module author's entry point, mirroring `defineModuleRuntime` (ADR 0006): validate at authoring
|
* The module author's entry point, mirroring `defineModuleRuntime` (ADR 0006): validate at authoring
|
||||||
* time, freeze, and preserve the literal type. A malformed definition fails where it was written
|
* time, freeze, and stamp the browser mount ABI this artifact was built against. A malformed
|
||||||
* rather than in a host, at mount time, in a browser.
|
* definition fails where it was written rather than in a host, at mount time, in a browser.
|
||||||
|
*
|
||||||
|
* The ABI marker is added here rather than written by the author, so it can never drift from the SDK
|
||||||
|
* the artifact actually compiled against — which is the whole point of having one (ADR 0040).
|
||||||
*/
|
*/
|
||||||
export function defineModuleUi<const Definition extends ModuleUiDefinition>(
|
export function defineModuleUi(definition: ModuleUiDefinitionInput): ModuleUiDefinition {
|
||||||
definition: Definition,
|
return parseModuleUiDefinition({ ...definition, abi: MODULE_UI_ABI_VERSION });
|
||||||
): Readonly<Definition> {
|
|
||||||
return parseModuleUiDefinition(definition) as Readonly<Definition>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ModuleUiContributions } from "./contribution.js";
|
||||||
import type { ModuleUiRealtimeClient } from "./realtime.js";
|
import type { ModuleUiRealtimeClient } from "./realtime.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,10 +55,15 @@ export type ModuleUiPrincipal =
|
|||||||
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
|
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
|
||||||
*
|
*
|
||||||
* This is deliberately the smallest set that proves the architecture rather than everything a real
|
* This is deliberately the smallest set that proves the architecture rather than everything a real
|
||||||
* screen will eventually want. Navigation, host theme and design tokens, module configuration,
|
* screen will eventually want. Navigation, module configuration, organization/platform context, and
|
||||||
* organization/platform context, and diagnostics reporting are **not** here yet: each needs a design
|
* diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative
|
||||||
* that does not exist, and a speculative field frozen into a public contract is harder to remove
|
* field frozen into a public contract is harder to remove than to add. See ADR 0022.
|
||||||
* than to add. See ADR 0022.
|
*
|
||||||
|
* The host **theme** is deliberately absent for a different reason. Design tokens reach a module as
|
||||||
|
* inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own
|
||||||
|
* root, they inherit into the artifact's own root, and module code writes `var(--ekklesia-…)`. A
|
||||||
|
* token map on this context would make styling a run-time negotiation, force every module to
|
||||||
|
* re-implement it from data, and give a host something it could forget to supply.
|
||||||
*
|
*
|
||||||
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
|
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
|
||||||
* host that never runs Core, which is what makes one module UI hostable by both. The same context is
|
* host that never runs Core, which is what makes one module UI hostable by both. The same context is
|
||||||
@@ -78,6 +84,16 @@ export interface ModuleUiHostContext {
|
|||||||
readonly permissions: ModuleUiPermissions;
|
readonly permissions: ModuleUiPermissions;
|
||||||
/** Host-owned delivery of this module's own runtime-published state. */
|
/** Host-owned delivery of this module's own runtime-published state. */
|
||||||
readonly realtime: ModuleUiRealtimeClient;
|
readonly realtime: ModuleUiRealtimeClient;
|
||||||
|
/**
|
||||||
|
* The renderers other modules contributed to capabilities **this** module provides (ADR 0041).
|
||||||
|
*
|
||||||
|
* Present on every context, and empty for the overwhelming majority of modules — a module that
|
||||||
|
* provides no extension point simply finds nothing in it. It is the one member that lets one
|
||||||
|
* module's UI extend another's without either importing the other, and it deliberately exposes
|
||||||
|
* identity and a mount operation rather than a URL: the host resolves publication, exactly as it
|
||||||
|
* resolves `api.request`.
|
||||||
|
*/
|
||||||
|
readonly contributions: ModuleUiContributions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
export * from "./contribution.js";
|
||||||
export * from "./define-module-ui.js";
|
export * from "./define-module-ui.js";
|
||||||
export * from "./host-context.js";
|
export * from "./host-context.js";
|
||||||
|
export * from "./json.js";
|
||||||
export * from "./module-ui.js";
|
export * from "./module-ui.js";
|
||||||
export * from "./realtime.js";
|
export * from "./realtime.js";
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* A JSON value crossing a host-owned boundary — a realtime snapshot, a contribution's input.
|
||||||
|
*
|
||||||
|
* Data, not vocabulary. Everything the platform carries between two pieces of module code is
|
||||||
|
* serializable and opaque to the host: the host validates that it *is* JSON and never what it
|
||||||
|
* means. That is what keeps a domain contract (a slide, a timeline, an announcement) entirely
|
||||||
|
* between the two modules that share it, with nothing for Core to learn or version.
|
||||||
|
*/
|
||||||
|
export type ModuleUiJsonValue =
|
||||||
|
| boolean
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| readonly ModuleUiJsonValue[]
|
||||||
|
| { readonly [key: string]: ModuleUiJsonValue };
|
||||||
@@ -1,18 +1,40 @@
|
|||||||
import type { ModuleUiHostContext } from "./host-context.js";
|
import type { ModuleUiHostContext } from "./host-context.js";
|
||||||
|
|
||||||
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
export type { Awaitable } from "./awaitable.js";
|
||||||
|
import type { Awaitable } from "./awaitable.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version of the browser mount ABI this package defines, and the only one a host accepts.
|
||||||
|
*
|
||||||
|
* Version 1 was `{ id, version, mount }` mounted straight into the host's own container. Version 2
|
||||||
|
* adds a declared `styles` stylesheet and makes every artifact mount inside an **isolated root** the
|
||||||
|
* host creates (ADR 0040). That is a behavioural change an artifact can observe — its DOM is no
|
||||||
|
* longer a direct child of the host's element — so it is versioned explicitly rather than
|
||||||
|
* introduced silently.
|
||||||
|
*
|
||||||
|
* There is deliberately no dual acceptance, exactly as there is none for a manifest version
|
||||||
|
* (ADR 0006, ADR 0014, ADR 0021, ADR 0033): an artifact built against version 1 fails to mount with
|
||||||
|
* `MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics. Migration is to
|
||||||
|
* rebuild the module, which is one `pnpm build` — the canonical builder owns every option, and a
|
||||||
|
* module that declares no stylesheet needs no source change at all.
|
||||||
|
*
|
||||||
|
* `defineModuleUi` stamps this for the author. A module never writes it.
|
||||||
|
*/
|
||||||
|
export const MODULE_UI_ABI_VERSION = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Released by a host when the module UI should be torn down. A module that acquires anything during
|
* Released by a host when the module UI should be torn down. A module that acquires anything during
|
||||||
* `mount` — a framework root, a subscription, a timer, an event listener — must return one of these
|
* `mount` — a framework root, a subscription, a timer, an event listener — must return one of these
|
||||||
* and release that work here, exactly as a backend module releases resources in its `stop` hook
|
* and release that work here, exactly as a backend module releases resources in its `stop` hook
|
||||||
* (ADR 0006). Returning nothing declares that the mount acquired nothing to release.
|
* (ADR 0006). Returning nothing declares that the mount acquired nothing to release.
|
||||||
|
*
|
||||||
|
* A module never has to remove its declared `styles`: the host installed them into the isolated root
|
||||||
|
* it owns, and it removes that whole root after this runs.
|
||||||
*/
|
*/
|
||||||
export type ModuleUiUnmount = () => Awaitable<void>;
|
export type ModuleUiUnmount = () => Awaitable<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
|
* What a module author passes to {@link defineModuleUi}.
|
||||||
* default-exports (ADR 0021, ADR 0022, ADR 0033).
|
|
||||||
*
|
*
|
||||||
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
|
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
|
||||||
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
|
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
|
||||||
@@ -23,19 +45,38 @@ export type ModuleUiUnmount = () => Awaitable<void>;
|
|||||||
* write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function,
|
* write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function,
|
||||||
* so a module's internal framework choice, and a host's, can each change without renegotiating the
|
* so a module's internal framework choice, and a host's, can each change without renegotiating the
|
||||||
* platform boundary.
|
* platform boundary.
|
||||||
|
*
|
||||||
|
* `styles` is the artifact's own compiled CSS **text**, not a URL, a filename, or a sidecar
|
||||||
|
* (ADR 0040). A module imports its local `.css` files and the canonical builder compiles them into
|
||||||
|
* this string, so the declared JavaScript artifact stays exactly as self-contained as it was before
|
||||||
|
* stylesheets existed: there is no second file to publish, nothing for a host to fetch, and no way
|
||||||
|
* for a stylesheet to become an asset nothing serves. The host installs it into the artifact's
|
||||||
|
* isolated root at mount and removes it at unmount.
|
||||||
*/
|
*/
|
||||||
export interface ModuleUiDefinition {
|
export interface ModuleUiDefinitionInput {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
|
readonly styles?: string;
|
||||||
readonly mount: (
|
readonly mount: (
|
||||||
container: Element,
|
container: Element,
|
||||||
context: ModuleUiHostContext,
|
context: ModuleUiHostContext,
|
||||||
) => Awaitable<ModuleUiUnmount | void>;
|
) => Awaitable<ModuleUiUnmount | void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
|
||||||
|
* default-exports (ADR 0021, ADR 0022, ADR 0033, ADR 0040): the author's declaration plus the ABI
|
||||||
|
* marker `defineModuleUi` stamped on it.
|
||||||
|
*/
|
||||||
|
export interface ModuleUiDefinition extends ModuleUiDefinitionInput {
|
||||||
|
readonly abi: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ModuleUiContractErrorCode =
|
export type ModuleUiContractErrorCode =
|
||||||
| "MODULE_UI_EXPORT_MISSING"
|
| "MODULE_UI_EXPORT_MISSING"
|
||||||
| "MODULE_UI_DEFINITION_INVALID"
|
| "MODULE_UI_DEFINITION_INVALID"
|
||||||
|
| "MODULE_UI_ABI_UNSUPPORTED"
|
||||||
|
| "MODULE_UI_STYLES_INVALID"
|
||||||
| "MODULE_UI_IDENTITY_MISMATCH";
|
| "MODULE_UI_IDENTITY_MISMATCH";
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({
|
const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({
|
||||||
@@ -43,6 +84,10 @@ const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Objec
|
|||||||
"A module UI entrypoint must have a default export produced by defineModuleUi.",
|
"A module UI entrypoint must have a default export produced by defineModuleUi.",
|
||||||
MODULE_UI_DEFINITION_INVALID:
|
MODULE_UI_DEFINITION_INVALID:
|
||||||
"A module UI definition must declare a non-empty id and version and a mount function, and nothing else.",
|
"A module UI definition must declare a non-empty id and version and a mount function, and nothing else.",
|
||||||
|
MODULE_UI_ABI_UNSUPPORTED:
|
||||||
|
"This module UI artifact was built for a different browser mount ABI. Rebuild the module against the current module UI SDK.",
|
||||||
|
MODULE_UI_STYLES_INVALID:
|
||||||
|
"A module UI definition's styles must be the artifact's compiled CSS text, as a non-empty string.",
|
||||||
MODULE_UI_IDENTITY_MISMATCH:
|
MODULE_UI_IDENTITY_MISMATCH:
|
||||||
"The module UI artifact's id and version do not match the module it was loaded for.",
|
"The module UI artifact's id and version do not match the module it was loaded for.",
|
||||||
});
|
});
|
||||||
@@ -64,7 +109,7 @@ export class ModuleUiContractError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const definitionKeys = new Set(["id", "mount", "version"]);
|
const definitionKeys = new Set(["abi", "id", "mount", "styles", "version"]);
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
@@ -80,12 +125,15 @@ function isNonEmptyString(value: unknown): value is string {
|
|||||||
* grammar — there is exactly one place each of those is defined, and it is not here.
|
* grammar — there is exactly one place each of those is defined, and it is not here.
|
||||||
*/
|
*/
|
||||||
export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition {
|
export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value) || value.abi !== MODULE_UI_ABI_VERSION) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
|
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
isNonEmptyString(value.id) &&
|
isNonEmptyString(value.id) &&
|
||||||
isNonEmptyString(value.version) &&
|
isNonEmptyString(value.version) &&
|
||||||
@@ -93,13 +141,31 @@ export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinitio
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an artifact's default export.
|
||||||
|
*
|
||||||
|
* The ABI marker is checked **first and on its own**, so an artifact built against the previous
|
||||||
|
* mount ABI is reported as exactly that rather than as a malformed definition — the difference
|
||||||
|
* between "rebuild your module" and a developer hunting for a typo.
|
||||||
|
*/
|
||||||
export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition {
|
export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
|
||||||
|
}
|
||||||
|
if (value.abi !== MODULE_UI_ABI_VERSION) {
|
||||||
|
throw new ModuleUiContractError("MODULE_UI_ABI_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
|
||||||
|
throw new ModuleUiContractError("MODULE_UI_STYLES_INVALID");
|
||||||
|
}
|
||||||
if (!isModuleUiDefinition(value)) {
|
if (!isModuleUiDefinition(value)) {
|
||||||
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
|
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
id: value.id,
|
id: value.id,
|
||||||
mount: value.mount,
|
mount: value.mount,
|
||||||
|
...(value.styles === undefined ? {} : { styles: value.styles }),
|
||||||
version: value.version,
|
version: value.version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
|
import type { ModuleUiJsonValue } from "./json.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A JSON value carried from a module's server runtime to its browser UI.
|
* A JSON value carried from a module's server runtime to its browser UI.
|
||||||
*
|
*
|
||||||
* Realtime state is deliberately data, not transport vocabulary. A module does not open a socket,
|
* Realtime state is deliberately data, not transport vocabulary. A module does not open a socket,
|
||||||
* choose an origin, attach credentials, or know how its host keeps a subscription alive. The host
|
* choose an origin, attach credentials, or know how its host keeps a subscription alive. The host
|
||||||
* owns all of that and gives the module only typed snapshots and updates.
|
* owns all of that and gives the module only typed snapshots and updates.
|
||||||
|
*
|
||||||
|
* An alias of the SDK's one {@link ModuleUiJsonValue}, kept under its original name because it is
|
||||||
|
* the published realtime contract. There is one JSON shape in this package, not one per feature.
|
||||||
*/
|
*/
|
||||||
export type ModuleUiRealtimeJsonValue =
|
export type ModuleUiRealtimeJsonValue = ModuleUiJsonValue;
|
||||||
| boolean
|
|
||||||
| number
|
|
||||||
| string
|
|
||||||
| null
|
|
||||||
| readonly ModuleUiRealtimeJsonValue[]
|
|
||||||
| { readonly [key: string]: ModuleUiRealtimeJsonValue };
|
|
||||||
|
|
||||||
/** Connection state useful for rendering a small, honest status indicator. */
|
/** Connection state useful for rendering a small, honest status indicator. */
|
||||||
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
|
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
|
||||||
|
|||||||
@@ -2,23 +2,33 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
defineModuleUi,
|
defineModuleUi,
|
||||||
isModuleUiDefinition,
|
isModuleUiDefinition,
|
||||||
|
MODULE_UI_ABI_VERSION,
|
||||||
ModuleUiContractError,
|
ModuleUiContractError,
|
||||||
|
NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
type ModuleUiApiClient,
|
type ModuleUiApiClient,
|
||||||
type ModuleUiDefinition,
|
type ModuleUiDefinition,
|
||||||
|
type ModuleUiDefinitionInput,
|
||||||
type ModuleUiHostContext,
|
type ModuleUiHostContext,
|
||||||
type ModuleUiRealtimeClient,
|
type ModuleUiRealtimeClient,
|
||||||
parseModuleUiDefinition,
|
parseModuleUiDefinition,
|
||||||
parseModuleUiExport,
|
parseModuleUiExport,
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
|
|
||||||
function validDefinition(): ModuleUiDefinition {
|
function definitionInput(): ModuleUiDefinitionInput {
|
||||||
return { id: "announcements", version: "0.1.0", mount: (): void => undefined };
|
return { id: "announcements", version: "0.1.0", mount: (): void => undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a compiled artifact actually default-exports: the author's declaration plus the ABI marker
|
||||||
|
* `defineModuleUi` stamps on it (ADR 0040). */
|
||||||
|
function validDefinition(): ModuleUiDefinition {
|
||||||
|
return { abi: MODULE_UI_ABI_VERSION, ...definitionInput() };
|
||||||
|
}
|
||||||
|
|
||||||
/** The contract treats the container as an opaque host-supplied mount target, so a plain object
|
/** The contract treats the container as an opaque host-supplied mount target, so a plain object
|
||||||
* with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the
|
* with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the
|
||||||
* contract, and this package deliberately does not take a DOM dependency to obtain one. */
|
* contract, and this package deliberately does not take a DOM dependency to obtain one. */
|
||||||
const noop = (): void => undefined;
|
const noop = (): void => undefined;
|
||||||
|
const abi = MODULE_UI_ABI_VERSION;
|
||||||
|
|
||||||
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
|
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
|
||||||
subscribe: () => Object.freeze({ close: noop }),
|
subscribe: () => Object.freeze({ close: noop }),
|
||||||
@@ -29,23 +39,66 @@ function fakeContainer(): Element & { textContent: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("module UI definition contract", () => {
|
describe("module UI definition contract", () => {
|
||||||
it("accepts and freezes a valid definition", () => {
|
it("accepts, stamps the ABI on, and freezes a valid definition", () => {
|
||||||
const definition = defineModuleUi(validDefinition());
|
const definition = defineModuleUi(definitionInput());
|
||||||
|
|
||||||
expect(definition.id).toBe("announcements");
|
expect(definition.id).toBe("announcements");
|
||||||
expect(definition.version).toBe("0.1.0");
|
expect(definition.version).toBe("0.1.0");
|
||||||
|
// Stamped by the SDK the artifact compiled against, never written by the author, so it can
|
||||||
|
// never drift from the contract the artifact was actually built for.
|
||||||
|
expect(definition.abi).toBe(MODULE_UI_ABI_VERSION);
|
||||||
|
expect(definition.styles).toBeUndefined();
|
||||||
expect(Object.isFrozen(definition)).toBe(true);
|
expect(Object.isFrozen(definition)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries a declared stylesheet through as the artifact's own compiled CSS text", () => {
|
||||||
|
const definition = defineModuleUi({ ...definitionInput(), styles: ".a{color:red}" });
|
||||||
|
|
||||||
|
expect(definition.styles).toBe(".a{color:red}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an artifact built against a different mount ABI, with its own code", () => {
|
||||||
|
// The whole point of the marker: a version-1 artifact is reported as "rebuild your module",
|
||||||
|
// not as a malformed definition a developer would hunt for a typo in.
|
||||||
|
const versionOne = { id: "announcements", version: "0.1.0", mount: noop };
|
||||||
|
|
||||||
|
expect(isModuleUiDefinition(versionOne)).toBe(false);
|
||||||
|
try {
|
||||||
|
parseModuleUiDefinition(versionOne);
|
||||||
|
expect.unreachable("expected a contract error");
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_ABI_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
expect(() =>
|
||||||
|
parseModuleUiDefinition({ ...versionOne, abi: MODULE_UI_ABI_VERSION + 1 }),
|
||||||
|
).toThrowError(expect.objectContaining({ code: "MODULE_UI_ABI_UNSUPPORTED" }));
|
||||||
|
});
|
||||||
|
|
||||||
it.each<[string, unknown]>([
|
it.each<[string, unknown]>([
|
||||||
["a missing id", { version: "0.1.0", mount: noop }],
|
["a blank stylesheet", " "],
|
||||||
["a blank id", { id: " ", version: "0.1.0", mount: noop }],
|
["a non-string stylesheet", 7],
|
||||||
["a non-string id", { id: 7, version: "0.1.0", mount: noop }],
|
["an array of stylesheets", [".a{}"]],
|
||||||
["a missing version", { id: "announcements", mount: noop }],
|
])("rejects %s", (_label, styles) => {
|
||||||
["a blank version", { id: "announcements", version: "", mount: noop }],
|
const candidate = { ...validDefinition(), styles };
|
||||||
["a missing mount", { id: "announcements", version: "0.1.0" }],
|
|
||||||
["a non-function mount", { id: "announcements", version: "0.1.0", mount: "render" }],
|
expect(isModuleUiDefinition(candidate)).toBe(false);
|
||||||
["an unknown property", { id: "announcements", version: "0.1.0", mount: noop, routes: [] }],
|
expect(() => parseModuleUiDefinition(candidate)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "MODULE_UI_STYLES_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<[string, unknown]>([
|
||||||
|
["a missing id", { abi, version: "0.1.0", mount: noop }],
|
||||||
|
["a blank id", { abi, id: " ", version: "0.1.0", mount: noop }],
|
||||||
|
["a non-string id", { abi, id: 7, version: "0.1.0", mount: noop }],
|
||||||
|
["a missing version", { abi, id: "announcements", mount: noop }],
|
||||||
|
["a blank version", { abi, id: "announcements", version: "", mount: noop }],
|
||||||
|
["a missing mount", { abi, id: "announcements", version: "0.1.0" }],
|
||||||
|
["a non-function mount", { abi, id: "announcements", version: "0.1.0", mount: "render" }],
|
||||||
|
[
|
||||||
|
"an unknown property",
|
||||||
|
{ abi, id: "announcements", version: "0.1.0", mount: noop, routes: [] },
|
||||||
|
],
|
||||||
])("rejects a definition with %s", (_label, candidate) => {
|
])("rejects a definition with %s", (_label, candidate) => {
|
||||||
expect(isModuleUiDefinition(candidate)).toBe(false);
|
expect(isModuleUiDefinition(candidate)).toBe(false);
|
||||||
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
|
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
|
||||||
@@ -64,7 +117,7 @@ describe("module UI definition contract", () => {
|
|||||||
|
|
||||||
it("reports a malformed definition with a stable, sanitized code", () => {
|
it("reports a malformed definition with a stable, sanitized code", () => {
|
||||||
try {
|
try {
|
||||||
parseModuleUiDefinition({ id: "announcements" });
|
parseModuleUiDefinition({ abi, id: "announcements" });
|
||||||
expect.unreachable("expected a contract error");
|
expect.unreachable("expected a contract error");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(ModuleUiContractError);
|
expect(error).toBeInstanceOf(ModuleUiContractError);
|
||||||
@@ -132,6 +185,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
|||||||
name: "ekklesia",
|
name: "ekklesia",
|
||||||
requestedUrls,
|
requestedUrls,
|
||||||
context: {
|
context: {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId,
|
moduleId,
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
@@ -156,6 +210,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
|||||||
name: "standalone",
|
name: "standalone",
|
||||||
requestedUrls,
|
requestedUrls,
|
||||||
context: {
|
context: {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId,
|
moduleId,
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user