From 2e0fe059c3aa177afdb0f027f6abb74c37892ae2 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 23 Aug 2026 20:50:56 -0500 Subject: [PATCH] feat: add display kiosk host --- CLAUDE.md | 10 +- CONTRIBUTING.md | 18 +- README.md | 59 +- apps/api/package.json | 2 +- apps/api/src/app.ts | 17 + apps/api/src/display-host-assets.ts | 62 ++ apps/api/src/module-display-routes.ts | 203 +++++ apps/api/src/module-ui-inventory.ts | 6 +- apps/api/src/module-ui-routes.ts | 104 ++- apps/api/src/server.ts | 27 +- apps/api/tests/api-http.integration.ts | 6 +- apps/api/tests/dashboard-routes.test.ts | 2 +- apps/api/tests/display-host-assets.test.ts | 73 ++ apps/api/tests/lifecycle.test.ts | 90 ++- .../tests/module-configuration-routes.test.ts | 2 +- apps/api/tests/module-display-routes.test.ts | 495 ++++++++++++ .../api/tests/module-lifecycle-routes.test.ts | 2 +- apps/api/tests/module-realtime-live.test.ts | 2 +- apps/api/tests/module-ui-host-e2e.test.ts | 135 +++- apps/api/tests/module-ui-inventory.test.ts | 2 +- apps/api/tests/module-ui-routes.test.ts | 206 ++++- core/src/modules/discovery.ts | 92 ++- core/src/modules/ui-publication.ts | 88 ++- core/tests/core-migration-discovery.test.ts | 2 +- core/tests/dashboard-flat-modules.test.ts | 2 +- core/tests/doctor.test.ts | 2 +- .../db-facade/ekklesia.module.json | 2 +- .../module-configuration-lifecycle.test.ts | 2 +- .../module-configuration-store.integration.ts | 2 +- core/tests/module-configuration.test.ts | 16 +- core/tests/module-discovery.test.ts | 137 +++- .../module-lifecycle-store.integration.ts | 2 +- core/tests/module-lifecycle.test.ts | 4 +- core/tests/module-migration-discovery.test.ts | 2 +- core/tests/module-permissions.test.ts | 2 +- core/tests/module-realtime-runtime.test.ts | 2 +- core/tests/module-runtime.test.ts | 2 +- core/tests/module-schema-discovery.test.ts | 2 +- core/tests/module-storage-lifecycle.test.ts | 2 +- core/tests/module-ui-publication.test.ts | 112 ++- .../0033-display-kiosk-host-contract.md | 224 ++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 45 +- docs/architecture/repository-boundaries.md | 9 + docs/development/api.md | 93 ++- docs/development/security.md | 26 +- docs/development/testing.md | 18 + docs/module-development/README.md | 214 ++++-- packages/contracts/src/module-manifest.ts | 87 ++- .../contracts/tests/module-manifest.test.ts | 164 +++- .../module-ui-build/src/build-module-ui.ts | 242 ++++-- packages/module-ui-build/src/cli.ts | 22 +- .../tests/build-module-ui.test.ts | 212 +++++- packages/module-ui-host/src/load-module-ui.ts | 11 +- .../artifact-module/ekklesia.module.json | 2 +- .../tests/real-artifact.test.ts | 6 +- packages/module-ui-sdk/src/host-context.ts | 4 +- packages/module-ui-sdk/src/module-ui.ts | 8 +- .../tests/browser-safety.test.ts | 4 +- pnpm-lock.yaml | 6 +- surfaces/shell/display.html | 14 + surfaces/shell/package.json | 4 + surfaces/shell/scripts/build-browser.mjs | 12 +- surfaces/shell/src/display-assets.ts | 14 + surfaces/shell/src/display/DisplayApp.tsx | 152 ++++ surfaces/shell/src/display/DisplayUiFrame.tsx | 233 ++++++ surfaces/shell/src/display/main.tsx | 45 ++ surfaces/shell/src/host-protocol.ts | 203 ++++- surfaces/shell/src/index.ts | 7 + surfaces/shell/tests/display-app.test.tsx | 710 ++++++++++++++++++ surfaces/shell/tests/display-assets.test.ts | 48 ++ .../shell/tests/display-host-protocol.test.ts | 187 +++++ surfaces/shell/tests/modularity.test.ts | 50 ++ surfaces/shell/tsconfig.build.json | 7 +- templates/module/README.md | 37 +- templates/module/ekklesia.module.json | 15 +- templates/module/src/display/index.tsx | 45 ++ templates/module/tests/manifest.test.ts | 12 +- templates/module/tests/module-ui.test.ts | 72 +- templates/module/tsconfig.ui.json | 2 +- 80 files changed, 4868 insertions(+), 397 deletions(-) create mode 100644 apps/api/src/display-host-assets.ts create mode 100644 apps/api/src/module-display-routes.ts create mode 100644 apps/api/tests/display-host-assets.test.ts create mode 100644 apps/api/tests/module-display-routes.test.ts create mode 100644 docs/architecture/decisions/0033-display-kiosk-host-contract.md create mode 100644 surfaces/shell/display.html create mode 100644 surfaces/shell/src/display-assets.ts create mode 100644 surfaces/shell/src/display/DisplayApp.tsx create mode 100644 surfaces/shell/src/display/DisplayUiFrame.tsx create mode 100644 surfaces/shell/src/display/main.tsx create mode 100644 surfaces/shell/tests/display-app.test.tsx create mode 100644 surfaces/shell/tests/display-assets.test.ts create mode 100644 surfaces/shell/tests/display-host-protocol.test.ts create mode 100644 templates/module/src/display/index.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 273ab7f..0bf4e5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,11 +15,11 @@ monorepo: - **Modules** (`modules/*`, template at `templates/module`) are independently versioned product 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 - surface directly. Per ADR 0021 a module may also own its own UI and declare a compiled - `entrypoints.ui` artifact, authored in React per ADR 0022 against `@ekklesia/module-ui-sdk`. No - host mounts that entrypoint yet, and `pnpm boundaries` still rejects every non-React UI framework - and non-`.tsx` presentation source inside a module package. -- **Surfaces** (future `surfaces/*`) own the containing application shell, navigation, and every + surface directly. Per ADR 0021 a module owns its UI; manifest v6 permits independently optional + primary and full-viewport display artifacts, authored in React against the same role-neutral + `@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while + `pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source. +- **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 or module internals directly. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21c73fc..ff17e7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI framework, `.jsx`, stylesheets, and other presentation formats remain rejected by - `pnpm boundaries`. UI source lives at `src/ui/index.tsx` and `pnpm build` compiles it to the - artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write - no bundler configuration. Styling is React inline style objects only for now, and no host mounts a - UI entrypoint yet. + `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and + `src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role + the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no + bundler configuration, and styling is React inline style objects only for now. Both roles use the + same UI SDK context with no presentation-mode flag. - Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never `@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own API by relative path — never by constructing `/api/v1/modules//...`, which is Ekklesia's private deployment detail and would break standalone hosting. - 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 - installed module UIs through `GET /api/v1/module-ui` and mounts them with - `@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026). + primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display + through `GET /api/v1/module-displays/`, mounts both with + `@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033). +- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at + `/display/`, never Dashboard UI with hidden chrome. Its page and descriptor require the + module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain + anonymous trusted code. Public/device identity, display inventory, and variants are deferred. - Dashboard groups are presentation configuration, never module structure. Do not add a parent module, child module, owning module, or group-derived dependency: real module relationships are the dependency contract's job alone, and grouping must never influence discovery, dependency diff --git a/README.md b/README.md index d2e80ca..7414c42 100644 --- a/README.md +++ b/README.md @@ -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 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 -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 untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the pass 5 closure below and @@ -22,7 +23,8 @@ pass 5 closure below and public module contracts, independently versioned capabilities, static module manifests, deterministic automatic discovery, compiled server loading, a staged capability registry, 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 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 @@ -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). 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 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 @@ -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 `@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 -nothing else. **A module UI asks its host for its own API by relative path** (`api.request("posts")`) +host context is deliberately five members — `moduleId`, `api`, `principal`, `permissions`, and +`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//...`, 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 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 from inside its own `start` hook — and its required dependents block behind it through ADR 0028's existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no -module code. That 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 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, 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 -display/kiosk hosting itself remain a later pass. +pass through realtime unchanged. + +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///display`, serves a stable permission-gated page at +`/display/`, and exposes that page's permission-gated descriptor at +`GET /api/v1/module-displays/`. 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 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 -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 -the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do -not exist. +a CLI, not a screen, and object storage is deployment configuration. Sign-in UI, a production CSP +and deployment for the built shells, general sidecar asset publication, React runtime sharing, the +standalone host, and every real feature module also do not exist. **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 diff --git a/apps/api/package.json b/apps/api/package.json index 800df94..b5a4816 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,13 +21,13 @@ "dependencies": { "@ekklesia/contracts": "workspace:*", "@ekklesia/core": "workspace:*", + "@ekklesia/shell": "workspace:*", "@hono/node-server": "2.0.10", "hono": "4.12.31" }, "devDependencies": { "@biomejs/biome": "2.5.3", "@ekklesia/module-ui-host": "workspace:*", - "@ekklesia/shell": "workspace:*", "@ekklesia/typescript-config": "workspace:*", "@types/node": "24.13.3", "@types/pg": "8.20.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d599a63..dca5455 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -32,6 +32,8 @@ import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-rou import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js"; import { createModuleConfigurationRoutes } from "./module-configuration-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 { 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 * fall through to an implicit one. See `./module-ui-routes.js`. */ 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 * 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 // 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`. diff --git a/apps/api/src/display-host-assets.ts b/apps/api/src/display-host-assets.ts new file mode 100644 index 0000000..b5a16f3 --- /dev/null +++ b/apps/api/src/display-host-assets.ts @@ -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 { + 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(); + } +} diff --git a/apps/api/src/module-display-routes.ts b/apps/api/src/module-display-routes.ts new file mode 100644 index 0000000..831da95 --- /dev/null +++ b/apps/api/src/module-display-routes.ts @@ -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/`, 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 { + 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/` 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); + } +} diff --git a/apps/api/src/module-ui-inventory.ts b/apps/api/src/module-ui-inventory.ts index 39e8514..297c9e0 100644 --- a/apps/api/src/module-ui-inventory.ts +++ b/apps/api/src/module-ui-inventory.ts @@ -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 * 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"], principal: EkklesiaPrincipal, declarations: readonly PermissionDeclaration[], @@ -118,7 +120,7 @@ async function describeModule( return { api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, id: published.moduleId, - permissions: await grantedPermissionIds(evaluator, principal, published.permissions), + permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions), realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) }, ui: { url: moduleUiArtifactUrl(published) }, version: published.version, diff --git a/apps/api/src/module-ui-routes.ts b/apps/api/src/module-ui-routes.ts index 493dfac..bf8e1be 100644 --- a/apps/api/src/module-ui-routes.ts +++ b/apps/api/src/module-ui-routes.ts @@ -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 - * 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 * `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 { + // 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 { /** The platform's publication policy, built by Core from the discovered, started module * inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives @@ -173,46 +228,23 @@ export interface CreateModuleUiRoutesOptions { export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): 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) => { const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version")); if (!published) { return notPublished(c); } - - // 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 serveArtifact(c, published); }); return routes; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 84b59d4..98c83fc 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -32,6 +32,7 @@ import { import { type ServerType, serve } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; +import { loadDisplayHostAssets } from "./display-host-assets.js"; import { type ClientAddressConfigInput, 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> | 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 // 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. @@ -524,11 +540,12 @@ export async function startApi( moduleRealtime: moduleRuntime.realtime, moduleServices: services, // Built from the exact **eligible** set the runtime just started, never the raw discovered - // inventory, and only once startup has succeeded — so the published set is the running set. A - // disabled or dependency-blocked module therefore publishes no artifact, appears in no browser - // inventory, and appears in no resolved Dashboard navigation, by construction rather than by a - // filter somewhere downstream. Modules declaring no `entrypoints.ui` are simply absent. - moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules), + // inventory, and exposed only after startup succeeded — so the published set is the running + // set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no + // browser inventory, and appears in no resolved Dashboard navigation, by construction rather + // than by a filter somewhere downstream. Modules declaring no UI role are simply absent. + moduleUi, + ...(displayHostAssets === undefined ? {} : { displayHostAssets }), moduleLifecycle: { // 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 diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 610ccf6..6f83bd4 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -322,7 +322,7 @@ async function createGatedRouteModule( compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js" }, id, - manifestVersion: 5, + manifestVersion: 6, name: `${id} module`, permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], version: "0.1.0", @@ -366,7 +366,7 @@ async function createFixtureModule( ): Promise { const moduleDirectory = path.join(modulesDirectory, id); const manifest = { - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", @@ -471,7 +471,7 @@ async function createUiModule( compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id, - manifestVersion: 5, + manifestVersion: 6, name: `${id} module`, version, }), diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index e72df91..551b782 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), }, id: fixture.id, - manifestVersion: 5, + manifestVersion: 6, name: fixture.name, version: "1.0.0", }), diff --git a/apps/api/tests/display-host-assets.test.ts b/apps/api/tests/display-host-assets.test.ts new file mode 100644 index 0000000..9a031c8 --- /dev/null +++ b/apps/api/tests/display-host-assets.test.ts @@ -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 = '
'; + 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 {};"], + ["", " \n"], + ] as const) { + expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError); + } + }); +}); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index 5dcb7f6..ce5657c 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -8,11 +8,16 @@ const betterAuthMock = vi.hoisted(() => vi.fn()); const drizzleAdapterMock = vi.hoisted(() => vi.fn()); const serveMock = vi.hoisted(() => vi.fn()); const processOnSpy = vi.hoisted(() => vi.fn()); +const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn()); vi.mock("pg", () => ({ Pool: poolConstructor })); vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("@hono/node-server", () => ({ serve: serveMock })); +vi.mock("../src/display-host-assets.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock }; +}); // 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 // failure overrides this with `mockImplementationOnce`. @@ -30,6 +35,7 @@ import { import { PermissionRegistryError } from "@ekklesia/core/authorization"; import { createApiApp } from "../src/app.js"; import { createApiRuntimeConfig } from "../src/config.js"; +import { DisplayHostAssetError } from "../src/display-host-assets.js"; import { installShutdownSignalHandlers, startApi } from "../src/server.js"; function validConfig() { @@ -82,7 +88,7 @@ async function createFixtureModule( ): Promise { const moduleDirectory = path.join(modulesDirectory, id); const manifest = { - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", @@ -149,6 +155,8 @@ interface WorkingFixtureModuleOptions { * can be exercised deterministically without any sleep. */ readonly stopNeverSettles?: boolean; readonly dependencies?: Record; + /** Declares one protected display artifact without importing it. */ + readonly withDisplay?: boolean; } /** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not @@ -162,12 +170,43 @@ async function createWorkingFixtureModule( options: WorkingFixtureModuleOptions = {}, ): Promise { 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, { - permissions: options.withRoute - ? [{ baseline: "public", description: "Ping.", id: permissionId }] - : [], + entrypoints: { + server: "./dist/server/index.js", + ...(options.withDisplay + ? { + display: { + artifact: "./dist/display/index.js", + permission: displayPermission, + }, + } + : {}), + }, + permissions, ...(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 ? `http.route({ method: "GET", @@ -215,7 +254,7 @@ function fakeDiscoveredModule( compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/does-not-exist/index.js" }, id, - manifestVersion: 5, + manifestVersion: 6, name: `${id} module`, permissions, version: "0.1.0", @@ -942,6 +981,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => { betterAuthMock.mockReset(); drizzleAdapterMock.mockClear(); serveMock.mockReset(); + loadDisplayHostAssetsMock.mockReset(); vi.mocked(createApiApp).mockClear(); }); @@ -954,6 +994,46 @@ describe("Ekklesia API module runtime startup and shutdown", () => { 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 () => { const modulesDirectory = await createTemporaryModulesDirectory(); const recorder = createEventRecorder(); diff --git a/apps/api/tests/module-configuration-routes.test.ts b/apps/api/tests/module-configuration-routes.test.ts index 3c964c1..67f3dad 100644 --- a/apps/api/tests/module-configuration-routes.test.ts +++ b/apps/api/tests/module-configuration-routes.test.ts @@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P configuration: fixture.configuration ?? [], entrypoints: { server: "./dist/server/index.js" }, id: fixture.id, - manifestVersion: 5, + manifestVersion: 6, name: `${fixture.id} module`, version: "1.0.0", }), diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts new file mode 100644 index 0000000..0a9a288 --- /dev/null +++ b/apps/api/tests/module-display-routes.test.ts @@ -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( + `
`, + "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 { + 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 { + 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://secret@127.0.0.1 /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("display host", "export const host = 1;\n"); + const mutable = { ...source }; + const { app } = await install({ assets: mutable, display: true }); + mutable.html = "mutated secret"; + 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); + }); +}); diff --git a/apps/api/tests/module-lifecycle-routes.test.ts b/apps/api/tests/module-lifecycle-routes.test.ts index 1ccb8d2..b075c16 100644 --- a/apps/api/tests/module-lifecycle-routes.test.ts +++ b/apps/api/tests/module-lifecycle-routes.test.ts @@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id: fixture.id, - manifestVersion: 5, + manifestVersion: 6, name: `${fixture.id} module`, version: "1.0.0", }), diff --git a/apps/api/tests/module-realtime-live.test.ts b/apps/api/tests/module-realtime-live.test.ts index 96c17c0..deceb09 100644 --- a/apps/api/tests/module-realtime-live.test.ts +++ b/apps/api/tests/module-realtime-live.test.ts @@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{ compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js" }, id: "announcements", - manifestVersion: 5, + manifestVersion: 6, name: "Announcements fixture", permissions: [ { diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index bbcf9ca..cc857b4 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -22,6 +22,7 @@ import { } from "@ekklesia/core/modules"; import { loadModuleUi } from "@ekklesia/module-ui-host"; import { + bootstrapDisplayHost, bootstrapHost, createModuleUiContext, type EkklesiaHostConfig, @@ -30,6 +31,7 @@ import { import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp } from "../src/app.js"; +import { createDisplayHostAssets } from "../src/display-host-assets.js"; import { createInMemoryModuleLifecycleStore, moduleLifecycleSnapshotFor, @@ -84,10 +86,14 @@ async function createFixtureModule( modulesDirectory: string, id = "example", name = "Example module", + withDisplay = false, ): Promise { const moduleDirectory = path.join(modulesDirectory, id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); + if (withDisplay) { + await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }); + } await writeFile( path.join(moduleDirectory, "package.json"), @@ -102,9 +108,20 @@ async function createFixtureModule( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ 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, - manifestVersion: 5, + manifestVersion: 6, name, permissions: [ { @@ -112,6 +129,15 @@ async function createFixtureModule( description: "Read example records.", id: `${id}.records.read`, }, + ...(withDisplay + ? [ + { + baseline: "restricted", + description: "View the example display.", + id: `${id}.display.view`, + }, + ] + : []), ], version: "0.1.0", }), @@ -154,9 +180,26 @@ async function createFixtureModule( }, };\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 { + readonly display?: boolean; readonly principal?: EkklesiaPrincipal; readonly grants?: readonly string[]; /** 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 { const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-")); temporaryDirectories.push(modulesDirectory); - await createFixtureModule(modulesDirectory); + await createFixtureModule(modulesDirectory, "example", "Example module", options.display); for (const extra of options.extraModules ?? []) { await createFixtureModule(modulesDirectory, extra.id, extra.name); } @@ -214,12 +257,21 @@ async function startHarness(options: HarnessOptions = {}): Promise { revokePlatformOwner: vi.fn(), } as unknown as AuthorizationStore; + const moduleUi = createModuleUiPublicationRegistry(modules); const app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, moduleRoutes: moduleRuntime.routes, moduleRealtime: moduleRuntime.realtime, moduleServices: services, - moduleUi: createModuleUiPublicationRegistry(modules), + moduleUi, + ...(moduleUi.listDisplays().length === 0 + ? {} + : { + displayHostAssets: createDisplayHostAssets( + '
', + "export const displayHost = true;\n", + ), + }), moduleLifecycle: { snapshot: moduleLifecycleSnapshotFor(modules), 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 () => { // Signed in, but holding no grant for the module's restricted permission. const harness = await startHarness({ diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index b8743f2..1b7bbc5 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -83,7 +83,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}), }, id: fixture.id, - manifestVersion: 5, + manifestVersion: 6, name: `${fixture.id} module`, permissions: (fixture.permissions ?? []).map((permission) => ({ ...permission, diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index 24dc41a..a59dd9a 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -6,13 +6,10 @@ import { type ModuleDatabase, type ModuleRuntimeServices, } from "@ekklesia/contracts"; -import { - type AuthorizationEvaluator, - type AuthorizationStore, - CORE_PERMISSION_REGISTRY, -} from "@ekklesia/core/authorization"; +import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { + composePlatformPermissionRegistry, createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, @@ -23,10 +20,12 @@ import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp } from "../src/app.js"; +import { createDisplayHostAssets } from "../src/display-host-assets.js"; import { MODULE_UI_ASSET_PREFIX, MODULE_UI_CACHE_CONTROL, MODULE_UI_CONTENT_TYPE, + moduleDisplayUiArtifactUrl, moduleUiArtifactUrl, } from "../src/module-ui-routes.js"; import { @@ -78,6 +77,8 @@ interface ModuleFixture { /** 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. */ 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 * 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. */ @@ -86,6 +87,7 @@ interface ModuleFixture { async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { const version = fixture.version ?? "0.1.0"; + const displayPermission = `${fixture.id}.display.view`; const moduleDirectory = path.join(modulesDirectory, fixture.id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await writeFile( @@ -104,10 +106,28 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P entrypoints: { server: "./dist/server/index.js", ...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }), + ...(fixture.display === undefined + ? {} + : { + display: { + artifact: "./dist/display/index.js", + permission: displayPermission, + }, + }), }, id: fixture.id, - manifestVersion: 5, + manifestVersion: 6, name: `${fixture.id} module`, + permissions: + fixture.display === undefined + ? [] + : [ + { + baseline: "restricted", + description: `View ${fixture.id}'s display`, + id: displayPermission, + }, + ], version, }), ); @@ -119,6 +139,10 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui); } + 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 ?? {})) { const target = path.join(moduleDirectory, relativePath); 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 * reconstruction of it. */ -async function install(fixtures: readonly ModuleFixture[]): Promise { +async function install( + fixtures: readonly ModuleFixture[], + publicationModuleIds?: readonly string[], +): Promise { const modulesDirectory = await createModulesDirectory(); for (const fixture of fixtures) { await createModule(modulesDirectory, fixture); @@ -149,6 +176,10 @@ async function install(fixtures: readonly ModuleFixture[]): Promise publicationModuleIds.includes(module.manifest.id)); const database = Object.freeze({}) as unknown as ModuleDatabase; const services: ModuleRuntimeServices = Object.freeze({ database }); @@ -172,11 +203,19 @@ async function install(fixtures: readonly ModuleFixture[]): Promise module.entrypoints.display !== undefined) + ? { + displayHostAssets: createDisplayHostAssets( + '
', + "export {};\n", + ), + } + : {}), moduleRoutes: Object.freeze([]), moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: services, - moduleUi: createModuleUiPublicationRegistry(modules), + moduleUi: createModuleUiPublicationRegistry(publicationModules), moduleLifecycle: { snapshot: moduleLifecycleSnapshotFor(modules), 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", () => { async function expectNotPublished(app: Hono, url: string): Promise { const response = await app.request(url); diff --git a/core/src/modules/discovery.ts b/core/src/modules/discovery.ts index 2ba2461..3bb6427 100644 --- a/core/src/modules/discovery.ts +++ b/core/src/modules/discovery.ts @@ -5,6 +5,7 @@ import { isVersionCompatible, type ModuleCapabilityDeclaration, type ModuleManifest, + type PermissionId, safeParseModuleManifest, semanticVersionSchema, } from "@ekklesia/contracts"; @@ -61,10 +62,16 @@ export interface ResolvedModuleEntrypoints { readonly schema?: string; readonly migrations?: string; readonly jobs?: string; - /** A module's declared 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 - * module package — nothing in this repository imports, evaluates, mounts, or renders it yet. */ + /** A module's primary presentation artifact (ADR 0021), resolved and validated exactly like the + * other executable entrypoints. Discovery never imports or evaluates browser artifacts. */ 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, @@ -258,11 +265,49 @@ async function resolveEntrypoints( ): Promise { const resolved: Partial> = {}; 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 (relativeEntrypoint === undefined) { - continue; + if (display !== undefined) { + const configuredDisplayPath = path.resolve(moduleDirectory, display.artifact); + 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 ( isCompiledEntrypoint(entrypointName) && ![".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.`, moduleDirectory: directoryName, moduleId: manifest.id, - path: `entrypoints.${entrypointName}`, + path: entrypointIssuePath, }); continue; } @@ -283,7 +328,7 @@ async function resolveEntrypoints( message: `${entrypointName} resolves outside its module directory.`, moduleDirectory: directoryName, moduleId: manifest.id, - path: `entrypoints.${entrypointName}`, + path: entrypointIssuePath, }); continue; } @@ -296,7 +341,7 @@ async function resolveEntrypoints( message: `${entrypointName} resolves through a link outside its module directory.`, moduleDirectory: directoryName, moduleId: manifest.id, - path: `entrypoints.${entrypointName}`, + path: entrypointIssuePath, }); continue; } @@ -312,7 +357,7 @@ async function resolveEntrypoints( : `${entrypointName} must resolve to a file.`, moduleDirectory: directoryName, moduleId: manifest.id, - path: `entrypoints.${entrypointName}`, + path: entrypointIssuePath, }); continue; } @@ -329,12 +374,27 @@ async function resolveEntrypoints( }.`, moduleDirectory: directoryName, 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; } @@ -344,6 +404,14 @@ async function resolveEntrypoints( ...(resolved.migrations ? { migrations: resolved.migrations } : {}), ...(resolved.jobs ? { jobs: resolved.jobs } : {}), ...(resolved.ui ? { ui: resolved.ui } : {}), + ...(resolved.display && display + ? { + display: { + artifactPath: resolved.display, + permission: display.permission, + }, + } + : {}), }; } diff --git a/core/src/modules/ui-publication.ts b/core/src/modules/ui-publication.ts index 8965706..c9d6b3c 100644 --- a/core/src/modules/ui-publication.ts +++ b/core/src/modules/ui-publication.ts @@ -1,4 +1,4 @@ -import type { PermissionDeclaration } from "@ekklesia/contracts"; +import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts"; import type { DiscoveredModule } from "./discovery.js"; /** @@ -40,6 +40,26 @@ export interface PublishableModuleUi { 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?" * @@ -60,6 +80,15 @@ export interface ModuleUiPublicationRegistry { * version are all indistinguishable here — each returns `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* * the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) — * and - * 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file - * inside the module package. + * 2. for each role independently, its manifest declares `entrypoints.ui` or + * `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 * discovery — which is the property `docs/development/security.md` requires: *the existence of an @@ -93,30 +123,47 @@ export function createModuleUiPublicationRegistry( modules: readonly DiscoveredModule[], ): ModuleUiPublicationRegistry { const byModuleId = new Map(); + const displaysByModuleId = new Map(); for (const module of modules) { const artifactPath = module.entrypoints.ui; - if (artifactPath === undefined) { - continue; + if (artifactPath !== undefined) { + byModuleId.set( + module.manifest.id, + Object.freeze({ + artifactPath, + moduleId: module.manifest.id, + name: module.manifest.name, + // Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding + // this record can never reach the manifest object graph itself. + permissions: Object.freeze([...module.manifest.permissions]), + version: module.manifest.version, + }), + ); + } + + 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, + }), + ); } - byModuleId.set( - module.manifest.id, - Object.freeze({ - artifactPath, - moduleId: module.manifest.id, - name: module.manifest.name, - // Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding - // this record can never reach the manifest object graph itself. - permissions: Object.freeze([...module.manifest.permissions]), - version: module.manifest.version, - }), - ); } const published = Object.freeze([...byModuleId.values()]); + const publishedDisplays = Object.freeze([...displaysByModuleId.values()]); return Object.freeze({ list: (): readonly PublishableModuleUi[] => published, + listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays, resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => { if (typeof moduleId !== "string" || typeof version !== "string") { return undefined; @@ -127,5 +174,12 @@ export function createModuleUiPublicationRegistry( // than to a different artifact that happens to exist. 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; + }, }); } diff --git a/core/tests/core-migration-discovery.test.ts b/core/tests/core-migration-discovery.test.ts index 115b00c..ef9ea23 100644 --- a/core/tests/core-migration-discovery.test.ts +++ b/core/tests/core-migration-discovery.test.ts @@ -91,7 +91,7 @@ async function createModule( writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/dashboard-flat-modules.test.ts b/core/tests/dashboard-flat-modules.test.ts index 5c37889..e389f34 100644 --- a/core/tests/dashboard-flat-modules.test.ts +++ b/core/tests/dashboard-flat-modules.test.ts @@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P : {}), entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id: fixture.id, - manifestVersion: 5, + manifestVersion: 6, name: fixture.name, version: "1.0.0", }), diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index a818795..1c506a3 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -42,7 +42,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { }, entrypoints: { server: "./dist/server.js" }, id, - manifestVersion: 5, + manifestVersion: 6, name: `${id} module`, permissions: [], storage: { buckets: [] }, diff --git a/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json index f4c4173..36d1d0c 100644 --- a/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json +++ b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 5, + "manifestVersion": 6, "id": "db-facade", "name": "Database Facade Integration Test Module", "description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.", diff --git a/core/tests/module-configuration-lifecycle.test.ts b/core/tests/module-configuration-lifecycle.test.ts index a4e9019..1b24dd1 100644 --- a/core/tests/module-configuration-lifecycle.test.ts +++ b/core/tests/module-configuration-lifecycle.test.ts @@ -17,7 +17,7 @@ function fakeModule( } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "1.0.0", diff --git a/core/tests/module-configuration-store.integration.ts b/core/tests/module-configuration-store.integration.ts index 86f46b2..d9e6cf7 100644 --- a/core/tests/module-configuration-store.integration.ts +++ b/core/tests/module-configuration-store.integration.ts @@ -192,7 +192,7 @@ afterAll(async () => { function manifestWith(configuration: readonly Record[], id = "announcements") { return parseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "1.0.0", diff --git a/core/tests/module-configuration.test.ts b/core/tests/module-configuration.test.ts index 482c072..42a6cec 100644 --- a/core/tests/module-configuration.test.ts +++ b/core/tests/module-configuration.test.ts @@ -21,7 +21,7 @@ function manifestWith( id = "announcements", ) { return parseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "1.0.0", @@ -92,7 +92,7 @@ describe("module configuration declarations", () => { it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => { const result = safeParseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id: "announcements", name: "announcements module", version: "1.0.0", @@ -114,7 +114,7 @@ describe("module configuration declarations", () => { it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => { const result = safeParseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id: "announcements", name: "announcements module", version: "1.0.0", @@ -130,7 +130,7 @@ describe("module configuration declarations", () => { it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => { const result = safeParseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id: "announcements", name: "announcements module", version: "1.0.0", @@ -169,7 +169,7 @@ describe("module configuration declarations", () => { ], ])("rejects %s", (_label, declaration) => { const result = safeParseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id: "announcements", name: "announcements module", version: "1.0.0", @@ -189,7 +189,7 @@ describe("module configuration declarations", () => { "valueOf", ])("rejects the reserved object property name %s as a configuration key", (key) => { const result = safeParseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id: "announcements", name: "announcements module", version: "1.0.0", @@ -210,7 +210,7 @@ describe("module configuration declarations", () => { "", ])("rejects the malformed configuration key %s", (key) => { const result = safeParseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id: "announcements", name: "announcements module", version: "1.0.0", @@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => { writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "1.0.0", diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index a150baf..583547c 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise { function manifestFor(id: string): Record { return { - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", @@ -748,7 +748,9 @@ describe("module discovery", () => { }); expect(discovered?.entrypoints.ui).toBeUndefined(); + expect(discovered?.entrypoints.display).toBeUndefined(); 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 () => { @@ -821,4 +823,137 @@ describe("module discovery", () => { 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 { + 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", + }), + ); + }); + }); }); diff --git a/core/tests/module-lifecycle-store.integration.ts b/core/tests/module-lifecycle-store.integration.ts index 2788b29..b2986c0 100644 --- a/core/tests/module-lifecycle-store.integration.ts +++ b/core/tests/module-lifecycle-store.integration.ts @@ -198,7 +198,7 @@ afterAll(async () => { * by eligibility resolution. */ function fakeModule(id: string, dependencies?: Record): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "1.0.0", diff --git a/core/tests/module-lifecycle.test.ts b/core/tests/module-lifecycle.test.ts index 0ffdd32..b2c599f 100644 --- a/core/tests/module-lifecycle.test.ts +++ b/core/tests/module-lifecycle.test.ts @@ -22,7 +22,7 @@ function fakeModule( } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", @@ -343,7 +343,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () => writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-migration-discovery.test.ts b/core/tests/module-migration-discovery.test.ts index 7daa0f5..9594bc4 100644 --- a/core/tests/module-migration-discovery.test.ts +++ b/core/tests/module-migration-discovery.test.ts @@ -65,7 +65,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts index ef7d94f..0e722a4 100644 --- a/core/tests/module-permissions.test.ts +++ b/core/tests/module-permissions.test.ts @@ -27,7 +27,7 @@ interface PermissionInput { * is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-realtime-runtime.test.ts b/core/tests/module-realtime-runtime.test.ts index 510e206..c274f6c 100644 --- a/core/tests/module-realtime-runtime.test.ts +++ b/core/tests/module-realtime-runtime.test.ts @@ -31,7 +31,7 @@ function manifest( permissions: readonly Record[] = [], ): Record { return { - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts index 1abe282..6f64359 100644 --- a/core/tests/module-runtime.test.ts +++ b/core/tests/module-runtime.test.ts @@ -57,7 +57,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-schema-discovery.test.ts b/core/tests/module-schema-discovery.test.ts index 4d33eba..906a86c 100644 --- a/core/tests/module-schema-discovery.test.ts +++ b/core/tests/module-schema-discovery.test.ts @@ -41,7 +41,7 @@ function manifestFor( options: { dependencies?: Record; schema?: string | false } = {}, ): Record { return { - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-storage-lifecycle.test.ts b/core/tests/module-storage-lifecycle.test.ts index 818c5d5..220ddf6 100644 --- a/core/tests/module-storage-lifecycle.test.ts +++ b/core/tests/module-storage-lifecycle.test.ts @@ -18,7 +18,7 @@ function fakeModule( } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 5, + manifestVersion: 6, id, name: `${id} module`, version: "1.0.0", diff --git a/core/tests/module-ui-publication.test.ts b/core/tests/module-ui-publication.test.ts index 8fc0e7a..32e81cb 100644 --- a/core/tests/module-ui-publication.test.ts +++ b/core/tests/module-ui-publication.test.ts @@ -27,10 +27,13 @@ interface ModuleFixture { readonly version?: string; /** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */ readonly ui?: string; + /** A dedicated full-viewport artifact, independent of the primary UI. */ + readonly display?: string; } async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { const version = fixture.version ?? "0.1.0"; + const displayPermission = `${fixture.id}.display.view`; const moduleDirectory = path.join(modulesDirectory, fixture.id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await writeFile( @@ -49,10 +52,28 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P entrypoints: { server: "./dist/server/index.js", ...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }), + ...(fixture.display === undefined + ? {} + : { + display: { + artifact: "./dist/display/index.js", + permission: displayPermission, + }, + }), }, id: fixture.id, - manifestVersion: 5, + manifestVersion: 6, name: `${fixture.id} module`, + permissions: + fixture.display === undefined + ? [] + : [ + { + baseline: "restricted", + description: `View ${fixture.id}'s display`, + id: displayPermission, + }, + ], version, }), ); @@ -64,6 +85,10 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui); } + 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 @@ -158,7 +183,9 @@ describe("module UI publication registry", () => { const registry = createModuleUiPublicationRegistry([]); expect(registry.list()).toEqual([]); + expect(registry.listDisplays()).toEqual([]); 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 () => { @@ -184,3 +211,86 @@ describe("module UI publication registry", () => { 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()); + }); +}); diff --git a/docs/architecture/decisions/0033-display-kiosk-host-contract.md b/docs/architecture/decisions/0033-display-kiosk-host-contract.md new file mode 100644 index 0000000..17197c6 --- /dev/null +++ b/docs/architecture/decisions/0033-display-kiosk-host-contract.md @@ -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///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/ +GET /api/v1/module-displays/ +``` + +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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index ece616f..2287e7a 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 - [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 +- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index b90e549..ca2d2e3 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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 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 -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 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. 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 -configured in its manifest — `manifestVersion: 5`, a hard cutover — using six types (`string`, -`integer`, `boolean`, `enum`, `url`, `secret`) with bounded constraints and no nesting. Static +([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). That pass introduced static +configuration with the `manifestVersion: 5` hard cutover; the current strict manifest is version 6 +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 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 @@ -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 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 -external-control concern for a future Companion boundary, not an internal module bus. Display/kiosk -hosting and its public/device principal are explicitly not part of this pass. +external-control concern for a future Companion boundary, not an internal module bus. + +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/`, a +protected descriptor at `/api/v1/module-displays/`, and the exact display artifact at +`/api/v1/module-ui///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. diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index 3b94130..8bd73ac 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -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 application shell and all platform policy, and module UI reaches the host only through `@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 ([ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md)). Core owns the policy (`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, 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. +- 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. - Builder code composes contracts and surface renderer identifiers; it does not import module or surface internals. diff --git a/docs/development/api.md b/docs/development/api.md index 9d0c1cc..b6311ee 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -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). | | `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//` | 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///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/` | 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/` | 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). | | `PUT /api/v1/module-lifecycle//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/` | 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. - **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. - 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` — 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 @@ -1027,7 +1031,9 @@ GET /api/v1/module-ui// 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 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 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. @@ -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 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/module-ui-host` accepts, which was previously impossible — but Ekklesia has no Dashboard -shell, no navigation, and no code that calls this route outside its own tests. +Ekklesia's Dashboard surface consumes the primary inventory and mounts these URLs through +`@ekklesia/module-ui-host`. The dedicated display surface reuses the same loader and ABI but obtains +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/ +GET /api/v1/module-displays/ +GET /api/v1/module-ui///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 diff --git a/docs/development/security.md b/docs/development/security.md index 72e7485..3d8b681 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -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 routing/lifecycle and migration execution), [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). ## 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 | | 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 | +| 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 | | 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 | @@ -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 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/` page and `/api/v1/module-displays/` 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 groups and placements are presentation configuration diff --git a/docs/development/testing.md b/docs/development/testing.md index 0555c36..f10aab7 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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 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 Pass 4 introduced several deadlines, and their tests deliberately differ in kind: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 9690e0a..a9f8118 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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 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 -> manifest declares, have Core validate it, have Ekklesia publish it at a browser URL, and have -> Ekklesia's browser shell discover and mount it — the loop closes end to end. **What does not exist -> is the Dashboard**: no navigation, grouping, icons, ordering, theme, or design system, and no -> standalone host. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint), +> **What exists today:** you can author independently optional primary and full-viewport display UI +> roles in React, build their declared artifacts, have Core validate and publish them, and have +> Ekklesia mount each in its proper browser surface. The primary shell has flat configurable +> navigation; the display shell has no Dashboard chrome. A finished design system, display +> 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 > [What actually mounts your UI](#what-actually-mounts-your-ui). @@ -38,12 +39,16 @@ modules/example/ │ └── _journal.json ├── dist/schema/index.js # when the module owns tables ├── 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 ├── ekklesia.module.json ├── package.json ├── src/schema/index.ts # when the module owns tables ├── 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 @@ -68,7 +73,7 @@ without executing module code: ```json { - "manifestVersion": 4, + "manifestVersion": 6, "id": "calendar", "name": "Calendar", "version": "0.1.0", @@ -102,6 +107,11 @@ without executing module code: "id": "calendar.events.read", "baseline": "restricted", "description": "Read calendar events." + }, + { + "id": "calendar.display.view", + "baseline": "authenticated", + "description": "View the calendar display." } ], "storage": { @@ -115,7 +125,12 @@ without executing module code: "entrypoints": { "server": "./dist/server/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 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 -version 3 manifest, and any other version, fails validation outright rather than being silently +`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a +version 5 manifest, and any other version, fails validation outright rather than being silently 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 -is a one-character edit and changes nothing else -([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md)). +Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display +changes only the version literal; `ui` remains independently optional +([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin with `.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or `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 permissions, your dependencies, or when you start. -Every entrypoint except `migrations` must be a compiled ESM `.js` or `.mjs` file — `server`, -`schema`, `jobs`, and `ui` alike. `migrations` is the one directory entrypoint. All entrypoints begin +Every artifact entrypoint must be a compiled ESM `.js` or `.mjs` file — `server`, `schema`, `jobs`, +`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. -Modules with no tables omit `entrypoints.schema`; modules with no migration history omit -`entrypoints.migrations`; modules with no UI omit `entrypoints.ui`. +A display artifact must remain distinct from every other configured entrypoint before and after +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 -`dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest +Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or +`renderers` key fails validation — and the manifest deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard 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; 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 -migration entrypoints: +A module may declare a primary/management UI, a dedicated display UI, both, or neither. The roles +are separate artifacts because a full-viewport display is not a Dashboard screen with hidden chrome: ```json { @@ -202,20 +218,25 @@ migration entrypoints: "server": "./dist/server/index.js", "schema": "./dist/schema/index.js", "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 -inside the module package (checked before *and* after symlink resolution), resolve to a real file, -and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts` path is rejected at -discovery — the platform never imports source TypeScript, and a UI artifact is a build output like -any other. +Both artifacts obey the same compiled-entrypoint rules as `server` and `schema`: package-relative, +inside the module before *and* after symlink resolution, a real `.js`/`.mjs` file, and never source +TypeScript. The display path must not collide with any other entrypoint. Its permission must be one +of this module's own declarations and must use `authenticated` or `restricted`, never `public`; +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 -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): ```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 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 @@ -297,13 +320,12 @@ API process. Write module UI as trusted first-party code, because that is what i ### 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 -src/ui/index.tsx # the compiled entrypoint's source -src/ui/ExampleApp.tsx # your components - ↓ pnpm build -dist/ui/index.js # the artifact your manifest declares +src/ui/index.tsx → entrypoints.ui +src/display/index.tsx → entrypoints.display.artifact + pnpm build ``` 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 -declare and the artifact you produce cannot drift. Declare it exactly as you would any other -entrypoint: +Output paths are **read from your manifest**, not inferred, so declarations and produced artifacts +cannot drift. Source paths are the exact conventions above; module authors do not configure them. +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 -{ "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 -and exits successfully, so the same script works for a backend-only module. +A module may declare either role alone. Declaring neither is not an error — the build reports that +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 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 @@ -352,7 +385,12 @@ not match `.tsx`, and a UI project covers the browser half: { "extends": "@ekklesia/typescript-config/browser-react.json", "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 -an oversight: esbuild would emit a `dist/ui/index.css` beside your artifact, but no manifest field -declares it and no host loads it — so it would work in your tests and silently do nothing in a real -host. A stylesheet contract needs a manifest field, a host loading mechanism, and a style-isolation -decision, none of which exist yet. Inline styles are the whole supported story for this phase. +an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no +manifest field declares it and no host loads it — so it would work in your tests and silently do +nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and +a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for +this phase. ### 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"` — is resolved and rejected by its real target. -> **Your artifact is served, and Ekklesia's shell mounts it.** The platform publishes your compiled UI -> at `GET /api/v1/module-ui//` +> **Your artifacts are served, and Ekklesia's surfaces mount them.** The platform publishes primary +> UI at `GET /api/v1/module-ui//` and display UI at the sibling `/display` +> artifact URL > ([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 > module list and a mount target, not a Dashboard. See > [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 -Discovery parses the manifest as inert JSON, resolves the `ui` path, validates it, and records it on -the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or renders a UI -entrypoint** — a UI artifact that throws on evaluation still discovers successfully, because it is -never evaluated. Hosts perform their own dynamic import and then validate the result with +Discovery parses the manifest as inert JSON, resolves each declared browser path, validates it, and +records it on the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or +renders either UI entrypoint** — an artifact that throws on evaluation still discovers successfully, +because it is never evaluated. Hosts perform their own dynamic import and then validate the result with `parseModuleUiExport`; Core never loads browser code. 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)): ```text -compiled artifact (dist/ui/index.js) +compiled primary or display artifact ↓ asset publication — host-specific; Ekklesia serves it at - ↓ GET /api/v1/module-ui//, and its shell finds it - ↓ through GET /api/v1/module-ui + ↓ one exact server-produced artifact URL browser-loadable module URL ↓ loadModuleUi({ source, container, context }) 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 -Core resolves `entrypoints.ui` to a path on the *server's filesystem*, which a browser cannot import. -Ekklesia turns that into a URL +Core resolves each declared browser artifact to a path on the *server's filesystem*, which a browser +cannot import. Ekklesia turns each role into one exact URL ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)): ```text GET /api/v1/module-ui// +GET /api/v1/module-ui///display ``` 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. 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 stylesheet import still fails the build. - **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 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 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 @@ -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, > 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/ stable full-viewport page +/api/v1/module-displays/ host descriptor +/api/v1/module-ui///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 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; it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are forwarded unchanged. -Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Public -display/device identity, durable replay, multi-node fanout, client-to-server commands, and -display/kiosk hosting remain future work. See -[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md). +Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Ekklesia's +authenticated display host consumes the same interface unchanged; public/device identity, durable +replay, multi-node fanout, and client-to-server commands remain future work. See +[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md) and +[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md). ## Drizzle schema definition @@ -1340,9 +1416,11 @@ disable or block a running module because a storage call timed out. ### Serving objects to a browser -Module UI gets no storage access and no credentials. Serve bytes through your own authorized route, -as in the example above: `browser → your module API → your scoped storage`. Signed URLs and public -object delivery do not exist yet. +Module UI gets no storage access and no credentials. Its authorized module route may either stream +through scoped storage or call `createReadUrl(key)` and return that short-lived bearer URL so the +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 diff --git a/packages/contracts/src/module-manifest.ts b/packages/contracts/src/module-manifest.ts index 2ad4775..cca6989 100644 --- a/packages/contracts/src/module-manifest.ts +++ b/packages/contracts/src/module-manifest.ts @@ -1,15 +1,16 @@ import { z } from "zod"; import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js"; -import { - type ModuleConfigurationDeclaration, - moduleConfigurationDeclarationSchema, -} from "./module-configuration.js"; import { capabilityIdSchema, logicalBucketIdSchema, moduleIdSchema, packageRelativePathSchema, + permissionIdSchema, } from "./identifiers.js"; +import { + type ModuleConfigurationDeclaration, + moduleConfigurationDeclarationSchema, +} from "./module-configuration.js"; import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js"; const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema); @@ -64,17 +65,27 @@ const moduleStorageSchema = z .default(() => ({ buckets: [] })); /** - * The compiled artifacts a module publishes for the platform to resolve. Every entry is a - * package-relative path to a build output, never source: Core resolves and validates these - * statically during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021). + * A module's optional full-viewport display UI. The artifact is kept beside its access policy so a + * display can never be declared without saying which one of the module's own permissions protects + * 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 - * statically validated here, exactly like `jobs` already is, but **no runtime consumer exists yet** - * — nothing in this repository imports, evaluates, mounts, or renders it. Its name is deliberately - * not `dashboard`: a module's UI must be hostable by Ekklesia's own application shell *and* by a - * future standalone host, so the contract cannot be named after one of them. It is deliberately not - * `surface` either — `surfaces/*` is a separate ownership layer (ADR 0001), and reusing that word - * for a module-owned artifact would conflate two different things. + * statically validated here and mounted by a compatible host. Its name is deliberately not + * `dashboard`: a module's primary UI must be hostable by Ekklesia's application shell *and* by a + * future standalone host. `display` is separate because full-viewport output and primary management + * UI are distinct product experiences; both still use the same browser mount ABI. */ const entrypointsSchema = z .object({ @@ -83,6 +94,7 @@ const entrypointsSchema = z migrations: packageRelativePathSchema.optional(), jobs: packageRelativePathSchema.optional(), ui: packageRelativePathSchema.optional(), + display: moduleDisplayEntrypointSchema.optional(), }) .strict(); @@ -106,10 +118,9 @@ function reportDuplicates( } /** - * The one manifest shape Core accepts. Adding a top-level `configuration` array is an incompatible - * shape change — version 4's manifest object is `.strict()` and rejects `configuration` outright — - * so the canonical manifest moves to `manifestVersion: 5` rather than reinterpreting version 4 - * (ADR 0029). + * The one manifest shape Core accepts. Version 5's strict `entrypoints` object rejects `display`, so + * the static display declaration is an incompatible shape change and the canonical manifest moves + * to `manifestVersion: 6` rather than silently reinterpreting version 5. * * 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 @@ -118,13 +129,13 @@ function reportDuplicates( * 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. * - * 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 * (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 is a one-character edit: no field added by version 5 is required. + * manifest document can therefore never be read as two different versions. Upgrading a version 5 + * 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 .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) { if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) { context.addIssue({ @@ -253,6 +297,7 @@ export function safeParseModuleManifest(input: unknown) { export type ModuleManifestInput = z.input; export type ModuleManifest = z.output; export type ModuleBucketDeclaration = z.output; +export type ModuleDisplayEntrypoint = z.output; /** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape * Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased * here for discoverability alongside the manifest's other per-field declaration types. */ diff --git a/packages/contracts/tests/module-manifest.test.ts b/packages/contracts/tests/module-manifest.test.ts index def6d1c..e8f3089 100644 --- a/packages/contracts/tests/module-manifest.test.ts +++ b/packages/contracts/tests/module-manifest.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; import { - isVersionCompatible, isModuleConfigurationBlockedReason, + isVersionCompatible, MODULE_BLOCKED_REASONS, MODULE_CONFIGURATION_BLOCKED_REASONS, MODULE_DEPENDENCY_KINDS, - MODULE_STORAGE_BLOCKED_REASON, MODULE_MANIFEST_VERSION, MODULE_RUNTIME_STATES, + MODULE_STORAGE_BLOCKED_REASON, packageRelativePathSchema, parseModuleManifest, parsePermissionId, @@ -16,7 +16,7 @@ import { function validManifest(): Record { return { - manifestVersion: 5, + manifestVersion: 6, id: "calendar", name: "Calendar", version: "0.1.0", @@ -226,9 +226,15 @@ describe("module manifest contract", () => { 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", () => { - expect(MODULE_MANIFEST_VERSION).toBe(5); - for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) { + expect(MODULE_MANIFEST_VERSION).toBe(6); + for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) { expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false); } expect( @@ -391,6 +397,7 @@ describe("module manifest contract", () => { const manifest = parseModuleManifest(validManifest()); expect(manifest.entrypoints.ui).toBeUndefined(); + expect(manifest.entrypoints.display).toBeUndefined(); 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"); }); + 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([ ["../outside/ui.js", "escapes the package boundary"], ["/absolute/ui.js", "is an absolute path"], diff --git a/packages/module-ui-build/src/build-module-ui.ts b/packages/module-ui-build/src/build-module-ui.ts index a8858a0..e9e704f 100644 --- a/packages/module-ui-build/src/build-module-ui.ts +++ b/packages/module-ui-build/src/build-module-ui.ts @@ -1,17 +1,18 @@ -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { packageRelativePathSchema } from "@ekklesia/contracts"; +import { moduleDisplayEntrypointSchema, packageRelativePathSchema } from "@ekklesia/contracts"; import { build, type Plugin } from "esbuild"; /** - * The one supported way to turn a module's React/TSX UI source into the compiled browser artifact - * its manifest declares as `entrypoints.ui` (ADR 0021, ADR 0022, ADR 0023). + * The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts + * its manifest declares as `entrypoints.ui` and `entrypoints.display`. * * 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. */ 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"; /** 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 = | "MODULE_UI_MANIFEST_UNREADABLE" - | "MODULE_UI_ENTRYPOINT_NOT_DECLARED" | "MODULE_UI_ENTRYPOINT_UNSAFE" + | "MODULE_UI_ENTRYPOINT_COLLISION" | "MODULE_UI_SOURCE_MISSING" | "MODULE_UI_BUILD_FAILED"; +export type ModuleUiArtifactKind = "ui" | "display"; + const errorMessages: Readonly> = Object.freeze({ 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: `entrypoints.ui 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_BUILD_FAILED: "The module UI bundle could not be produced.", + MODULE_UI_ENTRYPOINT_UNSAFE: + "A module UI entrypoint must be a package-relative path to a compiled .js or .mjs artifact inside the module.", + MODULE_UI_ENTRYPOINT_COLLISION: + "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 @@ -37,11 +42,17 @@ const errorMessages: Readonly> = Object.f * other Ekklesia error boundary. */ export class ModuleUiBuildError extends Error { 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]); this.name = "ModuleUiBuildError"; this.code = code; + if (kind !== undefined) { + this.kind = kind; + } this.stack = `${this.name}: ${this.message}`; } } @@ -83,9 +94,16 @@ export interface BuildModuleUiOptions { } 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; - /** Absolute path of the source entrypoint that produced it. */ + /** Absolute path of the fixed source entrypoint that produced it. */ readonly sourceFile: string; } @@ -93,9 +111,12 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** The declared artifact path, validated the same way module discovery validates it, so the build - * can never write somewhere discovery would later reject. */ -async function resolveDeclaredArtifact(moduleDirectory: string): Promise { +type DeclaredModuleUiArtifact = ModuleUiBuildArtifactResult; + +/** Reads the inert manifest and returns every declared UI artifact in deterministic role order. */ +async function resolveDeclaredArtifacts( + moduleDirectory: string, +): Promise { let manifest: unknown; try { manifest = JSON.parse( @@ -106,20 +127,81 @@ async function resolveDeclaredArtifact(moduleDirectory: string): Promise } const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined; - const declared = isRecord(entrypoints) ? entrypoints.ui : undefined; - if (declared === undefined) { - throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_NOT_DECLARED"); + if (!isRecord(entrypoints)) { + throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE"); } - // 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. - if ( - !packageRelativePathSchema.safeParse(declared).success || - typeof declared !== "string" || - ![".js", ".mjs"].includes(path.extname(declared).toLowerCase()) - ) { - throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE"); + + 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 + // never write somewhere discovery would later reject, even if that grammar changes. + if ( + typeof declared !== "string" || + !packageRelativePathSchema.safeParse(declared).success || + ![".js", ".mjs"].includes(path.extname(declared).toLowerCase()) + ) { + throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind); + } + artifacts.push({ + kind: declaration.kind, + outputFile: path.resolve(moduleDirectory, declared), + sourceFile: path.join(moduleDirectory, declaration.source), + }); } - return path.resolve(moduleDirectory, declared); + + 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,52 +212,70 @@ async function resolveDeclaredArtifact(moduleDirectory: string): Promise */ export async function buildModuleUi(options: BuildModuleUiOptions): Promise { const moduleDirectory = path.resolve(options.moduleDirectory); - const outputFile = await resolveDeclaredArtifact(moduleDirectory); - const sourceFile = path.join(moduleDirectory, MODULE_UI_SOURCE_ENTRYPOINT); + const artifacts = await resolveDeclaredArtifacts(moduleDirectory); + const pendingWrites: Array = []; - try { - await readFile(sourceFile); - } catch { - throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING"); - } - - try { - await build({ - absWorkingDir: moduleDirectory, - // 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). - bundle: true, - define: { - // React reads this at module scope. Without it the bundle would reference `process`, a - // Node global that does not exist in a browser, and crash on load. - "process.env.NODE_ENV": '"production"', - }, - entryPoints: [sourceFile], - // ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract - // requires a real default export. - format: "esm", - jsx: "automatic", - // Unminified deliberately: an operator is expected to review the artifact they install - // (see docs/development/security.md), and nothing else in this repository minifies. - minify: false, - outfile: outputFile, - platform: "browser", - plugins: [rejectStylesheetImports()], - sourcemap: true, - target: MODULE_UI_BUILD_TARGET, - // Fail rather than silently shipping a partial bundle. - logLevel: "silent", - }); - } catch (error) { - // esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a - // programmatic caller never receives raw paths or internals. - if (isRecord(error) && Array.isArray(error.errors)) { - throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED"), { - diagnostics: error.errors, + for (const artifact of artifacts) { + try { + const result = await build({ + absWorkingDir: moduleDirectory, + // 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). + bundle: true, + define: { + // React reads this at module scope. Without it the bundle would reference `process`, a + // Node global that does not exist in a browser, and crash on load. + "process.env.NODE_ENV": '"production"', + }, + entryPoints: [artifact.sourceFile], + // ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract + // requires a real default export. + format: "esm", + jsx: "automatic", + // Unminified deliberately: an operator is expected to review the artifact they install + // (see docs/development/security.md), and nothing else in this repository minifies. + minify: false, + outfile: artifact.outputFile, + platform: "browser", + plugins: [rejectStylesheetImports()], + sourcemap: true, + target: MODULE_UI_BUILD_TARGET, + // Fail rather than silently shipping a partial bundle. + 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) { + // esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a + // programmatic caller never receives raw paths or internals. + if (isRecord(error) && Array.isArray(error.errors)) { + throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind), { + diagnostics: error.errors, + }); + } + throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind); } - throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED"); } - 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 }))), + }); } diff --git a/packages/module-ui-build/src/cli.ts b/packages/module-ui-build/src/cli.ts index 306cb28..b990fb9 100644 --- a/packages/module-ui-build/src/cli.ts +++ b/packages/module-ui-build/src/cli.ts @@ -11,24 +11,28 @@ function isRecord(value: unknown): value is Record { /** * `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 - * command reports that there was nothing to build and exits successfully, so one `build` script - * works for every module. + * 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. When one or both roles + * are declared, this one command builds exactly that set. */ async function main(): Promise { const moduleDirectory = process.argv[2] ?? process.cwd(); try { 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; } catch (error) { if (error instanceof ModuleUiBuildError) { - if (error.code === "MODULE_UI_ENTRYPOINT_NOT_DECLARED") { - process.stdout.write("No entrypoints.ui declared; skipping the module UI build.\n"); - return 0; - } - process.stderr.write(`${error.message}\n`); + process.stderr.write(`${error.kind ? `${error.kind}: ` : ""}${error.message}\n`); // 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. const diagnostics = isRecord(error) ? error.diagnostics : undefined; diff --git a/packages/module-ui-build/tests/build-module-ui.test.ts b/packages/module-ui-build/tests/build-module-ui.test.ts index 15682df..346c170 100644 --- a/packages/module-ui-build/tests/build-module-ui.test.ts +++ b/packages/module-ui-build/tests/build-module-ui.test.ts @@ -17,6 +17,12 @@ afterEach(async () => { interface ModuleFixtureOptions { /** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */ 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 files?: Record; } @@ -28,14 +34,18 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise const manifest = options.manifest ?? JSON.stringify({ - manifestVersion: 5, + manifestVersion: 6, id: "example", name: "Example", version: "0.1.0", compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, 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.display === undefined ? {} : { display: options.display }), }, }); await writeFile(path.join(directory, "ekklesia.module.json"), manifest); @@ -73,9 +83,14 @@ describe("module UI build", () => { }); const result = await buildModuleUi({ moduleDirectory: directory }); + const artifactResult = result.artifacts[0]; - expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js")); - const artifact = await readFile(result.outputFile, "utf8"); + expect(result.artifacts).toHaveLength(1); + 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("as default"); }); @@ -88,7 +103,9 @@ describe("module UI build", () => { 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 () => { @@ -99,15 +116,15 @@ describe("module UI build", () => { 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 } }); - await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({ - code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED", - }); + await expect(buildModuleUi({ moduleDirectory: directory })).resolves.toEqual({ artifacts: [] }); }); 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({ 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 () => { const directory = await createModule({ manifest: "{ not json" }); @@ -138,9 +280,38 @@ describe("module UI build", () => { await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({ 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 () => { const directory = await createModule({ ui: "./dist/ui/index.js", @@ -212,6 +383,25 @@ describe("module UI build", () => { ).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 () => { const directory = await createModule({ ui: "./dist/ui/index.js", @@ -222,7 +412,7 @@ describe("module UI build", () => { }); 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).not.toContain("process.env.NODE_ENV"); diff --git a/packages/module-ui-host/src/load-module-ui.ts b/packages/module-ui-host/src/load-module-ui.ts index 1aef373..4984333 100644 --- a/packages/module-ui-host/src/load-module-ui.ts +++ b/packages/module-ui-host/src/load-module-ui.ts @@ -9,11 +9,12 @@ import { * 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 - * reason this type exists. Core's module discovery resolves `entrypoints.ui` to an absolute path on - * the server's disk (`ResolvedModuleEntrypoints.ui`); a browser cannot import that, and a host that - * passed one here would fail at run time in a way no test on a server would catch. Turning an - * installed artifact into a URL a browser may fetch is **asset publication**, a separate concern - * this package deliberately does not own and Ekklesia does not implement yet (ADR 0024). + * reason this type exists. Core's module discovery resolves each primary or display entrypoint to an + * absolute path on the server's disk; a browser cannot import that, and a host that passed one here + * would fail at run time in a way no test on a server would catch. Turning an installed artifact + * into a URL a browser may fetch is **asset publication**, a separate concern this generic package + * 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 * manifest. They are checked against the artifact before anything is mounted. diff --git a/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json b/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json index 1a7788d..f1c98aa 100644 --- a/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json +++ b/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 5, + "manifestVersion": 6, "id": "example", "name": "Example", "version": "0.1.0", diff --git a/packages/module-ui-host/tests/real-artifact.test.ts b/packages/module-ui-host/tests/real-artifact.test.ts index 252a666..fdba0f3 100644 --- a/packages/module-ui-host/tests/real-artifact.test.ts +++ b/packages/module-ui-host/tests/real-artifact.test.ts @@ -56,7 +56,11 @@ function standaloneContext(requested: string[]): ModuleUiHostContext { describe("loading a real compiled artifact", () => { beforeAll(async () => { 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")}`; }, 60_000); diff --git a/packages/module-ui-sdk/src/host-context.ts b/packages/module-ui-sdk/src/host-context.ts index 910cf53..c29bba6 100644 --- a/packages/module-ui-sdk/src/host-context.ts +++ b/packages/module-ui-sdk/src/host-context.ts @@ -41,7 +41,9 @@ export type ModuleUiPrincipal = ModuleUiAnonymousPrincipal | ModuleUiAuthenticat * than to add. See ADR 0022. * * 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. */ export interface ModuleUiHostContext { diff --git a/packages/module-ui-sdk/src/module-ui.ts b/packages/module-ui-sdk/src/module-ui.ts index 1d07486..9ae8452 100644 --- a/packages/module-ui-sdk/src/module-ui.ts +++ b/packages/module-ui-sdk/src/module-ui.ts @@ -11,7 +11,8 @@ export type Awaitable = Value | PromiseLike; export type ModuleUiUnmount = () => Awaitable; /** - * 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` * 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 * 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 - * Core out of the business of evaluating browser code entirely: module discovery still only - * resolves and validates the `entrypoints.ui` path and never executes it (ADR 0021). + * Core out of the business of evaluating browser code entirely: module discovery only resolves + * and validates each declared browser artifact path and never executes either role (ADR 0021, + * ADR 0033). */ export function parseModuleUiExport( namespace: unknown, diff --git a/packages/module-ui-sdk/tests/browser-safety.test.ts b/packages/module-ui-sdk/tests/browser-safety.test.ts index 7bd0983..6b4501c 100644 --- a/packages/module-ui-sdk/tests/browser-safety.test.ts +++ b/packages/module-ui-sdk/tests/browser-safety.test.ts @@ -14,8 +14,8 @@ async function sourceFiles(): Promise { * 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 * 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 - * way; `import type` is erased at build time, a plain `import` is not. + * (ADR 0022). ADR 0024 removed even the former type-only contracts dependency because its type graph + * leaked Node globals into browser programs; this package must remain completely dependency-free. */ describe("module UI SDK stays browser-safe", () => { it("imports nothing from another package at all, at type level or runtime", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64e6ebc..e7a48cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@ekklesia/core': specifier: workspace:* version: link:../../core + '@ekklesia/shell': + specifier: workspace:* + version: link:../../surfaces/shell '@hono/node-server': specifier: 2.0.10 version: 2.0.10(hono@4.12.31) @@ -55,9 +58,6 @@ importers: '@ekklesia/module-ui-host': specifier: workspace:* version: link:../../packages/module-ui-host - '@ekklesia/shell': - specifier: workspace:* - version: link:../../surfaces/shell '@ekklesia/typescript-config': specifier: workspace:* version: link:../../packages/typescript-config diff --git a/surfaces/shell/display.html b/surfaces/shell/display.html new file mode 100644 index 0000000..fa75345 --- /dev/null +++ b/surfaces/shell/display.html @@ -0,0 +1,14 @@ + + + + + + Display + + + +
+ + + + diff --git a/surfaces/shell/package.json b/surfaces/shell/package.json index 34f3e11..efe54ea 100644 --- a/surfaces/shell/package.json +++ b/surfaces/shell/package.json @@ -10,6 +10,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./display-assets": { + "types": "./dist/display-assets.d.ts", + "import": "./dist/display-assets.js" } }, "scripts": { diff --git a/surfaces/shell/scripts/build-browser.mjs b/surfaces/shell/scripts/build-browser.mjs index 5396e47..54e8e88 100644 --- a/surfaces/shell/scripts/build-browser.mjs +++ b/surfaces/shell/scripts/build-browser.mjs @@ -41,14 +41,17 @@ const buildOptions = { // React reads this at module scope and a browser has no `process`. "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", jsx: "automatic", logLevel: "info", // 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. minify: false, - outfile: path.join(outputDirectory, "app.js"), + outdir: outputDirectory, platform: "browser", sourcemap: true, // 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 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, "display.html"), + path.join(outputDirectory, "display", "index.html"), +); if (serve) { const context = await esbuild.context(buildOptions); diff --git a/surfaces/shell/src/display-assets.ts b/surfaces/shell/src/display-assets.ts new file mode 100644 index 0000000..14ffe62 --- /dev/null +++ b/surfaces/shell/src/display-assets.ts @@ -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, +}); diff --git a/surfaces/shell/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx new file mode 100644 index 0000000..172949f --- /dev/null +++ b/surfaces/shell/src/display/DisplayApp.tsx @@ -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 ( +
+

{props.children}

+ {props.onRetry ? ( + + ) : undefined} +
+ ); +} + +/** + * 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({ 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 ( +
+ {state.status === "loading" ? ( + Loading display… + ) : undefined} + {state.status === "signed-out" ? ( + + Sign in to use this display. + + ) : undefined} + {state.status === "unavailable" ? ( + + This display is unavailable. + + ) : undefined} + {state.status === "failed" ? ( + + This display could not be reached. + + ) : undefined} + {state.status === "ready" ? ( + + ) : undefined} +
+ ); +} diff --git a/surfaces/shell/src/display/DisplayUiFrame.tsx b/surfaces/shell/src/display/DisplayUiFrame.tsx new file mode 100644 index 0000000..458b1d1 --- /dev/null +++ b/surfaces/shell/src/display/DisplayUiFrame.tsx @@ -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(null); + const [status, setStatus] = useState("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> | 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 ( +
+
+ {status === "loading" ? ( +
+

Loading display…

+
+ ) : undefined} + {status === "failed" ? ( +
+

This display could not be loaded.

+ +
+ ) : undefined} +
+ ); +} + +/** Full-viewport module frame with a fresh container for every manual retry. */ +export function DisplayUiFrame(props: DisplayUiFrameProps): ReactElement { + const [attempt, setAttempt] = useState(0); + return ( + setAttempt((current) => current + 1)} + /> + ); +} diff --git a/surfaces/shell/src/display/main.tsx b/surfaces/shell/src/display/main.tsx new file mode 100644 index 0000000..5550d11 --- /dev/null +++ b/surfaces/shell/src/display/main.tsx @@ -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 ? ( + + ) : ( +
+ This display is unavailable. +
+ ), +); diff --git a/surfaces/shell/src/host-protocol.ts b/surfaces/shell/src/host-protocol.ts index 14a4d9e..43884ac 100644 --- a/surfaces/shell/src/host-protocol.ts +++ b/surfaces/shell/src/host-protocol.ts @@ -35,6 +35,12 @@ export type HostEventSourceFactory = (url: string, options: HostEventSourceOptio * code for a consumer that does not exist. */ const PRINCIPAL_PATH = "/api/v1/principal"; 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 = /** 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. */ | "MODULE_API_PATH_INVALID" /** 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> = Object.freeze({ HOST_REQUEST_FAILED: "Ekklesia could not be reached.", @@ -54,6 +66,9 @@ const errorMessages: Readonly> = Object.fr 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_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; } +function hasExactKeys(value: unknown, keys: readonly string[]): value is Record { + 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 { return isRecord(value) ? nonEmptyString(value[key]) : undefined; } @@ -129,6 +161,26 @@ function rootRelativePath(value: unknown, key: string): string | 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; } @@ -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/` 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. * @@ -271,6 +409,10 @@ async function requestPlatformJson( config: EkklesiaHostConfig, rootRelativePath: string, signal?: AbortSignal, + statusCodes: { + readonly forbidden?: HostProtocolErrorCode; + readonly notFound?: HostProtocolErrorCode; + } = {}, ): Promise { const performRequest = config.fetch ?? fetch; let response: Response; @@ -283,9 +425,15 @@ async function requestPlatformJson( } catch (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"); } + 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) { 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 { + 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; + } + | { + readonly status: "ready"; + readonly descriptor: HostModuleUiDescriptor; + readonly principal: Extract; + }; + +/** Principal first, then protected display metadata; anonymous browsers never probe a module id. */ +export async function bootstrapDisplayHost( + config: EkklesiaHostConfig, + moduleId: string, + signal?: AbortSignal, +): Promise { + 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 { readonly principal: ModuleUiPrincipal; readonly navigation: DashboardNavigation; diff --git a/surfaces/shell/src/index.ts b/surfaces/shell/src/index.ts index 0915667..9a9204a 100644 --- a/surfaces/shell/src/index.ts +++ b/surfaces/shell/src/index.ts @@ -11,19 +11,26 @@ */ export { bootstrapHost, + bootstrapDisplayHost, type DashboardNavigation, type DashboardNavigationEntry, type DashboardNavigationGroup, + type DisplayHostBootstrap, type EkklesiaHostConfig, type HostBootstrap, type HostEventSourceFactory, type HostEventSourceOptions, + type HostModuleDisplayResponse, type HostModuleUiDescriptor, HostProtocolError, type HostProtocolErrorCode, loadDashboardNavigation, loadHostPrincipal, + loadModuleDisplay, + moduleDisplayDescriptorPath, parseDashboardNavigation, + parseDisplayRoute, + parseHostModuleDisplay, parseHostPrincipal, resolveApiUrl, } from "./host-protocol.js"; diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx new file mode 100644 index 0000000..55fa30a --- /dev/null +++ b/surfaces/shell/tests/display-app.test.tsx @@ -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; + +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>(); + closeCalls = 0; + readyState: number = this.CONNECTING; + + addEventListener(type: string, listener: EventListener): void { + const listeners = this.listeners.get(type) ?? new Set(); + 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, + moduleId = "announcements", +): Promise { + await act(async () => { + root.render( + , + ); + }); +} + +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 => { + 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()); + 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(), + ); + 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( + + + , + ); + 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( + <> + + + , + ); + }); + expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(2); + + await act(async () => { + root.render( + , + ); + }); + + 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((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 => { + 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]); + }); +}); diff --git a/surfaces/shell/tests/display-assets.test.ts b/surfaces/shell/tests/display-assets.test.ts new file mode 100644 index 0000000..c46dd7b --- /dev/null +++ b/surfaces/shell/tests/display-assets.test.ts @@ -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 }; + 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(" = {}): { + readonly module: Record; +} { + 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 }).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", + }); + }); +}); diff --git a/surfaces/shell/tests/modularity.test.ts b/surfaces/shell/tests/modularity.test.ts index fe48af3..37cf953 100644 --- a/surfaces/shell/tests/modularity.test.ts +++ b/surfaces/shell/tests/modularity.test.ts @@ -170,3 +170,53 @@ describe("the shell builds no module knowledge into its bundle", () => { 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"); + }); +}); diff --git a/surfaces/shell/tsconfig.build.json b/surfaces/shell/tsconfig.build.json index 75099f4..afa1ebd 100644 --- a/surfaces/shell/tsconfig.build.json +++ b/surfaces/shell/tsconfig.build.json @@ -8,5 +8,10 @@ "sourceMap": true, "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" + ] } diff --git a/templates/module/README.md b/templates/module/README.md index 7b60417..5559d2e 100644 --- a/templates/module/README.md +++ b/templates/module/README.md @@ -1,6 +1,6 @@ # 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 generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP 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 provisioning are intentionally not included. -It also ships an **optional** React UI (`src/ui/`), compiled to the `entrypoints.ui` artifact its -manifest declares by `pnpm build` -([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). UI is a choice, not -a requirement: to keep a module backend-only, delete `src/ui/`, `tsconfig.ui.json`, -`tsconfig.ui-test.json`, `tests/module-ui.test.ts`, the `ui` entrypoint from the manifest, and the -React and module-UI dependencies. Everything else keeps working, and `ekklesia-build-module-ui` -simply reports that there is nothing to build. +It also ships two independently optional React roles: a primary/management UI at `src/ui/index.tsx` +and a full-viewport display at `src/display/index.tsx`. One invocation of `pnpm build` compiles each +declared role to the exact artifact in `entrypoints.ui` and `entrypoints.display.artifact` +([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). Both default-export +the same role-neutral `defineModuleUi({ id, version, mount })` ABI and receive the same +`ModuleUiHostContext`; there is deliberately no mode flag. The manifest entrypoint and the host route +choose the role. -No host mounts a module UI yet — the Dashboard shell does not exist. `tests/module-ui.test.ts` builds -the artifact from source, validates it with `parseModuleUiExport`, mounts it into a real DOM, and -unmounts it, which is what proves the pipeline today. See -[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint). +Either role can be omitted independently. To remove only the display example, delete `src/display/` +and `entrypoints.display` (and remove `example.display.view` if nothing else uses it). To keep the +module backend-only, also delete `src/ui/`, `tsconfig.ui.json`, `tsconfig.ui-test.json`, +`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: @@ -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 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`, 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 diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index 966350e..c18941b 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -1,8 +1,8 @@ { - "manifestVersion": 5, + "manifestVersion": 6, "id": "example", "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", "compatibility": { "platform": "^0.1.0", @@ -19,6 +19,11 @@ "id": "example.records.read", "baseline": "restricted", "description": "Read example records." + }, + { + "id": "example.display.view", + "baseline": "restricted", + "description": "View the example full-screen display." } ], "configuration": [ @@ -34,6 +39,10 @@ "server": "./dist/server/index.js", "schema": "./dist/schema/index.js", "migrations": "./drizzle", - "ui": "./dist/ui/index.js" + "ui": "./dist/ui/index.js", + "display": { + "artifact": "./dist/display/index.js", + "permission": "example.display.view" + } } } diff --git a/templates/module/src/display/index.tsx b/templates/module/src/display/index.tsx new file mode 100644 index 0000000..01f8a9c --- /dev/null +++ b/templates/module/src/display/index.tsx @@ -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 ( +
+

+ {mayView + ? `Example full-screen display (${props.context.moduleId})` + : "Display unavailable"} +

+
+ ); +} + +/** + * 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(); + return () => root.unmount(); + }, +}); diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 3b02764..0bab191 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -59,7 +59,7 @@ async function registerAndCaptureRoutes(): Promise<{ } 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 manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown; const manifest = parseModuleManifest(manifestInput); @@ -72,6 +72,11 @@ describe("module template", () => { baseline: "restricted", 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 // 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.entrypoints.schema).toBe("./dist/schema/index.js"); 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 () => { diff --git a/templates/module/tests/module-ui.test.ts b/templates/module/tests/module-ui.test.ts index ff04468..5f734f3 100644 --- a/templates/module/tests/module-ui.test.ts +++ b/templates/module/tests/module-ui.test.ts @@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { buildModuleUi } from "@ekklesia/module-ui-build"; import { parseModuleManifest } from "@ekklesia/module-sdk"; +import { buildModuleUi } from "@ekklesia/module-ui-build"; import { type ModuleUiApiClient, 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): * - * 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()` * * 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 artifactPath: string; +let displayArtifactHref: string; +let displayArtifactPath: string; let manifestUi: string; +let manifestDisplay: 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. */ @@ -72,30 +75,49 @@ describe("module UI build pipeline", () => { if (!manifest.entrypoints.ui) { 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; + manifestDisplay = manifest.entrypoints.display.artifact; manifestIdentity = { id: manifest.id, version: manifest.version }; const result = await buildModuleUi({ moduleDirectory }); - artifactPath = result.outputFile; - artifactHref = pathToFileURL(result.outputFile).href; + const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui"); + 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); - 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(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 () => { - const artifact = await readFile(artifactPath, "utf8"); + it("produces browser-safe ESM artifacts that need no host-provided React", async () => { + 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 - // import would mean the host had to supply that module. - expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu); - expect(artifact).toContain("export {"); - // No Ekklesia deployment detail may be baked into a portable artifact. - expect(artifact).not.toContain("/api/v1/modules"); - // `process.env` must be substituted at build time; a browser has no `process`. - expect(artifact).not.toContain("process.env.NODE_ENV"); + for (const artifact of artifacts) { + // 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).toContain("export {"); + // No Ekklesia deployment detail may be baked into a portable artifact. + expect(artifact).not.toContain("/api/v1/modules"); + // `process.env` must be substituted at build time; a browser has no `process`. + expect(artifact).not.toContain("process.env.NODE_ENV"); + } }); it("satisfies parseModuleUiExport and matches the manifest identity", async () => { @@ -139,6 +161,26 @@ describe("module UI build pipeline", () => { 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)(); + expect(container.textContent).toBe(""); + }); + it("asks its host for a relative path, never Ekklesia's module API prefix", async () => { const namespace = (await import(artifactHref)) as unknown; const definition = parseModuleUiExport(namespace, manifestIdentity); diff --git a/templates/module/tsconfig.ui.json b/templates/module/tsconfig.ui.json index ffb681f..d5bb7eb 100644 --- a/templates/module/tsconfig.ui.json +++ b/templates/module/tsconfig.ui.json @@ -4,5 +4,5 @@ "noEmit": true, "types": [] }, - "include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"] + "include": ["src/ui/**/*.tsx", "src/ui/**/*.ts", "src/display/**/*.tsx", "src/display/**/*.ts"] } -- 2.54.0