feat: add display kiosk host #32

Merged
Zayin_Admin merged 1 commits from phase-7a/display-kiosk-host into main 2026-08-24 01:54:11 +00:00
80 changed files with 4868 additions and 397 deletions
Showing only changes of commit 2e0fe059c3 - Show all commits
+5 -5
View File
@@ -15,11 +15,11 @@ monorepo:
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product - **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
packages. They own their own data and migrations, declare capabilities/permissions through packages. They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a `@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
surface directly. Per ADR 0021 a module may also own its own UI and declare a compiled surface directly. Per ADR 0021 a module owns its UI; manifest v6 permits independently optional
`entrypoints.ui` artifact, authored in React per ADR 0022 against `@ekklesia/module-ui-sdk`. No primary and full-viewport display artifacts, authored in React against the same role-neutral
host mounts that entrypoint yet, and `pnpm boundaries` still rejects every non-React UI framework `@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
and non-`.tsx` presentation source inside a module package. `pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
- **Surfaces** (future `surfaces/*`) own the containing application shell, navigation, and every - **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
presentation concern a module does not. They consume public API/client contracts only, never Core presentation concern a module does not. They consume public API/client contracts only, never Core
or module internals directly. or module internals directly.
+12 -6
View File
@@ -36,18 +36,24 @@ pnpm infrastructure:config
- 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`. Every other UI
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
`pnpm boundaries`. UI source lives at `src/ui/index.tsx` and `pnpm build` compiles it to the `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write `src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
no bundler configuration. Styling is React inline style objects only for now, and no host mounts a the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
UI entrypoint yet. bundler configuration, and styling is React inline style objects only for now. Both roles use the
same UI SDK context with no presentation-mode flag.
- 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
private deployment detail and would break standalone hosting. private deployment detail and would break standalone hosting.
- The containing application shell, navigation, global theme, and platform policy belong to Core and - The containing application shell, navigation, global theme, and platform policy belong to Core and
the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
installed module UIs through `GET /api/v1/module-ui` and mounts them with primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
`@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026). through `GET /api/v1/module-displays/<module-id>`, mounts both with
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
- 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
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
anonymous trusted code. Public/device identity, display inventory, and variants are deferred.
- 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
+48 -11
View File
@@ -14,7 +14,8 @@ independent whole-repository re-audit has closed, under the current trusted-cont
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
browser object delivery), and 5 (native module realtime transport) have landed.** The operational browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/
kiosk host) have landed.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
pass 5 closure below and pass 5 closure below and
@@ -22,7 +23,8 @@ pass 5 closure below and
public module contracts, independently versioned capabilities, static module manifests, public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry, deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
schema aggregation, and a cloneable backend-only module template. Migration artifacts have schema aggregation, and a cloneable module-product template with optional primary and display UI.
Migration artifacts have
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
declarations, and exact checksums. Core provides read-only applied-history planning and a locked, declarations, and exact checksums. Core provides read-only applied-history planning and a locked,
whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A
@@ -64,7 +66,7 @@ accounts/API tokens remain later work. See [Authorization](docs/development/auth
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md). [ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
Phase 5A wires module-declared permissions into that same authorization system: the module manifest Phase 5A wires module-declared permissions into that same authorization system: the module manifest
(version 3 at the time, version 4 today) declares permissions as complete (version 3 at the time, version 6 today) declares permissions as complete
`{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform `{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
permission registry — Core's own declarations plus every module's — before constructing its permission registry — Core's own declarations plus every module's — before constructing its
database pool, Better Auth instance, or HTTP server. A module's declared permission is then database pool, Better Auth instance, or HTTP server. A module's declared permission is then
@@ -255,8 +257,9 @@ returns an unmount function. A host needs an element and a context, never a matc
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The `@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
host context is deliberately four members — `moduleId`, `api`, `principal`, `permissions` — and host context is deliberately five members — `moduleId`, `api`, `principal`, `permissions`, and
nothing else. **A module UI asks its host for its own API by relative path** (`api.request("posts")`) `realtime` — and nothing else. **A module UI asks its host for its own API by relative path**
(`api.request("posts")`)
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
@@ -479,7 +482,8 @@ start({ config }) {
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
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 forces `manifestVersion: 5`, a hard cutover in this repository's usual style. 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.
**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
@@ -626,15 +630,48 @@ subscription.close();
There is no durable replay, global topic namespace, cross-module subscription, query-string token, There is no durable replay, global topic namespace, cross-module subscription, query-string token,
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
pass through realtime unchanged. The final unauthenticated/device principal for public displays and pass through realtime unchanged.
display/kiosk hosting itself remain a later pass.
Phase 7A pass 6 adds the smallest complete **authenticated display/kiosk host**
([ADR 0033](docs/architecture/decisions/0033-display-kiosk-host-contract.md)). Manifest version 6
adds an optional, independently declared display role:
```json
{
"entrypoints": {
"display": {
"artifact": "./dist/display/index.js",
"permission": "announcements.display.view"
}
}
}
```
The permission must be declared by that module and have an `authenticated` or `restricted` baseline;
public/device identity remains deferred. Primary UI and display UI are independently optional, must
use paths distinct from every other entrypoint, and use the same
`defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode
flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and
`src/display/index.tsx`) and atomically emits every declared role.
Ekklesia publishes the display bundle at
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
`GET /api/v1/module-displays/<module-id>`. The display surface is a separate full-viewport browser
application with no Dashboard chrome, no static module imports, and manual retry on failure. Only
eligible, running modules receive routes; manifest changes take effect on restart. Artifact bytes
remain anonymous trusted code, while the page and descriptor use normal cookie authentication and
the module-owned display permission. Realtime subscriptions and opaque signed object-delivery URLs
flow through the unchanged host context. Display inventory/management, variants and targeting,
public/device identity, browser Fullscreen API behavior, a standalone host, a shared CSS system, the
Announcements migration, and Companion integration remain deferred.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested **Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing, folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
and no module-management, settings, or storage UI — module enablement and configuration are an API and and no module-management, settings, or storage UI — module enablement and configuration are an API and
a CLI, not a screen, and object storage is deployment configuration. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond a CLI, not a screen, and object storage is deployment configuration. Sign-in UI, a production CSP
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do and deployment for the built shells, general sidecar asset publication, React runtime sharing, the
not exist. standalone host, and every real feature module also do not exist.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary **Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
+1 -1
View File
@@ -21,13 +21,13 @@
"dependencies": { "dependencies": {
"@ekklesia/contracts": "workspace:*", "@ekklesia/contracts": "workspace:*",
"@ekklesia/core": "workspace:*", "@ekklesia/core": "workspace:*",
"@ekklesia/shell": "workspace:*",
"@hono/node-server": "2.0.10", "@hono/node-server": "2.0.10",
"hono": "4.12.31" "hono": "4.12.31"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/shell": "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",
+17
View File
@@ -32,6 +32,8 @@ import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-rou
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js"; import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js"; import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js"; import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js";
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js"; import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
import { createModuleUiRoutes } from "./module-ui-routes.js"; import { createModuleUiRoutes } from "./module-ui-routes.js";
@@ -66,6 +68,10 @@ export interface CreateApiAppOptions {
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never * is: a caller that publishes no module UI must say so explicitly with an empty registry, never
* fall through to an implicit one. See `./module-ui-routes.js`. */ * fall through to an implicit one. See `./module-ui-routes.js`. */
readonly moduleUi: ModuleUiPublicationRegistry; readonly moduleUi: ModuleUiPublicationRegistry;
/** Surface-owned display host assets, read once by production startup. Optional only when the
* eligible publication registry contains no display entrypoint; route composition rejects an
* inconsistent display-bearing registry synchronously. */
readonly displayHostAssets?: DisplayHostAssets;
/** /**
* The platform's operational module lifecycle: the store holding which modules are enabled, and the * The platform's operational module lifecycle: the store holding which modules are enabled, and the
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028). * boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
@@ -264,6 +270,17 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}), }),
); );
// A separate, chrome-free browser host. Exact page and descriptor routes are derived from the
// same eligible/running publication registry as artifact bytes; a display-only module never
// enters Dashboard navigation, and no request value can select a file or permission.
mountModuleDisplayRoutes(app, {
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
});
// Layered deliberately on top of the generic inventory above rather than merged into it: the // Layered deliberately on top of the generic inventory above rather than merged into it: the
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume, // inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`. // and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
+62
View File
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
/**
* The two immutable, platform-owned files that make up Ekklesia's display browser host.
*
* 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
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
* genuinely immutable value that every response can reuse safely.
*/
export interface DisplayHostAssets {
readonly html: string;
readonly script: string;
readonly scriptEtag: string;
}
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
* location, and machine path are intentionally not retained or surfaced. */
export class DisplayHostAssetError extends Error {
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
constructor() {
super("The display host browser assets are unavailable.");
this.name = "DisplayHostAssetError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Captures one immutable, internally consistent display-host asset snapshot.
*
* The strong validator is always derived from the exact script text in the same snapshot; callers
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
* failed host build rather than a valid page that can never mount anything.
*/
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets {
if (html.trim().length === 0 || script.trim().length === 0) {
throw new DisplayHostAssetError();
}
return Object.freeze({
html,
script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
});
}
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
* than leaving a display route that can authorize successfully and then return a broken shell. */
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
try {
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
// rejected sibling promise is left running after this function has already failed.
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
return createDisplayHostAssets(html, script);
} catch {
throw new DisplayHostAssetError();
}
}
+203
View File
@@ -0,0 +1,203 @@
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
import type { ModuleUiPublicationRegistry, PublishableModuleDisplay } from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import { grantedModulePermissionIds } from "./module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
export function moduleDisplayPageUrl(moduleId: string): string {
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
}
export function moduleDisplayDescriptorUrl(moduleId: string): string {
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
}
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
* management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the
* boundary. */
export interface HostModuleDisplayDescriptor {
readonly id: string;
readonly version: string;
readonly display: { readonly url: string };
readonly api: { readonly baseUrl: string };
readonly realtime: { readonly baseUrl: string };
readonly permissions: readonly string[];
}
export interface HostModuleDisplayResource {
readonly module: HostModuleDisplayDescriptor;
}
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same immutable, eligible/running publication registry used for artifact bytes. */
readonly moduleUi: ModuleUiPublicationRegistry;
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
* surface-owned files before binding and supplies their frozen contents here. */
readonly assets?: DisplayHostAssets;
}
/** A composition error, detected before any display route is registered. Carries no module id,
* permission id, path, or package location. */
export class ModuleDisplayConfigurationError extends Error {
constructor() {
super("The module display host configuration is inconsistent.");
this.name = "ModuleDisplayConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function displayFailure(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
function etagMatches(headerValue: string | undefined, etag: string): boolean {
if (headerValue === undefined) {
return false;
}
if (headerValue.trim() === "*") {
return true;
}
return headerValue.split(",").some((candidate) => {
const value = candidate.trim();
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
});
}
/** Copies and validates injected assets before registering any route. Production's loader already
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
* bytes with a stale validator. */
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
try {
const snapshot = createDisplayHostAssets(input.html, input.script);
if (snapshot.scriptEtag !== input.scriptEtag) {
throw new ModuleDisplayConfigurationError();
}
return snapshot;
} catch {
throw new ModuleDisplayConfigurationError();
}
}
async function describeDisplay(
options: ModuleDisplayRoutesOptions,
principal: EkklesiaPrincipal,
display: PublishableModuleDisplay,
): Promise<HostModuleDisplayResource> {
return {
module: {
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
display: { url: moduleDisplayUiArtifactUrl(display) },
id: display.moduleId,
permissions: await grantedModulePermissionIds(
options.evaluator,
principal,
display.permissions,
),
realtime: { baseUrl: moduleRealtimeBaseUrl(display.moduleId) },
version: display.version,
},
};
}
function mountDisplay(
app: Hono,
options: ModuleDisplayRoutesOptions & { readonly assets: DisplayHostAssets },
display: PublishableModuleDisplay,
): void {
// The permission comes from an already-validated manifest/publication record. A request selects
// neither the permission nor the artifact; it can only hit this exact pre-composed route.
const requireDisplayPermission = requirePermission(options, display.displayPermission);
app.get(moduleDisplayPageUrl(display.moduleId), requireDisplayPermission, () => {
return new Response(options.assets.html, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "text/html; charset=utf-8",
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
app.get(moduleDisplayDescriptorUrl(display.moduleId), requireDisplayPermission, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
try {
return c.json(await describeDisplay(options, getAuthorizedPrincipal(c), display), 200);
} catch {
return displayFailure(c);
}
});
}
/**
* Mounts one stable page and one descriptor route per eligible, running display declaration.
*
* There is deliberately no display inventory in this pass. A management UI may need one later,
* but a bookmarked TV needs only its version-independent page URL. Unknown, dashboard-only,
* disabled, and dependency-blocked modules have no registered route and collapse into the
* application's ordinary sanitized 404.
*
* The page is an inert, module-agnostic shell: its bytes contain no module id and its one absolute
* script URL cannot be rebased by a deep `/display/<id>` path. The browser reads the validated id
* from its outer URL, requests the permission-gated descriptor, and only then imports the exact
* versioned display artifact that the server supplied.
*/
export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void {
const displays = options.moduleUi.listDisplays();
if (displays.length === 0) {
return;
}
if (!options.assets) {
throw new ModuleDisplayConfigurationError();
}
const assets = snapshotAssets(options.assets);
for (const display of displays) {
if (!options.registry.has(display.displayPermission)) {
throw new ModuleDisplayConfigurationError();
}
}
// One fixed host bundle, never a directory and never a request-selected package file. Like module
// artifact bytes, fetching this platform code grants no authority; the page and descriptor are
// where identity and the module-owned display permission are enforced.
app.get(DISPLAY_HOST_SCRIPT_PATH, (c) => {
if (etagMatches(c.req.header("if-none-match"), assets.scriptEtag)) {
return new Response(null, {
headers: { "Cache-Control": "no-cache", ETag: assets.scriptEtag },
status: 304,
});
}
return new Response(assets.script, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/javascript; charset=utf-8",
ETag: assets.scriptEtag,
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
for (const display of displays) {
mountDisplay(app, { ...options, assets }, display);
}
}
+4 -2
View File
@@ -90,7 +90,9 @@ export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddl
* silently read "denied" during a database outage would blank the whole UI and look like a * silently read "denied" during a database outage would blank the whole UI and look like a
* permissions problem. * permissions problem.
*/ */
async function grantedPermissionIds( /** Shared by the primary-UI inventory and the singular display descriptor. Both provide the same
* presentation-only subset of a module's own declarations and use Core's one evaluator. */
export async function grantedModulePermissionIds(
evaluator: AuthorizationMiddlewareOptions["evaluator"], evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal, principal: EkklesiaPrincipal,
declarations: readonly PermissionDeclaration[], declarations: readonly PermissionDeclaration[],
@@ -118,7 +120,7 @@ async function describeModule(
return { return {
api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
id: published.moduleId, id: published.moduleId,
permissions: await grantedPermissionIds(evaluator, principal, published.permissions), permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) }, realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
ui: { url: moduleUiArtifactUrl(published) }, ui: { url: moduleUiArtifactUrl(published) },
version: published.version, version: published.version,
+68 -36
View File
@@ -77,9 +77,22 @@ export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
)}`; )}`;
} }
/**
* Canonical browser URL for a module's independently declared display artifact.
*
* The literal trailing segment is host-owned, not a caller-selected role. Keeping this as a
* separate helper and an exact route means neither request input nor a generic role parameter can
* select a neighbouring artifact. A future standalone host may expose a different outer display
* page while consuming this same published module artifact internally.
*/
export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${moduleUiArtifactUrl(identity)}/display`;
}
/** /**
* 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 `entrypoints.ui`, or the requested version is not the installed one. * installed module declares no artifact for the requested UI role, or the requested version is not
* the installed one.
* *
* They are deliberately indistinguishable. The route requires no authentication (see * They are deliberately indistinguishable. The route requires no authentication (see
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate * `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
@@ -122,6 +135,48 @@ function isNotModified(headerValue: string | undefined, etag: string): boolean {
}); });
} }
interface PublishedArtifact {
readonly artifactPath: string;
}
/** Serve one exact path Core already validated, with one response contract for both UI roles. */
async function serveArtifact(c: Context, published: PublishedArtifact): Promise<Response> {
// Discovery validated this exact path earlier; the file may still have changed or vanished
// since. The response is produced from whatever a single read of that one path returns right
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
// something else.
let artifact: Buffer;
try {
artifact = await readFile(published.artifactPath);
} catch {
return artifactUnavailable(c);
}
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
// validator can never describe a different revision than the body does.
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
if (isNotModified(c.req.header("if-none-match"), etag)) {
return new Response(null, {
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
status: 304,
});
}
return new Response(artifact, {
headers: {
"Cache-Control": MODULE_UI_CACHE_CONTROL,
"Content-Type": MODULE_UI_CONTENT_TYPE,
ETag: etag,
// The declared type is the only type: a browser must not be allowed to sniff a module
// artifact into some other interpretation, and `import()` requires the JavaScript type
// anyway.
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
}
export interface CreateModuleUiRoutesOptions { export interface CreateModuleUiRoutesOptions {
/** The platform's publication policy, built by Core from the discovered, started module /** The platform's publication policy, built by Core from the discovered, started module
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives * inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
@@ -173,46 +228,23 @@ 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/display`, async (c) => {
const published = options.registry.resolveDisplay(
c.req.param("moduleId"),
c.req.param("version"),
);
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
});
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => { routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version")); const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
if (!published) { if (!published) {
return notPublished(c); return notPublished(c);
} }
return serveArtifact(c, published);
// Discovery validated this exact path earlier; the file may still have changed or vanished
// since. The response is produced from whatever a single read of that one path returns right
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
// something else.
let artifact: Buffer;
try {
artifact = await readFile(published.artifactPath);
} catch {
return artifactUnavailable(c);
}
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
// validator can never describe a different revision than the body does.
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
if (isNotModified(c.req.header("if-none-match"), etag)) {
return new Response(null, {
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
status: 304,
});
}
return new Response(artifact, {
headers: {
"Cache-Control": MODULE_UI_CACHE_CONTROL,
"Content-Type": MODULE_UI_CONTENT_TYPE,
ETag: etag,
// The declared type is the only type: a browser must not be allowed to sniff a module
// artifact into some other interpretation, and `import()` requires the JavaScript type
// anyway.
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
}); });
return routes; return routes;
+22 -5
View File
@@ -32,6 +32,7 @@ import {
import { type ServerType, serve } from "@hono/node-server"; import { type ServerType, serve } from "@hono/node-server";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { createApiApp } from "./app.js"; import { createApiApp } from "./app.js";
import { loadDisplayHostAssets } from "./display-host-assets.js";
import { import {
type ClientAddressConfigInput, type ClientAddressConfigInput,
type ClientAddressResolver, type ClientAddressResolver,
@@ -489,6 +490,21 @@ export async function startApi(
})), })),
); );
// This snapshot is inert metadata only: construction reads no artifact and executes no module.
// It lets the API prove that a display host build is available *before* importing any eligible
// module code. The snapshot is not exposed anywhere unless the complete runtime subsequently
// starts and application composition succeeds.
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
try {
displayHostAssets =
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
// Module load/register/start failures use Core's own existing rollback (already-started // Module load/register/start failures use Core's own existing rollback (already-started
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime // modules are stopped, in reverse order, before this rejects) — there is no separate runtime
// handle to stop here, only the pool this failure leaves open. // handle to stop here, only the pool this failure leaves open.
@@ -524,11 +540,12 @@ export async function startApi(
moduleRealtime: moduleRuntime.realtime, moduleRealtime: moduleRuntime.realtime,
moduleServices: services, moduleServices: services,
// Built from the exact **eligible** set the runtime just started, never the raw discovered // Built from the exact **eligible** set the runtime just started, never the raw discovered
// inventory, and only once startup has succeeded — so the published set is the running set. A // inventory, and exposed only after startup succeeded — so the published set is the running
// disabled or dependency-blocked module therefore publishes no artifact, appears in no browser // set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no
// inventory, and appears in no resolved Dashboard navigation, by construction rather than by a // browser inventory, and appears in no resolved Dashboard navigation, by construction rather
// filter somewhere downstream. Modules declaring no `entrypoints.ui` are simply absent. // than by a filter somewhere downstream. Modules declaring no UI role are simply absent.
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules), moduleUi,
...(displayHostAssets === undefined ? {} : { displayHostAssets }),
moduleLifecycle: { moduleLifecycle: {
// The boot snapshot: what this process discovered, what it resolved, and what the runtime // The boot snapshot: what this process discovered, what it resolved, and what the runtime
// reports it actually started — read from the runtime rather than assumed equal to the // reports it actually started — read from the runtime rather than assumed equal to the
+3 -3
View File
@@ -322,7 +322,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: 5, manifestVersion: 6,
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",
@@ -366,7 +366,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: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -471,7 +471,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: 5, manifestVersion: 6,
name: `${id} module`, name: `${id} module`,
version, version,
}), }),
+1 -1
View File
@@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
}, },
id: fixture.id, id: fixture.id,
manifestVersion: 5, manifestVersion: 6,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -0,0 +1,73 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const readFileMock = vi.hoisted(() => vi.fn());
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
vi.mock("@ekklesia/shell/display-assets", () => ({
displayHostAssetUrls: Object.freeze({
html: "file:///surface/browser/display/index.html",
script: "file:///surface/browser/display/app.js",
}),
}));
import {
createDisplayHostAssets,
DisplayHostAssetError,
loadDisplayHostAssets,
} from "../src/display-host-assets.js";
describe("display host asset loading", () => {
beforeEach(() => readFileMock.mockReset());
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
const html = '<!doctype html><div id="ekklesia-display"></div>';
const script = "export const displayHost = true;\n";
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
const assets = await loadDisplayHostAssets();
expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/display/index.html"), "utf8"],
[new URL("file:///surface/browser/display/app.js"), "utf8"],
]);
expect(assets).toEqual({
html,
script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
});
expect(Object.isFrozen(assets)).toBe(true);
});
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
readFileMock.mockRejectedValueOnce(
new Error("ENOENT /home/operator/private/surface-build secret-token"),
);
const failure = await loadDisplayHostAssets().catch((error: unknown) => error);
expect(readFileMock).toHaveBeenCalledTimes(1);
expect(failure).toBeInstanceOf(DisplayHostAssetError);
expect(failure).toMatchObject({
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
message: "The display host browser assets are unavailable.",
name: "DisplayHostAssetError",
});
expect((failure as Error).stack).toBe(
"DisplayHostAssetError: The display host browser assets are unavailable.",
);
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
expect(publicText).not.toContain(secret);
}
});
it("rejects an empty page or script as an unavailable host build", () => {
for (const [html, script] of [
["", "export {};"],
["<html></html>", " \n"],
] as const) {
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError);
}
});
});
+85 -5
View File
@@ -8,11 +8,16 @@ const betterAuthMock = vi.hoisted(() => vi.fn());
const drizzleAdapterMock = vi.hoisted(() => vi.fn()); const drizzleAdapterMock = vi.hoisted(() => vi.fn());
const serveMock = vi.hoisted(() => vi.fn()); const serveMock = vi.hoisted(() => vi.fn());
const processOnSpy = vi.hoisted(() => vi.fn()); const processOnSpy = vi.hoisted(() => vi.fn());
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor })); vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock })); vi.mock("@hono/node-server", () => ({ serve: serveMock }));
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
});
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still // Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
// gets the real Hono application; only the one test that deliberately forces an app-construction // gets the real Hono application; only the one test that deliberately forces an app-construction
// failure overrides this with `mockImplementationOnce`. // failure overrides this with `mockImplementationOnce`.
@@ -30,6 +35,7 @@ import {
import { PermissionRegistryError } from "@ekklesia/core/authorization"; import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { DisplayHostAssetError } from "../src/display-host-assets.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js"; import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() { function validConfig() {
@@ -82,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: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -149,6 +155,8 @@ interface WorkingFixtureModuleOptions {
* can be exercised deterministically without any sleep. */ * can be exercised deterministically without any sleep. */
readonly stopNeverSettles?: boolean; readonly stopNeverSettles?: boolean;
readonly dependencies?: Record<string, unknown>; readonly dependencies?: Record<string, unknown>;
/** Declares one protected display artifact without importing it. */
readonly withDisplay?: boolean;
} }
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not /** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
@@ -162,12 +170,43 @@ async function createWorkingFixtureModule(
options: WorkingFixtureModuleOptions = {}, options: WorkingFixtureModuleOptions = {},
): Promise<void> { ): Promise<void> {
const permissionId = `${id}.ping.read`; const permissionId = `${id}.ping.read`;
const displayPermission = `${id}.display.view`;
const permissions = [
...(options.withRoute
? [{ baseline: "public" as const, description: "Ping.", id: permissionId }]
: []),
...(options.withDisplay
? [
{
baseline: "restricted" as const,
description: "View the display.",
id: displayPermission,
},
]
: []),
];
await createFixtureModule(modulesDirectory, id, { await createFixtureModule(modulesDirectory, id, {
permissions: options.withRoute entrypoints: {
? [{ baseline: "public", description: "Ping.", id: permissionId }] server: "./dist/server/index.js",
: [], ...(options.withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: displayPermission,
},
}
: {}),
},
permissions,
...(options.dependencies ? { dependencies: options.dependencies } : {}), ...(options.dependencies ? { dependencies: options.dependencies } : {}),
}); });
if (options.withDisplay) {
await mkdir(path.join(modulesDirectory, id, "dist/display"), { recursive: true });
await writeFile(
path.join(modulesDirectory, id, "dist/display/index.js"),
'throw new Error("display artifact must never execute during server startup");\n',
);
}
const routeSource = options.withRoute const routeSource = options.withRoute
? `http.route({ ? `http.route({
method: "GET", method: "GET",
@@ -215,7 +254,7 @@ 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: 5, manifestVersion: 6,
name: `${id} module`, name: `${id} module`,
permissions, permissions,
version: "0.1.0", version: "0.1.0",
@@ -942,6 +981,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
betterAuthMock.mockReset(); betterAuthMock.mockReset();
drizzleAdapterMock.mockClear(); drizzleAdapterMock.mockClear();
serveMock.mockReset(); serveMock.mockReset();
loadDisplayHostAssetsMock.mockReset();
vi.mocked(createApiApp).mockClear(); vi.mocked(createApiApp).mockClear();
}); });
@@ -954,6 +994,46 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
return { poolEnd }; return { poolEnd };
} }
it("fails on missing display-host assets before importing module code and still closes startup resources", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "announcements", recorder, {
withDisplay: true,
});
const { poolEnd } = mockPoolAndAuth();
loadDisplayHostAssetsMock.mockRejectedValueOnce(new DisplayHostAssetError());
const failure = await startApi(validConfig(), {
modulesDirectoryOverride: modulesDirectory,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
message: "The display host browser assets are unavailable.",
name: "DisplayHostAssetError",
});
expect((failure as Error).stack).toBe(
"DisplayHostAssetError: The display host browser assets are unavailable.",
);
expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1);
expect(recorder.events).toEqual([]);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read display-host assets when the eligible set declares no display", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled();
await running.shutdown();
});
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => { it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory(); const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder(); const recorder = createEventRecorder();
@@ -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: 5, manifestVersion: 6,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -0,0 +1,495 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
import {
DISPLAY_HOST_SCRIPT_PATH,
ModuleDisplayConfigurationError,
moduleDisplayDescriptorUrl,
moduleDisplayPageUrl,
} from "../src/module-display-routes.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
const trustedOrigin = "https://dashboard.example.test";
const displayPermission = "announcements.display.view";
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
);
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly display?: boolean;
readonly id?: string;
readonly ui?: boolean;
readonly version?: string;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
const id = fixture.id ?? "announcements";
const version = fixture.version ?? "1.4.2";
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version,
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
...(fixture.display
? {
display: {
artifact: "./dist/display/index.js",
permission: `${id}.display.view`,
},
}
: {}),
},
id,
manifestVersion: 6,
name: `${id} module`,
permissions: fixture.display
? [
{
baseline: "restricted",
description: `View the ${id} display.`,
id: `${id}.display.view`,
},
]
: [],
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
);
if (fixture.ui) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
);
}
if (fixture.display) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/display/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
);
}
}
interface InstallOptions extends ModuleFixture {
readonly assets?: DisplayHostAssets;
readonly evaluator?: AuthorizationEvaluator;
readonly granted?: readonly string[];
readonly principal?: EkklesiaPrincipal;
/** The API builds publication from the eligible/running set. `false` models a module that was
* discovered but disabled or dependency-blocked before runtime startup. */
readonly published?: boolean;
readonly withAssets?: boolean;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
}
function fakeServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
function fakeAuthorizationStore(): AuthorizationStore {
return {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
}
async function install(options: InstallOptions): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
temporaryDirectories.push(modulesDirectory);
await createModule(modulesDirectory, options);
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const granted = new Set(options.granted ?? []);
const evaluator =
options.evaluator ??
createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
platformOwners: { isPlatformOwner: async () => false },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const publishedModules = options.published === false ? [] : modules;
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
const app = createApiApp({
authorization: {
evaluator,
registry: composePlatformPermissionRegistry(modules),
store: fakeAuthorizationStore(),
},
dashboard: createInMemoryDashboardStore(),
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
? { displayHostAssets: options.assets ?? displayHostAssets }
: {}),
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleRoutes: Object.freeze([]),
moduleServices: fakeServices(),
moduleStorage: moduleStorageOptionFor(modules),
moduleUi,
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
describe("module display routes — authorization and descriptor", () => {
it("does not let an anonymous viewer open either the page or descriptor", async () => {
const { app } = await install({ display: true });
const [page, descriptor] = await Promise.all([
app.request(moduleDisplayPageUrl("announcements")),
app.request(moduleDisplayDescriptorUrl("announcements")),
]);
expect(page.status).toBe(403);
expect(descriptor.status).toBe(403);
expect(page.headers.get("cache-control")).toBe("no-store");
expect(descriptor.headers.get("cache-control")).toBe("no-store");
expect(await page.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("requires the declared display permission, not merely an authenticated principal", async () => {
const { app } = await install({ display: true, principal: signedIn() });
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(403);
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
});
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
version: "1.4.2",
});
const page = await app.request(moduleDisplayPageUrl("announcements"));
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(page.headers.get("cache-control")).toBe("no-store");
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
expect(await page.text()).toBe(displayHostAssets.html);
expect(await descriptor.json()).toEqual({
module: {
api: { baseUrl: moduleApiBaseUrl("announcements") },
display: {
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
},
id: "announcements",
permissions: [displayPermission],
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
version: "1.4.2",
},
});
expect(descriptor.headers.get("cache-control")).toBe("no-store");
expect(descriptor.headers.get("content-type")).toContain("application/json");
expect(descriptor.headers.get("vary")).toContain("Origin");
expect(descriptor.headers.get("vary")).toContain("Cookie");
});
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
const authorize = vi
.fn()
.mockResolvedValueOnce({ allowed: true })
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
const evaluator: AuthorizationEvaluator = { authorize };
const { app } = await install({ display: true, evaluator, principal: signedIn() });
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
const body = await response.text();
expect(authorize).toHaveBeenCalledTimes(2);
expect(response.status).toBe(503);
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("vary")).toContain("Origin");
expect(response.headers.get("vary")).toContain("Cookie");
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
expect(body).not.toContain(secret);
}
});
it("keeps the outer page stable when the internal artifact version changes", () => {
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
);
});
});
describe("module display routes — role and lifecycle separation", () => {
it("keeps a display-only module out of the primary UI inventory", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
});
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
expect(inventory.status).toBe(200);
expect(await inventory.json()).toEqual({ modules: [] });
});
it("does not give a dashboard-only module a display route", async () => {
const { app } = await install({ principal: signedIn(), ui: true });
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
expect(inventory.status).toBe(200);
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
modules: [{ id: "announcements" }],
});
});
it("publishes both roles independently when a module declares both", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
ui: true,
});
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
expect(
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] })
.modules,
).toHaveLength(1);
});
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
published: false,
});
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
expect(
(
await app.request(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
)
).status,
).toBe(404);
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
});
it("registers only the exact stable page and descriptor paths", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
});
for (const route of [
"/display",
"/display/announcements/",
"/display/announcements/settings",
"/api/v1/module-displays",
"/api/v1/module-displays/announcements/",
"/api/v1/module-displays/announcements/settings",
]) {
expect((await app.request(route)).status, route).toBe(404);
}
});
it("fails composition when a display exists but its host assets were not supplied", async () => {
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
ModuleDisplayConfigurationError,
);
});
});
describe("module display host asset publication", () => {
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
const { app } = await install({ display: true });
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
expect(response.status).toBe(200);
expect(await response.text()).toBe(displayHostAssets.script);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
headers: { "if-none-match": displayHostAssets.scriptEtag },
});
expect(notModified.status).toBe(304);
expect(await notModified.text()).toBe("");
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
expect(notModified.headers.get("cache-control")).toBe("no-cache");
for (const header of [
`W/${displayHostAssets.scriptEtag}`,
"*",
`"stale", ${displayHostAssets.scriptEtag}`,
]) {
expect(
(
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
headers: { "if-none-match": header },
})
).status,
header,
).toBe(304);
}
expect(
(
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
headers: { "if-none-match": '"stale"' },
})
).status,
).toBe(200);
});
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
const { app } = await install({ display: true });
for (const candidate of [
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
"/assets/ekklesia-display/index.html",
"/assets/ekklesia-display/secret.txt",
"/assets/ekklesia-display/../secret.txt",
"/api/v1/module-ui/announcements/1.4.2/management",
]) {
expect((await app.request(candidate)).status, candidate).toBe(404);
}
});
it("uses identical non-interpolated HTML for every eligible module", async () => {
const first = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
});
const second = await install({
display: true,
granted: ["signage.display.view"],
id: "signage",
principal: signedIn(),
});
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
expect(firstHtml).toBe(secondHtml);
expect(firstHtml).not.toContain("announcements");
expect(secondHtml).not.toContain("signage");
});
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 mutable = { ...source };
const { app } = await install({ assets: mutable, display: true });
mutable.html = "<html>mutated secret</html>";
mutable.script = "throw new Error('mutated secret');";
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
await expect(
install({
assets: { ...source, scriptEtag: '"stale-validator"' },
display: true,
}),
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
});
it("does not publish the generic host script when no eligible display uses it", async () => {
const { app } = await install({ ui: true, withAssets: false });
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
});
});
@@ -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: 5, manifestVersion: 6,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
+1 -1
View File
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: "announcements", id: "announcements",
manifestVersion: 5, manifestVersion: 6,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
+131 -4
View File
@@ -22,6 +22,7 @@ import {
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; import { loadModuleUi } from "@ekklesia/module-ui-host";
import { import {
bootstrapDisplayHost,
bootstrapHost, bootstrapHost,
createModuleUiContext, createModuleUiContext,
type EkklesiaHostConfig, type EkklesiaHostConfig,
@@ -30,6 +31,7 @@ import {
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";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js";
import { import {
createInMemoryModuleLifecycleStore, createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor, moduleLifecycleSnapshotFor,
@@ -84,10 +86,14 @@ async function createFixtureModule(
modulesDirectory: string, modulesDirectory: string,
id = "example", id = "example",
name = "Example module", name = "Example module",
withDisplay = false,
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
if (withDisplay) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
}
await writeFile( await writeFile(
path.join(moduleDirectory, "package.json"), path.join(moduleDirectory, "package.json"),
@@ -102,9 +108,20 @@ async function createFixtureModule(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
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",
...(withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: `${id}.display.view`,
},
}
: {}),
},
id, id,
manifestVersion: 5, manifestVersion: 6,
name, name,
permissions: [ permissions: [
{ {
@@ -112,6 +129,15 @@ async function createFixtureModule(
description: "Read example records.", description: "Read example records.",
id: `${id}.records.read`, id: `${id}.records.read`,
}, },
...(withDisplay
? [
{
baseline: "restricted",
description: "View the example display.",
id: `${id}.display.view`,
},
]
: []),
], ],
version: "0.1.0", version: "0.1.0",
}), }),
@@ -154,9 +180,26 @@ async function createFixtureModule(
}, },
};\n`, };\n`,
); );
if (withDisplay) {
await writeFile(
path.join(moduleDirectory, "dist/display/index.js"),
`export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
async mount(container, context) {
const response = await context.api.request("records");
const records = response.ok ? await response.json() : [];
container.textContent =
context.moduleId + ":display:" + response.status + ":" + records.length;
return () => { container.textContent = ""; };
},
};\n`,
);
}
} }
interface HarnessOptions { interface HarnessOptions {
readonly display?: boolean;
readonly principal?: EkklesiaPrincipal; readonly principal?: EkklesiaPrincipal;
readonly grants?: readonly string[]; readonly grants?: readonly string[];
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a /** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
@@ -178,7 +221,7 @@ interface Harness {
async function startHarness(options: HarnessOptions = {}): Promise<Harness> { async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-")); const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
temporaryDirectories.push(modulesDirectory); temporaryDirectories.push(modulesDirectory);
await createFixtureModule(modulesDirectory); await createFixtureModule(modulesDirectory, "example", "Example module", options.display);
for (const extra of options.extraModules ?? []) { for (const extra of options.extraModules ?? []) {
await createFixtureModule(modulesDirectory, extra.id, extra.name); await createFixtureModule(modulesDirectory, extra.id, extra.name);
} }
@@ -214,12 +257,21 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
revokePlatformOwner: vi.fn(), revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore; } as unknown as AuthorizationStore;
const moduleUi = createModuleUiPublicationRegistry(modules);
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
moduleRoutes: moduleRuntime.routes, moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime, moduleRealtime: moduleRuntime.realtime,
moduleServices: services, moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules), moduleUi,
...(moduleUi.listDisplays().length === 0
? {}
: {
displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
"export const displayHost = true;\n",
),
}),
moduleLifecycle: { moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules), snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(), store: createInMemoryModuleLifecycleStore(),
@@ -326,6 +378,81 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
} }
}); });
it("opens the stable display route, resolves its real descriptor, and mounts the published display bytes", async () => {
const harness = await startHarness({
display: true,
grants: ["example.records.read", "example.display.view"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
// 1. A device keeps this version-independent host URL. It is a real permission-gated API route,
// not a test-only HTML wrapper around the browser functions below.
const page = await (harness.config.fetch as typeof fetch)(
"https://ekklesia.example.test/display/example",
);
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(await page.text()).toContain("/assets/ekklesia-display/app.js");
// 2. The display surface's own bootstrap resolves principal first, then the singular protected
// descriptor. The browser constructs no artifact, API, or realtime URL itself.
const bootstrap = await bootstrapDisplayHost(harness.config, "example");
expect(bootstrap.status).toBe("ready");
if (bootstrap.status !== "ready") {
throw new Error("the authenticated display did not resolve");
}
expect(bootstrap.descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
id: "example",
permissions: ["example.records.read", "example.display.view"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
uiUrl: "/api/v1/module-ui/example/0.1.0/display",
version: "0.1.0",
});
// 3. Fetch the exact role-specific bytes from the real publication route, then genuinely import
// those returned bytes. Reading the fixture file directly would not prove publication.
const sourceUrl = moduleUiSourceUrl(harness.config, bootstrap.descriptor);
expect(sourceUrl).toBe(
"https://ekklesia.example.test/api/v1/module-ui/example/0.1.0/display",
);
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader receives the unchanged five-member browser context and mounts the
// display artifact independently of the Dashboard artifact.
const context = createModuleUiContext({
config: harness.config,
descriptor: bootstrap.descriptor,
principal: bootstrap.principal,
});
expect(Object.keys(context).sort()).toEqual([
"api",
"moduleId",
"permissions",
"principal",
"realtime",
]);
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context,
source: {
moduleId: bootstrap.descriptor.id,
url: artifactUrl,
version: bootstrap.descriptor.version,
},
});
expect(container.textContent).toBe("example:display:200:2");
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
await mounted.unmount();
expect(container.textContent).toBe("");
expect(mounted.mounted).toBe(false);
} finally {
await harness.stop();
}
});
it("keeps server authorization authoritative when the UI ignores its own hint", async () => { it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
// Signed in, but holding no grant for the module's restricted permission. // Signed in, but holding no grant for the module's restricted permission.
const harness = await startHarness({ const harness = await startHarness({
+1 -1
View File
@@ -83,7 +83,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}), ...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
}, },
id: fixture.id, id: fixture.id,
manifestVersion: 5, manifestVersion: 6,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({ permissions: (fixture.permissions ?? []).map((permission) => ({
...permission, ...permission,
+197 -9
View File
@@ -6,13 +6,10 @@ import {
type ModuleDatabase, type ModuleDatabase,
type ModuleRuntimeServices, type ModuleRuntimeServices,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
type AuthorizationEvaluator,
type AuthorizationStore,
CORE_PERMISSION_REGISTRY,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModules, discoverModules,
@@ -23,10 +20,12 @@ 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";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js";
import { 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,
moduleDisplayUiArtifactUrl,
moduleUiArtifactUrl, moduleUiArtifactUrl,
} from "../src/module-ui-routes.js"; } from "../src/module-ui-routes.js";
import { import {
@@ -78,6 +77,8 @@ interface ModuleFixture {
/** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only /** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only
* module, whose manifest then declares no UI entrypoint at all. */ * module, whose manifest then declares no UI entrypoint at all. */
readonly ui?: string; readonly ui?: string;
/** Written to a distinct artifact and declared as the protected display entrypoint. */
readonly display?: string;
/** Extra package-relative files written alongside the artifact — a source map, a sibling bundle /** Extra package-relative files written alongside the artifact — a source map, a sibling bundle
* 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. */
@@ -86,6 +87,7 @@ interface ModuleFixture {
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> { async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const version = fixture.version ?? "0.1.0"; const version = fixture.version ?? "0.1.0";
const displayPermission = `${fixture.id}.display.view`;
const moduleDirectory = path.join(modulesDirectory, fixture.id); const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile( await writeFile(
@@ -104,10 +106,28 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
entrypoints: { entrypoints: {
server: "./dist/server/index.js", server: "./dist/server/index.js",
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }), ...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
...(fixture.display === undefined
? {}
: {
display: {
artifact: "./dist/display/index.js",
permission: displayPermission,
},
}),
}, },
id: fixture.id, id: fixture.id,
manifestVersion: 5, manifestVersion: 6,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions:
fixture.display === undefined
? []
: [
{
baseline: "restricted",
description: `View ${fixture.id}'s display`,
id: displayPermission,
},
],
version, version,
}), }),
); );
@@ -119,6 +139,10 @@ 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);
} }
if (fixture.display !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
}
for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) { for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) {
const target = path.join(moduleDirectory, relativePath); const target = path.join(moduleDirectory, relativePath);
await mkdir(path.dirname(target), { recursive: true }); await mkdir(path.dirname(target), { recursive: true });
@@ -139,7 +163,10 @@ interface Installation {
* artifact path is hand-built, so these tests exercise the actual security boundary rather than a * artifact path is hand-built, so these tests exercise the actual security boundary rather than a
* reconstruction of it. * reconstruction of it.
*/ */
async function install(fixtures: readonly ModuleFixture[]): Promise<Installation> { async function install(
fixtures: readonly ModuleFixture[],
publicationModuleIds?: readonly string[],
): Promise<Installation> {
const modulesDirectory = await createModulesDirectory(); const modulesDirectory = await createModulesDirectory();
for (const fixture of fixtures) { for (const fixture of fixtures) {
await createModule(modulesDirectory, fixture); await createModule(modulesDirectory, fixture);
@@ -149,6 +176,10 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
modulesDirectory, modulesDirectory,
platformVersion: "0.1.0", platformVersion: "0.1.0",
}); });
const publicationModules =
publicationModuleIds === undefined
? modules
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
const database = Object.freeze({}) as unknown as ModuleDatabase; const database = Object.freeze({}) as unknown as ModuleDatabase;
const services: ModuleRuntimeServices = Object.freeze({ database }); const services: ModuleRuntimeServices = Object.freeze({ database });
@@ -172,11 +203,19 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
} as unknown as AuthorizationStore; } as unknown as AuthorizationStore;
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store }, authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
...(publicationModules.some((module) => module.entrypoints.display !== undefined)
? {
displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="display-root"></div>',
"export {};\n",
),
}
: {}),
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services, moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules), moduleUi: createModuleUiPublicationRegistry(publicationModules),
moduleLifecycle: { moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules), snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(), store: createInMemoryModuleLifecycleStore(),
@@ -345,6 +384,155 @@ describe("module UI artifact publication — happy path", () => {
}); });
}); });
describe("module display UI artifact publication", () => {
it("serves the declared display bytes anonymously at the exact role-specific URL", async () => {
const source = `${artifactSource("announcements", "1.4.2")}// display\n`;
const { app } = await install([{ display: source, id: "announcements", version: "1.4.2" }]);
const url = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2/display`);
const response = await app.request(url);
expect(response.status).toBe(200);
expect(await response.text()).toBe(source);
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");
const etag = response.headers.get("etag") as string;
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u);
const revalidated = await app.request(url, { headers: { "if-none-match": etag } });
expect(revalidated.status).toBe(304);
expect(await revalidated.text()).toBe("");
expect(revalidated.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(revalidated.headers.get("etag")).toBe(etag);
});
it("keeps display-only, primary-only, both, and neither roles strictly separate", async () => {
const primary = `${artifactSource("signage", "0.1.0")}// primary\n`;
const display = `${artifactSource("signage", "0.1.0")}// display\n`;
const { app } = await install([
{ display: `${display}// announcements\n`, id: "announcements" },
{ id: "calendar", ui: `${primary}// calendar\n` },
{ display, id: "signage", ui: primary },
{ id: "backend" },
]);
const primaryUrl = (moduleId: string) => moduleUiArtifactUrl({ moduleId, version: "0.1.0" });
const displayUrl = (moduleId: string) =>
moduleDisplayUiArtifactUrl({ moduleId, version: "0.1.0" });
expect((await app.request(primaryUrl("announcements"))).status).toBe(404);
expect((await app.request(displayUrl("announcements"))).status).toBe(200);
expect((await app.request(primaryUrl("calendar"))).status).toBe(200);
expect((await app.request(displayUrl("calendar"))).status).toBe(404);
expect(await (await app.request(primaryUrl("signage"))).text()).toBe(primary);
expect(await (await app.request(displayUrl("signage"))).text()).toBe(display);
expect((await app.request(primaryUrl("backend"))).status).toBe(404);
expect((await app.request(displayUrl("backend"))).status).toBe(404);
});
it("refuses wrong versions with the same sanitized response as an undeclared display", async () => {
const { app } = await install([
{
display: `${artifactSource("announcements", "1.4.2")}// display\n`,
id: "announcements",
version: "1.4.2",
},
{ id: "calendar", ui: artifactSource("calendar", "0.1.0") },
]);
const responses = await Promise.all(
[
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "latest" }),
moduleDisplayUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }),
moduleDisplayUiArtifactUrl({ moduleId: "missing", version: "1.4.2" }),
].map(async (url) => {
const response = await app.request(url);
return { body: await response.text(), status: response.status };
}),
);
expect(responses).toEqual([
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
]);
});
it("never turns the display URL into a package directory or generic role selector", async () => {
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
const { app } = await install([
{
display,
extraFiles: {
".env": "DISPLAY_SECRET=do-not-serve\n",
"dist/display/index.js.map": "display source map secret",
"dist/display/internal-chunk.js": "export const internal = 'secret';\n",
},
id: "announcements",
},
]);
const exact = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
for (const url of [
`${exact}.map`,
`${exact}/index.js.map`,
`${exact}/internal-chunk.js`,
`${exact}/../../.env`,
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/dashboard`,
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/arbitrary-role`,
]) {
const response = await app.request(url);
expect(response.status, url).toBe(404);
const body = await response.text();
expect(body, url).not.toContain("secret");
expect(body, url).not.toContain("mount");
}
});
it("cannot publish a blocked module omitted from Core's eligible registry input", async () => {
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
const { app } = await install(
[
{ display, id: "announcements" },
{ display: `${display}// signage\n`, id: "signage" },
],
["signage"],
);
expect(
(
await app.request(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
)
).status,
).toBe(404);
expect(
(await app.request(moduleDisplayUiArtifactUrl({ moduleId: "signage", version: "0.1.0" })))
.status,
).toBe(200);
});
it("reports the same fixed 503 when the validated display artifact disappears", async () => {
const { app, modulesDirectory } = await install([
{
display: `${artifactSource("announcements", "0.1.0")}// display\n`,
id: "announcements",
},
]);
await rm(path.join(modulesDirectory, "announcements/dist/display/index.js"));
const response = await app.request(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" });
expect(response.headers.get("cache-control")).toBe("no-store");
});
});
describe("module UI artifact publication — module identity", () => { describe("module UI artifact publication — module identity", () => {
async function expectNotPublished(app: Hono, url: string): Promise<void> { async function expectNotPublished(app: Hono, url: string): Promise<void> {
const response = await app.request(url); const response = await app.request(url);
+80 -12
View File
@@ -5,6 +5,7 @@ import {
isVersionCompatible, isVersionCompatible,
type ModuleCapabilityDeclaration, type ModuleCapabilityDeclaration,
type ModuleManifest, type ModuleManifest,
type PermissionId,
safeParseModuleManifest, safeParseModuleManifest,
semanticVersionSchema, semanticVersionSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
@@ -61,10 +62,16 @@ export interface ResolvedModuleEntrypoints {
readonly schema?: string; readonly schema?: string;
readonly migrations?: string; readonly migrations?: string;
readonly jobs?: string; readonly jobs?: string;
/** A module's declared presentation artifact (ADR 0021), resolved and validated exactly like the /** A module's primary presentation artifact (ADR 0021), resolved and validated exactly like the
* other entrypoints. Discovery only proves the file exists, is compiled ESM, and stays inside the * other executable entrypoints. Discovery never imports or evaluates browser artifacts. */
* module package — nothing in this repository imports, evaluates, mounts, or renders it yet. */
readonly ui?: string; readonly ui?: string;
/** The independently mountable full-viewport artifact and its statically declared access policy.
* `artifactPath` is canonical and package-contained; `permission` already matched one of this
* module's own manifest declarations during manifest parsing. */
readonly display?: {
readonly artifactPath: string;
readonly permission: PermissionId;
};
} }
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types, // Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
@@ -258,11 +265,49 @@ async function resolveEntrypoints(
): Promise<ResolvedModuleEntrypoints | undefined> { ): Promise<ResolvedModuleEntrypoints | undefined> {
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {}; const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
const canonicalModuleDirectory = await realpath(moduleDirectory); const canonicalModuleDirectory = await realpath(moduleDirectory);
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
.filter((entry): entry is [string, string] => entry[1] !== undefined)
.map(([entrypointName, relativeEntrypoint]) => ({
entrypointName,
issuePath: `entrypoints.${entrypointName}`,
relativeEntrypoint,
}));
const configuredEntrypoints = [
...ordinaryConfiguredEntrypoints,
...(display === undefined
? []
: [
{
entrypointName: "display",
issuePath: "entrypoints.display.artifact",
relativeEntrypoint: display.artifact,
},
]),
];
let entrypointCollision = false;
for (const [entrypointName, relativeEntrypoint] of Object.entries(manifest.entrypoints)) { if (display !== undefined) {
if (relativeEntrypoint === undefined) { const configuredDisplayPath = path.resolve(moduleDirectory, display.artifact);
continue; for (const ordinary of ordinaryConfiguredEntrypoints) {
if (path.resolve(moduleDirectory, ordinary.relativeEntrypoint) === configuredDisplayPath) {
entrypointCollision = true;
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `display must not resolve to the same configured file as ${ordinary.entrypointName}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "entrypoints.display.artifact",
});
} }
}
}
for (const {
entrypointName,
issuePath: entrypointIssuePath,
relativeEntrypoint,
} of configuredEntrypoints) {
if ( if (
isCompiledEntrypoint(entrypointName) && isCompiledEntrypoint(entrypointName) &&
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase()) ![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
@@ -272,7 +317,7 @@ async function resolveEntrypoints(
message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`, message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
continue; continue;
} }
@@ -283,7 +328,7 @@ async function resolveEntrypoints(
message: `${entrypointName} resolves outside its module directory.`, message: `${entrypointName} resolves outside its module directory.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
continue; continue;
} }
@@ -296,7 +341,7 @@ async function resolveEntrypoints(
message: `${entrypointName} resolves through a link outside its module directory.`, message: `${entrypointName} resolves through a link outside its module directory.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
continue; continue;
} }
@@ -312,7 +357,7 @@ async function resolveEntrypoints(
: `${entrypointName} must resolve to a file.`, : `${entrypointName} must resolve to a file.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
continue; continue;
} }
@@ -329,12 +374,27 @@ async function resolveEntrypoints(
}.`, }.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
} }
} }
if (!resolved.server) { if (resolved.display !== undefined) {
for (const [entrypointName, canonicalEntrypoint] of Object.entries(resolved)) {
if (entrypointName !== "display" && canonicalEntrypoint === resolved.display) {
entrypointCollision = true;
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `display must not resolve through a link to the same file as ${entrypointName}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "entrypoints.display.artifact",
});
}
}
}
if (!resolved.server || entrypointCollision) {
return undefined; return undefined;
} }
@@ -344,6 +404,14 @@ async function resolveEntrypoints(
...(resolved.migrations ? { migrations: resolved.migrations } : {}), ...(resolved.migrations ? { migrations: resolved.migrations } : {}),
...(resolved.jobs ? { jobs: resolved.jobs } : {}), ...(resolved.jobs ? { jobs: resolved.jobs } : {}),
...(resolved.ui ? { ui: resolved.ui } : {}), ...(resolved.ui ? { ui: resolved.ui } : {}),
...(resolved.display && display
? {
display: {
artifactPath: resolved.display,
permission: display.permission,
},
}
: {}),
}; };
} }
+60 -6
View File
@@ -1,4 +1,4 @@
import type { PermissionDeclaration } from "@ekklesia/contracts"; import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts";
import type { DiscoveredModule } from "./discovery.js"; import type { DiscoveredModule } from "./discovery.js";
/** /**
@@ -40,6 +40,26 @@ export interface PublishableModuleUi {
readonly permissions: readonly PermissionDeclaration[]; readonly permissions: readonly PermissionDeclaration[];
} }
/**
* One installed module whose dedicated full-viewport display artifact may be published.
*
* This is deliberately a separate record from {@link PublishableModuleUi}. A primary UI and a
* display UI are two independently declared artifacts with different host roles, and a module may
* publish either one without publishing the other. `displayPermission` is the module-owned
* permission the manifest places beside `entrypoints.display`; it travels with the exact artifact
* identity so a display descriptor cannot accidentally authorize one artifact with another
* module's policy. The complete declaration list supports the same browser-safe permission hints
* as the primary UI while conferring no authority by itself.
*/
export interface PublishableModuleDisplay {
readonly moduleId: string;
readonly version: string;
readonly name: string;
readonly artifactPath: string;
readonly displayPermission: PermissionId;
readonly permissions: readonly PermissionDeclaration[];
}
/** /**
* 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?"
* *
@@ -60,6 +80,15 @@ export interface ModuleUiPublicationRegistry {
* version are all indistinguishable here — each returns `undefined`. * version are all indistinguishable here — each returns `undefined`.
*/ */
resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined; resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined;
/** Every dedicated display artifact, in discovery order. Stable and frozen. */
listDisplays(): readonly PublishableModuleDisplay[];
/**
* The dedicated display record for an exact module id and installed version, or `undefined`.
*
* This lookup never falls back to `entrypoints.ui`: a primary/dashboard artifact cannot become
* a display merely because a caller appends `/display` to its publication URL.
*/
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
} }
/** /**
@@ -70,8 +99,9 @@ export interface ModuleUiPublicationRegistry {
* 1. the module is in the **eligible** set the platform started — it survived discovery *and* * 1. the module is in the **eligible** set the platform started — it survived discovery *and*
* the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) — * the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) —
* and * and
* 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file * 2. for each role independently, its manifest declares `entrypoints.ui` or
* inside the module package. * `entrypoints.display`, which discovery resolved to a real compiled file inside the module
* package.
* *
* A module directory sitting on disk satisfies neither on its own — it has to have survived * A module directory sitting on disk satisfies neither on its own — it has to have survived
* discovery — which is the property `docs/development/security.md` requires: *the existence of an * discovery — which is the property `docs/development/security.md` requires: *the existence of an
@@ -93,12 +123,11 @@ export function createModuleUiPublicationRegistry(
modules: readonly DiscoveredModule[], modules: readonly DiscoveredModule[],
): ModuleUiPublicationRegistry { ): ModuleUiPublicationRegistry {
const byModuleId = new Map<string, PublishableModuleUi>(); const byModuleId = new Map<string, PublishableModuleUi>();
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
for (const module of modules) { for (const module of modules) {
const artifactPath = module.entrypoints.ui; const artifactPath = module.entrypoints.ui;
if (artifactPath === undefined) { if (artifactPath !== undefined) {
continue;
}
byModuleId.set( byModuleId.set(
module.manifest.id, module.manifest.id,
Object.freeze({ Object.freeze({
@@ -113,10 +142,28 @@ export function createModuleUiPublicationRegistry(
); );
} }
const display = module.entrypoints.display;
if (display !== undefined) {
displaysByModuleId.set(
module.manifest.id,
Object.freeze({
artifactPath: display.artifactPath,
displayPermission: display.permission,
moduleId: module.manifest.id,
name: module.manifest.name,
permissions: Object.freeze([...module.manifest.permissions]),
version: module.manifest.version,
}),
);
}
}
const published = Object.freeze([...byModuleId.values()]); const published = Object.freeze([...byModuleId.values()]);
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
return Object.freeze({ return Object.freeze({
list: (): readonly PublishableModuleUi[] => published, list: (): readonly PublishableModuleUi[] => published,
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => { resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
if (typeof moduleId !== "string" || typeof version !== "string") { if (typeof moduleId !== "string" || typeof version !== "string") {
return undefined; return undefined;
@@ -127,5 +174,12 @@ export function createModuleUiPublicationRegistry(
// than to a different artifact that happens to exist. // than to a different artifact that happens to exist.
return candidate?.version === version ? candidate : undefined; return candidate?.version === version ? candidate : undefined;
}, },
resolveDisplay: (moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined => {
if (typeof moduleId !== "string" || typeof version !== "string") {
return undefined;
}
const candidate = displaysByModuleId.get(moduleId);
return candidate?.version === version ? candidate : undefined;
},
}); });
} }
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}), : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 5, manifestVersion: 6,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
+1 -1
View File
@@ -42,7 +42,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 5, manifestVersion: 6,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
@@ -1,5 +1,5 @@
{ {
"manifestVersion": 5, "manifestVersion": 6,
"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: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -192,7 +192,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: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements", id = "announcements",
) { ) {
return parseModuleManifest({ return parseModuleManifest({
manifestVersion: 5, manifestVersion: 6,
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: 5, manifestVersion: 6,
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: 5, manifestVersion: 6,
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: 5, manifestVersion: 6,
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: 5, manifestVersion: 6,
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: 5, manifestVersion: 6,
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: 5, manifestVersion: 6,
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: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+136 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> { function manifestFor(id: string): Record<string, unknown> {
return { return {
manifestVersion: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -748,7 +748,9 @@ describe("module discovery", () => {
}); });
expect(discovered?.entrypoints.ui).toBeUndefined(); expect(discovered?.entrypoints.ui).toBeUndefined();
expect(discovered?.entrypoints.display).toBeUndefined();
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "ui")).toBe(false); expect(Object.hasOwn(discovered?.entrypoints ?? {}, "ui")).toBe(false);
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "display")).toBe(false);
}); });
it("rejects a source TypeScript UI entrypoint where compiled output is required", async () => { it("rejects a source TypeScript UI entrypoint where compiled output is required", async () => {
@@ -821,4 +823,137 @@ describe("module discovery", () => {
expect(discovered[0]?.entrypoints.ui).toContain("dist/ui/index.js"); expect(discovered[0]?.entrypoints.ui).toContain("dist/ui/index.js");
}); });
}); });
describe("module-owned display UI entrypoint", () => {
function manifestWithDisplay(
id: string,
artifact: string,
ui?: string,
): Record<string, unknown> {
const manifest = manifestFor(id);
manifest.entrypoints = {
server: "./dist/server/index.js",
...(ui === undefined ? {} : { ui }),
display: { artifact, permission: `${id}.records.read` },
};
return manifest;
}
it("resolves a display-only artifact and preserves its module-owned permission", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
manifest: manifestWithDisplay("announcements", "./dist/display/index.mjs"),
extraFiles: {
"dist/display/index.mjs": "throw new Error('discovery must stay inert');",
},
});
const [discovered] = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(discovered?.entrypoints.ui).toBeUndefined();
expect(discovered?.entrypoints.display).toEqual({
artifactPath: path.join(discovered?.directory ?? "", "dist/display/index.mjs"),
permission: "announcements.records.read",
});
expect(Object.isFrozen(discovered?.entrypoints.display)).toBe(true);
});
it("resolves independent primary and display artifacts without executing either", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./dist/display/index.js", "./dist/ui/index.js"),
extraFiles: {
"dist/display/index.js": "throw new Error('display must stay inert');",
"dist/ui/index.js": "throw new Error('primary UI must stay inert');",
},
});
const [discovered] = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(discovered?.entrypoints.ui).toContain("dist/ui/index.js");
expect(discovered?.entrypoints.display?.artifactPath).toContain("dist/display/index.js");
});
it("rejects source TypeScript and reports the nested display artifact path", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./src/display/index.tsx"),
extraFiles: { "src/display/index.tsx": "export default {};" },
});
const error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
expect(error.issues).toContainEqual(
expect.objectContaining({ path: "entrypoints.display.artifact" }),
);
});
it("rejects a declared display whose compiled artifact is missing", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./dist/display/index.js"),
});
const error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
expect(error.issues).toContainEqual(
expect.objectContaining({ path: "entrypoints.display.artifact" }),
);
});
it("rejects distinct configured UI paths that resolve through links to one artifact", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./dist/display/index.js", "./dist/ui/index.js"),
extraFiles: { "dist/shared.js": "export default {};" },
});
const moduleDirectory = path.join(modulesDirectory, "signage");
await Promise.all([
mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }),
mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }),
]);
await Promise.all([
symlink("../shared.js", path.join(moduleDirectory, "dist/ui/index.js")),
symlink("../shared.js", path.join(moduleDirectory, "dist/display/index.js")),
]);
const error = await discoveryError(modulesDirectory);
expect(error.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
path: "entrypoints.display.artifact",
}),
);
});
it("rejects a display artifact that resolves through a link to the server artifact", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./dist/display/index.js"),
});
const moduleDirectory = path.join(modulesDirectory, "signage");
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await symlink("../server/index.js", path.join(moduleDirectory, "dist/display/index.js"));
const error = await discoveryError(modulesDirectory);
expect(error.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
path: "entrypoints.display.artifact",
}),
);
});
});
}); });
@@ -198,7 +198,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: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+2 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -343,7 +343,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: 5, manifestVersion: 6,
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: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -27,7 +27,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: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -31,7 +31,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [], permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -57,7 +57,7 @@ function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -41,7 +41,7 @@ function manifestFor(
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {}, options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 5, manifestVersion: 6,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+111 -1
View File
@@ -27,10 +27,13 @@ interface ModuleFixture {
readonly version?: string; readonly version?: string;
/** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */ /** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */
readonly ui?: string; readonly ui?: string;
/** A dedicated full-viewport artifact, independent of the primary UI. */
readonly display?: string;
} }
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> { async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const version = fixture.version ?? "0.1.0"; const version = fixture.version ?? "0.1.0";
const displayPermission = `${fixture.id}.display.view`;
const moduleDirectory = path.join(modulesDirectory, fixture.id); const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile( await writeFile(
@@ -49,10 +52,28 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
entrypoints: { entrypoints: {
server: "./dist/server/index.js", server: "./dist/server/index.js",
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }), ...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
...(fixture.display === undefined
? {}
: {
display: {
artifact: "./dist/display/index.js",
permission: displayPermission,
},
}),
}, },
id: fixture.id, id: fixture.id,
manifestVersion: 5, manifestVersion: 6,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions:
fixture.display === undefined
? []
: [
{
baseline: "restricted",
description: `View ${fixture.id}'s display`,
id: displayPermission,
},
],
version, version,
}), }),
); );
@@ -64,6 +85,10 @@ 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);
} }
if (fixture.display !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
}
} }
/** 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
@@ -158,7 +183,9 @@ describe("module UI publication registry", () => {
const registry = createModuleUiPublicationRegistry([]); const registry = createModuleUiPublicationRegistry([]);
expect(registry.list()).toEqual([]); expect(registry.list()).toEqual([]);
expect(registry.listDisplays()).toEqual([]);
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined(); expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
expect(registry.resolveDisplay("announcements", "0.1.0")).toBeUndefined();
}); });
it("publishes an immutable snapshot that no caller can extend", async () => { it("publishes an immutable snapshot that no caller can extend", async () => {
@@ -184,3 +211,86 @@ describe("module UI publication registry", () => {
expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBeDefined(); expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBeDefined();
}); });
}); });
describe("module display UI publication registry", () => {
it("keeps primary UI and display publication independent for display-only, both, and neither", async () => {
const modules = await discover([
{ display: "export default { role: 'display' };", id: "announcements" },
{
display: "export default { role: 'display' };",
id: "signage",
ui: "export default { role: 'primary' };",
},
{ id: "calendar" },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.list().map((entry) => entry.moduleId)).toEqual(["signage"]);
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual([
"announcements",
"signage",
]);
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
expect(registry.resolveDisplay("calendar", "0.1.0")).toBeUndefined();
const signage = registry.resolveDisplay("signage", "0.1.0");
expect(signage).toMatchObject({
displayPermission: "signage.display.view",
moduleId: "signage",
name: "signage module",
version: "0.1.0",
});
expect(signage?.artifactPath).not.toBe(registry.resolve("signage", "0.1.0")?.artifactPath);
});
it("carries only discovery's canonical display artifact and declared permission metadata", async () => {
const modules = await discover([
{ display: 'throw new Error("display must remain inert");', id: "announcements" },
]);
const discovered = modules[0] as DiscoveredModule;
const registry = createModuleUiPublicationRegistry(modules);
const published = registry.resolveDisplay("announcements", "0.1.0");
expect(published?.artifactPath).toBe(discovered.entrypoints.display?.artifactPath);
expect(path.isAbsolute(published?.artifactPath as string)).toBe(true);
expect(published?.displayPermission).toBe(discovered.entrypoints.display?.permission);
expect(published?.permissions.map((permission) => permission.id)).toEqual([
"announcements.display.view",
]);
});
it("requires exact identity and fails closed on malformed lookup values", async () => {
const modules = await discover([
{ display: "export default {};", id: "announcements", version: "1.4.2" },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.resolveDisplay("announcements", "1.4.2")).toBeDefined();
for (const version of ["1.4.1", "latest", "^1.4.2", "1.4.2 ", ""]) {
expect(registry.resolveDisplay("announcements", version)).toBeUndefined();
}
for (const value of [undefined, null, 0, {}, [], Symbol("announcements")]) {
expect(registry.resolveDisplay(value, "1.4.2")).toBeUndefined();
expect(registry.resolveDisplay("announcements", value)).toBeUndefined();
}
});
it("publishes a frozen snapshot and cannot expose a module omitted from its eligible input", async () => {
const modules = await discover([
{ display: "export default {};", id: "announcements" },
{ display: "export default {};", id: "signage" },
]);
// Production passes Core's eligible/started set. Omitting a blocked module at this boundary is
// sufficient to make both its metadata and artifact structurally unresolvable.
const registry = createModuleUiPublicationRegistry(
modules.filter((module) => module.manifest.id === "signage"),
);
expect(registry.resolveDisplay("announcements", "0.1.0")).toBeUndefined();
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual(["signage"]);
expect(Object.isFrozen(registry.listDisplays())).toBe(true);
expect(Object.isFrozen(registry.listDisplays()[0])).toBe(true);
expect(Object.isFrozen(registry.listDisplays()[0]?.permissions)).toBe(true);
expect(registry.listDisplays()).toBe(registry.listDisplays());
});
});
@@ -0,0 +1,224 @@
# 0033: Display UI is a separate, permission-gated module entrypoint
- Status: Accepted
- Date: 2026-08-23
## Context
ADR 0021 through ADR 0027 established one optional module-owned browser artifact,
`entrypoints.ui`, and an Ekklesia Dashboard shell that discovers, publishes, and mounts it. That
contract is appropriate for a module's ordinary product interface: Announcements management,
calendar editing, settings, or operational controls.
A long-lived screen is a different presentation context. An announcement TV, lobby sign,
confidence monitor, or kiosk needs a clean viewport, no Dashboard navigation, and a stable bookmark.
Forcing that experience through the Dashboard would either expose management chrome on the screen or
make one large artifact branch on an Ekklesia-specific mode. Neither shape transfers cleanly to the
future one-module standalone host.
The real Announcements client pressure-tests the boundary. Its slideshow wants an authoritative
snapshot, subsequent state updates, direct image URLs, a black mode-off state, and local fade
transitions. It does not need Dashboard navigation or a second transport. Its current fixed LAN
addresses, anonymous Socket.IO connection, global socket, and incomplete teardown are migration
concerns, not contracts Core should copy.
## Decision
### A module may declare one separate display artifact
Manifest version 6 adds this optional declaration while retaining `entrypoints.ui` unchanged:
```json
{
"manifestVersion": 6,
"permissions": [
{
"id": "announcements.display.view",
"baseline": "restricted",
"description": "View the announcement display."
}
],
"entrypoints": {
"server": "./dist/server/index.js",
"ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "announcements.display.view"
}
}
}
```
`ui` remains the module's primary/management artifact and is the only role eligible for Dashboard
navigation. `display` is a full-viewport artifact and never implies Dashboard placement. Either
field may be absent, so a module may provide primary UI only, display only, both, or neither.
The display declaration carries its permission beside its artifact. The permission must be declared
and owned by that module, and its baseline must be `authenticated` or `restricted`, never `public`.
This makes it impossible to declare a display without an explicit access policy, silently reuse a
management permission chosen by Core, or introduce anonymous display access before a deliberate
device/public principal exists. The display artifact must differ from every other configured
entrypoint before and after canonical path resolution.
One display artifact per module is deliberate. Named variants such as lobby, stage, or confidence
display are deferred until a real module requires them; a generic display registry now would create
identity, routing, permission, and management contracts without a consumer.
Version 6 is a hard cutover. Version 5's strict `entrypoints` object did not admit `display`, so
silently accepting the new field under the old version would allow one version number to mean two
schemas. A module with no display upgrades by changing only the version literal.
### Discovery and build remain static
Discovery reads the manifest without importing browser code. The declared display artifact obeys
the same package containment, symlink, real-file, and compiled `.js`/`.mjs` rules as primary UI.
Complete discovery still succeeds or fails atomically before any runtime entrypoint executes.
The canonical module UI builder reads both declarations and builds exactly what is declared in one
invocation:
- `entrypoints.ui`, when present, from `src/ui/index.tsx`;
- `entrypoints.display.artifact`, when present, from `src/display/index.tsx`.
It validates every source and output before starting either build. The artifacts use the same
browser boundary and bundling policy from ADR 0023. No CSS sidecar, router, or alternate React
runtime contract is introduced.
### Both artifacts use the existing module UI ABI and context
A display artifact default-exports the same `defineModuleUi({ id, version, mount })` definition and
returns the same mount cleanup. The generic loader still validates identity and creates one
independent mounted instance per call. No display-specific ABI exists.
`ModuleUiHostContext` also remains unchanged: `moduleId`, the module-relative API client, principal,
permission hints, and realtime are sufficient. A `presentation.kind` flag is intentionally omitted.
The distinct static artifact already tells module code which product experience it implements; a
mode flag would encourage the single branching artifact this decision rejects. A module may share
ordinary internal components between its two sources and parameterize those components itself.
Nothing exposes a database, storage provider, S3 credential, bucket name, session token, server
path, or Ekklesia route. A standalone host can provide the exact same ABI and context at a different
outer URL.
### Publication distinguishes the role exactly
The existing eligible/running publication registry gains a display view. Primary UI keeps its
existing URL unchanged. The display artifact has one exact role-specific URL:
```text
/api/v1/module-ui/<module-id>/<version>/display
```
There is no request-controlled role, filename, or package path. The publisher reads only the exact
canonical path discovery accepted. A source map, sibling chunk, management artifact, and every
other package file remain unreachable. Artifact bytes keep ADR 0025's anonymous-code policy:
downloading trusted browser code grants no authority, while failures remain indistinguishable to
avoid enumeration. Authorization occurs before the host discloses a descriptor or mounts that code.
### Ekklesia owns a stable, permission-gated outer route
Ekklesia registers these exact routes only for eligible, running display publications:
```text
GET /display/<module-id>
GET /api/v1/module-displays/<module-id>
```
The first is the version-independent bookmark a device keeps across module upgrades. The second is
the singular bootstrap descriptor containing the current versioned display artifact URL, the
module's API and realtime bases, and that principal's module-scoped permission hints. There is no
display inventory or management UI in this pass.
Both routes require the manifest's module-owned display permission through the existing identity,
permission registry, and authorization evaluator. There is no anonymous exception. A future
device/kiosk/public-display principal is a separate security decision; until then, a display uses an
ordinary authenticated session and explicit permission.
Routes are composed from the eligible set after successful module startup. A dashboard-only,
disabled, dependency-blocked, failed, or unknown module has no display route or descriptor. A
running module remains available until process restart under ADR 0028; this decision adds no hot
disable behavior.
### The display browser root is separate from Dashboard chrome
`surfaces/shell` builds a second top-level browser application for the display route. It does not
render `HostApp`, Dashboard navigation, a header, sidebar, breadcrumbs, or operational controls, and
Dashboard code is not statically imported into its bundle. Keeping both applications in the one
surface package reuses the security-sensitive principal/API/realtime adaptation already there;
creating `surfaces/display` would either duplicate it or require a broader shared-package extraction,
while repository boundaries correctly forbid one surface from importing another.
The page gives its mount root the whole viewport, removes default margin and padding, hides document
overflow, and otherwise does not theme module output. Full-screen means **full viewport**. The host
does not invoke the browser Fullscreen API; OS/browser kiosk launch policy belongs to deployment.
The host owns neutral loading and sanitized failure output, clears partial DOM after a failed mount,
and provides a deliberate retry without a tight automatic loop. It aborts stale bootstrap work,
immediately unmounts a load that resolves after teardown, and swallows cleanup failure only while
teardown is already in progress. Multiple tabs and physical screens create independent loader,
context, and realtime instances; no browser-global module state is introduced by the host.
Some frameworks report a render failure only after their `mount` call has returned. Because the
display route is a dedicated page containing exactly one module artifact, an uncaught error targeted
at `window` or an unhandled promise rejection conservatively fails and unmounts that display attempt.
Element-targeted media/resource errors do not: those remain module state. There is no portable way to
attribute a global exception to a framework tree, and errors a module catches internally remain that
module's responsibility.
The surface's exact HTML and JavaScript files are read once during API startup and served from
frozen in-memory text at fixed routes. The API never exposes the surface output directory, derives a
file from request input, serves its source map, or interpolates a module id into HTML. A missing host
build fails startup before the listener binds.
### Existing delivery and realtime contracts are reused
Display UI calls `context.realtime.subscribe("state", ...)`. The existing SSE host supplies the
immediate authoritative snapshot, native reconnect, fresh post-reconnect snapshot, bounded delivery,
and cleanup defined in ADR 0032. No Socket.IO, WebSocket, MQTT, display-specific channel, or retry
transport is added.
If state contains opaque signed object URLs from ADR 0031, display UI assigns those URLs directly to
browser media elements. The display host does not proxy, inspect, rewrite, renew, or regenerate
them. The module remains responsible for publishing refreshed state before URLs expire. A temporary
storage failure is therefore module state/error policy, not a host storage abstraction.
## Consequences
- Management and display products evolve independently without a Dashboard mode branch.
- A TV bookmark survives module version upgrades while the internal artifact URL remains exact and
versioned.
- Display-only modules remain absent from Dashboard navigation.
- Existing trusted-code and same-realm risks remain: the display artifact is reviewed installed
code, not a sandbox.
- Authentication session expiry and the SSE connection-age bound eventually re-run server
authorization. This pass does not invent unattended-device credentials.
- A module may be mounted by many browser instances; per-device targeting and identity are deferred.
- The future standalone host may expose `/display` instead of Ekklesia's outer route without any
module change.
## Rejected alternatives
- **Reuse one artifact with an Ekklesia display-mode flag.** It couples portable module UI to this
host and conflates management with output.
- **Hide Dashboard chrome with CSS.** The navigation and management application would still be the
containing product and failure boundary.
- **Make every authenticated primary-UI viewer a display viewer.** Display access deserves its own
explicit module permission.
- **Make displays anonymous to match the legacy Announcements client.** That is an authorization
bypass, not hosting. A device/public principal needs a later threat model and lifecycle.
- **Expose a module or surface directory as static files.** It turns request paths into filesystem
selectors and defeats discovery's exact-artifact trust decision.
- **Add a second realtime transport or Companion controls.** Display consumes state; Companion's
future actions, variables, and feedbacks are a separate capability contract.
- **Invoke the Fullscreen API.** It requires a user gesture and confuses page layout with deployment
kiosk policy.
## Deferred
- device registration, pairing, credentials, and public-display policy;
- a display inventory and management Dashboard;
- named display variants and per-device targeting;
- the reusable one-module standalone host;
- generic CSS asset publication or isolation;
- an Announcements module migration;
- Companion action/variable/feedback contracts.
+1
View File
@@ -41,3 +41,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0030: Modules declare logical buckets; hosts provide object storage](0030-host-provided-module-object-storage.md) — Accepted - [0030: Modules declare logical buckets; hosts provide object storage](0030-host-provided-module-object-storage.md) — Accepted
- [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted - [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted - [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
+39 -6
View File
@@ -70,7 +70,8 @@ Every module-facing contract in this platform is written so that **full Ekklesia
thing that can satisfy it**. The intended second implementation is a reusable single-module thing that can satisfy it**. The intended second implementation is a reusable single-module
standalone host — conceptually `@ekklesia/standalone-host` — which loads exactly one module and standalone host — conceptually `@ekklesia/standalone-host` — which loads exactly one module and
supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module
lifecycle, and realtime, with display/kiosk hosting still later. lifecycle, and realtime. Ekklesia now also has a dedicated display surface, but the reusable
single-module standalone host remains later.
**No such package exists yet, and this pass does not create one.** It is recorded here because it is **No such package exists yet, and this pass does not create one.** It is recorded here because it is
what several contract decisions are *for*, and because a change that quietly makes it unimplementable what several contract decisions are *for*, and because a change that quietly makes it unimplementable
@@ -370,9 +371,10 @@ and nothing more; there is no runtime health-probe contract. Modules remain trus
disabling one is an operational control, never a sandbox. No installer exists. disabling one is an operational control, never a sandbox. No installer exists.
Phase 7A pass 2 adds the module configuration and secrets contract Phase 7A pass 2 adds the module configuration and secrets contract
([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). A module declares what it needs ([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). That pass introduced static
configured in its manifest — `manifestVersion: 5`, a hard cutover — using six types (`string`, configuration with the `manifestVersion: 5` hard cutover; the current strict manifest is version 6
`integer`, `boolean`, `enum`, `url`, `secret`) with bounded constraints and no nesting. Static after ADR 0033. A module uses six declaration types (`string`, `integer`, `boolean`, `enum`, `url`,
`secret`) with bounded constraints and no nesting. Static
declaration is what lets the platform validate configuration **before** deciding whether a module may declaration is what lets the platform validate configuration **before** deciding whether a module may
start, so an unconfigured module is reported `blocked · configuration_missing` rather than taking start, so an unconfigured module is reported `blocked · configuration_missing` rather than taking
startup down from inside its own `start` hook; discovery, `pnpm modules:validate`, and Doctor all read startup down from inside its own `start` hook; discovery, `pnpm modules:validate`, and Doctor all read
@@ -469,5 +471,36 @@ for requests already in flight. Reverse-order stop then deactivates each module'
immediately before that module's own stop hook, so dependencies remain operational while dependents immediately before that module's own stop hook, so dependencies remain operational while dependents
clean up and captured publishers cannot outlive their module. Direct object-delivery URLs remain clean up and captured publishers cannot outlive their module. Direct object-delivery URLs remain
opaque payload strings and are never re-signed per publication or subscriber. MQTT remains an opaque payload strings and are never re-signed per publication or subscriber. MQTT remains an
external-control concern for a future Companion boundary, not an internal module bus. Display/kiosk external-control concern for a future Companion boundary, not an internal module bus.
hosting and its public/device principal are explicitly not part of this pass.
Phase 7A pass 6 adds a dedicated display role and Ekklesia host
([ADR 0033](decisions/0033-display-kiosk-host-contract.md)). Manifest version 6 gives a module one
optional `entrypoints.display` object containing a compiled artifact and one module-owned permission.
The permission must have an `authenticated` or `restricted` baseline: public/device display identity
is still intentionally unsolved. The display artifact must be distinct from every other configured
entrypoint. Primary UI, display UI, both, and neither are all valid module shapes.
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
`presentation.kind`, or host-identity branch. The canonical builder reads only
`src/ui/index.tsx` and `src/display/index.tsx`, bundles React into each declared role, rejects CSS
sidecars, and publishes no output until every requested bundle has compiled. Core validates both
paths inertly and builds both publication records only from the dependency-resolved eligible/running
set; it never imports browser code.
Ekklesia keeps its primary Dashboard host unchanged and adds a separate full-viewport application.
For each running display-capable module, the API registers the stable page `/display/<module-id>`, a
protected descriptor at `/api/v1/module-displays/<module-id>`, and the exact display artifact at
`/api/v1/module-ui/<module-id>/<version>/display`. The page and descriptor use normal cookie identity
and the manifest's display permission; the artifact remains anonymous trusted code because bytes
confer no authority. There is no display inventory in this pass. The descriptor supplies the same
module API root, realtime root, principal-derived permission hints, and exact artifact identity the
generic loader already consumes. The surface adds no Dashboard chrome and does not request the
browser Fullscreen API; failures remain bounded behind a fixed state and manual retry.
Production reads the fixed display HTML and script once before binding the listener and serves that
frozen snapshot, never a directory. Enablement, dependency blocking, and runtime startup determine
whether routes exist, and installed-manifest changes remain restart-based. Realtime and direct signed
object URLs are unchanged and flow through the same portable contracts. Named display variants,
targeting, display inventory/management, device/public identity, a standalone host, shared CSS,
Announcements migration, and Companion integration remain later work.
@@ -36,6 +36,11 @@ These rules map platform ownership to filesystem and package boundaries. The exe
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
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
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
role-neutral UI SDK ABI and neither may import a surface or host loader.
- Module UI **asset publication** runs Core → `apps/api`, never the other way - Module UI **asset publication** runs Core → `apps/api`, never the other way
([ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md)). Core owns the policy ([ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md)). Core owns the policy
(`createModuleUiPublicationRegistry`, built only from already-validated `DiscoveredModule` (`createModuleUiPublicationRegistry`, built only from already-validated `DiscoveredModule`
@@ -69,6 +74,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader, mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact. concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
- 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
API. It contains no Dashboard chrome and does not move display rendering into the surface—the
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
surface internals. surface internals.
+88 -5
View File
@@ -81,6 +81,9 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). | | `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). | | `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). | | `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
| `GET /api/v1/module-ui/<module-id>/<version>/display` | Ekklesia | Publishes the exact validated display artifact for that eligible/running module. Artifact bytes are anonymous trusted code; this route is not the authorization boundary. See [Module display host](#module-display-host). |
| `GET /display/<module-id>` | Ekklesia | Stable, full-viewport display page with no Dashboard chrome. Requires the module-owned permission declared beside `entrypoints.display`. See [Module display host](#module-display-host). |
| `GET /api/v1/module-displays/<module-id>` | Ekklesia | Protected descriptor for one display page: exact artifact, module API/realtime roots, and presentation-only permission hints. Requires that display's module-owned permission. See [Module display host](#module-display-host). |
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). | | `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). | | `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
| `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). | | `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). |
@@ -990,7 +993,8 @@ root of the same resource the artifact route below serves: one prefix, two repre
prefix, which lets one change without touching host or module code. prefix, which lets one change without touching host or module code.
- **The module set is the publication registry**, not a rescan: modules that survived discovery and - **The module set is the publication registry**, not a rescan: modules that survived discovery and
declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent. declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent.
Order is the registry's own deterministic discovery order. Order is the registry's own deterministic discovery order. Dedicated display artifacts are not
merged into this primary-UI inventory; this pass deliberately adds no display enumeration API.
- **Authenticated, unlike the artifact route.** Requires `core.modules.ui.read`, baseline - **Authenticated, unlike the artifact route.** Requires `core.modules.ui.read`, baseline
`authenticated` — any signed-in user, and nobody else. The artifact bytes are anonymous because `authenticated` — any signed-in user, and nobody else. The artifact bytes are anonymous because
fetching them confers no authority, but that route is deliberately not an enumeration oracle, and fetching them confers no authority, but that route is deliberately not an enumeration oracle, and
@@ -1027,7 +1031,9 @@ GET /api/v1/module-ui/<module-id>/<version>
joined, resolved, or concatenated into a filesystem path, and the route declares no wildcard joined, resolved, or concatenated into a filesystem path, and the route declares no wildcard
segment — so traversal, encoded separators, sibling bundle chunks, package-root files, and source segment — so traversal, encoded separators, sibling bundle chunks, package-root files, and source
maps are unreachable by construction. **An installed module directory is never a static file maps are unreachable by construction. **An installed module directory is never a static file
root**, and only the single artifact its manifest declares is ever served. root**, and only the single primary artifact its manifest declares is ever served. A separately
declared display artifact uses the exact sibling route described below; neither role can select
the other's file.
- **A sibling of `/api/v1/modules/`, never a child.** A module may legitimately register the route - **A sibling of `/api/v1/modules/`, never a child.** A module may legitimately register the route
path `/ui/:version`, so nesting publication inside the module prefix would let a module and the path `/ui/:version`, so nesting publication inside the module prefix would let a module and the
platform compete for one URL with routing order deciding the winner. platform compete for one URL with routing order deciding the winner.
@@ -1058,9 +1064,86 @@ construction of this URL. It accepts identity only and percent-encodes both comp
root-relative, so a same-origin host uses it unchanged and a split-origin host resolves it against root-relative, so a same-origin host uses it unchanged and a split-origin host resolves it against
the same API base URL it already needs for `ModuleUiApiClient`. the same API base URL it already needs for `ModuleUiApiClient`.
**Nothing mounts a module UI yet.** A host can now obtain a browser URL that Ekklesia's Dashboard surface consumes the primary inventory and mounts these URLs through
`@ekklesia/module-ui-host` accepts, which was previously impossible — but Ekklesia has no Dashboard `@ekklesia/module-ui-host`. The dedicated display surface reuses the same loader and ABI but obtains
shell, no navigation, and no code that calls this route outside its own tests. its role-specific URL only from the protected descriptor below.
## Module display host
Manifest version 6 adds one independently optional display declaration
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
```json
{
"entrypoints": {
"display": {
"artifact": "./dist/display/index.js",
"permission": "announcements.display.view"
}
}
}
```
That permission must be declared by the same module and have an `authenticated` or `restricted`
baseline; `public` is rejected because public/device display identity is deferred. The artifact path
uses the normal compiled, package-contained entrypoint rules and may not collide with server,
schema, migrations, jobs, or primary UI. A module may declare primary UI only, display only, both,
or neither.
For every display-capable module in the immutable eligible/running publication registry, API
composition installs three exact routes:
```text
GET /display/<module-id>
GET /api/v1/module-displays/<module-id>
GET /api/v1/module-ui/<module-id>/<version>/display
```
The first is a stable bookmark independent of the installed version. It serves the same frozen,
module-agnostic full-viewport HTML shell for every module, with no Dashboard application or chrome.
The second returns the only descriptor that shell needs:
```json
{
"module": {
"id": "announcements",
"version": "0.1.0",
"display": {
"url": "/api/v1/module-ui/announcements/0.1.0/display"
},
"api": { "baseUrl": "/api/v1/modules/announcements" },
"realtime": { "baseUrl": "/api/v1/module-realtime/announcements" },
"permissions": ["announcements.display.view"]
}
}
```
- **Page and descriptor authorization.** Both run the ordinary cookie principal through Core's one
evaluator for exactly the display permission stored in the validated publication record. There
is no query token, public exception, device principal, or separate authorization system. The
descriptor's `permissions` remains presentation-only; module HTTP and realtime routes still
enforce their own declared permissions.
- **Artifact publication.** The versioned `/display` suffix publishes exactly the canonical display
artifact, byte-for-byte, with the same MIME, `nosniff`, strong ETag, `no-cache`, sanitized `404`/
`503`, and anonymous-code policy as primary UI. It never falls back to `entrypoints.ui`, and no
request component becomes a filesystem path. Fetching code grants no API, realtime, or data
authority.
- **Lifecycle and restart.** Unknown, primary-only, disabled, dependency-blocked, and failed-startup
modules have no display routes and collapse into the normal sanitized `404`. Production reads the
exact display-host HTML and script once before binding, freezes them, and serves fixed paths rather
than a directory. Installation, manifest, enablement, and build changes take effect on restart.
- **Browser behavior.** The shell fetches principal first, then the protected descriptor, and only
then dynamically imports the supplied artifact. It supplies the same role-neutral
`ModuleUiHostContext` used by primary UI—API, principal, permissions, and realtime—with no mode
flag. Failures become fixed states with manual retry. The host fills the viewport but does not
request the browser Fullscreen API.
- **Unchanged data paths.** Realtime stays on the permission-gated SSE route above. Signed object
delivery URLs remain opaque bearer strings generated by module backend code and fetched directly
from storage; the display host neither proxies nor re-signs them.
There is deliberately no display inventory or management API. Named variants, targeting,
public/device identity, a standalone host, a shared stylesheet contract, Announcements migration,
and Companion integration remain future work.
## Module database access ## Module database access
+25 -1
View File
@@ -9,7 +9,9 @@ Related: [ADR 0006](../architecture/decisions/0006-versioned-capabilities-and-co
(module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md) (module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md)
(module routing/lifecycle and migration execution), (module routing/lifecycle and migration execution),
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md) [ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md)
(native module realtime), and (native module realtime),
[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)
(authenticated display hosting), and
[Module development](../module-development/README.md). [Module development](../module-development/README.md).
## Installed modules are trusted compiled backend code ## Installed modules are trusted compiled backend code
@@ -47,6 +49,7 @@ cooperative. None of them constrains a module that is not:
| Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them | | Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them |
| Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds | | Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds |
| Scoped realtime channels, publishers, and subscriptions | Correct module-local state wiring, permission enforcement, and finite fanout — not process isolation | | Scoped realtime channels, publishers, and subscriptions | Correct module-local state wiring, permission enforcement, and finite fanout — not process isolation |
| Display declaration, permission, and exact publication record | Correct role selection and normal authorization for an eligible/running module — not browser isolation |
| `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects | | `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects |
| The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL | | The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL |
| Capability registry | Explicit inter-module wiring, not access control | | Capability registry | Explicit inter-module wiring, not access control |
@@ -155,6 +158,27 @@ shell owns the container, the loading state, and every error, so a failing modul
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above. JavaScript, exactly as described above.
### 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
([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,
and public/device identity is not approximated with an anonymous exception. Both the stable
`/display/<module-id>` page and `/api/v1/module-displays/<module-id>` descriptor evaluate that exact
permission through the normal Core evaluator. Client permission hints remain presentation only.
The versioned display artifact is anonymous code just like primary UI code. It is served only from
the frozen eligible/running publication registry, by exact module identity and version, and never
falls back to primary UI or opens the module directory. Fetching it grants no module HTTP, realtime,
storage, or data authority. Disabled and dependency-blocked modules receive no page, descriptor, or
artifact route; this is operational exposure control, not sandboxing.
The full-viewport host is a separate same-realm surface, not an iframe or containment boundary. It
uses normal cookie authorization, the same scoped API/realtime context, and opaque signed object URLs
without logging, proxying, extending, or re-signing them. It does not invoke the browser Fullscreen
API. Display inventory, device credentials, public principals, variants, and targeting are deferred
rather than silently granted.
### Dashboard arrangement is not an access control ### Dashboard arrangement is not an access control
Dashboard groups and placements are presentation configuration Dashboard groups and placements are presentation configuration
+18
View File
@@ -275,6 +275,24 @@ enablement. During realtime development, run the focused live Node suite repeate
`pnpm verify` and `pnpm test:migrations:integration`; consecutive runs are specifically intended to `pnpm verify` and `pnpm test:migrations:integration`; consecutive runs are specifically intended to
catch retained timers, listeners, subscribers, or loopback servers. catch retained timers, listeners, subscribers, or loopback servers.
Phase 7A pass 6 adds display evidence across the same boundaries
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). Contract tests prove the
manifest v6 hard cutover, optional role combinations, module-owned non-public display permission,
and collision rejection. Discovery tests prove inert compiled-artifact validation, containment,
symlink collision detection, deterministic diagnostics, and that modules without a display remain
valid. Builder tests prove the exact `src/ui/index.tsx` / `src/display/index.tsx` sources,
deterministic role order, preflight/compile atomicity, no runtime-entrypoint overwrite, and identical
browser/CSS boundaries.
Core/API tests prove independently frozen primary and display publication records, exact anonymous
artifact routing, permission-gated stable page and descriptor routes, eligible/running lifecycle
filtering, sanitized failures, and startup-time frozen host assets. Surface tests prove principal-
first descriptor bootstrap, the role-neutral host context, full-viewport mounting without Dashboard
chrome or static module imports, late-import cleanup, manual retry, realtime reuse, and unmount.
The canonical template builds and mounts both artifacts through `parseModuleUiExport`; contract
fixtures also retain no-display and backend-only cases. These are code-only tests: display hosting
adds no PostgreSQL, object-store, or Docker requirement beyond the existing suites.
### Which kind of timing proof lives where ### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind: Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
+146 -68
View File
@@ -9,11 +9,12 @@ platform context, the application shell, top-level routing, navigation, the glob
design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host
application's. A module consumes those; it never redefines or bypasses them. application's. A module consumes those; it never redefines or bypasses them.
> **What exists today:** you can author a module UI in React, build it into the compiled artifact your > **What exists today:** you can author independently optional primary and full-viewport display UI
> manifest declares, have Core validate it, have Ekklesia publish it at a browser URL, and have > roles in React, build their declared artifacts, have Core validate and publish them, and have
> Ekklesia's browser shell discover and mount it — the loop closes end to end. **What does not exist > Ekklesia mount each in its proper browser surface. The primary shell has flat configurable
> is the Dashboard**: no navigation, grouping, icons, ordering, theme, or design system, and no > navigation; the display shell has no Dashboard chrome. A finished design system, display
> standalone host. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint), > inventory/management, public/device identity, and the standalone host do not exist. See
> [Declaring UI entrypoints](#declaring-ui-entrypoints),
> [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact), and > [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact), and
> [What actually mounts your UI](#what-actually-mounts-your-ui). > [What actually mounts your UI](#what-actually-mounts-your-ui).
@@ -38,12 +39,16 @@ modules/example/
│ └── _journal.json │ └── _journal.json
├── dist/schema/index.js # when the module owns tables ├── dist/schema/index.js # when the module owns tables
├── dist/server/index.js ├── dist/server/index.js
├── dist/ui/index.js # when the module declares primary UI
├── dist/display/index.js # when the module declares a display
├── drizzle.config.ts # when generating migrations ├── drizzle.config.ts # when generating migrations
├── ekklesia.module.json ├── ekklesia.module.json
├── package.json ├── package.json
├── src/schema/index.ts # when the module owns tables ├── src/schema/index.ts # when the module owns tables
├── src/schema/tables.ts # pure Drizzle declarations used by Drizzle Kit ├── src/schema/tables.ts # pure Drizzle declarations used by Drizzle Kit
└── src/server/index.ts ├── src/server/index.ts
├── src/ui/index.tsx # fixed primary-UI build source, when declared
└── src/display/index.tsx # fixed display build source, when declared
``` ```
The schema and migration paths are optional. A tableless module omits `entrypoints.schema`; a The schema and migration paths are optional. A tableless module omits `entrypoints.schema`; a
@@ -68,7 +73,7 @@ without executing module code:
```json ```json
{ {
"manifestVersion": 4, "manifestVersion": 6,
"id": "calendar", "id": "calendar",
"name": "Calendar", "name": "Calendar",
"version": "0.1.0", "version": "0.1.0",
@@ -102,6 +107,11 @@ without executing module code:
"id": "calendar.events.read", "id": "calendar.events.read",
"baseline": "restricted", "baseline": "restricted",
"description": "Read calendar events." "description": "Read calendar events."
},
{
"id": "calendar.display.view",
"baseline": "authenticated",
"description": "View the calendar display."
} }
], ],
"storage": { "storage": {
@@ -115,7 +125,12 @@ without executing module code:
"entrypoints": { "entrypoints": {
"server": "./dist/server/index.js", "server": "./dist/server/index.js",
"schema": "./dist/schema/index.js", "schema": "./dist/schema/index.js",
"migrations": "./drizzle" "migrations": "./drizzle",
"ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "calendar.display.view"
}
} }
} }
``` ```
@@ -125,12 +140,12 @@ 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: 4` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a `manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 3 manifest, and any other version, fails validation outright rather than being silently version 5 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 4 adds one optional field, `entrypoints.ui`, so upgrading a backend-only version 3 manifest Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display
is a one-character edit and changes nothing else changes only the version literal; `ui` remains independently optional
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md)). ([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
@@ -176,14 +191,15 @@ relationship the platform has, and it is what orders discovery and startup. An a
your module into a different folder changes nothing about your artifact, your API root, your your module into a different folder changes nothing about your artifact, your API root, your
permissions, your dependencies, or when you start. permissions, your dependencies, or when you start.
Every entrypoint except `migrations` must be a compiled ESM `.js` or `.mjs` file — `server`, Every artifact entrypoint must be a compiled ESM `.js` or `.mjs` file — `server`, `schema`, `jobs`,
`schema`, `jobs`, and `ui` alike. `migrations` is the one directory entrypoint. All entrypoints begin `ui`, and `display.artifact` alike. `migrations` is the one directory entrypoint. All paths begin
with `./`, remain inside the package, and exist. Production never imports source TypeScript directly. with `./`, remain inside the package, and exist. Production never imports source TypeScript directly.
Modules with no tables omit `entrypoints.schema`; modules with no migration history omit A display artifact must remain distinct from every other configured entrypoint before and after
`entrypoints.migrations`; modules with no UI omit `entrypoints.ui`. symlink resolution. Modules with no tables omit `entrypoints.schema`; modules with no migration
history omit `entrypoints.migrations`; modules may omit `ui`, `display`, or both.
Unknown manifest properties are still rejected. `ui` is the only entrypoint version 4 adds — a Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or
`dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest `renderers` key fails validation — and the manifest
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
organization is presentation configuration owned by the Dashboard, never module architecture. organization is presentation configuration owned by the Dashboard, never module architecture.
@@ -191,10 +207,10 @@ Storage bucket IDs are logical names scoped to the module. Modules may request m
Core will later map them to deployment-specific physical S3 buckets and provide scoped access; Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
module code must not configure SeaweedFS or another S3 client directly. module code must not configure SeaweedFS or another S3 client directly.
## Declaring a UI entrypoint ## Declaring UI entrypoints
A module may declare one optional presentation entrypoint alongside its server, schema, and A module may declare a primary/management UI, a dedicated display UI, both, or neither. The roles
migration entrypoints: are separate artifacts because a full-viewport display is not a Dashboard screen with hidden chrome:
```json ```json
{ {
@@ -202,20 +218,25 @@ migration entrypoints:
"server": "./dist/server/index.js", "server": "./dist/server/index.js",
"schema": "./dist/schema/index.js", "schema": "./dist/schema/index.js",
"migrations": "./drizzle", "migrations": "./drizzle",
"ui": "./dist/ui/index.js" "ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "calendar.display.view"
}
} }
} }
``` ```
`ui` obeys exactly the rules `server` and `schema` already obey. It must be package-relative, stay Both artifacts obey the same compiled-entrypoint rules as `server` and `schema`: package-relative,
inside the module package (checked before *and* after symlink resolution), resolve to a real file, inside the module before *and* after symlink resolution, a real `.js`/`.mjs` file, and never source
and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts` path is rejected at TypeScript. The display path must not collide with any other entrypoint. Its permission must be one
discovery — the platform never imports source TypeScript, and a UI artifact is a build output like of this module's own declarations and must use `authenticated` or `restricted`, never `public`;
any other. anonymous/device display identity is deferred. There is one display role per module for now. Named
variants and targeting are later contracts, not ad hoc manifest keys.
### What the artifact must export ### What the artifact must export
The artifact default-exports one definition, built with `defineModuleUi` from Each artifact default-exports one definition, built with `defineModuleUi` from
`@ekklesia/module-ui-sdk` (ADR 0022): `@ekklesia/module-ui-sdk` (ADR 0022):
```tsx ```tsx
@@ -244,7 +265,9 @@ return an unmount and release it there, the same discipline your `stop` hook fol
React is the supported way to write module UI. It is deliberately **not** part of the contract: the React is the supported way to write module UI. It is deliberately **not** part of the contract: the
platform hands you an element and a context, so your framework choice and the host's stay platform hands you an element and a context, so your framework choice and the host's stay
independent. independent. Primary and display artifacts use this exact same ABI and context. There is no mode
flag, `presentation.kind`, Dashboard boolean, or host identity to branch on; the separately declared
artifact is the role selection.
### What the host gives you ### What the host gives you
@@ -297,13 +320,12 @@ API process. Write module UI as trusted first-party code, because that is what i
### Building your UI ### Building your UI
Put your UI source at `src/ui/index.tsx`, declare the artifact in your manifest, and build: Put each declared role at its fixed source path and build once:
```text ```text
src/ui/index.tsx # the compiled entrypoint's source src/ui/index.tsx → entrypoints.ui
src/ui/ExampleApp.tsx # your components src/display/index.tsx → entrypoints.display.artifact
↓ pnpm build pnpm build
dist/ui/index.js # the artifact your manifest declares
``` ```
You write no bundler configuration. `@ekklesia/module-ui-build` owns all of it You write no bundler configuration. `@ekklesia/module-ui-build` owns all of it
@@ -328,20 +350,31 @@ script runs both halves of the module:
} }
``` ```
The output path is **read from your manifest**, not from a build convention, so the artifact you Output paths are **read from your manifest**, not inferred, so declarations and produced artifacts
declare and the artifact you produce cannot drift. Declare it exactly as you would any other cannot drift. Source paths are the exact conventions above; module authors do not configure them.
entrypoint: The builder resolves and preflights all roles in deterministic `ui`, then `display` order, compiles
both in memory, and writes only after every declared bundle succeeds. A missing or broken display
source therefore cannot leave a newly written primary artifact (or vice versa).
```json ```json
{ "entrypoints": { "ui": "./dist/ui/index.js" } } {
"entrypoints": {
"ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "calendar.display.view"
}
}
}
``` ```
A module with no `entrypoints.ui` is not an error — the build reports that there is nothing to build A module may declare either role alone. Declaring neither is not an error — the build reports that
and exits successfully, so the same script works for a backend-only module. there is nothing to build and exits successfully, so the same script works for a backend-only module.
The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not
compile, an import cannot be resolved, the declared path is unsafe or is not a `.js`/`.mjs` artifact, compile, an import cannot be resolved, the declared path is unsafe or is not a `.js`/`.mjs` artifact,
or `src/ui/index.tsx` is missing. or a declared role's fixed source is missing. It also refuses any display output that could
overwrite `server`, `schema`, `migrations`, `jobs`, or primary UI before invoking esbuild.
#### TypeScript projects #### TypeScript projects
@@ -352,7 +385,12 @@ not match `.tsx`, and a UI project covers the browser half:
{ {
"extends": "@ekklesia/typescript-config/browser-react.json", "extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": { "noEmit": true, "types": [] }, "compilerOptions": { "noEmit": true, "types": [] },
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"] "include": [
"src/ui/**/*.tsx",
"src/ui/**/*.ts",
"src/display/**/*.tsx",
"src/display/**/*.ts"
]
} }
``` ```
@@ -384,10 +422,11 @@ Style with React's `style` prop:
``` ```
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than **Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
an oversight: esbuild would emit a `dist/ui/index.css` beside your artifact, but no manifest field an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no
declares it and no host loads it — so it would work in your tests and silently do nothing in a real manifest field declares it and no host loads it — so it would work in your tests and silently do
host. A stylesheet contract needs a manifest field, a host loading mechanism, and a style-isolation nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and
decision, none of which exist yet. Inline styles are the whole supported story for this phase. a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for
this phase.
### What you may and may not use today ### What you may and may not use today
@@ -407,10 +446,11 @@ Layer rules are unchanged: module UI source may not import Core, an app, a surfa
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` — another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
is resolved and rejected by its real target. is resolved and rejected by its real target.
> **Your artifact is served, and Ekklesia's shell mounts it.** The platform publishes your compiled UI > **Your artifacts are served, and Ekklesia's surfaces mount them.** The platform publishes primary
> at `GET /api/v1/module-ui/<module-id>/<version>` > UI at `GET /api/v1/module-ui/<module-id>/<version>` and display UI at the sibling `/display`
> artifact URL
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)) and its browser > ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)) and its browser
> shell discovers and mounts it > surfaces discover and mount them
> ([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). That shell is a > ([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). That shell is a
> module list and a mount target, not a Dashboard. See > module list and a mount target, not a Dashboard. See
> [How a host loads your UI](#how-a-host-loads-your-ui) and > [How a host loads your UI](#how-a-host-loads-your-ui) and
@@ -418,10 +458,10 @@ is resolved and rejected by its real target.
### What this does and does not do today ### What this does and does not do today
Discovery parses the manifest as inert JSON, resolves the `ui` path, validates it, and records it on Discovery parses the manifest as inert JSON, resolves each declared browser path, validates it, and
the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or renders a UI records it on the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or
entrypoint** — a UI artifact that throws on evaluation still discovers successfully, because it is renders either UI entrypoint** — an artifact that throws on evaluation still discovers successfully,
never evaluated. Hosts perform their own dynamic import and then validate the result with because it is never evaluated. Hosts perform their own dynamic import and then validate the result with
`parseModuleUiExport`; Core never loads browser code. `parseModuleUiExport`; Core never loads browser code.
Core also never *builds* anything. Building is an authoring and packaging step you run; Core Core also never *builds* anything. Building is an authoring and packaging step you run; Core
@@ -435,10 +475,9 @@ You do not write this — a host does — but knowing the shape explains what yo
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)): ([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)):
```text ```text
compiled artifact (dist/ui/index.js) compiled primary or display artifact
↓ asset publication — host-specific; Ekklesia serves it at ↓ asset publication — host-specific; Ekklesia serves it at
↓ GET /api/v1/module-ui/<module-id>/<version>, and its shell finds it ↓ one exact server-produced artifact URL
↓ through GET /api/v1/module-ui
browser-loadable module URL browser-loadable module URL
↓ loadModuleUi({ source, container, context }) ↓ loadModuleUi({ source, container, context })
dynamic import → parseModuleUiExport → identity checked against the manifest dynamic import → parseModuleUiExport → identity checked against the manifest
@@ -470,24 +509,26 @@ What that means for you as a module author:
### How Ekklesia publishes your artifact ### How Ekklesia publishes your artifact
Core resolves `entrypoints.ui` to a path on the *server's filesystem*, which a browser cannot import. Core resolves each declared browser artifact to a path on the *server's filesystem*, which a browser
Ekklesia turns that into a URL cannot import. Ekklesia turns each role into one exact URL
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)): ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)):
```text ```text
GET /api/v1/module-ui/<module-id>/<version> GET /api/v1/module-ui/<module-id>/<version>
GET /api/v1/module-ui/<module-id>/<version>/display
``` ```
Four things about it matter to you as a module author: Four things about it matter to you as a module author:
- **Only the artifact your manifest declares is served.** The module id and version in that 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; there is no sidecar contract yet, which is also why a
stylesheet import still fails the build. stylesheet import still fails the build.
- **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 is public.** The route requires no authentication — fetching it grants no - **Your artifact bytes are public.** These two routes require no authentication — fetching code
grants no
authority, and your backend routes remain permission-enforced regardless. Never put a secret, a authority, and your backend routes remain permission-enforced regardless. Never put a secret, a
private endpoint, or anything you would not publish in your UI bundle. private endpoint, or anything you would not publish in your UI bundle.
- **It is served byte-for-byte, never executed on the server.** No transformation, no re-bundling, no - **It is served byte-for-byte, never executed on the server.** No transformation, no re-bundling, no
@@ -526,6 +567,40 @@ Three things follow for you:
> `name`, never its id — and under "Other" until someone arranges it. There is no navigation editor, > `name`, never its id — and under "Other" until someone arranges it. There is no navigation editor,
> no nested folders, no icons, no theme, and no Dashboard Home yet. > no nested folders, no icons, no theme, and no Dashboard Home yet.
### How Ekklesia hosts your display
The display is not part of the primary inventory or Dashboard navigation. For a module that is
eligible, running, and declares `entrypoints.display`, Ekklesia registers exactly these routes:
```text
/display/<module-id> stable full-viewport page
/api/v1/module-displays/<module-id> host descriptor
/api/v1/module-ui/<module-id>/<version>/display compiled artifact bytes
```
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
rejected in manifest version 6; 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,
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,
not through hot loading.
The stable page runs a separate `surfaces/shell` browser application with no Dashboard navigation,
theme chrome, or primary UI fallback. It asks for the protected descriptor, then passes the exact
artifact URL, module API base, realtime base, principal, and permission hints into the generic
loader. The host owns credentials and routes; your display still calls
`context.api.request("relative-path")` and `context.realtime.subscribe(...)`. Signed object-delivery
URLs remain opaque strings in HTTP or realtime state and should be refreshed according to their
expiry exactly as in any primary UI. Nothing in the display ABI identifies Ekklesia, so the same
artifact remains compatible with a future standalone host.
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
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
CSS, Announcements migration, and Companion integration are deferred.
### What a module UI will never own ### What a module UI will never own
A module UI consumes platform policy and never redefines it. A module UI consumes platform policy and never redefines it.
@@ -835,10 +910,11 @@ letting requests already in flight finish publishing. Reverse-order stop then di
publisher immediately before that module's own stop hook. Call `close()` during UI unmount as well; publisher immediately before that module's own stop hook. Call `close()` during UI unmount as well;
it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are
forwarded unchanged. forwarded unchanged.
Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Public Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Ekklesia's
display/device identity, durable replay, multi-node fanout, client-to-server commands, and authenticated display host consumes the same interface unchanged; public/device identity, durable
display/kiosk hosting remain future work. See replay, multi-node fanout, and client-to-server commands remain future work. See
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md). [ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md) and
[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md).
## Drizzle schema definition ## Drizzle schema definition
@@ -1340,9 +1416,11 @@ disable or block a running module because a storage call timed out.
### Serving objects to a browser ### Serving objects to a browser
Module UI gets no storage access and no credentials. Serve bytes through your own authorized route, Module UI gets no storage access and no credentials. Its authorized module route may either stream
as in the example above: `browser → your module API → your scoped storage`. Signed URLs and public through scoped storage or call `createReadUrl(key)` and return that short-lived bearer URL so the
object delivery do not exist yet. browser fetches bytes directly from object storage. Primary and display UI consume that URL the same
way: keep it opaque, never persist or log it, and refresh it before expiry. A display host adds no
storage authority and realtime transport does not extend a URL's lifetime.
### Whether your module runs at all is your host's decision ### Whether your module runs at all is your host's decision
+66 -21
View File
@@ -1,15 +1,16 @@
import { z } from "zod"; import { z } from "zod";
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js"; import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
import {
type ModuleConfigurationDeclaration,
moduleConfigurationDeclarationSchema,
} from "./module-configuration.js";
import { import {
capabilityIdSchema, capabilityIdSchema,
logicalBucketIdSchema, logicalBucketIdSchema,
moduleIdSchema, moduleIdSchema,
packageRelativePathSchema, packageRelativePathSchema,
permissionIdSchema,
} from "./identifiers.js"; } from "./identifiers.js";
import {
type ModuleConfigurationDeclaration,
moduleConfigurationDeclarationSchema,
} from "./module-configuration.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);
@@ -64,17 +65,27 @@ const moduleStorageSchema = z
.default(() => ({ buckets: [] })); .default(() => ({ buckets: [] }));
/** /**
* The compiled artifacts a module publishes for the platform to resolve. Every entry is a * A module's optional full-viewport display UI. The artifact is kept beside its access policy so a
* package-relative path to a build output, never source: Core resolves and validates these * display can never be declared without saying which one of the module's own permissions protects
* statically during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021). * it. Core validates the reference against `permissions` below without executing module code.
*/
export const moduleDisplayEntrypointSchema = z
.object({
artifact: packageRelativePathSchema,
permission: permissionIdSchema,
})
.strict();
/**
* The compiled artifacts a module publishes for the platform to resolve. File entries are
* package-relative paths to build output, never source: Core resolves and validates them statically
* during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021).
* *
* `ui` is a module's optional, host-neutral presentation entrypoint (ADR 0021). It is declared and * `ui` is a module's optional, host-neutral presentation entrypoint (ADR 0021). It is declared and
* statically validated here, exactly like `jobs` already is, but **no runtime consumer exists yet** * statically validated here and mounted by a compatible host. Its name is deliberately not
* — nothing in this repository imports, evaluates, mounts, or renders it. Its name is deliberately * `dashboard`: a module's primary UI must be hostable by Ekklesia's application shell *and* by a
* not `dashboard`: a module's UI must be hostable by Ekklesia's own application shell *and* by a * future standalone host. `display` is separate because full-viewport output and primary management
* future standalone host, so the contract cannot be named after one of them. It is deliberately not * UI are distinct product experiences; both still use the same browser mount ABI.
* `surface` either — `surfaces/*` is a separate ownership layer (ADR 0001), and reusing that word
* for a module-owned artifact would conflate two different things.
*/ */
const entrypointsSchema = z const entrypointsSchema = z
.object({ .object({
@@ -83,6 +94,7 @@ const entrypointsSchema = z
migrations: packageRelativePathSchema.optional(), migrations: packageRelativePathSchema.optional(),
jobs: packageRelativePathSchema.optional(), jobs: packageRelativePathSchema.optional(),
ui: packageRelativePathSchema.optional(), ui: packageRelativePathSchema.optional(),
display: moduleDisplayEntrypointSchema.optional(),
}) })
.strict(); .strict();
@@ -106,10 +118,9 @@ function reportDuplicates(
} }
/** /**
* The one manifest shape Core accepts. Adding a top-level `configuration` array is an incompatible * The one manifest shape Core accepts. Version 5's strict `entrypoints` object rejects `display`, so
* shape change — version 4's manifest object is `.strict()` and rejects `configuration` outright — * the static display declaration is an incompatible shape change and the canonical manifest moves
* so the canonical manifest moves to `manifestVersion: 5` rather than reinterpreting version 4 * to `manifestVersion: 6` rather than silently reinterpreting version 5.
* (ADR 0029).
* *
* 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
@@ -118,13 +129,13 @@ 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 4, and every other version, fails validation: there is no compatibility adapter, no * Version 5, 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), and version 3 by version 4 (ADR 0021). One
* manifest document can therefore never be read as two different versions. Upgrading a version 4 * manifest document can therefore never be read as two different versions. Upgrading a version 5
* manifest is a one-character edit: no field added by version 5 is required. * manifest is a one-character edit for a module that declares no display: the new field is optional.
*/ */
export const MODULE_MANIFEST_VERSION = 5; export const MODULE_MANIFEST_VERSION = 6;
export const moduleManifestSchema = z export const moduleManifestSchema = z
.object({ .object({
@@ -199,6 +210,39 @@ export const moduleManifestSchema = z
} }
}); });
const display = manifest.entrypoints.display;
if (display !== undefined) {
const displayPermission = manifest.permissions.find(
(permission) => permission.id === display.permission,
);
if (displayPermission === undefined) {
context.addIssue({
code: "custom",
message: "A display entrypoint must reference a permission declared by this module.",
path: ["entrypoints", "display", "permission"],
});
} else if (displayPermission.baseline === "public") {
context.addIssue({
code: "custom",
message: "A display entrypoint permission must require an authenticated principal.",
path: ["entrypoints", "display", "permission"],
});
}
if (
Object.entries(manifest.entrypoints).some(
([entrypointName, configured]) =>
entrypointName !== "display" && configured === display.artifact,
)
) {
context.addIssue({
code: "custom",
message:
"The display UI must use a different path from every other configured entrypoint.",
path: ["entrypoints", "display", "artifact"],
});
}
}
for (const dependencyKind of ["required", "optional"] as const) { for (const dependencyKind of ["required", "optional"] as const) {
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) { if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
context.addIssue({ context.addIssue({
@@ -253,6 +297,7 @@ 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>;
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
* here for discoverability alongside the manifest's other per-field declaration types. */ * here for discoverability alongside the manifest's other per-field declaration types. */
@@ -1,13 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
isVersionCompatible,
isModuleConfigurationBlockedReason, isModuleConfigurationBlockedReason,
isVersionCompatible,
MODULE_BLOCKED_REASONS, MODULE_BLOCKED_REASONS,
MODULE_CONFIGURATION_BLOCKED_REASONS, MODULE_CONFIGURATION_BLOCKED_REASONS,
MODULE_DEPENDENCY_KINDS, MODULE_DEPENDENCY_KINDS,
MODULE_STORAGE_BLOCKED_REASON,
MODULE_MANIFEST_VERSION, MODULE_MANIFEST_VERSION,
MODULE_RUNTIME_STATES, MODULE_RUNTIME_STATES,
MODULE_STORAGE_BLOCKED_REASON,
packageRelativePathSchema, packageRelativePathSchema,
parseModuleManifest, parseModuleManifest,
parsePermissionId, parsePermissionId,
@@ -16,7 +16,7 @@ import {
function validManifest(): Record<string, unknown> { function validManifest(): Record<string, unknown> {
return { return {
manifestVersion: 5, manifestVersion: 6,
id: "calendar", id: "calendar",
name: "Calendar", name: "Calendar",
version: "0.1.0", version: "0.1.0",
@@ -226,9 +226,15 @@ 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", () => {
const version5Manifest = { ...validManifest(), manifestVersion: 5 };
expect(safeParseModuleManifest(version5Manifest).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(5); expect(MODULE_MANIFEST_VERSION).toBe(6);
for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) { for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false); expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
} }
expect( expect(
@@ -391,6 +397,7 @@ describe("module manifest contract", () => {
const manifest = parseModuleManifest(validManifest()); const manifest = parseModuleManifest(validManifest());
expect(manifest.entrypoints.ui).toBeUndefined(); expect(manifest.entrypoints.ui).toBeUndefined();
expect(manifest.entrypoints.display).toBeUndefined();
expect(manifest.entrypoints.server).toBe("./dist/server/index.js"); expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
}); });
@@ -405,6 +412,153 @@ describe("module manifest contract", () => {
expect(manifest.entrypoints.server).toBe("./dist/server/index.js"); expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
}); });
it("accepts an independently declared display UI protected by a module-owned permission", () => {
const manifest = parseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "calendar.events.read",
},
},
});
expect(manifest.entrypoints.ui).toBeUndefined();
expect(manifest.entrypoints.display).toEqual({
artifact: "./dist/display/index.js",
permission: "calendar.events.read",
});
});
it("rejects a display permission that this module did not declare", () => {
const result = safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "calendar.display.view",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"entrypoints.display.permission",
);
}
});
it("rejects a display permission whose baseline would admit an anonymous principal", () => {
const result = safeParseModuleManifest({
...validManifest(),
permissions: [
{
id: "calendar.display.view",
baseline: "public",
description: "View the calendar display.",
},
],
entrypoints: {
server: "./dist/server/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "calendar.display.view",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"entrypoints.display.permission",
);
}
});
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
it(`rejects a display artifact that is also configured as the ${entrypointName} entrypoint`, () => {
const sharedPath = "./dist/shared.js";
const result = safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: entrypointName === "server" ? sharedPath : "./dist/server/index.js",
...(entrypointName === "server" ? {} : { [entrypointName]: sharedPath }),
display: {
artifact: sharedPath,
permission: "calendar.events.read",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"entrypoints.display.artifact",
);
}
});
}
it("rejects primary and display UI declarations that name the same artifact", () => {
const result = safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/ui/index.js",
permission: "calendar.events.read",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"entrypoints.display.artifact",
);
}
});
it.each([
"../outside/display.js",
"/absolute/display.js",
"./dist/../../display.js",
"file:///dist/display.js",
".\\dist\\display.js",
"./dist/display.js?raw",
"",
])("rejects the unsafe display artifact path %s", (artifact) => {
expect(
safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
display: { artifact, permission: "calendar.events.read" },
},
}).success,
).toBe(false);
});
it("rejects unknown properties in the static display declaration", () => {
expect(
safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "calendar.events.read",
public: true,
},
},
}).success,
).toBe(false);
});
it.each([ it.each([
["../outside/ui.js", "escapes the package boundary"], ["../outside/ui.js", "escapes the package boundary"],
["/absolute/ui.js", "is an absolute path"], ["/absolute/ui.js", "is an absolute path"],
+137 -37
View File
@@ -1,17 +1,18 @@
import { readFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { packageRelativePathSchema } from "@ekklesia/contracts"; import { moduleDisplayEntrypointSchema, packageRelativePathSchema } from "@ekklesia/contracts";
import { build, type Plugin } from "esbuild"; import { build, type Plugin } from "esbuild";
/** /**
* The one supported way to turn a module's React/TSX UI source into the compiled browser artifact * The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts
* its manifest declares as `entrypoints.ui` (ADR 0021, ADR 0022, ADR 0023). * its manifest declares as `entrypoints.ui` and `entrypoints.display`.
* *
* Module authors do not write bundler configuration. They put their UI at * Module authors do not write bundler configuration. They put their UI at
* {@link MODULE_UI_SOURCE_ENTRYPOINT}, declare `entrypoints.ui` in their manifest, and run the * the fixed role-specific source locations below, declare either or both artifacts, and run one
* build; every option below is owned here so one decision applies to every module. * build; every option below is owned here so one decision applies to every module.
*/ */
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_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. */
@@ -19,17 +20,21 @@ export const MODULE_UI_BUILD_TARGET = "es2022";
export type ModuleUiBuildErrorCode = export type ModuleUiBuildErrorCode =
| "MODULE_UI_MANIFEST_UNREADABLE" | "MODULE_UI_MANIFEST_UNREADABLE"
| "MODULE_UI_ENTRYPOINT_NOT_DECLARED"
| "MODULE_UI_ENTRYPOINT_UNSAFE" | "MODULE_UI_ENTRYPOINT_UNSAFE"
| "MODULE_UI_ENTRYPOINT_COLLISION"
| "MODULE_UI_SOURCE_MISSING" | "MODULE_UI_SOURCE_MISSING"
| "MODULE_UI_BUILD_FAILED"; | "MODULE_UI_BUILD_FAILED";
export type ModuleUiArtifactKind = "ui" | "display";
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.`,
MODULE_UI_ENTRYPOINT_NOT_DECLARED: `${MODULE_MANIFEST_FILENAME} declares no entrypoints.ui, so there is no module UI to build.`, MODULE_UI_ENTRYPOINT_UNSAFE:
MODULE_UI_ENTRYPOINT_UNSAFE: `entrypoints.ui must be a package-relative path to a compiled .js or .mjs artifact inside the module.`, "A module UI entrypoint must be a package-relative path to a compiled .js or .mjs artifact inside the module.",
MODULE_UI_SOURCE_MISSING: `Module UI source was not found at ${MODULE_UI_SOURCE_ENTRYPOINT}.`, MODULE_UI_ENTRYPOINT_COLLISION:
MODULE_UI_BUILD_FAILED: "The module UI bundle could not be produced.", "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_BUILD_FAILED: "A module UI bundle could not be produced.",
}); });
/** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by /** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by
@@ -37,11 +42,17 @@ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.f
* other Ekklesia error boundary. */ * other Ekklesia error boundary. */
export class ModuleUiBuildError extends Error { export class ModuleUiBuildError extends Error {
readonly code: ModuleUiBuildErrorCode; readonly code: ModuleUiBuildErrorCode;
/** Safe role vocabulary that lets a developer identify which declared artifact failed without
* exposing a filesystem path or raw manifest value. */
readonly kind?: ModuleUiArtifactKind;
constructor(code: ModuleUiBuildErrorCode) { constructor(code: ModuleUiBuildErrorCode, kind?: ModuleUiArtifactKind) {
super(errorMessages[code]); super(errorMessages[code]);
this.name = "ModuleUiBuildError"; this.name = "ModuleUiBuildError";
this.code = code; this.code = code;
if (kind !== undefined) {
this.kind = kind;
}
this.stack = `${this.name}: ${this.message}`; this.stack = `${this.name}: ${this.message}`;
} }
} }
@@ -83,9 +94,16 @@ export interface BuildModuleUiOptions {
} }
export interface ModuleUiBuildResult { export interface ModuleUiBuildResult {
/** Absolute path of the artifact actually written, which is exactly what the manifest declares. */ /** Built artifacts in stable role order: primary UI first, then display. Empty for backend-only
* modules, which makes the canonical command usable unchanged by every module package. */
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
}
export interface ModuleUiBuildArtifactResult {
readonly kind: ModuleUiArtifactKind;
/** Absolute path of the artifact actually written, exactly as the manifest declares. */
readonly outputFile: string; readonly outputFile: string;
/** Absolute path of the source entrypoint that produced it. */ /** Absolute path of the fixed source entrypoint that produced it. */
readonly sourceFile: string; readonly sourceFile: string;
} }
@@ -93,9 +111,12 @@ 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);
} }
/** The declared artifact path, validated the same way module discovery validates it, so the build type DeclaredModuleUiArtifact = ModuleUiBuildArtifactResult;
* can never write somewhere discovery would later reject. */
async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string> { /** Reads the inert manifest and returns every declared UI artifact in deterministic role order. */
async function resolveDeclaredArtifacts(
moduleDirectory: string,
): Promise<readonly DeclaredModuleUiArtifact[]> {
let manifest: unknown; let manifest: unknown;
try { try {
manifest = JSON.parse( manifest = JSON.parse(
@@ -106,20 +127,81 @@ async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string>
} }
const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined; const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined;
const declared = isRecord(entrypoints) ? entrypoints.ui : undefined; if (!isRecord(entrypoints)) {
if (declared === undefined) { throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_NOT_DECLARED");
} }
const display = entrypoints.display;
const declarations: readonly {
readonly declared: unknown;
readonly kind: ModuleUiArtifactKind;
readonly present: boolean;
readonly source: string;
}[] = [
{
declared: entrypoints.ui,
kind: "ui",
present: entrypoints.ui !== undefined,
source: MODULE_UI_SOURCE_ENTRYPOINT,
},
{
declared: isRecord(display) ? display.artifact : display,
kind: "display",
present: display !== undefined,
source: MODULE_DISPLAY_SOURCE_ENTRYPOINT,
},
];
const artifacts: DeclaredModuleUiArtifact[] = [];
for (const declaration of declarations) {
if (!declaration.present) {
continue;
}
const declared = declaration.declared;
// The path grammar is Core's, not this tool's: reusing the contracts schema means the build can // The path grammar is Core's, not this tool's: reusing the contracts schema means the build can
// never write to a location module discovery would later reject, even if that grammar changes. // never write somewhere discovery would later reject, even if that grammar changes.
if ( if (
!packageRelativePathSchema.safeParse(declared).success ||
typeof declared !== "string" || typeof declared !== "string" ||
!packageRelativePathSchema.safeParse(declared).success ||
![".js", ".mjs"].includes(path.extname(declared).toLowerCase()) ![".js", ".mjs"].includes(path.extname(declared).toLowerCase())
) { ) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE"); throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
} }
return path.resolve(moduleDirectory, declared); artifacts.push({
kind: declaration.kind,
outputFile: path.resolve(moduleDirectory, declared),
sourceFile: path.join(moduleDirectory, declaration.source),
});
}
if (display !== undefined && !moduleDisplayEntrypointSchema.safeParse(display).success) {
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
}
const displayArtifact = artifacts.find((artifact) => artifact.kind === "display");
if (displayArtifact !== undefined) {
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
const configured = entrypoints[entrypointName];
if (
typeof configured === "string" &&
path.resolve(moduleDirectory, configured) === displayArtifact.outputFile
) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", "display");
}
}
}
// Complete preflight before esbuild starts: a missing second source must not leave the first role's
// output looking like a successful package build.
for (const artifact of artifacts) {
try {
await readFile(artifact.sourceFile);
} catch {
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING", artifact.kind);
}
}
return artifacts;
} }
/** /**
@@ -130,17 +212,12 @@ async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string>
*/ */
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> { export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
const moduleDirectory = path.resolve(options.moduleDirectory); const moduleDirectory = path.resolve(options.moduleDirectory);
const outputFile = await resolveDeclaredArtifact(moduleDirectory); const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
const sourceFile = path.join(moduleDirectory, MODULE_UI_SOURCE_ENTRYPOINT); const pendingWrites: Array<readonly { path: string; contents: Uint8Array }[]> = [];
for (const artifact of artifacts) {
try { try {
await readFile(sourceFile); const result = await build({
} catch {
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING");
}
try {
await build({
absWorkingDir: moduleDirectory, absWorkingDir: moduleDirectory,
// Every dependency, React included, is bundled. The artifact must run in any host, including // Every dependency, React included, is bundled. The artifact must run in any host, including
// one that uses no React at all, so nothing is externalized (ADR 0023). // one that uses no React at all, so nothing is externalized (ADR 0023).
@@ -150,7 +227,7 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
// Node global that does not exist in a browser, and crash on load. // Node global that does not exist in a browser, and crash on load.
"process.env.NODE_ENV": '"production"', "process.env.NODE_ENV": '"production"',
}, },
entryPoints: [sourceFile], entryPoints: [artifact.sourceFile],
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract // ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
// requires a real default export. // requires a real default export.
format: "esm", format: "esm",
@@ -158,24 +235,47 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
// Unminified deliberately: an operator is expected to review the artifact they install // Unminified deliberately: an operator is expected to review the artifact they install
// (see docs/development/security.md), and nothing else in this repository minifies. // (see docs/development/security.md), and nothing else in this repository minifies.
minify: false, minify: false,
outfile: outputFile, outfile: artifact.outputFile,
platform: "browser", platform: "browser",
plugins: [rejectStylesheetImports()], plugins: [rejectStylesheetImports()],
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.
logLevel: "silent", logLevel: "silent",
// Compile every role successfully before writing any role. This makes a two-artifact build
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
write: false,
}); });
pendingWrites.push(
result.outputFiles.map((outputFile) => ({
contents: outputFile.contents,
path: outputFile.path,
})),
);
} catch (error) { } catch (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"), { throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind), {
diagnostics: error.errors, diagnostics: error.errors,
}); });
} }
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED"); throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind);
}
} }
return { outputFile, sourceFile }; for (const [index, outputs] of pendingWrites.entries()) {
try {
for (const output of outputs) {
await mkdir(path.dirname(output.path), { recursive: true });
await writeFile(output.path, output.contents);
}
} catch {
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifacts[index]?.kind);
}
}
return Object.freeze({
artifacts: Object.freeze(artifacts.map((artifact) => Object.freeze({ ...artifact }))),
});
} }
+13 -9
View File
@@ -11,24 +11,28 @@ function isRecord(value: unknown): value is Record<string, unknown> {
/** /**
* `ekklesia-build-module-ui` — the canonical module UI build command, run from a module directory. * `ekklesia-build-module-ui` — the canonical module UI build command, run from a module directory.
* *
* A module with no `entrypoints.ui` is not an error: backend-only modules stay first-class and this * A module with neither UI role is not an error: backend-only modules stay first-class and this
* command reports that there was nothing to build and exits successfully, so one `build` script * command reports that there was nothing to build and exits successfully. When one or both roles
* works for every module. * are declared, this one command builds exactly that set.
*/ */
async function main(): Promise<number> { async function main(): Promise<number> {
const moduleDirectory = process.argv[2] ?? process.cwd(); const moduleDirectory = process.argv[2] ?? process.cwd();
try { try {
const result = await buildModuleUi({ moduleDirectory }); const result = await buildModuleUi({ moduleDirectory });
process.stdout.write(`Built module UI: ${path.relative(moduleDirectory, result.outputFile)}\n`); if (result.artifacts.length === 0) {
process.stdout.write("No module UI entrypoints declared; skipping the module UI build.\n");
return 0;
}
for (const artifact of result.artifacts) {
process.stdout.write(
`Built module UI (${artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
);
}
return 0; return 0;
} catch (error) { } catch (error) {
if (error instanceof ModuleUiBuildError) { if (error instanceof ModuleUiBuildError) {
if (error.code === "MODULE_UI_ENTRYPOINT_NOT_DECLARED") { process.stderr.write(`${error.kind ? `${error.kind}: ` : ""}${error.message}\n`);
process.stdout.write("No entrypoints.ui declared; skipping the module UI build.\n");
return 0;
}
process.stderr.write(`${error.message}\n`);
// esbuild's own diagnostics carry the file, line, and reason a developer needs. They are // esbuild's own diagnostics carry the file, line, and reason a developer needs. They are
// printed here, never folded into the sanitized error message itself. // printed here, never folded into the sanitized error message itself.
const diagnostics = isRecord(error) ? error.diagnostics : undefined; const diagnostics = isRecord(error) ? error.diagnostics : undefined;
@@ -17,6 +17,12 @@ afterEach(async () => {
interface ModuleFixtureOptions { interface ModuleFixtureOptions {
/** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */ /** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */
readonly ui?: string; readonly ui?: string;
readonly schema?: string;
readonly migrations?: string;
readonly jobs?: string;
readonly server?: string;
/** The complete inert `entrypoints.display` value, including its permission policy. */
readonly display?: unknown;
readonly manifest?: string; readonly manifest?: string;
readonly files?: Record<string, string>; readonly files?: Record<string, string>;
} }
@@ -28,14 +34,18 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
const manifest = const manifest =
options.manifest ?? options.manifest ??
JSON.stringify({ JSON.stringify({
manifestVersion: 5, manifestVersion: 6,
id: "example", id: "example",
name: "Example", name: "Example",
version: "0.1.0", version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
entrypoints: { entrypoints: {
server: "./dist/server/index.js", server: options.server ?? "./dist/server/index.js",
...(options.schema === undefined ? {} : { schema: options.schema }),
...(options.migrations === undefined ? {} : { migrations: options.migrations }),
...(options.jobs === undefined ? {} : { jobs: options.jobs }),
...(options.ui === undefined ? {} : { ui: options.ui }), ...(options.ui === undefined ? {} : { ui: options.ui }),
...(options.display === undefined ? {} : { display: options.display }),
}, },
}); });
await writeFile(path.join(directory, "ekklesia.module.json"), manifest); await writeFile(path.join(directory, "ekklesia.module.json"), manifest);
@@ -73,9 +83,14 @@ describe("module UI build", () => {
}); });
const result = await buildModuleUi({ moduleDirectory: directory }); const result = await buildModuleUi({ moduleDirectory: directory });
const artifactResult = result.artifacts[0];
expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js")); expect(result.artifacts).toHaveLength(1);
const artifact = await readFile(result.outputFile, "utf8"); expect(artifactResult).toMatchObject({
kind: "ui",
outputFile: path.join(directory, "dist", "ui", "index.js"),
});
const artifact = await readFile(artifactResult?.outputFile ?? "", "utf8");
expect(artifact).toContain("export {"); expect(artifact).toContain("export {");
expect(artifact).toContain("as default"); expect(artifact).toContain("as default");
}); });
@@ -88,7 +103,9 @@ describe("module UI build", () => {
const result = await buildModuleUi({ moduleDirectory: directory }); const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.outputFile).toBe(path.join(directory, "build", "browser", "ui.mjs")); expect(result.artifacts[0]?.outputFile).toBe(
path.join(directory, "build", "browser", "ui.mjs"),
);
}); });
it("emits a source map alongside the artifact", async () => { it("emits a source map alongside the artifact", async () => {
@@ -99,15 +116,15 @@ describe("module UI build", () => {
const result = await buildModuleUi({ moduleDirectory: directory }); const result = await buildModuleUi({ moduleDirectory: directory });
await expect(readFile(`${result.outputFile}.map`, "utf8")).resolves.toContain("sources"); await expect(readFile(`${result.artifacts[0]?.outputFile}.map`, "utf8")).resolves.toContain(
"sources",
);
}); });
it("reports a backend-only module as having no UI to build rather than failing silently", async () => { it("returns an explicit empty result for a backend-only module", async () => {
const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } }); const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({ await expect(buildModuleUi({ moduleDirectory: directory })).resolves.toEqual({ artifacts: [] });
code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED",
});
}); });
it("fails when the declared UI has no source entrypoint", async () => { it("fails when the declared UI has no source entrypoint", async () => {
@@ -115,9 +132,134 @@ describe("module UI build", () => {
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({ await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_SOURCE_MISSING", code: "MODULE_UI_SOURCE_MISSING",
kind: "ui",
}); });
}); });
it("builds a display-only module from the fixed display source", async () => {
const directory = await createModule({
display: {
artifact: "./dist/display/index.js",
permission: "example.display.view",
},
files: { "src/display/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.artifacts).toEqual([
{
kind: "display",
outputFile: path.join(directory, "dist/display/index.js"),
sourceFile: path.join(directory, "src/display/index.tsx"),
},
]);
await expect(readFile(result.artifacts[0]?.outputFile ?? "", "utf8")).resolves.toContain(
"as default",
);
});
it("builds primary and display artifacts in deterministic role order", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/display/index.mjs",
permission: "example.display.view",
},
files: {
"src/ui/index.tsx": plainUiSource.replaceAll("example", "primary"),
"src/display/index.tsx": plainUiSource.replaceAll("example", "display"),
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
await expect(readFile(path.join(directory, "dist/ui/index.js"), "utf8")).resolves.toContain(
'"primary"',
);
await expect(
readFile(path.join(directory, "dist/display/index.mjs"), "utf8"),
).resolves.toContain('"display"');
});
it("preflights every declared source before writing either artifact", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "example.display.view",
},
files: { "src/ui/index.tsx": plainUiSource },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_SOURCE_MISSING",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/ui/index.js"))).rejects.toThrow();
});
it("writes neither role when the second declared bundle fails to compile", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "example.display.view",
},
files: {
"src/ui/index.tsx": plainUiSource,
"src/display/index.tsx": "export default { broken:",
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_BUILD_FAILED",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/ui/index.js"))).rejects.toThrow();
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
});
it("rejects colliding primary and display output paths before building", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/ui/index.js",
permission: "example.display.view",
},
files: {
"src/ui/index.tsx": plainUiSource,
"src/display/index.tsx": plainUiSource,
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_COLLISION",
kind: "display",
});
});
for (const entrypointName of ["server", "schema", "migrations", "jobs"] as const) {
it(`rejects a display output that would overwrite the declared ${entrypointName} artifact before building`, async () => {
const sharedPath = "./dist/display/index.js";
const directory = await createModule({
[entrypointName]: sharedPath,
display: {
artifact: sharedPath,
permission: "example.display.view",
},
files: { "src/display/index.tsx": plainUiSource },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_COLLISION",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
});
}
it("fails when the manifest cannot be read", async () => { it("fails when the manifest cannot be read", async () => {
const directory = await createModule({ manifest: "{ not json" }); const directory = await createModule({ manifest: "{ not json" });
@@ -138,9 +280,38 @@ describe("module UI build", () => {
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({ await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_UNSAFE", code: "MODULE_UI_ENTRYPOINT_UNSAFE",
kind: "ui",
}); });
}); });
it("applies the same safe compiled-output rules to display artifacts", async () => {
const directory = await createModule({
display: {
artifact: "./src/display/index.tsx",
permission: "example.display.view",
},
files: { "src/display/index.tsx": plainUiSource },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
kind: "display",
});
});
it("does not reinterpret a bare display path as the version 6 display declaration", async () => {
const directory = await createModule({
display: "./dist/display/index.js",
files: { "src/display/index.tsx": plainUiSource },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_MANIFEST_UNREADABLE",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
});
it("surfaces a build failure with esbuild's diagnostics attached", async () => { it("surfaces a build failure with esbuild's diagnostics attached", async () => {
const directory = await createModule({ const directory = await createModule({
ui: "./dist/ui/index.js", ui: "./dist/ui/index.js",
@@ -212,6 +383,25 @@ describe("module UI build", () => {
).rejects.toThrow(); ).rejects.toThrow();
}); });
it("enforces the stylesheet boundary for display UI too", async () => {
const directory = await createModule({
display: {
artifact: "./dist/display/index.js",
permission: "example.display.view",
},
files: {
"src/display/index.tsx": 'import "./styles.css";\nexport default {};',
"src/display/styles.css": ".display { position: fixed; }",
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_BUILD_FAILED",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
});
it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => { it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => {
const directory = await createModule({ const directory = await createModule({
ui: "./dist/ui/index.js", ui: "./dist/ui/index.js",
@@ -222,7 +412,7 @@ describe("module UI build", () => {
}); });
const result = await buildModuleUi({ moduleDirectory: directory }); const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.outputFile, "utf8"); const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
expect(artifact).toContain('"production"'); expect(artifact).toContain('"production"');
expect(artifact).not.toContain("process.env.NODE_ENV"); expect(artifact).not.toContain("process.env.NODE_ENV");
@@ -9,11 +9,12 @@ import {
* How a host obtains a module's UI artifact. * How a host obtains a module's UI artifact.
* *
* `url` is a **browser-loadable module URL**, not a filesystem path. That distinction is the whole * `url` is a **browser-loadable module URL**, not a filesystem path. That distinction is the whole
* reason this type exists. Core's module discovery resolves `entrypoints.ui` to an absolute path on * reason this type exists. Core's module discovery resolves each primary or display entrypoint to an
* the server's disk (`ResolvedModuleEntrypoints.ui`); a browser cannot import that, and a host that * absolute path on the server's disk; a browser cannot import that, and a host that passed one here
* passed one here would fail at run time in a way no test on a server would catch. Turning an * would fail at run time in a way no test on a server would catch. Turning an installed artifact
* installed artifact into a URL a browser may fetch is **asset publication**, a separate concern * into a URL a browser may fetch is **asset publication**, a separate concern this generic package
* this package deliberately does not own and Ekklesia does not implement yet (ADR 0024). * deliberately does not own. Ekklesia implements that concern for both roles at its API edge (ADR
* 0025, ADR 0033).
* *
* `moduleId` and `version` are the identity the host already read from the module's validated * `moduleId` and `version` are the identity the host already read from the module's validated
* manifest. They are checked against the artifact before anything is mounted. * manifest. They are checked against the artifact before anything is mounted.
@@ -1,5 +1,5 @@
{ {
"manifestVersion": 5, "manifestVersion": 6,
"id": "example", "id": "example",
"name": "Example", "name": "Example",
"version": "0.1.0", "version": "0.1.0",
@@ -56,7 +56,11 @@ function standaloneContext(requested: string[]): ModuleUiHostContext {
describe("loading a real compiled artifact", () => { describe("loading a real compiled artifact", () => {
beforeAll(async () => { beforeAll(async () => {
const result = await buildModuleUi({ moduleDirectory: fixtureDirectory }); const result = await buildModuleUi({ moduleDirectory: fixtureDirectory });
artifactSource = await readFile(result.outputFile, "utf8"); const primaryArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
if (!primaryArtifact) {
throw new Error("The real-artifact fixture must declare a primary UI artifact.");
}
artifactSource = await readFile(primaryArtifact.outputFile, "utf8");
artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`; artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`;
}, 60_000); }, 60_000);
+3 -1
View File
@@ -41,7 +41,9 @@ export type ModuleUiPrincipal = ModuleUiAnonymousPrincipal | ModuleUiAuthenticat
* than to add. See ADR 0022. * than to add. See ADR 0022.
* *
* 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. This package has * host that never runs Core, which is what makes one module UI hostable by both. The same context is
* supplied to primary and display artifacts: there is deliberately no role or presentation-mode
* flag, because the separately declared artifact already selects the experience. This package has
* no dependencies at all, so importing it cannot pull a backend type graph into a browser program. * no dependencies at all, so importing it cannot pull a backend type graph into a browser program.
*/ */
export interface ModuleUiHostContext { export interface ModuleUiHostContext {
+5 -3
View File
@@ -11,7 +11,8 @@ export type Awaitable<Value> = Value | PromiseLike<Value>;
export type ModuleUiUnmount = () => Awaitable<void>; export type ModuleUiUnmount = () => Awaitable<void>;
/** /**
* What a module's compiled `entrypoints.ui` artifact default-exports (ADR 0021, ADR 0022). * What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
* 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
@@ -115,8 +116,9 @@ export interface ModuleUiIdentity {
* Deliberately does **not** import anything itself. Each host performs its own dynamic import — a * Deliberately does **not** import anything itself. Each host performs its own dynamic import — a
* browser host from a served URL, a test host from a file URL — and passes the resulting module * browser host from a served URL, a test host from a file URL — and passes the resulting module
* namespace here. That keeps this package free of any loader, bundler, or URL assumption, and keeps * namespace here. That keeps this package free of any loader, bundler, or URL assumption, and keeps
* Core out of the business of evaluating browser code entirely: module discovery still only * Core out of the business of evaluating browser code entirely: module discovery only resolves
* resolves and validates the `entrypoints.ui` path and never executes it (ADR 0021). * and validates each declared browser artifact path and never executes either role (ADR 0021,
* ADR 0033).
*/ */
export function parseModuleUiExport( export function parseModuleUiExport(
namespace: unknown, namespace: unknown,
@@ -14,8 +14,8 @@ async function sourceFiles(): Promise<string[]> {
* This package is bundled into a browser. `@ekklesia/module-sdk` re-exports all of * This package is bundled into a browser. `@ekklesia/module-sdk` re-exports all of
* `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel export, so a single * `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel export, so a single
* value import from a backend package here would drag that whole graph into every module UI bundle * value import from a backend package here would drag that whole graph into every module UI bundle
* (ADR 0022). The dependency on `@ekklesia/contracts` is deliberately type-only and must stay that * (ADR 0022). ADR 0024 removed even the former type-only contracts dependency because its type graph
* way; `import type` is erased at build time, a plain `import` is not. * leaked Node globals into browser programs; this package must remain completely dependency-free.
*/ */
describe("module UI SDK stays browser-safe", () => { describe("module UI SDK stays browser-safe", () => {
it("imports nothing from another package at all, at type level or runtime", async () => { it("imports nothing from another package at all, at type level or runtime", async () => {
+3 -3
View File
@@ -42,6 +42,9 @@ importers:
'@ekklesia/core': '@ekklesia/core':
specifier: workspace:* specifier: workspace:*
version: link:../../core version: link:../../core
'@ekklesia/shell':
specifier: workspace:*
version: link:../../surfaces/shell
'@hono/node-server': '@hono/node-server':
specifier: 2.0.10 specifier: 2.0.10
version: 2.0.10([email protected]) version: 2.0.10([email protected])
@@ -55,9 +58,6 @@ importers:
'@ekklesia/module-ui-host': '@ekklesia/module-ui-host':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/module-ui-host version: link:../../packages/module-ui-host
'@ekklesia/shell':
specifier: workspace:*
version: link:../../surfaces/shell
'@ekklesia/typescript-config': '@ekklesia/typescript-config':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/typescript-config version: link:../../packages/typescript-config
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" style="height: 100%; margin: 0; overflow: hidden">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<title>Display</title>
</head>
<body style="height: 100%; margin: 0; overflow: hidden">
<!-- Structural canvas only. The module owns every visible pixel after mounting. -->
<div id="ekklesia-display" style="height: 100%; min-height: 100dvh; overflow: hidden"></div>
<!-- Fixed host asset, never a module-derived path and never an inline bootstrap script. -->
<script src="/assets/ekklesia-display/app.js" type="module"></script>
</body>
</html>
+4
View File
@@ -10,6 +10,10 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
},
"./display-assets": {
"types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js"
} }
}, },
"scripts": { "scripts": {
+10 -2
View File
@@ -41,14 +41,17 @@ const buildOptions = {
// React reads this at module scope and a browser has no `process`. // React reads this at module scope and a browser has no `process`.
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"), "process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
}, },
entryPoints: [path.join(packageDirectory, "src", "browser", "main.tsx")], entryPoints: {
app: path.join(packageDirectory, "src", "browser", "main.tsx"),
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
},
format: "esm", format: "esm",
jsx: "automatic", jsx: "automatic",
logLevel: "info", logLevel: "info",
// Unminified, matching the module UI build: an operator is expected to be able to read what their // Unminified, matching the module UI build: an operator is expected to be able to read what their
// installation actually serves, and nothing else in this repository minifies. // installation actually serves, and nothing else in this repository minifies.
minify: false, minify: false,
outfile: path.join(outputDirectory, "app.js"), outdir: outputDirectory,
platform: "browser", platform: "browser",
sourcemap: true, sourcemap: true,
// 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.
@@ -57,7 +60,12 @@ const buildOptions = {
await rm(outputDirectory, { force: true, recursive: true }); await rm(outputDirectory, { force: true, recursive: true });
await mkdir(outputDirectory, { recursive: true }); await mkdir(outputDirectory, { recursive: true });
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html")); await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
await copyFile(
path.join(packageDirectory, "display.html"),
path.join(outputDirectory, "display", "index.html"),
);
if (serve) { if (serve) {
const context = await esbuild.context(buildOptions); const context = await esbuild.context(buildOptions);
+14
View File
@@ -0,0 +1,14 @@
/**
* Exact built files a deployable app may publish for the Ekklesia display host.
*
* They are file URLs relative to this package's compiled `dist/index.js`, not repository paths and
* not request-derived selectors. `apps/api` reads these exact two files before binding and serves
* their bytes at fixed host-owned routes; the module display artifact remains a separate, validated
* publication. Nothing here exposes a directory or a generic static-file root.
*/
const packageRoot = new URL("../", import.meta.url);
export const displayHostAssetUrls = Object.freeze({
html: new URL("dist/browser/display/index.html", packageRoot).href,
script: new URL("dist/browser/display/app.js", packageRoot).href,
});
+152
View File
@@ -0,0 +1,152 @@
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react";
import {
bootstrapDisplayHost,
type DisplayHostBootstrap,
type EkklesiaHostConfig,
HostProtocolError,
} from "../host-protocol.js";
import { DisplayUiFrame } from "./DisplayUiFrame.js";
type DisplayState =
| { readonly status: "loading"; readonly generation: number }
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
| { readonly status: "signed-out" }
| { readonly status: "unavailable" }
| { readonly status: "failed" };
export interface DisplayAppProps {
readonly config: EkklesiaHostConfig;
readonly moduleId: string;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter;
}
const displayStyles = {
host: {
height: "100%",
minHeight: "100dvh",
minWidth: 0,
overflow: "hidden",
width: "100%",
},
message: {
alignItems: "center",
color: "#57534e",
display: "flex",
flexDirection: "column",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
height: "100%",
justifyContent: "center",
margin: 0,
minHeight: "100dvh",
padding: "1rem",
textAlign: "center",
},
retry: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
},
} as const;
function DisplayMessage(props: {
readonly children: string;
readonly testId: string;
readonly onRetry?: () => void;
}): ReactElement {
return (
<div aria-live="polite" data-testid={props.testId} style={displayStyles.message}>
<p style={{ margin: 0 }}>{props.children}</p>
{props.onRetry ? (
<button onClick={props.onRetry} style={displayStyles.retry} type="button">
Retry
</button>
) : undefined}
</div>
);
}
/**
* The host for one display-capable module, with no Dashboard application mounted around it.
*
* Bootstrap is principal-first, so an anonymous browser never probes a module descriptor. The
* descriptor itself is permission-protected server-side; only after it succeeds can an artifact URL
* reach the generic loader. A changed `moduleId` or retry aborts the old fetch and keys a new frame,
* so neither a late descriptor nor a late import can replace the current display.
*/
export function DisplayApp(props: DisplayAppProps): ReactElement {
const [state, setState] = useState<DisplayState>({ generation: 0, status: "loading" });
const [attempt, setAttempt] = useState(0);
const { config, importModule, moduleId } = props;
useEffect(() => {
const abort = new AbortController();
setState({ generation: attempt, status: "loading" });
void bootstrapDisplayHost(config, moduleId, abort.signal)
.then((bootstrap) => {
if (abort.signal.aborted) {
return;
}
setState(
bootstrap.status === "signed-out"
? { status: "signed-out" }
: { bootstrap, status: "ready" },
);
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
return;
}
console.error("[ekklesia] display host bootstrap failed", error);
setState(
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "DISPLAY_NOT_AVAILABLE")
? { status: "unavailable" }
: error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED"
? { status: "signed-out" }
: { status: "failed" },
);
});
return () => abort.abort();
}, [attempt, config, moduleId]);
const retry = (): void => setAttempt((current) => current + 1);
return (
<div data-testid="display-host" style={displayStyles.host}>
{state.status === "loading" ? (
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
) : undefined}
{state.status === "signed-out" ? (
<DisplayMessage onRetry={retry} testId="display-host-signed-out">
Sign in to use this display.
</DisplayMessage>
) : undefined}
{state.status === "unavailable" ? (
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
This display is unavailable.
</DisplayMessage>
) : undefined}
{state.status === "failed" ? (
<DisplayMessage onRetry={retry} testId="display-host-error">
This display could not be reached.
</DisplayMessage>
) : undefined}
{state.status === "ready" ? (
<DisplayUiFrame
config={config}
descriptor={state.bootstrap.descriptor}
{...(importModule ? { importModule } : {})}
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
principal={state.bootstrap.principal}
/>
) : undefined}
</div>
);
}
@@ -0,0 +1,233 @@
import {
loadModuleUi,
ModuleUiHostError,
type ModuleUiHostErrorCode,
type ModuleUiImporter,
} from "@ekklesia/module-ui-host";
import {
ModuleUiContractError,
type ModuleUiContractErrorCode,
type ModuleUiPrincipal,
} from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
type FrameStatus = "loading" | "ready" | "failed";
type DisplayUiFailureCode =
| ModuleUiContractErrorCode
| ModuleUiHostErrorCode
| "DISPLAY_UI_LOAD_FAILED"
| "DISPLAY_UI_RUNTIME_ERROR"
| "DISPLAY_UI_UNHANDLED_REJECTION";
function loadFailureCode(error: unknown): DisplayUiFailureCode {
if (error instanceof ModuleUiHostError || error instanceof ModuleUiContractError) {
return error.code;
}
return "DISPLAY_UI_LOAD_FAILED";
}
export interface DisplayUiFrameProps {
readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter;
}
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
readonly onRetry: () => void;
}
const frameStyles = {
container: {
height: "100%",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
width: "100%",
},
fallback: {
alignItems: "center",
color: "#57534e",
display: "flex",
flexDirection: "column",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
inset: 0,
justifyContent: "center",
padding: "1rem",
position: "absolute",
textAlign: "center",
},
frame: {
height: "100%",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
position: "relative",
width: "100%",
},
message: { margin: 0 },
retry: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
},
} as const;
/**
* One disposable display mount attempt.
*
* Its container is never reused by another attempt. A dynamic import cannot be aborted, so this is
* what makes a late result harmless: cleanup detaches this whole attempt; if the import later
* resolves, it mounts only into that detached element and is immediately unmounted. A failed mount
* may have written partial DOM before throwing, and the generic loader intentionally leaves that to
* its host, so this boundary always clears the container before exposing its fixed failure state.
*/
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, onRetry, principal } = props;
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
let cancelled = false;
let failed = false;
let listening = true;
let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined;
setStatus("loading");
const stopListening = (): void => {
if (!listening) {
return;
}
listening = false;
window.removeEventListener("error", handlePageError);
window.removeEventListener("unhandledrejection", handleUnhandledRejection);
};
const failSafely = (code: DisplayUiFailureCode): void => {
if (cancelled || failed) {
return;
}
failed = true;
stopListening();
container.replaceChildren();
console.error(`[ekklesia] display UI failure module=${descriptor.id} code=${code}`);
setStatus("failed");
const cleanup = mounted?.unmount();
if (cleanup) {
void cleanup.catch(() => undefined).finally(() => container.replaceChildren());
}
};
// A display route is a dedicated, single-module page. Treat any uncaught page exception or
// rejection as fatal for that display attempt: browser frameworks may report render failures
// after their mount call has already returned, beyond the generic loader's synchronous/awaited
// boundary. This is intentionally page-wide rather than error attribution; there is no robust
// cross-framework way to map a global exception back to a particular component tree.
function handlePageError(event: ErrorEvent): void {
// Element-targeted `error` events represent resource/media failures. Those are module state,
// not a crashed runtime, and must not blank a whole display.
if (event.target !== event.currentTarget) {
return;
}
event.preventDefault();
failSafely("DISPLAY_UI_RUNTIME_ERROR");
}
function handleUnhandledRejection(event: PromiseRejectionEvent): void {
event.preventDefault();
failSafely("DISPLAY_UI_UNHANDLED_REJECTION");
}
window.addEventListener("error", handlePageError);
window.addEventListener("unhandledrejection", handleUnhandledRejection);
void (async () => {
try {
const instance = await loadModuleUi({
container,
context: createModuleUiContext({ config, descriptor, principal }),
...(importModule ? { importModule } : {}),
source: {
moduleId: descriptor.id,
url: moduleUiSourceUrl(config, descriptor),
version: descriptor.version,
},
});
if (cancelled || failed) {
await instance.unmount().catch(() => undefined);
container.replaceChildren();
return;
}
mounted = instance;
setStatus("ready");
} catch (error) {
failSafely(loadFailureCode(error));
}
})();
return () => {
cancelled = true;
stopListening();
const cleanup = mounted?.unmount();
if (cleanup) {
void cleanup.catch(() => undefined).finally(() => container.replaceChildren());
} else {
container.replaceChildren();
}
};
}, [config, descriptor, importModule, principal]);
return (
<section aria-live="polite" data-testid="display-frame" style={frameStyles.frame}>
<div
data-testid="display-module-container"
ref={containerRef}
style={frameStyles.container}
/>
{status === "loading" ? (
<div data-testid="display-loading" style={frameStyles.fallback}>
<p style={frameStyles.message}>Loading display…</p>
</div>
) : undefined}
{status === "failed" ? (
<div data-testid="display-error" style={frameStyles.fallback}>
<p style={frameStyles.message}>This display could not be loaded.</p>
<button
data-testid="display-retry"
onClick={onRetry}
style={frameStyles.retry}
type="button"
>
Retry
</button>
</div>
) : undefined}
</section>
);
}
/** Full-viewport module frame with a fresh container for every manual retry. */
export function DisplayUiFrame(props: DisplayUiFrameProps): ReactElement {
const [attempt, setAttempt] = useState(0);
return (
<DisplayUiAttempt
{...props}
key={attempt}
onRetry={() => setAttempt((current) => current + 1)}
/>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js";
declare const __EKKLESIA_API_BASE_URL__: string;
const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
};
const container = document.getElementById("ekklesia-display");
if (!container) {
throw new Error("The Ekklesia display host requires a #ekklesia-display element.");
}
let moduleId: string | undefined;
try {
moduleId = parseDisplayRoute(window.location.pathname);
} catch {
moduleId = undefined;
}
createRoot(container).render(
moduleId ? (
<DisplayApp config={config} moduleId={moduleId} />
) : (
<div
data-testid="display-route-invalid"
style={{
alignItems: "center",
color: "#57534e",
display: "flex",
fontFamily: "system-ui, sans-serif",
height: "100%",
justifyContent: "center",
minHeight: "100dvh",
padding: "1rem",
textAlign: "center",
}}
>
This display is unavailable.
</div>
),
);
+201 -2
View File
@@ -35,6 +35,12 @@ export type HostEventSourceFactory = (url: string, options: HostEventSourceOptio
* code for a consumer that does not exist. */ * code for a consumer that does not exist. */
const PRINCIPAL_PATH = "/api/v1/principal"; const PRINCIPAL_PATH = "/api/v1/principal";
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation"; const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
const MODULE_DISPLAYS_PATH = "/api/v1/module-displays";
const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const MODULE_ID_MAX_LENGTH = 64;
const SEMANTIC_VERSION_PATTERN =
/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/u;
export type HostProtocolErrorCode = export type HostProtocolErrorCode =
/** The caller is not signed in, or lacks `core.modules.ui.read`. */ /** The caller is not signed in, or lacks `core.modules.ui.read`. */
@@ -46,7 +52,13 @@ export type HostProtocolErrorCode =
/** A module UI asked for something outside its own API root. */ /** A module UI asked for something outside its own API root. */
| "MODULE_API_PATH_INVALID" | "MODULE_API_PATH_INVALID"
/** A module UI asked for a live channel outside the public local-channel grammar. */ /** A module UI asked for a live channel outside the public local-channel grammar. */
| "MODULE_REALTIME_CHANNEL_INVALID"; | "MODULE_REALTIME_CHANNEL_INVALID"
/** The browser was not opened at one exact, valid display route. */
| "DISPLAY_ROUTE_INVALID"
/** The requested module does not currently publish a display. */
| "DISPLAY_NOT_AVAILABLE"
/** The signed-in principal may not mount the requested display. */
| "DISPLAY_ACCESS_DENIED";
const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
HOST_REQUEST_FAILED: "Ekklesia could not be reached.", HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
@@ -54,6 +66,9 @@ const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.fr
HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.", HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.",
MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.", MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.",
MODULE_REALTIME_CHANNEL_INVALID: "A module asked for an invalid live channel.", MODULE_REALTIME_CHANNEL_INVALID: "A module asked for an invalid live channel.",
DISPLAY_ACCESS_DENIED: "You do not have access to this display.",
DISPLAY_NOT_AVAILABLE: "This display is not available.",
DISPLAY_ROUTE_INVALID: "This is not a valid display address.",
}); });
/** /**
@@ -103,6 +118,23 @@ function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined; return typeof value === "string" && value.length > 0 ? value : undefined;
} }
function hasExactKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
if (!isRecord(value)) {
return false;
}
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function isModuleId(value: string): boolean {
return value.length <= MODULE_ID_MAX_LENGTH && MODULE_ID_PATTERN.test(value);
}
function isSemanticVersion(value: string): boolean {
return SEMANTIC_VERSION_PATTERN.test(value);
}
function nestedString(value: unknown, key: string): string | undefined { function nestedString(value: unknown, key: string): string | undefined {
return isRecord(value) ? nonEmptyString(value[key]) : undefined; return isRecord(value) ? nonEmptyString(value[key]) : undefined;
} }
@@ -129,6 +161,26 @@ function rootRelativePath(value: unknown, key: string): string | undefined {
) { ) {
return undefined; return undefined;
} }
const segments = candidate.slice(1).split("/");
if (segments.some((segment) => segment.length === 0)) {
return undefined;
}
try {
for (const segment of segments) {
const decoded = decodeURIComponent(segment);
if (
decoded === "." ||
decoded === ".." ||
decoded.includes("/") ||
decoded.includes("\\") ||
decoded.includes("\0")
) {
return undefined;
}
}
} catch {
return undefined;
}
return candidate; return candidate;
} }
@@ -166,6 +218,92 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
}); });
} }
/**
* The display host's one-module response.
*
* The wire names the artifact `display`, not `ui`, so a server cannot accidentally hand the
* Dashboard artifact to the full-viewport host and still pass validation. Once validated, the
* result deliberately narrows to the same generic mount descriptor every host adapter already
* consumes: the generic loader neither knows nor needs to know which outer shell selected it.
*/
export interface HostModuleDisplayResponse {
readonly module: HostModuleUiDescriptor;
}
/** Strictly validates the display descriptor response, including every nested URL container. */
export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayResponse {
if (!hasExactKeys(payload, ["module"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const entry = payload.module;
if (
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
!hasExactKeys(entry.api, ["baseUrl"]) ||
!hasExactKeys(entry.display, ["url"]) ||
!hasExactKeys(entry.realtime, ["baseUrl"])
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const id = nonEmptyString(entry.id);
const version = nonEmptyString(entry.version);
const apiBaseUrl = rootRelativePath(entry.api, "baseUrl");
const uiUrl = rootRelativePath(entry.display, "url");
const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl");
const permissions = entry.permissions;
if (
id === undefined ||
!isModuleId(id) ||
version === undefined ||
!isSemanticVersion(version) ||
apiBaseUrl === undefined ||
uiUrl === undefined ||
realtimeBaseUrl === undefined ||
!Array.isArray(permissions) ||
permissions.some((permission) => typeof permission !== "string" || permission.length === 0) ||
new Set(permissions).size !== permissions.length
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
module: Object.freeze({
apiBaseUrl,
id,
permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
uiUrl,
version,
}),
});
}
/** Reads one exact `/display/<module-id>` pathname without accepting a trailing path or traversal. */
export function parseDisplayRoute(pathname: string): string {
const match = /^\/display\/([^/]+)$/u.exec(pathname);
if (!match?.[1]) {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
let moduleId: string;
try {
moduleId = decodeURIComponent(match[1]);
} catch {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
if (!isModuleId(moduleId)) {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
return moduleId;
}
/** The only platform path a display browser builds, from a module id already validated above. */
export function moduleDisplayDescriptorPath(moduleId: string): string {
if (!isModuleId(moduleId)) {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
return `${MODULE_DISPLAYS_PATH}/${encodeURIComponent(moduleId)}`;
}
/** /**
* One module as the Dashboard presents it. * One module as the Dashboard presents it.
* *
@@ -271,6 +409,10 @@ async function requestPlatformJson(
config: EkklesiaHostConfig, config: EkklesiaHostConfig,
rootRelativePath: string, rootRelativePath: string,
signal?: AbortSignal, signal?: AbortSignal,
statusCodes: {
readonly forbidden?: HostProtocolErrorCode;
readonly notFound?: HostProtocolErrorCode;
} = {},
): Promise<unknown> { ): Promise<unknown> {
const performRequest = config.fetch ?? fetch; const performRequest = config.fetch ?? fetch;
let response: Response; let response: Response;
@@ -283,9 +425,15 @@ async function requestPlatformJson(
} catch (error) { } catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error }); throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
} }
if (response.status === 401 || response.status === 403) { if (response.status === 401) {
throw new HostProtocolError("HOST_UNAUTHENTICATED"); throw new HostProtocolError("HOST_UNAUTHENTICATED");
} }
if (response.status === 403) {
throw new HostProtocolError(statusCodes.forbidden ?? "HOST_UNAUTHENTICATED");
}
if (response.status === 404 && statusCodes.notFound) {
throw new HostProtocolError(statusCodes.notFound);
}
if (!response.ok) { if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED"); throw new HostProtocolError("HOST_REQUEST_FAILED");
} }
@@ -316,6 +464,57 @@ export async function loadDashboardNavigation(
); );
} }
/**
* Resolves the one display the outer host route named.
*
* The module id chooses only this host-owned descriptor resource. It never chooses a file: the
* response supplies the validated display artifact URL, API root, and realtime root, and the
* strict parser above rejects anything absolute, protocol-relative, or structurally ambiguous.
*/
export async function loadModuleDisplay(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<HostModuleUiDescriptor> {
const payload = await requestPlatformJson(config, moduleDisplayDescriptorPath(moduleId), signal, {
forbidden: "DISPLAY_ACCESS_DENIED",
notFound: "DISPLAY_NOT_AVAILABLE",
});
const descriptor = parseHostModuleDisplay(payload).module;
if (descriptor.id !== moduleId) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return descriptor;
}
export type DisplayHostBootstrap =
| {
readonly status: "signed-out";
readonly principal: Extract<ModuleUiPrincipal, { readonly kind: "anonymous" }>;
}
| {
readonly status: "ready";
readonly descriptor: HostModuleUiDescriptor;
readonly principal: Extract<ModuleUiPrincipal, { readonly kind: "authenticated" }>;
};
/** Principal first, then protected display metadata; anonymous browsers never probe a module id. */
export async function bootstrapDisplayHost(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<DisplayHostBootstrap> {
const principal = await loadHostPrincipal(config, signal);
if (principal.kind === "anonymous") {
return Object.freeze({ principal, status: "signed-out" });
}
return Object.freeze({
descriptor: await loadModuleDisplay(config, moduleId, signal),
principal,
status: "ready",
});
}
export interface HostBootstrap { export interface HostBootstrap {
readonly principal: ModuleUiPrincipal; readonly principal: ModuleUiPrincipal;
readonly navigation: DashboardNavigation; readonly navigation: DashboardNavigation;
+7
View File
@@ -11,19 +11,26 @@
*/ */
export { export {
bootstrapHost, bootstrapHost,
bootstrapDisplayHost,
type DashboardNavigation, type DashboardNavigation,
type DashboardNavigationEntry, type DashboardNavigationEntry,
type DashboardNavigationGroup, type DashboardNavigationGroup,
type DisplayHostBootstrap,
type EkklesiaHostConfig, type EkklesiaHostConfig,
type HostBootstrap, type HostBootstrap,
type HostEventSourceFactory, type HostEventSourceFactory,
type HostEventSourceOptions, type HostEventSourceOptions,
type HostModuleDisplayResponse,
type HostModuleUiDescriptor, type HostModuleUiDescriptor,
HostProtocolError, HostProtocolError,
type HostProtocolErrorCode, type HostProtocolErrorCode,
loadDashboardNavigation, loadDashboardNavigation,
loadHostPrincipal, loadHostPrincipal,
loadModuleDisplay,
moduleDisplayDescriptorPath,
parseDashboardNavigation, parseDashboardNavigation,
parseDisplayRoute,
parseHostModuleDisplay,
parseHostPrincipal, parseHostPrincipal,
resolveApiUrl, resolveApiUrl,
} from "./host-protocol.js"; } from "./host-protocol.js";
+710
View File
@@ -0,0 +1,710 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act, Component, type ReactElement, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { DisplayApp } from "../src/display/DisplayApp.js";
import type {
EkklesiaHostConfig,
HostEventSourceFactory,
HostEventSourceOptions,
} from "../src/host-protocol.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
consoleError.mockRestore();
});
function descriptorPayload(id = "announcements", version = "1.2.0") {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
id,
permissions: [`${id}.slideshow.view`],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
version,
},
};
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
interface PlatformOptions {
readonly descriptorStatus?: number;
readonly principal?: unknown;
}
function platform(options: PlatformOptions = {}) {
const calls: Array<{ readonly init: RequestInit | undefined; readonly url: string }> = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
if (url.endsWith("/api/v1/principal")) {
return json(options.principal ?? { kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/module-displays/announcements")) {
return json(descriptorPayload(), options.descriptorStatus ?? 200);
}
if (url.endsWith("/api/v1/modules/announcements/slides")) {
return json([{ id: "slide-1" }]);
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { calls, fetch };
}
class FakeEventSource {
readonly CLOSED = 2 as const;
readonly CONNECTING = 0 as const;
readonly OPEN = 1 as const;
readonly listeners = new Map<string, Set<EventListener>>();
closeCalls = 0;
readyState: number = this.CONNECTING;
addEventListener(type: string, listener: EventListener): void {
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: EventListener): void {
this.listeners.get(type)?.delete(listener);
}
close(): void {
this.closeCalls += 1;
this.readyState = this.CLOSED;
}
emit(type: string, data?: string): void {
if (type === "open") {
this.readyState = this.OPEN;
}
const event = Object.freeze({ data, type }) as unknown as Event;
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
function eventSources() {
const calls: Array<{
readonly options: HostEventSourceOptions;
readonly source: FakeEventSource;
readonly url: string;
}> = [];
const factory = ((url: string, options: HostEventSourceOptions): EventSource => {
const source = new FakeEventSource();
calls.push({ options, source, url });
return source as unknown as EventSource;
}) as HostEventSourceFactory;
return { calls, factory };
}
function artifact(
id = "announcements",
version = "1.2.0",
options: {
readonly cleanup?: () => void;
readonly mount?: (container: Element, context: ModuleUiHostContext) => undefined | (() => void);
} = {},
) {
return {
default: {
id,
mount(element: Element, context: ModuleUiHostContext) {
if (options.mount) {
return options.mount(element, context);
}
element.textContent = `${context.moduleId}:display`;
return () => {
options.cleanup?.();
element.replaceChildren();
};
},
version,
},
};
}
async function render(
config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>,
moduleId = "announcements",
): Promise<void> {
await act(async () => {
root.render(
<DisplayApp
config={config}
{...(importModule ? { importModule } : {})}
moduleId={moduleId}
/>,
);
});
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
}
describe("display host — full-viewport boundary", () => {
it("mounts only the display artifact with no Dashboard chrome", async () => {
const server = platform();
const imported: string[] = [];
await render({ apiBaseUrl, fetch: server.fetch }, async (url) => {
imported.push(url);
return artifact();
});
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`]);
expect(query("display-module-container")?.textContent).toBe("announcements:display");
expect(container.querySelector("header")).toBeNull();
expect(container.querySelector("nav")).toBeNull();
expect(container.querySelector("aside")).toBeNull();
expect(container.textContent).not.toContain("Ekklesia");
expect(query("display-host")?.style.height).toBe("100%");
expect(query("display-host")?.style.minHeight).toBe("100dvh");
});
it("constructs the same browser-safe API, principal, permission, and realtime context", async () => {
const server = platform();
const live = eventSources();
let seenContext: ModuleUiHostContext | undefined;
await render({ apiBaseUrl, eventSourceFactory: live.factory, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", {
mount(element, context) {
seenContext = context;
void context.api.request("slides");
const subscription = context.realtime.subscribe("state", {});
element.textContent = context.permissions.has("announcements.slideshow.view")
? "permitted"
: "denied";
return () => subscription.close();
},
}),
);
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
"api",
"moduleId",
"permissions",
"principal",
"realtime",
]);
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(query("display-module-container")?.textContent).toBe("permitted");
expect(
server.calls.some((call) => call.url.endsWith("/api/v1/modules/announcements/slides")),
).toBe(true);
expect(live.calls[0]).toMatchObject({
options: { withCredentials: true },
url: `${apiBaseUrl}/api/v1/module-realtime/announcements/state`,
});
});
it("uses a genuine dynamic import and renders an Announcements snapshot with its direct object URL unchanged", async () => {
const server = platform();
const live = eventSources();
const requestedArtifacts: string[] = [];
const source = `
export default {
id: "announcements",
version: "1.2.0",
mount(container, context) {
const image = document.createElement("img");
image.setAttribute("data-testid", "announcement-slide");
container.append(image);
const subscription = context.realtime.subscribe("state", {
onSnapshot(state) {
image.src = state.mode === "on" ? state.slides[state.currentSlide].url : "";
},
onUpdate(state) {
image.src = state.mode === "on" ? state.slides[state.currentSlide].url : "";
},
});
return () => { subscription.close(); container.replaceChildren(); };
},
};
`;
await render(
{ apiBaseUrl, eventSourceFactory: live.factory, fetch: server.fetch },
async (url) => {
requestedArtifacts.push(url);
return import(moduleUrl(source));
},
);
const deliveryUrl =
"https://objects.example.test/ekklesia-announcements-assets/slides%2Fwelcome.jpg?X-Amz-Signature=opaque";
await act(async () => {
live.calls[0]?.source.emit("open");
live.calls[0]?.source.emit(
"snapshot",
JSON.stringify({
payload: {
currentSlide: 0,
mode: "on",
slideSeconds: 7,
slides: [{ url: deliveryUrl }],
},
sequence: 1,
}),
);
});
expect(requestedArtifacts).toEqual([
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
]);
expect(container.querySelector('[data-testid="announcement-slide"]')?.getAttribute("src")).toBe(
deliveryUrl,
);
expect(container.innerHTML).not.toContain("socket.io");
expect(container.innerHTML).not.toContain("mqtt");
});
it("keeps one Announcements-shaped module's management and slideshow roots distinct", async () => {
const live = eventSources();
const requestedArtifacts: string[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return json({
groups: [],
ungrouped: [
{
module: {
api: { baseUrl: "/api/v1/modules/announcements" },
id: "announcements",
permissions: ["announcements.slideshow.view"],
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
ui: { url: "/api/v1/module-ui/announcements/1.2.0" },
version: "1.2.0",
},
name: "Announcements",
},
],
});
}
if (url.endsWith("/api/v1/module-displays/announcements")) {
return json(descriptorPayload());
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
const config = { apiBaseUrl, eventSourceFactory: live.factory, fetch };
const importer = async (url: string): Promise<unknown> => {
requestedArtifacts.push(url);
if (!url.endsWith("/display")) {
return artifact("announcements", "1.2.0", {
mount(element) {
element.textContent = "announcement management";
return () => element.replaceChildren();
},
});
}
return artifact("announcements", "1.2.0", {
mount(element, context) {
const image = document.createElement("img");
image.dataset.testid = "both-role-slide";
element.append(image);
const subscription = context.realtime.subscribe<{
readonly currentSlide: number;
readonly mode: "off" | "on";
readonly slides: readonly { readonly url: string }[];
}>("state", {
onSnapshot(state) {
image.src = state.mode === "on" ? (state.slides[state.currentSlide]?.url ?? "") : "";
},
});
return () => {
subscription.close();
element.replaceChildren();
};
},
});
};
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
expect(container.textContent).toContain("announcement management");
expect(container.querySelector("nav")).not.toBeNull();
expect(requestedArtifacts).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`]);
await act(async () => root.unmount());
root = createRoot(container);
await act(async () =>
root.render(<DisplayApp config={config} importModule={importer} moduleId="announcements" />),
);
const deliveryUrl =
"https://objects.example.test/module-assets/slide.jpg?X-Amz-Signature=opaque";
await act(async () => {
live.calls[0]?.source.emit("open");
live.calls[0]?.source.emit(
"snapshot",
JSON.stringify({
payload: { currentSlide: 0, mode: "on", slides: [{ url: deliveryUrl }] },
sequence: 1,
}),
);
});
expect(requestedArtifacts).toEqual([
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`,
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
]);
expect(container.querySelector('[data-testid="both-role-slide"]')?.getAttribute("src")).toBe(
deliveryUrl,
);
expect(container.querySelector("nav")).toBeNull();
expect(container.querySelector("header")).toBeNull();
expect(JSON.stringify(requestedArtifacts)).not.toMatch(/socket\.io|mqtt|10\.|localhost/u);
});
});
describe("display host — authentication and controlled failures", () => {
it("mounts nothing and never requests a descriptor for an anonymous browser", async () => {
const server = platform({ principal: { kind: "anonymous" } });
const importer = vi.fn(async () => artifact());
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-host-signed-out")).not.toBeNull();
expect(importer).not.toHaveBeenCalled();
expect(server.calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
it("does not import protected display code when the descriptor is denied or unavailable", async () => {
for (const status of [403, 404]) {
const server = platform({ descriptorStatus: status });
const importer = vi.fn(async () => artifact());
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-host-unavailable"), String(status)).not.toBeNull();
expect(importer, String(status)).not.toHaveBeenCalled();
}
});
it("clears partial mount DOM, renders no caught detail, and retries in a fresh container", async () => {
const server = platform();
let attempts = 0;
const containers: Element[] = [];
const importer = vi.fn(async () => {
attempts += 1;
return artifact("announcements", "1.2.0", {
mount(element) {
containers.push(element);
if (attempts === 1) {
element.textContent = "secret at /srv/modules/announcements";
throw new Error("mount exploded with token=secret");
}
element.textContent = "recovered";
return () => element.replaceChildren();
},
});
});
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-error")).not.toBeNull();
expect(container.textContent).not.toContain("/srv/");
expect(container.textContent).not.toContain("secret");
await act(async () => query("display-retry")?.click());
expect(query("display-error")).toBeNull();
expect(query("display-module-container")?.textContent).toBe("recovered");
expect(containers).toHaveLength(2);
expect(containers[0]).not.toBe(containers[1]);
expect(containers[0]?.isConnected).toBe(false);
expect(consoleError).toHaveBeenCalledWith(
"[ekklesia] display UI failure module=announcements code=MODULE_UI_MOUNT_FAILED",
);
});
it.each([
"error",
"unhandledrejection",
] as const)("turns a post-mount page %s into one sanitized failure with manual retry", async (eventType) => {
const server = platform();
const cleanups: Element[] = [];
const mountedInto: Element[] = [];
const importer = vi.fn(async () =>
artifact("announcements", "1.2.0", {
mount(element) {
mountedInto.push(element);
element.textContent = "private render at /srv/modules/announcements";
return () => {
cleanups.push(element);
element.replaceChildren();
};
},
}),
);
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-module-container")?.textContent).toContain("/srv/modules");
consoleError.mockClear();
await act(async () => {
const event =
eventType === "error"
? new ErrorEvent("error", {
error: new Error("token=secret-render-error"),
message: "token=secret-render-error",
})
: new Event("unhandledrejection");
window.dispatchEvent(event);
// A second signal from the same failed render must not run cleanup or transition twice.
window.dispatchEvent(new Event(eventType));
});
expect(query("display-error")).not.toBeNull();
expect(query("display-module-container")?.textContent).toBe("");
expect(container.textContent).toBe("This display could not be loaded.Retry");
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
expect(importer).toHaveBeenCalledTimes(1);
expect(cleanups).toEqual([mountedInto[0]]);
expect(consoleError).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
`[ekklesia] display UI failure module=announcements code=${
eventType === "error" ? "DISPLAY_UI_RUNTIME_ERROR" : "DISPLAY_UI_UNHANDLED_REJECTION"
}`,
);
await act(async () => query("display-retry")?.click());
expect(importer).toHaveBeenCalledTimes(2);
expect(query("display-error")).toBeNull();
expect(query("display-module-container")?.textContent).toContain("/srv/modules");
expect(mountedInto[0]?.isConnected).toBe(false);
expect(mountedInto[1]?.isConnected).toBe(true);
});
it("catches a React render failure reported after the artifact mount has returned", async () => {
const server = platform();
const cleanup = vi.fn();
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", {
mount(element) {
const moduleRoot = createRoot(element);
class ReportRenderFailure extends Component<{ readonly children: ReactNode }> {
override componentDidCatch(error: unknown): void {
// React's browser default reports an uncaught render through the page's global error
// facility. This test boundary makes that report deterministic under happy-dom,
// whose test runner otherwise intercepts the global exception first.
window.dispatchEvent(
new ErrorEvent("error", {
cancelable: true,
error,
message: error instanceof Error ? error.message : "display render failure",
}),
);
}
override render(): ReactNode {
return this.props.children;
}
}
const BrokenDisplay = (): ReactElement => {
throw new Error("late component secret at /srv/modules/announcements");
};
moduleRoot.render(
<ReportRenderFailure>
<BrokenDisplay />
</ReportRenderFailure>,
);
return () => {
cleanup();
moduleRoot.unmount();
};
},
}),
);
await act(async () => undefined);
expect(query("display-error")).not.toBeNull();
expect(container.textContent).toBe("This display could not be loaded.Retry");
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("does not treat a module media/resource error as a crashed display runtime", async () => {
const server = platform();
const cleanup = vi.fn();
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", {
mount(element) {
const image = document.createElement("img");
image.dataset.testid = "display-media";
element.append(image);
return () => cleanup();
},
}),
);
await act(async () => {
container.querySelector('[data-testid="display-media"]')?.dispatchEvent(
new ErrorEvent("error", {
bubbles: true,
cancelable: true,
message: "a signed media URL expired",
}),
);
});
expect(query("display-error")).toBeNull();
expect(query("display-media")).not.toBeNull();
expect(cleanup).not.toHaveBeenCalled();
});
it("removes the page failure listeners when a display attempt is torn down", async () => {
const server = platform();
const cleanup = vi.fn();
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", { cleanup }),
);
await act(async () => root.render(null));
expect(cleanup).toHaveBeenCalledTimes(1);
consoleError.mockClear();
window.dispatchEvent(new ErrorEvent("error", { message: "after teardown" }));
window.dispatchEvent(new Event("unhandledrejection"));
expect(cleanup).toHaveBeenCalledTimes(1);
expect(consoleError).not.toHaveBeenCalled();
});
});
describe("display host — independent instances and stale work", () => {
it("mounts and unmounts two browser instances independently", async () => {
const server = platform();
const cleanups: string[] = [];
const importer = async () =>
artifact("announcements", "1.2.0", {
cleanup: () => cleanups.push("cleanup"),
});
const config = { apiBaseUrl, fetch: server.fetch };
await act(async () => {
root.render(
<>
<DisplayApp
config={config}
importModule={importer}
key="first"
moduleId="announcements"
/>
<DisplayApp
config={config}
importModule={importer}
key="second"
moduleId="announcements"
/>
</>,
);
});
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(2);
await act(async () => {
root.render(
<DisplayApp
config={config}
importModule={importer}
key="second"
moduleId="announcements"
/>,
);
});
expect(cleanups).toHaveLength(1);
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(1);
expect(query("display-module-container")?.textContent).toBe("announcements:display");
});
it("discards a late import into its detached container without disturbing the newer route", async () => {
let releaseAnnouncements: (() => void) | undefined;
const pendingAnnouncements = new Promise<void>((resolve) => {
releaseAnnouncements = resolve;
});
const mountedInto: Element[] = [];
const staleUnmounts: Element[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/module-displays/calendar")) {
return json(descriptorPayload("calendar", "2.0.0"));
}
return json(descriptorPayload());
}) as unknown as typeof globalThis.fetch;
const config = { apiBaseUrl, fetch };
const importer = async (url: string): Promise<unknown> => {
const isCalendar = url.includes("/calendar/");
if (!isCalendar) {
await pendingAnnouncements;
}
return artifact(isCalendar ? "calendar" : "announcements", isCalendar ? "2.0.0" : "1.2.0", {
mount(element) {
mountedInto.push(element);
element.textContent = isCalendar ? "calendar-live" : "announcements-stale";
return () => {
if (!isCalendar) {
staleUnmounts.push(element);
}
element.replaceChildren();
};
},
});
};
await render(config, importer);
await render(config, importer, "calendar");
expect(query("display-module-container")?.textContent).toBe("calendar-live");
const liveContainer = query("display-module-container");
await act(async () => {
releaseAnnouncements?.();
await pendingAnnouncements;
});
expect(query("display-module-container")).toBe(liveContainer);
expect(liveContainer?.textContent).toBe("calendar-live");
const staleContainer = mountedInto.find((element) => element !== liveContainer);
expect(staleContainer?.isConnected).toBe(false);
expect(staleUnmounts).toEqual([staleContainer]);
});
});
@@ -0,0 +1,48 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { displayHostAssetUrls } from "../src/display-assets.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("display host asset locator", () => {
it("exports only frozen immutable href strings for the two exact built files", () => {
expect(Object.isFrozen(displayHostAssetUrls)).toBe(true);
expect(displayHostAssetUrls).toEqual({
html: new URL("dist/browser/display/index.html", `file://${packageDirectory}/`).href,
script: new URL("dist/browser/display/app.js", `file://${packageDirectory}/`).href,
});
expect(typeof displayHostAssetUrls.html).toBe("string");
expect(typeof displayHostAssetUrls.script).toBe("string");
});
it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./display-assets"]).toEqual({
import: "./dist/display-assets.js",
types: "./dist/display-assets.d.ts",
});
const rootSource = await readFile(path.join(packageDirectory, "src/index.ts"), "utf8");
expect(rootSource).not.toContain("displayHostAssetUrls");
expect(rootSource).not.toContain("display-assets");
});
it("uses a fixed root script asset and only structural full-viewport page styles", async () => {
const html = await readFile(path.join(packageDirectory, "display.html"), "utf8");
expect(html).toContain('id="ekklesia-display"');
expect(html).toContain('src="/assets/ekklesia-display/app.js"');
expect(html).toContain('name="viewport"');
expect(html).toContain("height: 100%");
expect(html).toContain("margin: 0");
expect(html).not.toContain("<nav");
expect(html).not.toContain("<header");
expect(html).not.toContain("<aside");
expect(html).not.toContain("fullscreen");
});
});
@@ -0,0 +1,187 @@
import { describe, expect, it, vi } from "vitest";
import {
bootstrapDisplayHost,
loadModuleDisplay,
moduleDisplayDescriptorPath,
parseDisplayRoute,
parseHostModuleDisplay,
} from "../src/host-protocol.js";
const apiBaseUrl = "https://ekklesia.example.test";
function displayPayload(overrides: Record<string, unknown> = {}): {
readonly module: Record<string, unknown>;
} {
return {
module: {
api: { baseUrl: "/api/v1/modules/announcements" },
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
id: "announcements",
permissions: ["announcements.slideshow.view"],
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
version: "1.2.0",
...overrides,
},
};
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
describe("display host protocol — strict descriptor", () => {
it("parses the exact display role into the generic mount descriptor", () => {
const parsed = parseHostModuleDisplay(displayPayload());
expect(parsed).toEqual({
module: {
apiBaseUrl: "/api/v1/modules/announcements",
id: "announcements",
permissions: ["announcements.slideshow.view"],
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
uiUrl: "/api/v1/module-ui/announcements/1.2.0/display",
version: "1.2.0",
},
});
expect(Object.isFrozen(parsed)).toBe(true);
expect(Object.isFrozen(parsed.module)).toBe(true);
expect(Object.isFrozen(parsed.module.permissions)).toBe(true);
});
it("rejects malformed identities, shapes, permissions, and any non-root-relative URL", () => {
const invalid: unknown[] = [
{},
{ ...displayPayload(), extra: true },
displayPayload({ id: "Announcements" }),
displayPayload({ id: "announcements/other" }),
displayPayload({ id: "a".repeat(65) }),
displayPayload({ version: "v1.2.0" }),
displayPayload({ version: "01.2.0" }),
displayPayload({ permissions: "announcements.slideshow.view" }),
displayPayload({ permissions: [""] }),
displayPayload({
permissions: ["announcements.slideshow.view", "announcements.slideshow.view"],
}),
displayPayload({ api: { baseUrl: "https://evil.example.test/api" } }),
displayPayload({ display: { url: "//evil.example.test/display.js" } }),
displayPayload({ display: { url: "/display.js?token=secret" } }),
displayPayload({ display: { url: "/api/%2e%2e/grants" } }),
displayPayload({ display: { url: "/api/%2Fgrants" } }),
displayPayload({ display: { url: "/api//display.js" } }),
displayPayload({ realtime: { baseUrl: "/\\evil.example.test/live" } }),
displayPayload({ display: { url: "/display.js", extra: true } }),
];
for (const payload of invalid) {
expect(() => parseHostModuleDisplay(payload), JSON.stringify(payload)).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
}
});
it("does not accept a Dashboard artifact in place of a display artifact", () => {
const module = (displayPayload() as { module: Record<string, unknown> }).module;
const { display: _display, ...withoutDisplay } = module;
expect(() =>
parseHostModuleDisplay({
module: {
...withoutDisplay,
ui: { url: "/api/v1/module-ui/announcements/1.2.0" },
},
}),
).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }));
});
});
describe("display host protocol — outer route", () => {
it("accepts one exact stable version-independent module route", () => {
expect(parseDisplayRoute("/display/announcements")).toBe("announcements");
expect(moduleDisplayDescriptorPath("announcements")).toBe(
"/api/v1/module-displays/announcements",
);
});
it("rejects extra segments, trailing slashes, malformed escapes, and path escapes", () => {
for (const pathname of [
"/display",
"/display/",
"/display/announcements/",
"/display/announcements/settings",
"/display/%2fapi",
"/display/..",
"/display/%",
"/modules/announcements/display",
]) {
expect(() => parseDisplayRoute(pathname), pathname).toThrowError(
expect.objectContaining({ code: "DISPLAY_ROUTE_INVALID" }),
);
}
});
});
describe("display host protocol — authenticated bootstrap", () => {
it("loads principal first, then the exact descriptor, with host-owned credentials", async () => {
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
return url.endsWith("/api/v1/principal")
? json({ kind: "authenticated", userId: "user-1" })
: json(displayPayload());
}) as unknown as typeof globalThis.fetch;
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
expect(bootstrap.status).toBe("ready");
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/module-displays/announcements`,
]);
expect(calls.every((call) => call.init?.credentials === "include")).toBe(true);
});
it("does not probe a display descriptor for an anonymous principal", async () => {
const fetch = vi.fn(async () =>
json({ kind: "anonymous" }),
) as unknown as typeof globalThis.fetch;
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
expect(bootstrap).toEqual({ principal: { kind: "anonymous" }, status: "signed-out" });
expect(fetch).toHaveBeenCalledTimes(1);
});
it("distinguishes denied, unavailable, unauthenticated, and infrastructure failures safely", async () => {
for (const [status, code] of [
[401, "HOST_UNAUTHENTICATED"],
[403, "DISPLAY_ACCESS_DENIED"],
[404, "DISPLAY_NOT_AVAILABLE"],
[503, "HOST_REQUEST_FAILED"],
] as const) {
const fetch = vi.fn(async () =>
json({ detail: "/srv/modules/announcements secret-token" }, status),
) as unknown as typeof globalThis.fetch;
const error = await loadModuleDisplay({ apiBaseUrl, fetch }, "announcements").catch(
(caught: unknown) => caught,
);
expect(error).toMatchObject({ code });
expect((error as Error).message).not.toContain("/srv/");
expect((error as Error).message).not.toContain("secret-token");
}
});
it("rejects a stale or misrouted descriptor whose identity differs from the requested display", async () => {
const fetch = vi.fn(async () =>
json(displayPayload({ id: "calendar" })),
) as unknown as typeof globalThis.fetch;
await expect(loadModuleDisplay({ apiBaseUrl, fetch }, "announcements")).rejects.toMatchObject({
code: "HOST_RESPONSE_INVALID",
});
});
});
+50
View File
@@ -170,3 +170,53 @@ describe("the shell builds no module knowledge into its bundle", () => {
expect(bundle).not.toContain("createObjectURL"); expect(bundle).not.toContain("createObjectURL");
}); });
}); });
describe("the display host is a separate generic browser bundle", () => {
let bundle: string;
beforeAll(async () => {
const result = await build({
absWorkingDir: packageDirectory,
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"',
},
entryPoints: [path.join(sourceDirectory, "display", "main.tsx")],
format: "esm",
jsx: "automatic",
logLevel: "silent",
minify: false,
platform: "browser",
target: "es2022",
write: false,
});
bundle = result.outputFiles.map((file) => file.text).join("\n");
}, 60_000);
it("contains the generic loader's one dynamic import and no statically known module", () => {
expect([...bundle.matchAll(/\bimport\s*\(/gu)]).toHaveLength(1);
expect(bundle).toContain("(url) => import(url)");
expect(bundle).not.toContain("ekklesia.module.json");
expect(bundle).not.toContain("/api/v1/modules");
for (const moduleId of ["announcements", "calendar", "livestream", "signage"]) {
expect(bundle.toLowerCase()).not.toContain(moduleId);
}
});
it("knows only its host route resources, with no Dashboard application or transport coupling", () => {
expect(bundle).toContain('"/api/v1/principal"');
expect(bundle).toContain('"/api/v1/module-displays"');
expect(bundle).not.toContain('"/api/v1/dashboard/navigation"');
expect(bundle).not.toContain("Loading Ekklesia");
expect(bundle).not.toContain("Sign in to Ekklesia");
expect(bundle.toLowerCase()).not.toContain("socket.io");
expect(bundle.toLowerCase()).not.toContain("mqtt");
});
it("uses neither eval nor a blob URL to load display code", () => {
expect(bundle).not.toContain("new Function(");
expect(bundle).not.toMatch(/\beval\s*\(/u);
expect(bundle).not.toContain("createObjectURL");
});
});
+6 -1
View File
@@ -8,5 +8,10 @@
"sourceMap": true, "sourceMap": true,
"types": [] "types": []
}, },
"include": ["src/index.ts", "src/host-protocol.ts", "src/module-context.ts"] "include": [
"src/index.ts",
"src/display-assets.ts",
"src/host-protocol.ts",
"src/module-context.ts"
]
} }
+24 -13
View File
@@ -1,6 +1,6 @@
# Ekklesia module template # Ekklesia module template
This is the smallest supported backend-only Ekklesia module. It proves the static manifest, This is the smallest supported Ekklesia module product. It proves the static manifest,
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
route (`GET /records`, declared in `register`) that reads that table through route (`GET /records`, declared in `register`) that reads that table through
@@ -8,18 +8,25 @@ route (`GET /records`, declared in `register`) that reads that table through
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
provisioning are intentionally not included. provisioning are intentionally not included.
It also ships an **optional** React UI (`src/ui/`), compiled to the `entrypoints.ui` artifact its It also ships two independently optional React roles: a primary/management UI at `src/ui/index.tsx`
manifest declares by `pnpm build` and a full-viewport display at `src/display/index.tsx`. One invocation of `pnpm build` compiles each
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). UI is a choice, not declared role to the exact artifact in `entrypoints.ui` and `entrypoints.display.artifact`
a requirement: to keep a module backend-only, delete `src/ui/`, `tsconfig.ui.json`, ([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). Both default-export
`tsconfig.ui-test.json`, `tests/module-ui.test.ts`, the `ui` entrypoint from the manifest, and the the same role-neutral `defineModuleUi({ id, version, mount })` ABI and receive the same
React and module-UI dependencies. Everything else keeps working, and `ekklesia-build-module-ui` `ModuleUiHostContext`; there is deliberately no mode flag. The manifest entrypoint and the host route
simply reports that there is nothing to build. choose the role.
No host mounts a module UI yet — the Dashboard shell does not exist. `tests/module-ui.test.ts` builds Either role can be omitted independently. To remove only the display example, delete `src/display/`
the artifact from source, validates it with `parseModuleUiExport`, mounts it into a real DOM, and and `entrypoints.display` (and remove `example.display.view` if nothing else uses it). To keep the
unmounts it, which is what proves the pipeline today. See module backend-only, also delete `src/ui/`, `tsconfig.ui.json`, `tsconfig.ui-test.json`,
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint). `tests/module-ui.test.ts`, the `ui` entrypoint, and the React/module-UI dependencies. The builder
succeeds with an empty artifact list when neither role is declared.
`tests/module-ui.test.ts` builds both artifacts from source, validates them with
`parseModuleUiExport`, mounts each into a real DOM, and unmounts it. Ekklesia mounts the primary UI
inside its Dashboard shell and serves the display without Dashboard chrome at the stable
`/display/example` page. See
[Declaring UI entrypoints](../../docs/module-development/README.md#declaring-ui-entrypoints).
To create a module inside this repository: To create a module inside this repository:
@@ -46,7 +53,11 @@ To create a module inside this repository:
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`. TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces. The module owns its feature screens and display renderer. Do not add platform navigation, the
containing shell, global theme, authentication screens, or top-level routing; those belong to
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page
or descriptor; `context.permissions` remains a presentation-only hint.
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`, Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a
raw `pg` client — `context.services.database` is the only supported way to query this module's own raw `pg` client — `context.services.database` is the only supported way to query this module's own
+12 -3
View File
@@ -1,8 +1,8 @@
{ {
"manifestVersion": 5, "manifestVersion": 6,
"id": "example", "id": "example",
"name": "Example Module", "name": "Example Module",
"description": "A minimal Ekklesia module template with backend behavior and optional UI.", "description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
"version": "0.1.0", "version": "0.1.0",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.1.0",
@@ -19,6 +19,11 @@
"id": "example.records.read", "id": "example.records.read",
"baseline": "restricted", "baseline": "restricted",
"description": "Read example records." "description": "Read example records."
},
{
"id": "example.display.view",
"baseline": "restricted",
"description": "View the example full-screen display."
} }
], ],
"configuration": [ "configuration": [
@@ -34,6 +39,10 @@
"server": "./dist/server/index.js", "server": "./dist/server/index.js",
"schema": "./dist/schema/index.js", "schema": "./dist/schema/index.js",
"migrations": "./drizzle", "migrations": "./drizzle",
"ui": "./dist/ui/index.js" "ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "example.display.view"
}
} }
} }
+45
View File
@@ -0,0 +1,45 @@
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { createRoot } from "react-dom/client";
function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
const mayView = props.context.permissions.has("example.display.view");
return (
<main
data-testid="example-display"
style={{
alignItems: "center",
background: "#111827",
color: "#f9fafb",
display: "flex",
fontFamily: "system-ui, sans-serif",
height: "100%",
justifyContent: "center",
minHeight: "100vh",
padding: "2rem",
textAlign: "center",
width: "100%",
}}
>
<h1 style={{ margin: 0 }}>
{mayView
? `Example full-screen display (${props.context.moduleId})`
: "Display unavailable"}
</h1>
</main>
);
}
/**
* A display uses the same role-neutral ABI as the primary UI. Its separate manifest entrypoint —
* not a mode flag in `ModuleUiHostContext` — tells a host which artifact it is mounting.
*/
export default defineModuleUi({
id: "example",
version: "0.1.0",
mount(container, context) {
const root = createRoot(container);
root.render(<ExampleDisplay context={context} />);
return () => root.unmount();
},
});
+11 -1
View File
@@ -59,7 +59,7 @@ async function registerAndCaptureRoutes(): Promise<{
} }
describe("module template", () => { describe("module template", () => {
it("ships a valid backend-only manifest", async () => { it("ships a valid product manifest with independently declared primary and display UI", async () => {
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url); const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown; const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown;
const manifest = parseModuleManifest(manifestInput); const manifest = parseModuleManifest(manifestInput);
@@ -72,6 +72,11 @@ describe("module template", () => {
baseline: "restricted", baseline: "restricted",
description: "Read example records.", description: "Read example records.",
}, },
{
id: "example.display.view",
baseline: "restricted",
description: "View the example full-screen display.",
},
]); ]);
// The template declares **no** logical storage bucket, deliberately. Since ADR 0030 a declared // The template declares **no** logical storage bucket, deliberately. Since ADR 0030 a declared
// bucket is a real requirement: a module that declares one is blocked on an installation with no // bucket is a real requirement: a module that declares one is blocked on an installation with no
@@ -80,6 +85,11 @@ describe("module template", () => {
expect(manifest.storage.buckets).toEqual([]); expect(manifest.storage.buckets).toEqual([]);
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js"); expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
expect(manifest.entrypoints.migrations).toBe("./drizzle"); expect(manifest.entrypoints.migrations).toBe("./drizzle");
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
expect(manifest.entrypoints.display).toEqual({
artifact: "./dist/display/index.js",
permission: "example.display.view",
});
}); });
it("packages a generated stable Drizzle migration and matching policy", async () => { it("packages a generated stable Drizzle migration and matching policy", async () => {
+51 -9
View File
@@ -2,8 +2,8 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import { parseModuleManifest } from "@ekklesia/module-sdk"; import { parseModuleManifest } from "@ekklesia/module-sdk";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import { import {
type ModuleUiApiClient, type ModuleUiApiClient,
type ModuleUiHostContext, type ModuleUiHostContext,
@@ -14,7 +14,7 @@ import { beforeAll, describe, expect, it } from "vitest";
/** /**
* The end-to-end proof for the module UI build pipeline (ADR 0023): * The end-to-end proof for the module UI build pipeline (ADR 0023):
* *
* React/TSX source -> `pnpm build` -> compiled artifact at the manifest's `entrypoints.ui` * React/TSX source -> `pnpm build` -> compiled artifacts at the manifest's UI entrypoints
* -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()` * -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()`
* *
* The artifact is rebuilt from source here rather than read from a checked-in file, so the test can * The artifact is rebuilt from source here rather than read from a checked-in file, so the test can
@@ -26,7 +26,10 @@ const moduleDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)
let artifactHref: string; let artifactHref: string;
let artifactPath: string; let artifactPath: string;
let displayArtifactHref: string;
let displayArtifactPath: string;
let manifestUi: string; let manifestUi: string;
let manifestDisplay: string;
let manifestIdentity: { id: string; version: string }; let manifestIdentity: { id: string; version: string };
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */ /** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
@@ -72,30 +75,49 @@ describe("module UI build pipeline", () => {
if (!manifest.entrypoints.ui) { if (!manifest.entrypoints.ui) {
throw new Error("The template manifest must declare entrypoints.ui for this test."); throw new Error("The template manifest must declare entrypoints.ui for this test.");
} }
if (!manifest.entrypoints.display) {
throw new Error("The template manifest must declare entrypoints.display for this test.");
}
manifestUi = manifest.entrypoints.ui; manifestUi = manifest.entrypoints.ui;
manifestDisplay = manifest.entrypoints.display.artifact;
manifestIdentity = { id: manifest.id, version: manifest.version }; manifestIdentity = { id: manifest.id, version: manifest.version };
const result = await buildModuleUi({ moduleDirectory }); const result = await buildModuleUi({ moduleDirectory });
artifactPath = result.outputFile; const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
artifactHref = pathToFileURL(result.outputFile).href; const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display");
if (!uiArtifact || !displayArtifact) {
throw new Error("The template build must produce both declared UI roles.");
}
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
artifactPath = uiArtifact.outputFile;
artifactHref = pathToFileURL(uiArtifact.outputFile).href;
displayArtifactPath = displayArtifact.outputFile;
displayArtifactHref = pathToFileURL(displayArtifact.outputFile).href;
}, 60_000); }, 60_000);
it("writes the artifact exactly where the manifest declares it", () => { it("writes both artifacts exactly where the manifest declares them", () => {
expect(manifestUi).toBe("./dist/ui/index.js"); expect(manifestUi).toBe("./dist/ui/index.js");
expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true); expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true);
expect(manifestDisplay).toBe("./dist/display/index.js");
expect(displayArtifactPath.endsWith(path.join("dist", "display", "index.js"))).toBe(true);
}); });
it("produces a browser-safe ESM artifact that needs no host-provided React", async () => { it("produces browser-safe ESM artifacts that need no host-provided React", async () => {
const artifact = await readFile(artifactPath, "utf8"); const artifacts = await Promise.all([
readFile(artifactPath, "utf8"),
readFile(displayArtifactPath, "utf8"),
]);
// React is bundled, so the artifact resolves nothing at run time (ADR 0023). A bare top-level for (const artifact of artifacts) {
// import would mean the host had to supply that module. // React is bundled, so each artifact resolves nothing at run time (ADR 0023). A bare
// top-level import would mean the host had to supply that module.
expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu); expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
expect(artifact).toContain("export {"); expect(artifact).toContain("export {");
// No Ekklesia deployment detail may be baked into a portable artifact. // No Ekklesia deployment detail may be baked into a portable artifact.
expect(artifact).not.toContain("/api/v1/modules"); expect(artifact).not.toContain("/api/v1/modules");
// `process.env` must be substituted at build time; a browser has no `process`. // `process.env` must be substituted at build time; a browser has no `process`.
expect(artifact).not.toContain("process.env.NODE_ENV"); expect(artifact).not.toContain("process.env.NODE_ENV");
}
}); });
it("satisfies parseModuleUiExport and matches the manifest identity", async () => { it("satisfies parseModuleUiExport and matches the manifest identity", async () => {
@@ -139,6 +161,26 @@ describe("module UI build pipeline", () => {
container.remove(); container.remove();
}); });
it("mounts the display artifact through the same ABI, then unmounts it", async () => {
const namespace = (await import(displayArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
const container = document.createElement("div");
const requested: string[] = [];
const unmount = await definition.mount(
container,
recordingHost({ canRead: true, records: [], requested, resolve: () => undefined }),
);
await flush();
expect(container.querySelector("[data-testid='example-display']")?.textContent).toContain(
"Example full-screen display (example)",
);
expect(requested).toEqual([]);
await (unmount as () => Promise<void>)();
expect(container.textContent).toBe("");
});
it("asks its host for a relative path, never Ekklesia's module API prefix", async () => { it("asks its host for a relative path, never Ekklesia's module API prefix", async () => {
const namespace = (await import(artifactHref)) as unknown; const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity); const definition = parseModuleUiExport(namespace, manifestIdentity);
+1 -1
View File
@@ -4,5 +4,5 @@
"noEmit": true, "noEmit": true,
"types": [] "types": []
}, },
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"] "include": ["src/ui/**/*.tsx", "src/ui/**/*.ts", "src/display/**/*.tsx", "src/display/**/*.ts"]
} }