feat: add display kiosk host #32
@@ -15,11 +15,11 @@ monorepo:
|
|||||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||||
surface directly. Per ADR 0021 a module may also own its own UI and declare a compiled
|
surface directly. Per ADR 0021 a module owns its UI; manifest v6 permits independently optional
|
||||||
`entrypoints.ui` artifact, authored in React per ADR 0022 against `@ekklesia/module-ui-sdk`. No
|
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||||
host mounts that entrypoint yet, and `pnpm boundaries` still rejects every non-React UI framework
|
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||||
and non-`.tsx` presentation source inside a module package.
|
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||||
- **Surfaces** (future `surfaces/*`) own the containing application shell, navigation, and every
|
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
presentation concern a module does not. They consume public API/client contracts only, never Core
|
||||||
or module internals directly.
|
or module internals directly.
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -36,18 +36,24 @@ pnpm infrastructure:config
|
|||||||
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
||||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
||||||
`pnpm boundaries`. UI source lives at `src/ui/index.tsx` and `pnpm build` compiles it to the
|
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||||
artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write
|
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||||
no bundler configuration. Styling is React inline style objects only for now, and no host mounts a
|
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
|
||||||
UI entrypoint yet.
|
bundler configuration, and styling is React inline style objects only for now. Both roles use the
|
||||||
|
same UI SDK context with no presentation-mode flag.
|
||||||
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
||||||
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
||||||
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
||||||
private deployment detail and would break standalone hosting.
|
private deployment detail and would break standalone hosting.
|
||||||
- The containing application shell, navigation, global theme, and platform policy belong to Core and
|
- The containing application shell, navigation, global theme, and platform policy belong to Core and
|
||||||
the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
|
the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
|
||||||
installed module UIs through `GET /api/v1/module-ui` and mounts them with
|
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
||||||
`@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026).
|
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
||||||
|
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
||||||
|
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
|
||||||
|
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
|
||||||
|
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
|
||||||
|
anonymous trusted code. Public/device identity, display inventory, and variants are deferred.
|
||||||
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
||||||
module, child module, owning module, or group-derived dependency: real module relationships are
|
module, child module, owning module, or group-derived dependency: real module relationships are
|
||||||
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ independent whole-repository re-audit has closed, under the current trusted-cont
|
|||||||
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
|
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
|
||||||
completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the
|
completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the
|
||||||
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
|
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
|
||||||
browser object delivery), and 5 (native module realtime transport) have landed.** The operational
|
browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/
|
||||||
|
kiosk host) have landed.** The operational
|
||||||
requirements that remain are prerequisites for two specific *future* activities — executing
|
requirements that remain are prerequisites for two specific *future* activities — executing
|
||||||
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
||||||
pass 5 closure below and
|
pass 5 closure below and
|
||||||
@@ -22,7 +23,8 @@ pass 5 closure below and
|
|||||||
public module contracts, independently versioned capabilities, static module manifests,
|
public module contracts, independently versioned capabilities, static module manifests,
|
||||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
||||||
schema aggregation, and a cloneable backend-only module template. Migration artifacts have
|
schema aggregation, and a cloneable module-product template with optional primary and display UI.
|
||||||
|
Migration artifacts have
|
||||||
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
|
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
|
||||||
declarations, and exact checksums. Core provides read-only applied-history planning and a locked,
|
declarations, and exact checksums. Core provides read-only applied-history planning and a locked,
|
||||||
whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A
|
whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A
|
||||||
@@ -64,7 +66,7 @@ accounts/API tokens remain later work. See [Authorization](docs/development/auth
|
|||||||
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
||||||
|
|
||||||
Phase 5A wires module-declared permissions into that same authorization system: the module manifest
|
Phase 5A wires module-declared permissions into that same authorization system: the module manifest
|
||||||
(version 3 at the time, version 4 today) declares permissions as complete
|
(version 3 at the time, version 6 today) declares permissions as complete
|
||||||
`{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
|
`{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
|
||||||
permission registry — Core's own declarations plus every module's — before constructing its
|
permission registry — Core's own declarations plus every module's — before constructing its
|
||||||
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
|
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
|
||||||
@@ -255,8 +257,9 @@ returns an unmount function. A host needs an element and a context, never a matc
|
|||||||
|
|
||||||
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
|
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
|
||||||
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
|
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
|
||||||
host context is deliberately four members — `moduleId`, `api`, `principal`, `permissions` — and
|
host context is deliberately five members — `moduleId`, `api`, `principal`, `permissions`, and
|
||||||
nothing else. **A module UI asks its host for its own API by relative path** (`api.request("posts")`)
|
`realtime` — and nothing else. **A module UI asks its host for its own API by relative path**
|
||||||
|
(`api.request("posts")`)
|
||||||
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
|
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
|
||||||
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
|
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
|
||||||
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
|
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
|
||||||
@@ -479,7 +482,8 @@ start({ config }) {
|
|||||||
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
|
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
|
||||||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||||
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||||
module code. That forces `manifestVersion: 5`, a hard cutover in this repository's usual style.
|
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||||||
|
manifest is version 6 after ADR 0033 added the optional display declaration.
|
||||||
|
|
||||||
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||||
no key management, so a `secret` key's value is supplied by the deployment
|
no key management, so a `secret` key's value is supplied by the deployment
|
||||||
@@ -626,15 +630,48 @@ subscription.close();
|
|||||||
|
|
||||||
There is no durable replay, global topic namespace, cross-module subscription, query-string token,
|
There is no durable replay, global topic namespace, cross-module subscription, query-string token,
|
||||||
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
|
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
|
||||||
pass through realtime unchanged. The final unauthenticated/device principal for public displays and
|
pass through realtime unchanged.
|
||||||
display/kiosk hosting itself remain a later pass.
|
|
||||||
|
Phase 7A pass 6 adds the smallest complete **authenticated display/kiosk host**
|
||||||
|
([ADR 0033](docs/architecture/decisions/0033-display-kiosk-host-contract.md)). Manifest version 6
|
||||||
|
adds an optional, independently declared display role:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entrypoints": {
|
||||||
|
"display": {
|
||||||
|
"artifact": "./dist/display/index.js",
|
||||||
|
"permission": "announcements.display.view"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The permission must be declared by that module and have an `authenticated` or `restricted` baseline;
|
||||||
|
public/device identity remains deferred. Primary UI and display UI are independently optional, must
|
||||||
|
use paths distinct from every other entrypoint, and use the same
|
||||||
|
`defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode
|
||||||
|
flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and
|
||||||
|
`src/display/index.tsx`) and atomically emits every declared role.
|
||||||
|
|
||||||
|
Ekklesia publishes the display bundle at
|
||||||
|
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
||||||
|
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
|
||||||
|
`GET /api/v1/module-displays/<module-id>`. The display surface is a separate full-viewport browser
|
||||||
|
application with no Dashboard chrome, no static module imports, and manual retry on failure. Only
|
||||||
|
eligible, running modules receive routes; manifest changes take effect on restart. Artifact bytes
|
||||||
|
remain anonymous trusted code, while the page and descriptor use normal cookie authentication and
|
||||||
|
the module-owned display permission. Realtime subscriptions and opaque signed object-delivery URLs
|
||||||
|
flow through the unchanged host context. Display inventory/management, variants and targeting,
|
||||||
|
public/device identity, browser Fullscreen API behavior, a standalone host, a shared CSS system, the
|
||||||
|
Announcements migration, and Companion integration remain deferred.
|
||||||
|
|
||||||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
||||||
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
||||||
a CLI, not a screen, and object storage is deployment configuration. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
|
a CLI, not a screen, and object storage is deployment configuration. Sign-in UI, a production CSP
|
||||||
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do
|
and deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
||||||
not exist.
|
standalone host, and every real feature module also do not exist.
|
||||||
|
|
||||||
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
||||||
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
||||||
|
|||||||
@@ -21,13 +21,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/contracts": "workspace:*",
|
"@ekklesia/contracts": "workspace:*",
|
||||||
"@ekklesia/core": "workspace:*",
|
"@ekklesia/core": "workspace:*",
|
||||||
|
"@ekklesia/shell": "workspace:*",
|
||||||
"@hono/node-server": "2.0.10",
|
"@hono/node-server": "2.0.10",
|
||||||
"hono": "4.12.31"
|
"hono": "4.12.31"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@ekklesia/module-ui-host": "workspace:*",
|
"@ekklesia/module-ui-host": "workspace:*",
|
||||||
"@ekklesia/shell": "workspace:*",
|
|
||||||
"@ekklesia/typescript-config": "workspace:*",
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-rou
|
|||||||
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
||||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||||
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||||
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||||
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||||
|
|
||||||
@@ -66,6 +68,10 @@ export interface CreateApiAppOptions {
|
|||||||
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
|
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
|
||||||
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
||||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
/** Surface-owned display host assets, read once by production startup. Optional only when the
|
||||||
|
* eligible publication registry contains no display entrypoint; route composition rejects an
|
||||||
|
* inconsistent display-bearing registry synchronously. */
|
||||||
|
readonly displayHostAssets?: DisplayHostAssets;
|
||||||
/**
|
/**
|
||||||
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
|
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
|
||||||
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
|
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
|
||||||
@@ -264,6 +270,17 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A separate, chrome-free browser host. Exact page and descriptor routes are derived from the
|
||||||
|
// same eligible/running publication registry as artifact bytes; a display-only module never
|
||||||
|
// enters Dashboard navigation, and no request value can select a file or permission.
|
||||||
|
mountModuleDisplayRoutes(app, {
|
||||||
|
evaluator: options.authorization.evaluator,
|
||||||
|
moduleUi: options.moduleUi,
|
||||||
|
registry: options.authorization.registry,
|
||||||
|
runtime,
|
||||||
|
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
// Layered deliberately on top of the generic inventory above rather than merged into it: the
|
// Layered deliberately on top of the generic inventory above rather than merged into it: the
|
||||||
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
|
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
|
||||||
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
|
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||||
|
*
|
||||||
|
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
||||||
|
* surface package. Request input is never involved in this read and the package is never exposed as
|
||||||
|
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
|
||||||
|
* genuinely immutable value that every response can reuse safely.
|
||||||
|
*/
|
||||||
|
export interface DisplayHostAssets {
|
||||||
|
readonly html: string;
|
||||||
|
readonly script: string;
|
||||||
|
readonly scriptEtag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
|
* location, and machine path are intentionally not retained or surfaced. */
|
||||||
|
export class DisplayHostAssetError extends Error {
|
||||||
|
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("The display host browser assets are unavailable.");
|
||||||
|
this.name = "DisplayHostAssetError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures one immutable, internally consistent display-host asset snapshot.
|
||||||
|
*
|
||||||
|
* The strong validator is always derived from the exact script text in the same snapshot; callers
|
||||||
|
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||||
|
* failed host build rather than a valid page that can never mount anything.
|
||||||
|
*/
|
||||||
|
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||||
|
throw new DisplayHostAssetError();
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
html,
|
||||||
|
script,
|
||||||
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
||||||
|
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||||
|
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||||
|
try {
|
||||||
|
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
|
||||||
|
// rejected sibling promise is left running after this function has already failed.
|
||||||
|
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||||
|
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||||
|
return createDisplayHostAssets(html, script);
|
||||||
|
} catch {
|
||||||
|
throw new DisplayHostAssetError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||||
|
import type { ModuleUiPublicationRegistry, PublishableModuleDisplay } from "@ekklesia/core/modules";
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
|
import { grantedModulePermissionIds } from "./module-ui-inventory.js";
|
||||||
|
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||||
|
|
||||||
|
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
||||||
|
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
|
||||||
|
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||||
|
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||||
|
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||||
|
|
||||||
|
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
|
||||||
|
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
|
||||||
|
* management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the
|
||||||
|
* boundary. */
|
||||||
|
export interface HostModuleDisplayDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly display: { readonly url: string };
|
||||||
|
readonly api: { readonly baseUrl: string };
|
||||||
|
readonly realtime: { readonly baseUrl: string };
|
||||||
|
readonly permissions: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostModuleDisplayResource {
|
||||||
|
readonly module: HostModuleDisplayDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The same immutable, eligible/running publication registry used for artifact bytes. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||||
|
* surface-owned files before binding and supplies their frozen contents here. */
|
||||||
|
readonly assets?: DisplayHostAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||||
|
* permission id, path, or package location. */
|
||||||
|
export class ModuleDisplayConfigurationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("The module display host configuration is inconsistent.");
|
||||||
|
this.name = "ModuleDisplayConfigurationError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayFailure(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
|
||||||
|
function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||||
|
if (headerValue === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (headerValue.trim() === "*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return headerValue.split(",").some((candidate) => {
|
||||||
|
const value = candidate.trim();
|
||||||
|
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||||
|
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||||
|
* bytes with a stale validator. */
|
||||||
|
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||||
|
try {
|
||||||
|
const snapshot = createDisplayHostAssets(input.html, input.script);
|
||||||
|
if (snapshot.scriptEtag !== input.scriptEtag) {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeDisplay(
|
||||||
|
options: ModuleDisplayRoutesOptions,
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
display: PublishableModuleDisplay,
|
||||||
|
): Promise<HostModuleDisplayResource> {
|
||||||
|
return {
|
||||||
|
module: {
|
||||||
|
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||||
|
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||||
|
id: display.moduleId,
|
||||||
|
permissions: await grantedModulePermissionIds(
|
||||||
|
options.evaluator,
|
||||||
|
principal,
|
||||||
|
display.permissions,
|
||||||
|
),
|
||||||
|
realtime: { baseUrl: moduleRealtimeBaseUrl(display.moduleId) },
|
||||||
|
version: display.version,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountDisplay(
|
||||||
|
app: Hono,
|
||||||
|
options: ModuleDisplayRoutesOptions & { readonly assets: DisplayHostAssets },
|
||||||
|
display: PublishableModuleDisplay,
|
||||||
|
): void {
|
||||||
|
// The permission comes from an already-validated manifest/publication record. A request selects
|
||||||
|
// neither the permission nor the artifact; it can only hit this exact pre-composed route.
|
||||||
|
const requireDisplayPermission = requirePermission(options, display.displayPermission);
|
||||||
|
|
||||||
|
app.get(moduleDisplayPageUrl(display.moduleId), requireDisplayPermission, () => {
|
||||||
|
return new Response(options.assets.html, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(moduleDisplayDescriptorUrl(display.moduleId), requireDisplayPermission, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
try {
|
||||||
|
return c.json(await describeDisplay(options, getAuthorizedPrincipal(c), display), 200);
|
||||||
|
} catch {
|
||||||
|
return displayFailure(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts one stable page and one descriptor route per eligible, running display declaration.
|
||||||
|
*
|
||||||
|
* There is deliberately no display inventory in this pass. A management UI may need one later,
|
||||||
|
* but a bookmarked TV needs only its version-independent page URL. Unknown, dashboard-only,
|
||||||
|
* disabled, and dependency-blocked modules have no registered route and collapse into the
|
||||||
|
* application's ordinary sanitized 404.
|
||||||
|
*
|
||||||
|
* The page is an inert, module-agnostic shell: its bytes contain no module id and its one absolute
|
||||||
|
* script URL cannot be rebased by a deep `/display/<id>` path. The browser reads the validated id
|
||||||
|
* from its outer URL, requests the permission-gated descriptor, and only then imports the exact
|
||||||
|
* versioned display artifact that the server supplied.
|
||||||
|
*/
|
||||||
|
export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void {
|
||||||
|
const displays = options.moduleUi.listDisplays();
|
||||||
|
if (displays.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!options.assets) {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
const assets = snapshotAssets(options.assets);
|
||||||
|
for (const display of displays) {
|
||||||
|
if (!options.registry.has(display.displayPermission)) {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One fixed host bundle, never a directory and never a request-selected package file. Like module
|
||||||
|
// artifact bytes, fetching this platform code grants no authority; the page and descriptor are
|
||||||
|
// where identity and the module-owned display permission are enforced.
|
||||||
|
app.get(DISPLAY_HOST_SCRIPT_PATH, (c) => {
|
||||||
|
if (etagMatches(c.req.header("if-none-match"), assets.scriptEtag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": "no-cache", ETag: assets.scriptEtag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(assets.script, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/javascript; charset=utf-8",
|
||||||
|
ETag: assets.scriptEtag,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const display of displays) {
|
||||||
|
mountDisplay(app, { ...options, assets }, display);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,7 +90,9 @@ export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddl
|
|||||||
* silently read "denied" during a database outage would blank the whole UI and look like a
|
* silently read "denied" during a database outage would blank the whole UI and look like a
|
||||||
* permissions problem.
|
* permissions problem.
|
||||||
*/
|
*/
|
||||||
async function grantedPermissionIds(
|
/** Shared by the primary-UI inventory and the singular display descriptor. Both provide the same
|
||||||
|
* presentation-only subset of a module's own declarations and use Core's one evaluator. */
|
||||||
|
export async function grantedModulePermissionIds(
|
||||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
principal: EkklesiaPrincipal,
|
principal: EkklesiaPrincipal,
|
||||||
declarations: readonly PermissionDeclaration[],
|
declarations: readonly PermissionDeclaration[],
|
||||||
@@ -118,7 +120,7 @@ async function describeModule(
|
|||||||
return {
|
return {
|
||||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||||
id: published.moduleId,
|
id: published.moduleId,
|
||||||
permissions: await grantedPermissionIds(evaluator, principal, published.permissions),
|
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||||
ui: { url: moduleUiArtifactUrl(published) },
|
ui: { url: moduleUiArtifactUrl(published) },
|
||||||
version: published.version,
|
version: published.version,
|
||||||
|
|||||||
@@ -77,9 +77,22 @@ export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
|
|||||||
)}`;
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical browser URL for a module's independently declared display artifact.
|
||||||
|
*
|
||||||
|
* The literal trailing segment is host-owned, not a caller-selected role. Keeping this as a
|
||||||
|
* separate helper and an exact route means neither request input nor a generic role parameter can
|
||||||
|
* select a neighbouring artifact. A future standalone host may expose a different outer display
|
||||||
|
* page while consuming this same published module artifact internally.
|
||||||
|
*/
|
||||||
|
export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||||
|
return `${moduleUiArtifactUrl(identity)}/display`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||||
* installed module declares no `entrypoints.ui`, or the requested version is not the installed one.
|
* installed module declares no artifact for the requested UI role, or the requested version is not
|
||||||
|
* the installed one.
|
||||||
*
|
*
|
||||||
* They are deliberately indistinguishable. The route requires no authentication (see
|
* They are deliberately indistinguishable. The route requires no authentication (see
|
||||||
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
|
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
|
||||||
@@ -122,6 +135,48 @@ function isNotModified(headerValue: string | undefined, etag: string): boolean {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PublishedArtifact {
|
||||||
|
readonly artifactPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serve one exact path Core already validated, with one response contract for both UI roles. */
|
||||||
|
async function serveArtifact(c: Context, published: PublishedArtifact): Promise<Response> {
|
||||||
|
// Discovery validated this exact path earlier; the file may still have changed or vanished
|
||||||
|
// since. The response is produced from whatever a single read of that one path returns right
|
||||||
|
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
|
||||||
|
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
|
||||||
|
// something else.
|
||||||
|
let artifact: Buffer;
|
||||||
|
try {
|
||||||
|
artifact = await readFile(published.artifactPath);
|
||||||
|
} catch {
|
||||||
|
return artifactUnavailable(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
|
||||||
|
// validator can never describe a different revision than the body does.
|
||||||
|
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
|
||||||
|
if (isNotModified(c.req.header("if-none-match"), etag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(artifact, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": MODULE_UI_CACHE_CONTROL,
|
||||||
|
"Content-Type": MODULE_UI_CONTENT_TYPE,
|
||||||
|
ETag: etag,
|
||||||
|
// The declared type is the only type: a browser must not be allowed to sniff a module
|
||||||
|
// artifact into some other interpretation, and `import()` requires the JavaScript type
|
||||||
|
// anyway.
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateModuleUiRoutesOptions {
|
export interface CreateModuleUiRoutesOptions {
|
||||||
/** The platform's publication policy, built by Core from the discovered, started module
|
/** The platform's publication policy, built by Core from the discovered, started module
|
||||||
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
|
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
|
||||||
@@ -173,46 +228,23 @@ export interface CreateModuleUiRoutesOptions {
|
|||||||
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||||
|
const published = options.registry.resolveDisplay(
|
||||||
|
c.req.param("moduleId"),
|
||||||
|
c.req.param("version"),
|
||||||
|
);
|
||||||
|
if (!published) {
|
||||||
|
return notPublished(c);
|
||||||
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
|
});
|
||||||
|
|
||||||
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
|
||||||
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
|
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
|
||||||
if (!published) {
|
if (!published) {
|
||||||
return notPublished(c);
|
return notPublished(c);
|
||||||
}
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
// Discovery validated this exact path earlier; the file may still have changed or vanished
|
|
||||||
// since. The response is produced from whatever a single read of that one path returns right
|
|
||||||
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
|
|
||||||
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
|
|
||||||
// something else.
|
|
||||||
let artifact: Buffer;
|
|
||||||
try {
|
|
||||||
artifact = await readFile(published.artifactPath);
|
|
||||||
} catch {
|
|
||||||
return artifactUnavailable(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
|
|
||||||
// validator can never describe a different revision than the body does.
|
|
||||||
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
|
|
||||||
if (isNotModified(c.req.header("if-none-match"), etag)) {
|
|
||||||
return new Response(null, {
|
|
||||||
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
|
|
||||||
status: 304,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(artifact, {
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": MODULE_UI_CACHE_CONTROL,
|
|
||||||
"Content-Type": MODULE_UI_CONTENT_TYPE,
|
|
||||||
ETag: etag,
|
|
||||||
// The declared type is the only type: a browser must not be allowed to sniff a module
|
|
||||||
// artifact into some other interpretation, and `import()` requires the JavaScript type
|
|
||||||
// anyway.
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
},
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return routes;
|
return routes;
|
||||||
|
|||||||
+22
-5
@@ -32,6 +32,7 @@ import {
|
|||||||
import { type ServerType, serve } from "@hono/node-server";
|
import { type ServerType, serve } from "@hono/node-server";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { createApiApp } from "./app.js";
|
import { createApiApp } from "./app.js";
|
||||||
|
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
type ClientAddressConfigInput,
|
type ClientAddressConfigInput,
|
||||||
type ClientAddressResolver,
|
type ClientAddressResolver,
|
||||||
@@ -489,6 +490,21 @@ export async function startApi(
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// This snapshot is inert metadata only: construction reads no artifact and executes no module.
|
||||||
|
// It lets the API prove that a display host build is available *before* importing any eligible
|
||||||
|
// module code. The snapshot is not exposed anywhere unless the complete runtime subsequently
|
||||||
|
// starts and application composition succeeds.
|
||||||
|
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
||||||
|
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||||
|
try {
|
||||||
|
displayHostAssets =
|
||||||
|
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||||
|
} catch (error) {
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseDatabase(databaseHandle);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Module load/register/start failures use Core's own existing rollback (already-started
|
// Module load/register/start failures use Core's own existing rollback (already-started
|
||||||
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
||||||
// handle to stop here, only the pool this failure leaves open.
|
// handle to stop here, only the pool this failure leaves open.
|
||||||
@@ -524,11 +540,12 @@ export async function startApi(
|
|||||||
moduleRealtime: moduleRuntime.realtime,
|
moduleRealtime: moduleRuntime.realtime,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||||
// inventory, and only once startup has succeeded — so the published set is the running set. A
|
// inventory, and exposed only after startup succeeded — so the published set is the running
|
||||||
// disabled or dependency-blocked module therefore publishes no artifact, appears in no browser
|
// set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no
|
||||||
// inventory, and appears in no resolved Dashboard navigation, by construction rather than by a
|
// browser inventory, and appears in no resolved Dashboard navigation, by construction rather
|
||||||
// filter somewhere downstream. Modules declaring no `entrypoints.ui` are simply absent.
|
// than by a filter somewhere downstream. Modules declaring no UI role are simply absent.
|
||||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
moduleUi,
|
||||||
|
...(displayHostAssets === undefined ? {} : { displayHostAssets }),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
|
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
|
||||||
// reports it actually started — read from the runtime rather than assumed equal to the
|
// reports it actually started — read from the runtime rather than assumed equal to the
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ async function createGatedRouteModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -366,7 +366,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -471,7 +471,7 @@ async function createUiModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
vi.mock("@ekklesia/shell/display-assets", () => ({
|
||||||
|
displayHostAssetUrls: Object.freeze({
|
||||||
|
html: "file:///surface/browser/display/index.html",
|
||||||
|
script: "file:///surface/browser/display/app.js",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDisplayHostAssets,
|
||||||
|
DisplayHostAssetError,
|
||||||
|
loadDisplayHostAssets,
|
||||||
|
} from "../src/display-host-assets.js";
|
||||||
|
|
||||||
|
describe("display host asset loading", () => {
|
||||||
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
|
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
|
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||||
|
const script = "export const displayHost = true;\n";
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||||
|
|
||||||
|
const assets = await loadDisplayHostAssets();
|
||||||
|
|
||||||
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
|
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||||
|
]);
|
||||||
|
expect(assets).toEqual({
|
||||||
|
html,
|
||||||
|
script,
|
||||||
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||||
|
readFileMock.mockRejectedValueOnce(
|
||||||
|
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failure = await loadDisplayHostAssets().catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(failure).toBeInstanceOf(DisplayHostAssetError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The display host browser assets are unavailable.",
|
||||||
|
name: "DisplayHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DisplayHostAssetError: The display host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||||
|
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||||
|
expect(publicText).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty page or script as an unavailable host build", () => {
|
||||||
|
for (const [html, script] of [
|
||||||
|
["", "export {};"],
|
||||||
|
["<html></html>", " \n"],
|
||||||
|
] as const) {
|
||||||
|
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,11 +8,16 @@ const betterAuthMock = vi.hoisted(() => vi.fn());
|
|||||||
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
||||||
const serveMock = vi.hoisted(() => vi.fn());
|
const serveMock = vi.hoisted(() => vi.fn());
|
||||||
const processOnSpy = vi.hoisted(() => vi.fn());
|
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||||
|
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||||
|
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
||||||
|
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
||||||
|
});
|
||||||
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
|
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
|
||||||
// gets the real Hono application; only the one test that deliberately forces an app-construction
|
// gets the real Hono application; only the one test that deliberately forces an app-construction
|
||||||
// failure overrides this with `mockImplementationOnce`.
|
// failure overrides this with `mockImplementationOnce`.
|
||||||
@@ -30,6 +35,7 @@ import {
|
|||||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||||
|
|
||||||
function validConfig() {
|
function validConfig() {
|
||||||
@@ -82,7 +88,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -149,6 +155,8 @@ interface WorkingFixtureModuleOptions {
|
|||||||
* can be exercised deterministically without any sleep. */
|
* can be exercised deterministically without any sleep. */
|
||||||
readonly stopNeverSettles?: boolean;
|
readonly stopNeverSettles?: boolean;
|
||||||
readonly dependencies?: Record<string, unknown>;
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
/** Declares one protected display artifact without importing it. */
|
||||||
|
readonly withDisplay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
|
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
|
||||||
@@ -162,12 +170,43 @@ async function createWorkingFixtureModule(
|
|||||||
options: WorkingFixtureModuleOptions = {},
|
options: WorkingFixtureModuleOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const permissionId = `${id}.ping.read`;
|
const permissionId = `${id}.ping.read`;
|
||||||
|
const displayPermission = `${id}.display.view`;
|
||||||
|
const permissions = [
|
||||||
|
...(options.withRoute
|
||||||
|
? [{ baseline: "public" as const, description: "Ping.", id: permissionId }]
|
||||||
|
: []),
|
||||||
|
...(options.withDisplay
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "View the display.",
|
||||||
|
id: displayPermission,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
await createFixtureModule(modulesDirectory, id, {
|
await createFixtureModule(modulesDirectory, id, {
|
||||||
permissions: options.withRoute
|
entrypoints: {
|
||||||
? [{ baseline: "public", description: "Ping.", id: permissionId }]
|
server: "./dist/server/index.js",
|
||||||
: [],
|
...(options.withDisplay
|
||||||
|
? {
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: displayPermission,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
permissions,
|
||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
});
|
});
|
||||||
|
if (options.withDisplay) {
|
||||||
|
await mkdir(path.join(modulesDirectory, id, "dist/display"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(modulesDirectory, id, "dist/display/index.js"),
|
||||||
|
'throw new Error("display artifact must never execute during server startup");\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
const routeSource = options.withRoute
|
const routeSource = options.withRoute
|
||||||
? `http.route({
|
? `http.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -215,7 +254,7 @@ function fakeDiscoveredModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions,
|
permissions,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -942,6 +981,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
betterAuthMock.mockReset();
|
betterAuthMock.mockReset();
|
||||||
drizzleAdapterMock.mockClear();
|
drizzleAdapterMock.mockClear();
|
||||||
serveMock.mockReset();
|
serveMock.mockReset();
|
||||||
|
loadDisplayHostAssetsMock.mockReset();
|
||||||
vi.mocked(createApiApp).mockClear();
|
vi.mocked(createApiApp).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -954,6 +994,46 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
return { poolEnd };
|
return { poolEnd };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("fails on missing display-host assets before importing module code and still closes startup resources", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||||
|
withDisplay: true,
|
||||||
|
});
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
loadDisplayHostAssetsMock.mockRejectedValueOnce(new DisplayHostAssetError());
|
||||||
|
|
||||||
|
const failure = await startApi(validConfig(), {
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
}).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The display host browser assets are unavailable.",
|
||||||
|
name: "DisplayHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DisplayHostAssetError: The display host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recorder.events).toEqual([]);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read display-host assets when the eligible set declares no display", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||||
|
mockPoolAndAuth();
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||||
|
|
||||||
|
expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled();
|
||||||
|
await running.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
|
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
configuration: fixture.configuration ?? [],
|
configuration: fixture.configuration ?? [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModules,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
|
import {
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
ModuleDisplayConfigurationError,
|
||||||
|
moduleDisplayDescriptorUrl,
|
||||||
|
moduleDisplayPageUrl,
|
||||||
|
} from "../src/module-display-routes.js";
|
||||||
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||||
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
import {
|
||||||
|
createInMemoryModuleLifecycleStore,
|
||||||
|
moduleLifecycleSnapshotFor,
|
||||||
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const displayPermission = "announcements.display.view";
|
||||||
|
|
||||||
|
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||||
|
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
||||||
|
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly display?: boolean;
|
||||||
|
readonly id?: string;
|
||||||
|
readonly ui?: boolean;
|
||||||
|
readonly version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
|
||||||
|
const id = fixture.id ?? "announcements";
|
||||||
|
const version = fixture.version ?? "1.4.2";
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${id}`,
|
||||||
|
type: "module",
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
|
...(fixture.display
|
||||||
|
? {
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
manifestVersion: 6,
|
||||||
|
name: `${id} module`,
|
||||||
|
permissions: fixture.display
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: `View the ${id} display.`,
|
||||||
|
id: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
|
||||||
|
);
|
||||||
|
if (fixture.ui) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fixture.display) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/display/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallOptions extends ModuleFixture {
|
||||||
|
readonly assets?: DisplayHostAssets;
|
||||||
|
readonly evaluator?: AuthorizationEvaluator;
|
||||||
|
readonly granted?: readonly string[];
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
/** The API builds publication from the eligible/running set. `false` models a module that was
|
||||||
|
* discovered but disabled or dependency-blocked before runtime startup. */
|
||||||
|
readonly published?: boolean;
|
||||||
|
readonly withAssets?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly app: Hono;
|
||||||
|
readonly modules: readonly DiscoveredModule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeServices(): ModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeAuthorizationStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install(options: InstallOptions): Promise<Installation> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
await createModule(modulesDirectory, options);
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
const granted = new Set(options.granted ?? []);
|
||||||
|
const evaluator =
|
||||||
|
options.evaluator ??
|
||||||
|
createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
||||||
|
platformOwners: { isPlatformOwner: async () => false },
|
||||||
|
});
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const publishedModules = options.published === false ? [] : modules;
|
||||||
|
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: composePlatformPermissionRegistry(modules),
|
||||||
|
store: fakeAuthorizationStore(),
|
||||||
|
},
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||||
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||||
|
: {}),
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeServices(),
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
moduleUi,
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, modules };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedIn(): EkklesiaPrincipal {
|
||||||
|
return createAuthenticatedPrincipal(viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module display routes — authorization and descriptor", () => {
|
||||||
|
it("does not let an anonymous viewer open either the page or descriptor", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const [page, descriptor] = await Promise.all([
|
||||||
|
app.request(moduleDisplayPageUrl("announcements")),
|
||||||
|
app.request(moduleDisplayDescriptorUrl("announcements")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(page.status).toBe(403);
|
||||||
|
expect(descriptor.status).toBe(403);
|
||||||
|
expect(page.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await page.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
||||||
|
const { app } = await install({ display: true, principal: signedIn() });
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(403);
|
||||||
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
version: "1.4.2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await app.request(moduleDisplayPageUrl("announcements"));
|
||||||
|
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||||
|
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(page.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
expect(await page.text()).toBe(displayHostAssets.html);
|
||||||
|
expect(await descriptor.json()).toEqual({
|
||||||
|
module: {
|
||||||
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||||
|
display: {
|
||||||
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
|
},
|
||||||
|
id: "announcements",
|
||||||
|
permissions: [displayPermission],
|
||||||
|
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||||
|
version: "1.4.2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(descriptor.headers.get("content-type")).toContain("application/json");
|
||||||
|
expect(descriptor.headers.get("vary")).toContain("Origin");
|
||||||
|
expect(descriptor.headers.get("vary")).toContain("Cookie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
|
||||||
|
const authorize = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ allowed: true })
|
||||||
|
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
|
||||||
|
const evaluator: AuthorizationEvaluator = { authorize };
|
||||||
|
const { app } = await install({ display: true, evaluator, principal: signedIn() });
|
||||||
|
|
||||||
|
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(authorize).toHaveBeenCalledTimes(2);
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("vary")).toContain("Origin");
|
||||||
|
expect(response.headers.get("vary")).toContain("Cookie");
|
||||||
|
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
|
||||||
|
expect(body).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the outer page stable when the internal artifact version changes", () => {
|
||||||
|
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
|
||||||
|
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module display routes — role and lifecycle separation", () => {
|
||||||
|
it("keeps a display-only module out of the primary UI inventory", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
expect(inventory.status).toBe(200);
|
||||||
|
expect(await inventory.json()).toEqual({ modules: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not give a dashboard-only module a display route", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn(), ui: true });
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||||
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
expect(inventory.status).toBe(200);
|
||||||
|
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
|
||||||
|
modules: [{ id: "announcements" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes both roles independently when a module declares both", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
ui: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||||
|
expect(
|
||||||
|
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] })
|
||||||
|
.modules,
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
published: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||||
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers only the exact stable page and descriptor paths", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const route of [
|
||||||
|
"/display",
|
||||||
|
"/display/announcements/",
|
||||||
|
"/display/announcements/settings",
|
||||||
|
"/api/v1/module-displays",
|
||||||
|
"/api/v1/module-displays/announcements/",
|
||||||
|
"/api/v1/module-displays/announcements/settings",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(route)).status, route).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails composition when a display exists but its host assets were not supplied", async () => {
|
||||||
|
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
|
||||||
|
ModuleDisplayConfigurationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module display host asset publication", () => {
|
||||||
|
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(displayHostAssets.script);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
|
||||||
|
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||||
|
});
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
expect(await notModified.text()).toBe("");
|
||||||
|
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
|
||||||
|
expect(notModified.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
|
||||||
|
for (const header of [
|
||||||
|
`W/${displayHostAssets.scriptEtag}`,
|
||||||
|
"*",
|
||||||
|
`"stale", ${displayHostAssets.scriptEtag}`,
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": header },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
header,
|
||||||
|
).toBe(304);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": '"stale"' },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
|
||||||
|
"/assets/ekklesia-display/index.html",
|
||||||
|
"/assets/ekklesia-display/secret.txt",
|
||||||
|
"/assets/ekklesia-display/../secret.txt",
|
||||||
|
"/api/v1/module-ui/announcements/1.4.2/management",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses identical non-interpolated HTML for every eligible module", async () => {
|
||||||
|
const first = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
const second = await install({
|
||||||
|
display: true,
|
||||||
|
granted: ["signage.display.view"],
|
||||||
|
id: "signage",
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
|
||||||
|
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
|
||||||
|
expect(firstHtml).toBe(secondHtml);
|
||||||
|
expect(firstHtml).not.toContain("announcements");
|
||||||
|
expect(secondHtml).not.toContain("signage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
||||||
|
const source = createDisplayHostAssets("<html>display host</html>", "export const host = 1;\n");
|
||||||
|
const mutable = { ...source };
|
||||||
|
const { app } = await install({ assets: mutable, display: true });
|
||||||
|
mutable.html = "<html>mutated secret</html>";
|
||||||
|
mutable.script = "throw new Error('mutated secret');";
|
||||||
|
|
||||||
|
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
install({
|
||||||
|
assets: { ...source, scriptEtag: '"stale-validator"' },
|
||||||
|
display: true,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish the generic host script when no eligible display uses it", async () => {
|
||||||
|
const { app } = await install({ ui: true, withAssets: false });
|
||||||
|
|
||||||
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||||
import {
|
import {
|
||||||
|
bootstrapDisplayHost,
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
createModuleUiContext,
|
createModuleUiContext,
|
||||||
type EkklesiaHostConfig,
|
type EkklesiaHostConfig,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
moduleLifecycleSnapshotFor,
|
moduleLifecycleSnapshotFor,
|
||||||
@@ -84,10 +86,14 @@ async function createFixtureModule(
|
|||||||
modulesDirectory: string,
|
modulesDirectory: string,
|
||||||
id = "example",
|
id = "example",
|
||||||
name = "Example module",
|
name = "Example module",
|
||||||
|
withDisplay = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
if (withDisplay) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "package.json"),
|
path.join(moduleDirectory, "package.json"),
|
||||||
@@ -102,9 +108,20 @@ async function createFixtureModule(
|
|||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
...(withDisplay
|
||||||
|
? {
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name,
|
name,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
@@ -112,6 +129,15 @@ async function createFixtureModule(
|
|||||||
description: "Read example records.",
|
description: "Read example records.",
|
||||||
id: `${id}.records.read`,
|
id: `${id}.records.read`,
|
||||||
},
|
},
|
||||||
|
...(withDisplay
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the example display.",
|
||||||
|
id: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
}),
|
}),
|
||||||
@@ -154,9 +180,26 @@ async function createFixtureModule(
|
|||||||
},
|
},
|
||||||
};\n`,
|
};\n`,
|
||||||
);
|
);
|
||||||
|
if (withDisplay) {
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/display/index.js"),
|
||||||
|
`export default {
|
||||||
|
id: ${JSON.stringify(id)},
|
||||||
|
version: "0.1.0",
|
||||||
|
async mount(container, context) {
|
||||||
|
const response = await context.api.request("records");
|
||||||
|
const records = response.ok ? await response.json() : [];
|
||||||
|
container.textContent =
|
||||||
|
context.moduleId + ":display:" + response.status + ":" + records.length;
|
||||||
|
return () => { container.textContent = ""; };
|
||||||
|
},
|
||||||
|
};\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HarnessOptions {
|
interface HarnessOptions {
|
||||||
|
readonly display?: boolean;
|
||||||
readonly principal?: EkklesiaPrincipal;
|
readonly principal?: EkklesiaPrincipal;
|
||||||
readonly grants?: readonly string[];
|
readonly grants?: readonly string[];
|
||||||
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
|
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
|
||||||
@@ -178,7 +221,7 @@ interface Harness {
|
|||||||
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
|
||||||
temporaryDirectories.push(modulesDirectory);
|
temporaryDirectories.push(modulesDirectory);
|
||||||
await createFixtureModule(modulesDirectory);
|
await createFixtureModule(modulesDirectory, "example", "Example module", options.display);
|
||||||
for (const extra of options.extraModules ?? []) {
|
for (const extra of options.extraModules ?? []) {
|
||||||
await createFixtureModule(modulesDirectory, extra.id, extra.name);
|
await createFixtureModule(modulesDirectory, extra.id, extra.name);
|
||||||
}
|
}
|
||||||
@@ -214,12 +257,21 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
revokePlatformOwner: vi.fn(),
|
revokePlatformOwner: vi.fn(),
|
||||||
} as unknown as AuthorizationStore;
|
} as unknown as AuthorizationStore;
|
||||||
|
|
||||||
|
const moduleUi = createModuleUiPublicationRegistry(modules);
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
moduleRoutes: moduleRuntime.routes,
|
moduleRoutes: moduleRuntime.routes,
|
||||||
moduleRealtime: moduleRuntime.realtime,
|
moduleRealtime: moduleRuntime.realtime,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi,
|
||||||
|
...(moduleUi.listDisplays().length === 0
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
displayHostAssets: createDisplayHostAssets(
|
||||||
|
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
||||||
|
"export const displayHost = true;\n",
|
||||||
|
),
|
||||||
|
}),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
store: createInMemoryModuleLifecycleStore(),
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
@@ -326,6 +378,81 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the stable display route, resolves its real descriptor, and mounts the published display bytes", async () => {
|
||||||
|
const harness = await startHarness({
|
||||||
|
display: true,
|
||||||
|
grants: ["example.records.read", "example.display.view"],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// 1. A device keeps this version-independent host URL. It is a real permission-gated API route,
|
||||||
|
// not a test-only HTML wrapper around the browser functions below.
|
||||||
|
const page = await (harness.config.fetch as typeof fetch)(
|
||||||
|
"https://ekklesia.example.test/display/example",
|
||||||
|
);
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(await page.text()).toContain("/assets/ekklesia-display/app.js");
|
||||||
|
|
||||||
|
// 2. The display surface's own bootstrap resolves principal first, then the singular protected
|
||||||
|
// descriptor. The browser constructs no artifact, API, or realtime URL itself.
|
||||||
|
const bootstrap = await bootstrapDisplayHost(harness.config, "example");
|
||||||
|
expect(bootstrap.status).toBe("ready");
|
||||||
|
if (bootstrap.status !== "ready") {
|
||||||
|
throw new Error("the authenticated display did not resolve");
|
||||||
|
}
|
||||||
|
expect(bootstrap.descriptor).toEqual({
|
||||||
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
id: "example",
|
||||||
|
permissions: ["example.records.read", "example.display.view"],
|
||||||
|
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||||
|
uiUrl: "/api/v1/module-ui/example/0.1.0/display",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Fetch the exact role-specific bytes from the real publication route, then genuinely import
|
||||||
|
// those returned bytes. Reading the fixture file directly would not prove publication.
|
||||||
|
const sourceUrl = moduleUiSourceUrl(harness.config, bootstrap.descriptor);
|
||||||
|
expect(sourceUrl).toBe(
|
||||||
|
"https://ekklesia.example.test/api/v1/module-ui/example/0.1.0/display",
|
||||||
|
);
|
||||||
|
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||||
|
|
||||||
|
// 4. The generic loader receives the unchanged five-member browser context and mounts the
|
||||||
|
// display artifact independently of the Dashboard artifact.
|
||||||
|
const context = createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor: bootstrap.descriptor,
|
||||||
|
principal: bootstrap.principal,
|
||||||
|
});
|
||||||
|
expect(Object.keys(context).sort()).toEqual([
|
||||||
|
"api",
|
||||||
|
"moduleId",
|
||||||
|
"permissions",
|
||||||
|
"principal",
|
||||||
|
"realtime",
|
||||||
|
]);
|
||||||
|
const container = containerSink();
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
container: container as unknown as LoaderContainer,
|
||||||
|
context,
|
||||||
|
source: {
|
||||||
|
moduleId: bootstrap.descriptor.id,
|
||||||
|
url: artifactUrl,
|
||||||
|
version: bootstrap.descriptor.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toBe("example:display:200:2");
|
||||||
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
|
await mounted.unmount();
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
expect(mounted.mounted).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
|
it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
|
||||||
// Signed in, but holding no grant for the module's restricted permission.
|
// Signed in, but holding no grant for the module's restricted permission.
|
||||||
const harness = await startHarness({
|
const harness = await startHarness({
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||||
...permission,
|
...permission,
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ import {
|
|||||||
type ModuleDatabase,
|
type ModuleDatabase,
|
||||||
type ModuleRuntimeServices,
|
type ModuleRuntimeServices,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
||||||
type AuthorizationEvaluator,
|
|
||||||
type AuthorizationStore,
|
|
||||||
CORE_PERMISSION_REGISTRY,
|
|
||||||
} from "@ekklesia/core/authorization";
|
|
||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
@@ -23,10 +20,12 @@ import type { Hono } from "hono";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
MODULE_UI_ASSET_PREFIX,
|
MODULE_UI_ASSET_PREFIX,
|
||||||
MODULE_UI_CACHE_CONTROL,
|
MODULE_UI_CACHE_CONTROL,
|
||||||
MODULE_UI_CONTENT_TYPE,
|
MODULE_UI_CONTENT_TYPE,
|
||||||
|
moduleDisplayUiArtifactUrl,
|
||||||
moduleUiArtifactUrl,
|
moduleUiArtifactUrl,
|
||||||
} from "../src/module-ui-routes.js";
|
} from "../src/module-ui-routes.js";
|
||||||
import {
|
import {
|
||||||
@@ -78,6 +77,8 @@ interface ModuleFixture {
|
|||||||
/** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only
|
/** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only
|
||||||
* module, whose manifest then declares no UI entrypoint at all. */
|
* module, whose manifest then declares no UI entrypoint at all. */
|
||||||
readonly ui?: string;
|
readonly ui?: string;
|
||||||
|
/** Written to a distinct artifact and declared as the protected display entrypoint. */
|
||||||
|
readonly display?: string;
|
||||||
/** Extra package-relative files written alongside the artifact — a source map, a sibling bundle
|
/** Extra package-relative files written alongside the artifact — a source map, a sibling bundle
|
||||||
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
||||||
* files that genuinely exist on disk rather than against hypothetical ones. */
|
* files that genuinely exist on disk rather than against hypothetical ones. */
|
||||||
@@ -86,6 +87,7 @@ interface ModuleFixture {
|
|||||||
|
|
||||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
const version = fixture.version ?? "0.1.0";
|
const version = fixture.version ?? "0.1.0";
|
||||||
|
const displayPermission = `${fixture.id}.display.view`;
|
||||||
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -104,10 +106,28 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
|
...(fixture.display === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: displayPermission,
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
|
permissions:
|
||||||
|
fixture.display === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: `View ${fixture.id}'s display`,
|
||||||
|
id: displayPermission,
|
||||||
|
},
|
||||||
|
],
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -119,6 +139,10 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
||||||
}
|
}
|
||||||
|
if (fixture.display !== undefined) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||||
|
}
|
||||||
for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) {
|
for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) {
|
||||||
const target = path.join(moduleDirectory, relativePath);
|
const target = path.join(moduleDirectory, relativePath);
|
||||||
await mkdir(path.dirname(target), { recursive: true });
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
@@ -139,7 +163,10 @@ interface Installation {
|
|||||||
* artifact path is hand-built, so these tests exercise the actual security boundary rather than a
|
* artifact path is hand-built, so these tests exercise the actual security boundary rather than a
|
||||||
* reconstruction of it.
|
* reconstruction of it.
|
||||||
*/
|
*/
|
||||||
async function install(fixtures: readonly ModuleFixture[]): Promise<Installation> {
|
async function install(
|
||||||
|
fixtures: readonly ModuleFixture[],
|
||||||
|
publicationModuleIds?: readonly string[],
|
||||||
|
): Promise<Installation> {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
for (const fixture of fixtures) {
|
for (const fixture of fixtures) {
|
||||||
await createModule(modulesDirectory, fixture);
|
await createModule(modulesDirectory, fixture);
|
||||||
@@ -149,6 +176,10 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
|||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.1.0",
|
||||||
});
|
});
|
||||||
|
const publicationModules =
|
||||||
|
publicationModuleIds === undefined
|
||||||
|
? modules
|
||||||
|
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
||||||
|
|
||||||
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
||||||
const services: ModuleRuntimeServices = Object.freeze({ database });
|
const services: ModuleRuntimeServices = Object.freeze({ database });
|
||||||
@@ -172,11 +203,19 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
|||||||
} as unknown as AuthorizationStore;
|
} as unknown as AuthorizationStore;
|
||||||
|
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store },
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
|
...(publicationModules.some((module) => module.entrypoints.display !== undefined)
|
||||||
|
? {
|
||||||
|
displayHostAssets: createDisplayHostAssets(
|
||||||
|
'<!doctype html><div id="display-root"></div>',
|
||||||
|
"export {};\n",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(publicationModules),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
store: createInMemoryModuleLifecycleStore(),
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
@@ -345,6 +384,155 @@ describe("module UI artifact publication — happy path", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("module display UI artifact publication", () => {
|
||||||
|
it("serves the declared display bytes anonymously at the exact role-specific URL", async () => {
|
||||||
|
const source = `${artifactSource("announcements", "1.4.2")}// display\n`;
|
||||||
|
const { app } = await install([{ display: source, id: "announcements", version: "1.4.2" }]);
|
||||||
|
const url = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
|
||||||
|
|
||||||
|
expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2/display`);
|
||||||
|
const response = await app.request(url);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(source);
|
||||||
|
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
||||||
|
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
const etag = response.headers.get("etag") as string;
|
||||||
|
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u);
|
||||||
|
|
||||||
|
const revalidated = await app.request(url, { headers: { "if-none-match": etag } });
|
||||||
|
expect(revalidated.status).toBe(304);
|
||||||
|
expect(await revalidated.text()).toBe("");
|
||||||
|
expect(revalidated.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
||||||
|
expect(revalidated.headers.get("etag")).toBe(etag);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps display-only, primary-only, both, and neither roles strictly separate", async () => {
|
||||||
|
const primary = `${artifactSource("signage", "0.1.0")}// primary\n`;
|
||||||
|
const display = `${artifactSource("signage", "0.1.0")}// display\n`;
|
||||||
|
const { app } = await install([
|
||||||
|
{ display: `${display}// announcements\n`, id: "announcements" },
|
||||||
|
{ id: "calendar", ui: `${primary}// calendar\n` },
|
||||||
|
{ display, id: "signage", ui: primary },
|
||||||
|
{ id: "backend" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const primaryUrl = (moduleId: string) => moduleUiArtifactUrl({ moduleId, version: "0.1.0" });
|
||||||
|
const displayUrl = (moduleId: string) =>
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId, version: "0.1.0" });
|
||||||
|
|
||||||
|
expect((await app.request(primaryUrl("announcements"))).status).toBe(404);
|
||||||
|
expect((await app.request(displayUrl("announcements"))).status).toBe(200);
|
||||||
|
expect((await app.request(primaryUrl("calendar"))).status).toBe(200);
|
||||||
|
expect((await app.request(displayUrl("calendar"))).status).toBe(404);
|
||||||
|
expect(await (await app.request(primaryUrl("signage"))).text()).toBe(primary);
|
||||||
|
expect(await (await app.request(displayUrl("signage"))).text()).toBe(display);
|
||||||
|
expect((await app.request(primaryUrl("backend"))).status).toBe(404);
|
||||||
|
expect((await app.request(displayUrl("backend"))).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses wrong versions with the same sanitized response as an undeclared display", async () => {
|
||||||
|
const { app } = await install([
|
||||||
|
{
|
||||||
|
display: `${artifactSource("announcements", "1.4.2")}// display\n`,
|
||||||
|
id: "announcements",
|
||||||
|
version: "1.4.2",
|
||||||
|
},
|
||||||
|
{ id: "calendar", ui: artifactSource("calendar", "0.1.0") },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const responses = await Promise.all(
|
||||||
|
[
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "latest" }),
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }),
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "missing", version: "1.4.2" }),
|
||||||
|
].map(async (url) => {
|
||||||
|
const response = await app.request(url);
|
||||||
|
return { body: await response.text(), status: response.status };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(responses).toEqual([
|
||||||
|
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
||||||
|
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
||||||
|
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never turns the display URL into a package directory or generic role selector", async () => {
|
||||||
|
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
|
||||||
|
const { app } = await install([
|
||||||
|
{
|
||||||
|
display,
|
||||||
|
extraFiles: {
|
||||||
|
".env": "DISPLAY_SECRET=do-not-serve\n",
|
||||||
|
"dist/display/index.js.map": "display source map secret",
|
||||||
|
"dist/display/internal-chunk.js": "export const internal = 'secret';\n",
|
||||||
|
},
|
||||||
|
id: "announcements",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const exact = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
||||||
|
|
||||||
|
for (const url of [
|
||||||
|
`${exact}.map`,
|
||||||
|
`${exact}/index.js.map`,
|
||||||
|
`${exact}/internal-chunk.js`,
|
||||||
|
`${exact}/../../.env`,
|
||||||
|
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/dashboard`,
|
||||||
|
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/arbitrary-role`,
|
||||||
|
]) {
|
||||||
|
const response = await app.request(url);
|
||||||
|
expect(response.status, url).toBe(404);
|
||||||
|
const body = await response.text();
|
||||||
|
expect(body, url).not.toContain("secret");
|
||||||
|
expect(body, url).not.toContain("mount");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot publish a blocked module omitted from Core's eligible registry input", async () => {
|
||||||
|
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
|
||||||
|
const { app } = await install(
|
||||||
|
[
|
||||||
|
{ display, id: "announcements" },
|
||||||
|
{ display: `${display}// signage\n`, id: "signage" },
|
||||||
|
],
|
||||||
|
["signage"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(await app.request(moduleDisplayUiArtifactUrl({ moduleId: "signage", version: "0.1.0" })))
|
||||||
|
.status,
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the same fixed 503 when the validated display artifact disappears", async () => {
|
||||||
|
const { app, modulesDirectory } = await install([
|
||||||
|
{
|
||||||
|
display: `${artifactSource("announcements", "0.1.0")}// display\n`,
|
||||||
|
id: "announcements",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await rm(path.join(modulesDirectory, "announcements/dist/display/index.js"));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("module UI artifact publication — module identity", () => {
|
describe("module UI artifact publication — module identity", () => {
|
||||||
async function expectNotPublished(app: Hono, url: string): Promise<void> {
|
async function expectNotPublished(app: Hono, url: string): Promise<void> {
|
||||||
const response = await app.request(url);
|
const response = await app.request(url);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
type ModuleCapabilityDeclaration,
|
type ModuleCapabilityDeclaration,
|
||||||
type ModuleManifest,
|
type ModuleManifest,
|
||||||
|
type PermissionId,
|
||||||
safeParseModuleManifest,
|
safeParseModuleManifest,
|
||||||
semanticVersionSchema,
|
semanticVersionSchema,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -61,10 +62,16 @@ export interface ResolvedModuleEntrypoints {
|
|||||||
readonly schema?: string;
|
readonly schema?: string;
|
||||||
readonly migrations?: string;
|
readonly migrations?: string;
|
||||||
readonly jobs?: string;
|
readonly jobs?: string;
|
||||||
/** A module's declared presentation artifact (ADR 0021), resolved and validated exactly like the
|
/** A module's primary presentation artifact (ADR 0021), resolved and validated exactly like the
|
||||||
* other entrypoints. Discovery only proves the file exists, is compiled ESM, and stays inside the
|
* other executable entrypoints. Discovery never imports or evaluates browser artifacts. */
|
||||||
* module package — nothing in this repository imports, evaluates, mounts, or renders it yet. */
|
|
||||||
readonly ui?: string;
|
readonly ui?: string;
|
||||||
|
/** The independently mountable full-viewport artifact and its statically declared access policy.
|
||||||
|
* `artifactPath` is canonical and package-contained; `permission` already matched one of this
|
||||||
|
* module's own manifest declarations during manifest parsing. */
|
||||||
|
readonly display?: {
|
||||||
|
readonly artifactPath: string;
|
||||||
|
readonly permission: PermissionId;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
||||||
@@ -258,11 +265,49 @@ async function resolveEntrypoints(
|
|||||||
): Promise<ResolvedModuleEntrypoints | undefined> {
|
): Promise<ResolvedModuleEntrypoints | undefined> {
|
||||||
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
||||||
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
||||||
|
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
||||||
|
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
||||||
|
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||||
|
.map(([entrypointName, relativeEntrypoint]) => ({
|
||||||
|
entrypointName,
|
||||||
|
issuePath: `entrypoints.${entrypointName}`,
|
||||||
|
relativeEntrypoint,
|
||||||
|
}));
|
||||||
|
const configuredEntrypoints = [
|
||||||
|
...ordinaryConfiguredEntrypoints,
|
||||||
|
...(display === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
entrypointName: "display",
|
||||||
|
issuePath: "entrypoints.display.artifact",
|
||||||
|
relativeEntrypoint: display.artifact,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
let entrypointCollision = false;
|
||||||
|
|
||||||
for (const [entrypointName, relativeEntrypoint] of Object.entries(manifest.entrypoints)) {
|
if (display !== undefined) {
|
||||||
if (relativeEntrypoint === undefined) {
|
const configuredDisplayPath = path.resolve(moduleDirectory, display.artifact);
|
||||||
continue;
|
for (const ordinary of ordinaryConfiguredEntrypoints) {
|
||||||
|
if (path.resolve(moduleDirectory, ordinary.relativeEntrypoint) === configuredDisplayPath) {
|
||||||
|
entrypointCollision = true;
|
||||||
|
issues.push({
|
||||||
|
code: "MODULE_ENTRYPOINT_INVALID",
|
||||||
|
message: `display must not resolve to the same configured file as ${ordinary.entrypointName}.`,
|
||||||
|
moduleDirectory: directoryName,
|
||||||
|
moduleId: manifest.id,
|
||||||
|
path: "entrypoints.display.artifact",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const {
|
||||||
|
entrypointName,
|
||||||
|
issuePath: entrypointIssuePath,
|
||||||
|
relativeEntrypoint,
|
||||||
|
} of configuredEntrypoints) {
|
||||||
if (
|
if (
|
||||||
isCompiledEntrypoint(entrypointName) &&
|
isCompiledEntrypoint(entrypointName) &&
|
||||||
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
||||||
@@ -272,7 +317,7 @@ async function resolveEntrypoints(
|
|||||||
message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`,
|
message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`,
|
||||||
moduleDirectory: directoryName,
|
moduleDirectory: directoryName,
|
||||||
moduleId: manifest.id,
|
moduleId: manifest.id,
|
||||||
path: `entrypoints.${entrypointName}`,
|
path: entrypointIssuePath,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -283,7 +328,7 @@ async function resolveEntrypoints(
|
|||||||
message: `${entrypointName} resolves outside its module directory.`,
|
message: `${entrypointName} resolves outside its module directory.`,
|
||||||
moduleDirectory: directoryName,
|
moduleDirectory: directoryName,
|
||||||
moduleId: manifest.id,
|
moduleId: manifest.id,
|
||||||
path: `entrypoints.${entrypointName}`,
|
path: entrypointIssuePath,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -296,7 +341,7 @@ async function resolveEntrypoints(
|
|||||||
message: `${entrypointName} resolves through a link outside its module directory.`,
|
message: `${entrypointName} resolves through a link outside its module directory.`,
|
||||||
moduleDirectory: directoryName,
|
moduleDirectory: directoryName,
|
||||||
moduleId: manifest.id,
|
moduleId: manifest.id,
|
||||||
path: `entrypoints.${entrypointName}`,
|
path: entrypointIssuePath,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -312,7 +357,7 @@ async function resolveEntrypoints(
|
|||||||
: `${entrypointName} must resolve to a file.`,
|
: `${entrypointName} must resolve to a file.`,
|
||||||
moduleDirectory: directoryName,
|
moduleDirectory: directoryName,
|
||||||
moduleId: manifest.id,
|
moduleId: manifest.id,
|
||||||
path: `entrypoints.${entrypointName}`,
|
path: entrypointIssuePath,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -329,12 +374,27 @@ async function resolveEntrypoints(
|
|||||||
}.`,
|
}.`,
|
||||||
moduleDirectory: directoryName,
|
moduleDirectory: directoryName,
|
||||||
moduleId: manifest.id,
|
moduleId: manifest.id,
|
||||||
path: `entrypoints.${entrypointName}`,
|
path: entrypointIssuePath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved.server) {
|
if (resolved.display !== undefined) {
|
||||||
|
for (const [entrypointName, canonicalEntrypoint] of Object.entries(resolved)) {
|
||||||
|
if (entrypointName !== "display" && canonicalEntrypoint === resolved.display) {
|
||||||
|
entrypointCollision = true;
|
||||||
|
issues.push({
|
||||||
|
code: "MODULE_ENTRYPOINT_INVALID",
|
||||||
|
message: `display must not resolve through a link to the same file as ${entrypointName}.`,
|
||||||
|
moduleDirectory: directoryName,
|
||||||
|
moduleId: manifest.id,
|
||||||
|
path: "entrypoints.display.artifact",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.server || entrypointCollision) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +404,14 @@ async function resolveEntrypoints(
|
|||||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||||
...(resolved.jobs ? { jobs: resolved.jobs } : {}),
|
...(resolved.jobs ? { jobs: resolved.jobs } : {}),
|
||||||
...(resolved.ui ? { ui: resolved.ui } : {}),
|
...(resolved.ui ? { ui: resolved.ui } : {}),
|
||||||
|
...(resolved.display && display
|
||||||
|
? {
|
||||||
|
display: {
|
||||||
|
artifactPath: resolved.display,
|
||||||
|
permission: display.permission,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PermissionDeclaration } from "@ekklesia/contracts";
|
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts";
|
||||||
import type { DiscoveredModule } from "./discovery.js";
|
import type { DiscoveredModule } from "./discovery.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +40,26 @@ export interface PublishableModuleUi {
|
|||||||
readonly permissions: readonly PermissionDeclaration[];
|
readonly permissions: readonly PermissionDeclaration[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One installed module whose dedicated full-viewport display artifact may be published.
|
||||||
|
*
|
||||||
|
* This is deliberately a separate record from {@link PublishableModuleUi}. A primary UI and a
|
||||||
|
* display UI are two independently declared artifacts with different host roles, and a module may
|
||||||
|
* publish either one without publishing the other. `displayPermission` is the module-owned
|
||||||
|
* permission the manifest places beside `entrypoints.display`; it travels with the exact artifact
|
||||||
|
* identity so a display descriptor cannot accidentally authorize one artifact with another
|
||||||
|
* module's policy. The complete declaration list supports the same browser-safe permission hints
|
||||||
|
* as the primary UI while conferring no authority by itself.
|
||||||
|
*/
|
||||||
|
export interface PublishableModuleDisplay {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly artifactPath: string;
|
||||||
|
readonly displayPermission: PermissionId;
|
||||||
|
readonly permissions: readonly PermissionDeclaration[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
|
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
|
||||||
*
|
*
|
||||||
@@ -60,6 +80,15 @@ export interface ModuleUiPublicationRegistry {
|
|||||||
* version are all indistinguishable here — each returns `undefined`.
|
* version are all indistinguishable here — each returns `undefined`.
|
||||||
*/
|
*/
|
||||||
resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined;
|
resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined;
|
||||||
|
/** Every dedicated display artifact, in discovery order. Stable and frozen. */
|
||||||
|
listDisplays(): readonly PublishableModuleDisplay[];
|
||||||
|
/**
|
||||||
|
* The dedicated display record for an exact module id and installed version, or `undefined`.
|
||||||
|
*
|
||||||
|
* This lookup never falls back to `entrypoints.ui`: a primary/dashboard artifact cannot become
|
||||||
|
* a display merely because a caller appends `/display` to its publication URL.
|
||||||
|
*/
|
||||||
|
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,8 +99,9 @@ export interface ModuleUiPublicationRegistry {
|
|||||||
* 1. the module is in the **eligible** set the platform started — it survived discovery *and*
|
* 1. the module is in the **eligible** set the platform started — it survived discovery *and*
|
||||||
* the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) —
|
* the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) —
|
||||||
* and
|
* and
|
||||||
* 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file
|
* 2. for each role independently, its manifest declares `entrypoints.ui` or
|
||||||
* inside the module package.
|
* `entrypoints.display`, which discovery resolved to a real compiled file inside the module
|
||||||
|
* package.
|
||||||
*
|
*
|
||||||
* A module directory sitting on disk satisfies neither on its own — it has to have survived
|
* A module directory sitting on disk satisfies neither on its own — it has to have survived
|
||||||
* discovery — which is the property `docs/development/security.md` requires: *the existence of an
|
* discovery — which is the property `docs/development/security.md` requires: *the existence of an
|
||||||
@@ -93,30 +123,47 @@ export function createModuleUiPublicationRegistry(
|
|||||||
modules: readonly DiscoveredModule[],
|
modules: readonly DiscoveredModule[],
|
||||||
): ModuleUiPublicationRegistry {
|
): ModuleUiPublicationRegistry {
|
||||||
const byModuleId = new Map<string, PublishableModuleUi>();
|
const byModuleId = new Map<string, PublishableModuleUi>();
|
||||||
|
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
||||||
|
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
const artifactPath = module.entrypoints.ui;
|
const artifactPath = module.entrypoints.ui;
|
||||||
if (artifactPath === undefined) {
|
if (artifactPath !== undefined) {
|
||||||
continue;
|
byModuleId.set(
|
||||||
|
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 published = Object.freeze([...byModuleId.values()]);
|
||||||
|
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
list: (): readonly PublishableModuleUi[] => published,
|
list: (): readonly PublishableModuleUi[] => published,
|
||||||
|
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
||||||
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
||||||
if (typeof moduleId !== "string" || typeof version !== "string") {
|
if (typeof moduleId !== "string" || typeof version !== "string") {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -127,5 +174,12 @@ export function createModuleUiPublicationRegistry(
|
|||||||
// than to a different artifact that happens to exist.
|
// than to a different artifact that happens to exist.
|
||||||
return candidate?.version === version ? candidate : undefined;
|
return candidate?.version === version ? candidate : undefined;
|
||||||
},
|
},
|
||||||
|
resolveDisplay: (moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined => {
|
||||||
|
if (typeof moduleId !== "string" || typeof version !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const candidate = displaysByModuleId.get(moduleId);
|
||||||
|
return candidate?.version === version ? candidate : undefined;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function createModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
: {}),
|
: {}),
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 5,
|
"manifestVersion": 6,
|
||||||
"id": "db-facade",
|
"id": "db-facade",
|
||||||
"name": "Database Facade Integration Test Module",
|
"name": "Database Facade Integration Test Module",
|
||||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function manifestWith(
|
|||||||
id = "announcements",
|
id = "announcements",
|
||||||
) {
|
) {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
|
|||||||
|
|
||||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
|
|||||||
|
|
||||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
|
|||||||
|
|
||||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
|||||||
],
|
],
|
||||||
])("rejects %s", (_label, declaration) => {
|
])("rejects %s", (_label, declaration) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
|||||||
"valueOf",
|
"valueOf",
|
||||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
|||||||
"",
|
"",
|
||||||
])("rejects the malformed configuration key %s", (key) => {
|
])("rejects the malformed configuration key %s", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(id: string): Record<string, unknown> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -748,7 +748,9 @@ describe("module discovery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(discovered?.entrypoints.ui).toBeUndefined();
|
expect(discovered?.entrypoints.ui).toBeUndefined();
|
||||||
|
expect(discovered?.entrypoints.display).toBeUndefined();
|
||||||
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "ui")).toBe(false);
|
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "ui")).toBe(false);
|
||||||
|
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "display")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a source TypeScript UI entrypoint where compiled output is required", async () => {
|
it("rejects a source TypeScript UI entrypoint where compiled output is required", async () => {
|
||||||
@@ -821,4 +823,137 @@ describe("module discovery", () => {
|
|||||||
expect(discovered[0]?.entrypoints.ui).toContain("dist/ui/index.js");
|
expect(discovered[0]?.entrypoints.ui).toContain("dist/ui/index.js");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("module-owned display UI entrypoint", () => {
|
||||||
|
function manifestWithDisplay(
|
||||||
|
id: string,
|
||||||
|
artifact: string,
|
||||||
|
ui?: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const manifest = manifestFor(id);
|
||||||
|
manifest.entrypoints = {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(ui === undefined ? {} : { ui }),
|
||||||
|
display: { artifact, permission: `${id}.records.read` },
|
||||||
|
};
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("resolves a display-only artifact and preserves its module-owned permission", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "announcements", {
|
||||||
|
manifest: manifestWithDisplay("announcements", "./dist/display/index.mjs"),
|
||||||
|
extraFiles: {
|
||||||
|
"dist/display/index.mjs": "throw new Error('discovery must stay inert');",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [discovered] = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(discovered?.entrypoints.ui).toBeUndefined();
|
||||||
|
expect(discovered?.entrypoints.display).toEqual({
|
||||||
|
artifactPath: path.join(discovered?.directory ?? "", "dist/display/index.mjs"),
|
||||||
|
permission: "announcements.records.read",
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(discovered?.entrypoints.display)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves independent primary and display artifacts without executing either", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "signage", {
|
||||||
|
manifest: manifestWithDisplay("signage", "./dist/display/index.js", "./dist/ui/index.js"),
|
||||||
|
extraFiles: {
|
||||||
|
"dist/display/index.js": "throw new Error('display must stay inert');",
|
||||||
|
"dist/ui/index.js": "throw new Error('primary UI must stay inert');",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [discovered] = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(discovered?.entrypoints.ui).toContain("dist/ui/index.js");
|
||||||
|
expect(discovered?.entrypoints.display?.artifactPath).toContain("dist/display/index.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects source TypeScript and reports the nested display artifact path", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "signage", {
|
||||||
|
manifest: manifestWithDisplay("signage", "./src/display/index.tsx"),
|
||||||
|
extraFiles: { "src/display/index.tsx": "export default {};" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await discoveryError(modulesDirectory);
|
||||||
|
|
||||||
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
|
||||||
|
expect(error.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ path: "entrypoints.display.artifact" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a declared display whose compiled artifact is missing", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "signage", {
|
||||||
|
manifest: manifestWithDisplay("signage", "./dist/display/index.js"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await discoveryError(modulesDirectory);
|
||||||
|
|
||||||
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
|
||||||
|
expect(error.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ path: "entrypoints.display.artifact" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects distinct configured UI paths that resolve through links to one artifact", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "signage", {
|
||||||
|
manifest: manifestWithDisplay("signage", "./dist/display/index.js", "./dist/ui/index.js"),
|
||||||
|
extraFiles: { "dist/shared.js": "export default {};" },
|
||||||
|
});
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, "signage");
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }),
|
||||||
|
mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }),
|
||||||
|
]);
|
||||||
|
await Promise.all([
|
||||||
|
symlink("../shared.js", path.join(moduleDirectory, "dist/ui/index.js")),
|
||||||
|
symlink("../shared.js", path.join(moduleDirectory, "dist/display/index.js")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const error = await discoveryError(modulesDirectory);
|
||||||
|
|
||||||
|
expect(error.issues).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "MODULE_ENTRYPOINT_INVALID",
|
||||||
|
path: "entrypoints.display.artifact",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a display artifact that resolves through a link to the server artifact", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "signage", {
|
||||||
|
manifest: manifestWithDisplay("signage", "./dist/display/index.js"),
|
||||||
|
});
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, "signage");
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
|
await symlink("../server/index.js", path.join(moduleDirectory, "dist/display/index.js"));
|
||||||
|
|
||||||
|
const error = await discoveryError(modulesDirectory);
|
||||||
|
|
||||||
|
expect(error.issues).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "MODULE_ENTRYPOINT_INVALID",
|
||||||
|
path: "entrypoints.display.artifact",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ afterAll(async () => {
|
|||||||
* by eligibility resolution. */
|
* by eligibility resolution. */
|
||||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -343,7 +343,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface PermissionInput {
|
|||||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function manifest(
|
|||||||
permissions: readonly Record<string, string>[] = [],
|
permissions: readonly Record<string, string>[] = [],
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function manifestFor(
|
|||||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ interface ModuleFixture {
|
|||||||
readonly version?: string;
|
readonly version?: string;
|
||||||
/** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */
|
/** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */
|
||||||
readonly ui?: string;
|
readonly ui?: string;
|
||||||
|
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
||||||
|
readonly display?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
const version = fixture.version ?? "0.1.0";
|
const version = fixture.version ?? "0.1.0";
|
||||||
|
const displayPermission = `${fixture.id}.display.view`;
|
||||||
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -49,10 +52,28 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
|
...(fixture.display === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: displayPermission,
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
|
permissions:
|
||||||
|
fixture.display === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: `View ${fixture.id}'s display`,
|
||||||
|
id: displayPermission,
|
||||||
|
},
|
||||||
|
],
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -64,6 +85,10 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
||||||
}
|
}
|
||||||
|
if (fixture.display !== undefined) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
|
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
|
||||||
@@ -158,7 +183,9 @@ describe("module UI publication registry", () => {
|
|||||||
const registry = createModuleUiPublicationRegistry([]);
|
const registry = createModuleUiPublicationRegistry([]);
|
||||||
|
|
||||||
expect(registry.list()).toEqual([]);
|
expect(registry.list()).toEqual([]);
|
||||||
|
expect(registry.listDisplays()).toEqual([]);
|
||||||
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
|
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
|
||||||
|
expect(registry.resolveDisplay("announcements", "0.1.0")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("publishes an immutable snapshot that no caller can extend", async () => {
|
it("publishes an immutable snapshot that no caller can extend", async () => {
|
||||||
@@ -184,3 +211,86 @@ describe("module UI publication registry", () => {
|
|||||||
expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBeDefined();
|
expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("module display UI publication registry", () => {
|
||||||
|
it("keeps primary UI and display publication independent for display-only, both, and neither", async () => {
|
||||||
|
const modules = await discover([
|
||||||
|
{ display: "export default { role: 'display' };", id: "announcements" },
|
||||||
|
{
|
||||||
|
display: "export default { role: 'display' };",
|
||||||
|
id: "signage",
|
||||||
|
ui: "export default { role: 'primary' };",
|
||||||
|
},
|
||||||
|
{ id: "calendar" },
|
||||||
|
]);
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
|
||||||
|
expect(registry.list().map((entry) => entry.moduleId)).toEqual(["signage"]);
|
||||||
|
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual([
|
||||||
|
"announcements",
|
||||||
|
"signage",
|
||||||
|
]);
|
||||||
|
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
|
||||||
|
expect(registry.resolveDisplay("calendar", "0.1.0")).toBeUndefined();
|
||||||
|
|
||||||
|
const signage = registry.resolveDisplay("signage", "0.1.0");
|
||||||
|
expect(signage).toMatchObject({
|
||||||
|
displayPermission: "signage.display.view",
|
||||||
|
moduleId: "signage",
|
||||||
|
name: "signage module",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
expect(signage?.artifactPath).not.toBe(registry.resolve("signage", "0.1.0")?.artifactPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries only discovery's canonical display artifact and declared permission metadata", async () => {
|
||||||
|
const modules = await discover([
|
||||||
|
{ display: 'throw new Error("display must remain inert");', id: "announcements" },
|
||||||
|
]);
|
||||||
|
const discovered = modules[0] as DiscoveredModule;
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
const published = registry.resolveDisplay("announcements", "0.1.0");
|
||||||
|
|
||||||
|
expect(published?.artifactPath).toBe(discovered.entrypoints.display?.artifactPath);
|
||||||
|
expect(path.isAbsolute(published?.artifactPath as string)).toBe(true);
|
||||||
|
expect(published?.displayPermission).toBe(discovered.entrypoints.display?.permission);
|
||||||
|
expect(published?.permissions.map((permission) => permission.id)).toEqual([
|
||||||
|
"announcements.display.view",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires exact identity and fails closed on malformed lookup values", async () => {
|
||||||
|
const modules = await discover([
|
||||||
|
{ display: "export default {};", id: "announcements", version: "1.4.2" },
|
||||||
|
]);
|
||||||
|
const registry = createModuleUiPublicationRegistry(modules);
|
||||||
|
|
||||||
|
expect(registry.resolveDisplay("announcements", "1.4.2")).toBeDefined();
|
||||||
|
for (const version of ["1.4.1", "latest", "^1.4.2", "1.4.2 ", ""]) {
|
||||||
|
expect(registry.resolveDisplay("announcements", version)).toBeUndefined();
|
||||||
|
}
|
||||||
|
for (const value of [undefined, null, 0, {}, [], Symbol("announcements")]) {
|
||||||
|
expect(registry.resolveDisplay(value, "1.4.2")).toBeUndefined();
|
||||||
|
expect(registry.resolveDisplay("announcements", value)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes a frozen snapshot and cannot expose a module omitted from its eligible input", async () => {
|
||||||
|
const modules = await discover([
|
||||||
|
{ display: "export default {};", id: "announcements" },
|
||||||
|
{ display: "export default {};", id: "signage" },
|
||||||
|
]);
|
||||||
|
// Production passes Core's eligible/started set. Omitting a blocked module at this boundary is
|
||||||
|
// sufficient to make both its metadata and artifact structurally unresolvable.
|
||||||
|
const registry = createModuleUiPublicationRegistry(
|
||||||
|
modules.filter((module) => module.manifest.id === "signage"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registry.resolveDisplay("announcements", "0.1.0")).toBeUndefined();
|
||||||
|
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual(["signage"]);
|
||||||
|
expect(Object.isFrozen(registry.listDisplays())).toBe(true);
|
||||||
|
expect(Object.isFrozen(registry.listDisplays()[0])).toBe(true);
|
||||||
|
expect(Object.isFrozen(registry.listDisplays()[0]?.permissions)).toBe(true);
|
||||||
|
expect(registry.listDisplays()).toBe(registry.listDisplays());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# 0033: Display UI is a separate, permission-gated module entrypoint
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-23
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0021 through ADR 0027 established one optional module-owned browser artifact,
|
||||||
|
`entrypoints.ui`, and an Ekklesia Dashboard shell that discovers, publishes, and mounts it. That
|
||||||
|
contract is appropriate for a module's ordinary product interface: Announcements management,
|
||||||
|
calendar editing, settings, or operational controls.
|
||||||
|
|
||||||
|
A long-lived screen is a different presentation context. An announcement TV, lobby sign,
|
||||||
|
confidence monitor, or kiosk needs a clean viewport, no Dashboard navigation, and a stable bookmark.
|
||||||
|
Forcing that experience through the Dashboard would either expose management chrome on the screen or
|
||||||
|
make one large artifact branch on an Ekklesia-specific mode. Neither shape transfers cleanly to the
|
||||||
|
future one-module standalone host.
|
||||||
|
|
||||||
|
The real Announcements client pressure-tests the boundary. Its slideshow wants an authoritative
|
||||||
|
snapshot, subsequent state updates, direct image URLs, a black mode-off state, and local fade
|
||||||
|
transitions. It does not need Dashboard navigation or a second transport. Its current fixed LAN
|
||||||
|
addresses, anonymous Socket.IO connection, global socket, and incomplete teardown are migration
|
||||||
|
concerns, not contracts Core should copy.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### A module may declare one separate display artifact
|
||||||
|
|
||||||
|
Manifest version 6 adds this optional declaration while retaining `entrypoints.ui` unchanged:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"manifestVersion": 6,
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"id": "announcements.display.view",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "View the announcement display."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"entrypoints": {
|
||||||
|
"server": "./dist/server/index.js",
|
||||||
|
"ui": "./dist/ui/index.js",
|
||||||
|
"display": {
|
||||||
|
"artifact": "./dist/display/index.js",
|
||||||
|
"permission": "announcements.display.view"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ui` remains the module's primary/management artifact and is the only role eligible for Dashboard
|
||||||
|
navigation. `display` is a full-viewport artifact and never implies Dashboard placement. Either
|
||||||
|
field may be absent, so a module may provide primary UI only, display only, both, or neither.
|
||||||
|
|
||||||
|
The display declaration carries its permission beside its artifact. The permission must be declared
|
||||||
|
and owned by that module, and its baseline must be `authenticated` or `restricted`, never `public`.
|
||||||
|
This makes it impossible to declare a display without an explicit access policy, silently reuse a
|
||||||
|
management permission chosen by Core, or introduce anonymous display access before a deliberate
|
||||||
|
device/public principal exists. The display artifact must differ from every other configured
|
||||||
|
entrypoint before and after canonical path resolution.
|
||||||
|
|
||||||
|
One display artifact per module is deliberate. Named variants such as lobby, stage, or confidence
|
||||||
|
display are deferred until a real module requires them; a generic display registry now would create
|
||||||
|
identity, routing, permission, and management contracts without a consumer.
|
||||||
|
|
||||||
|
Version 6 is a hard cutover. Version 5's strict `entrypoints` object did not admit `display`, so
|
||||||
|
silently accepting the new field under the old version would allow one version number to mean two
|
||||||
|
schemas. A module with no display upgrades by changing only the version literal.
|
||||||
|
|
||||||
|
### Discovery and build remain static
|
||||||
|
|
||||||
|
Discovery reads the manifest without importing browser code. The declared display artifact obeys
|
||||||
|
the same package containment, symlink, real-file, and compiled `.js`/`.mjs` rules as primary UI.
|
||||||
|
Complete discovery still succeeds or fails atomically before any runtime entrypoint executes.
|
||||||
|
|
||||||
|
The canonical module UI builder reads both declarations and builds exactly what is declared in one
|
||||||
|
invocation:
|
||||||
|
|
||||||
|
- `entrypoints.ui`, when present, from `src/ui/index.tsx`;
|
||||||
|
- `entrypoints.display.artifact`, when present, from `src/display/index.tsx`.
|
||||||
|
|
||||||
|
It validates every source and output before starting either build. The artifacts use the same
|
||||||
|
browser boundary and bundling policy from ADR 0023. No CSS sidecar, router, or alternate React
|
||||||
|
runtime contract is introduced.
|
||||||
|
|
||||||
|
### Both artifacts use the existing module UI ABI and context
|
||||||
|
|
||||||
|
A display artifact default-exports the same `defineModuleUi({ id, version, mount })` definition and
|
||||||
|
returns the same mount cleanup. The generic loader still validates identity and creates one
|
||||||
|
independent mounted instance per call. No display-specific ABI exists.
|
||||||
|
|
||||||
|
`ModuleUiHostContext` also remains unchanged: `moduleId`, the module-relative API client, principal,
|
||||||
|
permission hints, and realtime are sufficient. A `presentation.kind` flag is intentionally omitted.
|
||||||
|
The distinct static artifact already tells module code which product experience it implements; a
|
||||||
|
mode flag would encourage the single branching artifact this decision rejects. A module may share
|
||||||
|
ordinary internal components between its two sources and parameterize those components itself.
|
||||||
|
|
||||||
|
Nothing exposes a database, storage provider, S3 credential, bucket name, session token, server
|
||||||
|
path, or Ekklesia route. A standalone host can provide the exact same ABI and context at a different
|
||||||
|
outer URL.
|
||||||
|
|
||||||
|
### Publication distinguishes the role exactly
|
||||||
|
|
||||||
|
The existing eligible/running publication registry gains a display view. Primary UI keeps its
|
||||||
|
existing URL unchanged. The display artifact has one exact role-specific URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/v1/module-ui/<module-id>/<version>/display
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no request-controlled role, filename, or package path. The publisher reads only the exact
|
||||||
|
canonical path discovery accepted. A source map, sibling chunk, management artifact, and every
|
||||||
|
other package file remain unreachable. Artifact bytes keep ADR 0025's anonymous-code policy:
|
||||||
|
downloading trusted browser code grants no authority, while failures remain indistinguishable to
|
||||||
|
avoid enumeration. Authorization occurs before the host discloses a descriptor or mounts that code.
|
||||||
|
|
||||||
|
### Ekklesia owns a stable, permission-gated outer route
|
||||||
|
|
||||||
|
Ekklesia registers these exact routes only for eligible, running display publications:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /display/<module-id>
|
||||||
|
GET /api/v1/module-displays/<module-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The first is the version-independent bookmark a device keeps across module upgrades. The second is
|
||||||
|
the singular bootstrap descriptor containing the current versioned display artifact URL, the
|
||||||
|
module's API and realtime bases, and that principal's module-scoped permission hints. There is no
|
||||||
|
display inventory or management UI in this pass.
|
||||||
|
|
||||||
|
Both routes require the manifest's module-owned display permission through the existing identity,
|
||||||
|
permission registry, and authorization evaluator. There is no anonymous exception. A future
|
||||||
|
device/kiosk/public-display principal is a separate security decision; until then, a display uses an
|
||||||
|
ordinary authenticated session and explicit permission.
|
||||||
|
|
||||||
|
Routes are composed from the eligible set after successful module startup. A dashboard-only,
|
||||||
|
disabled, dependency-blocked, failed, or unknown module has no display route or descriptor. A
|
||||||
|
running module remains available until process restart under ADR 0028; this decision adds no hot
|
||||||
|
disable behavior.
|
||||||
|
|
||||||
|
### The display browser root is separate from Dashboard chrome
|
||||||
|
|
||||||
|
`surfaces/shell` builds a second top-level browser application for the display route. It does not
|
||||||
|
render `HostApp`, Dashboard navigation, a header, sidebar, breadcrumbs, or operational controls, and
|
||||||
|
Dashboard code is not statically imported into its bundle. Keeping both applications in the one
|
||||||
|
surface package reuses the security-sensitive principal/API/realtime adaptation already there;
|
||||||
|
creating `surfaces/display` would either duplicate it or require a broader shared-package extraction,
|
||||||
|
while repository boundaries correctly forbid one surface from importing another.
|
||||||
|
|
||||||
|
The page gives its mount root the whole viewport, removes default margin and padding, hides document
|
||||||
|
overflow, and otherwise does not theme module output. Full-screen means **full viewport**. The host
|
||||||
|
does not invoke the browser Fullscreen API; OS/browser kiosk launch policy belongs to deployment.
|
||||||
|
|
||||||
|
The host owns neutral loading and sanitized failure output, clears partial DOM after a failed mount,
|
||||||
|
and provides a deliberate retry without a tight automatic loop. It aborts stale bootstrap work,
|
||||||
|
immediately unmounts a load that resolves after teardown, and swallows cleanup failure only while
|
||||||
|
teardown is already in progress. Multiple tabs and physical screens create independent loader,
|
||||||
|
context, and realtime instances; no browser-global module state is introduced by the host.
|
||||||
|
|
||||||
|
Some frameworks report a render failure only after their `mount` call has returned. Because the
|
||||||
|
display route is a dedicated page containing exactly one module artifact, an uncaught error targeted
|
||||||
|
at `window` or an unhandled promise rejection conservatively fails and unmounts that display attempt.
|
||||||
|
Element-targeted media/resource errors do not: those remain module state. There is no portable way to
|
||||||
|
attribute a global exception to a framework tree, and errors a module catches internally remain that
|
||||||
|
module's responsibility.
|
||||||
|
|
||||||
|
The surface's exact HTML and JavaScript files are read once during API startup and served from
|
||||||
|
frozen in-memory text at fixed routes. The API never exposes the surface output directory, derives a
|
||||||
|
file from request input, serves its source map, or interpolates a module id into HTML. A missing host
|
||||||
|
build fails startup before the listener binds.
|
||||||
|
|
||||||
|
### Existing delivery and realtime contracts are reused
|
||||||
|
|
||||||
|
Display UI calls `context.realtime.subscribe("state", ...)`. The existing SSE host supplies the
|
||||||
|
immediate authoritative snapshot, native reconnect, fresh post-reconnect snapshot, bounded delivery,
|
||||||
|
and cleanup defined in ADR 0032. No Socket.IO, WebSocket, MQTT, display-specific channel, or retry
|
||||||
|
transport is added.
|
||||||
|
|
||||||
|
If state contains opaque signed object URLs from ADR 0031, display UI assigns those URLs directly to
|
||||||
|
browser media elements. The display host does not proxy, inspect, rewrite, renew, or regenerate
|
||||||
|
them. The module remains responsible for publishing refreshed state before URLs expire. A temporary
|
||||||
|
storage failure is therefore module state/error policy, not a host storage abstraction.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Management and display products evolve independently without a Dashboard mode branch.
|
||||||
|
- A TV bookmark survives module version upgrades while the internal artifact URL remains exact and
|
||||||
|
versioned.
|
||||||
|
- Display-only modules remain absent from Dashboard navigation.
|
||||||
|
- Existing trusted-code and same-realm risks remain: the display artifact is reviewed installed
|
||||||
|
code, not a sandbox.
|
||||||
|
- Authentication session expiry and the SSE connection-age bound eventually re-run server
|
||||||
|
authorization. This pass does not invent unattended-device credentials.
|
||||||
|
- A module may be mounted by many browser instances; per-device targeting and identity are deferred.
|
||||||
|
- The future standalone host may expose `/display` instead of Ekklesia's outer route without any
|
||||||
|
module change.
|
||||||
|
|
||||||
|
## Rejected alternatives
|
||||||
|
|
||||||
|
- **Reuse one artifact with an Ekklesia display-mode flag.** It couples portable module UI to this
|
||||||
|
host and conflates management with output.
|
||||||
|
- **Hide Dashboard chrome with CSS.** The navigation and management application would still be the
|
||||||
|
containing product and failure boundary.
|
||||||
|
- **Make every authenticated primary-UI viewer a display viewer.** Display access deserves its own
|
||||||
|
explicit module permission.
|
||||||
|
- **Make displays anonymous to match the legacy Announcements client.** That is an authorization
|
||||||
|
bypass, not hosting. A device/public principal needs a later threat model and lifecycle.
|
||||||
|
- **Expose a module or surface directory as static files.** It turns request paths into filesystem
|
||||||
|
selectors and defeats discovery's exact-artifact trust decision.
|
||||||
|
- **Add a second realtime transport or Companion controls.** Display consumes state; Companion's
|
||||||
|
future actions, variables, and feedbacks are a separate capability contract.
|
||||||
|
- **Invoke the Fullscreen API.** It requires a user gesture and confuses page layout with deployment
|
||||||
|
kiosk policy.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- device registration, pairing, credentials, and public-display policy;
|
||||||
|
- a display inventory and management Dashboard;
|
||||||
|
- named display variants and per-device targeting;
|
||||||
|
- the reusable one-module standalone host;
|
||||||
|
- generic CSS asset publication or isolation;
|
||||||
|
- an Announcements module migration;
|
||||||
|
- Companion action/variable/feedback contracts.
|
||||||
@@ -41,3 +41,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0030: Modules declare logical buckets; hosts provide object storage](0030-host-provided-module-object-storage.md) — Accepted
|
- [0030: Modules declare logical buckets; hosts provide object storage](0030-host-provided-module-object-storage.md) — Accepted
|
||||||
- [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted
|
- [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted
|
||||||
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
|
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
|
||||||
|
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ Every module-facing contract in this platform is written so that **full Ekklesia
|
|||||||
thing that can satisfy it**. The intended second implementation is a reusable single-module
|
thing that can satisfy it**. The intended second implementation is a reusable single-module
|
||||||
standalone host — conceptually `@ekklesia/standalone-host` — which loads exactly one module and
|
standalone host — conceptually `@ekklesia/standalone-host` — which loads exactly one module and
|
||||||
supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module
|
supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module
|
||||||
lifecycle, and realtime, with display/kiosk hosting still later.
|
lifecycle, and realtime. Ekklesia now also has a dedicated display surface, but the reusable
|
||||||
|
single-module standalone host remains later.
|
||||||
|
|
||||||
**No such package exists yet, and this pass does not create one.** It is recorded here because it is
|
**No such package exists yet, and this pass does not create one.** It is recorded here because it is
|
||||||
what several contract decisions are *for*, and because a change that quietly makes it unimplementable
|
what several contract decisions are *for*, and because a change that quietly makes it unimplementable
|
||||||
@@ -370,9 +371,10 @@ and nothing more; there is no runtime health-probe contract. Modules remain trus
|
|||||||
disabling one is an operational control, never a sandbox. No installer exists.
|
disabling one is an operational control, never a sandbox. No installer exists.
|
||||||
|
|
||||||
Phase 7A pass 2 adds the module configuration and secrets contract
|
Phase 7A pass 2 adds the module configuration and secrets contract
|
||||||
([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). A module declares what it needs
|
([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). That pass introduced static
|
||||||
configured in its manifest — `manifestVersion: 5`, a hard cutover — using six types (`string`,
|
configuration with the `manifestVersion: 5` hard cutover; the current strict manifest is version 6
|
||||||
`integer`, `boolean`, `enum`, `url`, `secret`) with bounded constraints and no nesting. Static
|
after ADR 0033. A module uses six declaration types (`string`, `integer`, `boolean`, `enum`, `url`,
|
||||||
|
`secret`) with bounded constraints and no nesting. Static
|
||||||
declaration is what lets the platform validate configuration **before** deciding whether a module may
|
declaration is what lets the platform validate configuration **before** deciding whether a module may
|
||||||
start, so an unconfigured module is reported `blocked · configuration_missing` rather than taking
|
start, so an unconfigured module is reported `blocked · configuration_missing` rather than taking
|
||||||
startup down from inside its own `start` hook; discovery, `pnpm modules:validate`, and Doctor all read
|
startup down from inside its own `start` hook; discovery, `pnpm modules:validate`, and Doctor all read
|
||||||
@@ -469,5 +471,36 @@ for requests already in flight. Reverse-order stop then deactivates each module'
|
|||||||
immediately before that module's own stop hook, so dependencies remain operational while dependents
|
immediately before that module's own stop hook, so dependencies remain operational while dependents
|
||||||
clean up and captured publishers cannot outlive their module. Direct object-delivery URLs remain
|
clean up and captured publishers cannot outlive their module. Direct object-delivery URLs remain
|
||||||
opaque payload strings and are never re-signed per publication or subscriber. MQTT remains an
|
opaque payload strings and are never re-signed per publication or subscriber. MQTT remains an
|
||||||
external-control concern for a future Companion boundary, not an internal module bus. Display/kiosk
|
external-control concern for a future Companion boundary, not an internal module bus.
|
||||||
hosting and its public/device principal are explicitly not part of this pass.
|
|
||||||
|
Phase 7A pass 6 adds a dedicated display role and Ekklesia host
|
||||||
|
([ADR 0033](decisions/0033-display-kiosk-host-contract.md)). Manifest version 6 gives a module one
|
||||||
|
optional `entrypoints.display` object containing a compiled artifact and one module-owned permission.
|
||||||
|
The permission must have an `authenticated` or `restricted` baseline: public/device display identity
|
||||||
|
is still intentionally unsolved. The display artifact must be distinct from every other configured
|
||||||
|
entrypoint. Primary UI, display UI, both, and neither are all valid module shapes.
|
||||||
|
|
||||||
|
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
|
||||||
|
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
|
||||||
|
`presentation.kind`, or host-identity branch. The canonical builder reads only
|
||||||
|
`src/ui/index.tsx` and `src/display/index.tsx`, bundles React into each declared role, rejects CSS
|
||||||
|
sidecars, and publishes no output until every requested bundle has compiled. Core validates both
|
||||||
|
paths inertly and builds both publication records only from the dependency-resolved eligible/running
|
||||||
|
set; it never imports browser code.
|
||||||
|
|
||||||
|
Ekklesia keeps its primary Dashboard host unchanged and adds a separate full-viewport application.
|
||||||
|
For each running display-capable module, the API registers the stable page `/display/<module-id>`, a
|
||||||
|
protected descriptor at `/api/v1/module-displays/<module-id>`, and the exact display artifact at
|
||||||
|
`/api/v1/module-ui/<module-id>/<version>/display`. The page and descriptor use normal cookie identity
|
||||||
|
and the manifest's display permission; the artifact remains anonymous trusted code because bytes
|
||||||
|
confer no authority. There is no display inventory in this pass. The descriptor supplies the same
|
||||||
|
module API root, realtime root, principal-derived permission hints, and exact artifact identity the
|
||||||
|
generic loader already consumes. The surface adds no Dashboard chrome and does not request the
|
||||||
|
browser Fullscreen API; failures remain bounded behind a fixed state and manual retry.
|
||||||
|
|
||||||
|
Production reads the fixed display HTML and script once before binding the listener and serves that
|
||||||
|
frozen snapshot, never a directory. Enablement, dependency blocking, and runtime startup determine
|
||||||
|
whether routes exist, and installed-manifest changes remain restart-based. Realtime and direct signed
|
||||||
|
object URLs are unchanged and flow through the same portable contracts. Named display variants,
|
||||||
|
targeting, display inventory/management, device/public identity, a standalone host, shared CSS,
|
||||||
|
Announcements migration, and Companion integration remain later work.
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
Node's global declarations — into browser code. Core still owns the containing
|
Node's global declarations — into browser code. Core still owns the containing
|
||||||
application shell and all platform policy, and module UI reaches the host only through
|
application shell and all platform policy, and module UI reaches the host only through
|
||||||
`@ekklesia/module-ui-sdk`.
|
`@ekklesia/module-ui-sdk`.
|
||||||
|
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||||
|
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
||||||
|
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
||||||
|
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
|
||||||
|
role-neutral UI SDK ABI and neither may import a surface or host loader.
|
||||||
- Module UI **asset publication** runs Core → `apps/api`, never the other way
|
- Module UI **asset publication** runs Core → `apps/api`, never the other way
|
||||||
([ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md)). Core owns the policy
|
([ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md)). Core owns the policy
|
||||||
(`createModuleUiPublicationRegistry`, built only from already-validated `DiscoveredModule`
|
(`createModuleUiPublicationRegistry`, built only from already-validated `DiscoveredModule`
|
||||||
@@ -69,6 +74,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
|
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
|
||||||
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
|
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
|
||||||
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
|
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
|
||||||
|
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
|
||||||
|
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
|
||||||
|
API. It contains no Dashboard chrome and does not move display rendering into the surface—the
|
||||||
|
module still owns that renderer.
|
||||||
- Separate surfaces do not import each other's internals.
|
- Separate surfaces do not import each other's internals.
|
||||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||||
surface internals.
|
surface internals.
|
||||||
|
|||||||
+88
-5
@@ -81,6 +81,9 @@ No real values are shown here — configure per environment and never commit the
|
|||||||
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
|
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
|
||||||
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
|
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
|
||||||
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
||||||
|
| `GET /api/v1/module-ui/<module-id>/<version>/display` | Ekklesia | Publishes the exact validated display artifact for that eligible/running module. Artifact bytes are anonymous trusted code; this route is not the authorization boundary. See [Module display host](#module-display-host). |
|
||||||
|
| `GET /display/<module-id>` | Ekklesia | Stable, full-viewport display page with no Dashboard chrome. Requires the module-owned permission declared beside `entrypoints.display`. See [Module display host](#module-display-host). |
|
||||||
|
| `GET /api/v1/module-displays/<module-id>` | Ekklesia | Protected descriptor for one display page: exact artifact, module API/realtime roots, and presentation-only permission hints. Requires that display's module-owned permission. See [Module display host](#module-display-host). |
|
||||||
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
|
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
|
||||||
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
|
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
|
||||||
| `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). |
|
| `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). |
|
||||||
@@ -990,7 +993,8 @@ root of the same resource the artifact route below serves: one prefix, two repre
|
|||||||
prefix, which lets one change without touching host or module code.
|
prefix, which lets one change without touching host or module code.
|
||||||
- **The module set is the publication registry**, not a rescan: modules that survived discovery and
|
- **The module set is the publication registry**, not a rescan: modules that survived discovery and
|
||||||
declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent.
|
declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent.
|
||||||
Order is the registry's own deterministic discovery order.
|
Order is the registry's own deterministic discovery order. Dedicated display artifacts are not
|
||||||
|
merged into this primary-UI inventory; this pass deliberately adds no display enumeration API.
|
||||||
- **Authenticated, unlike the artifact route.** Requires `core.modules.ui.read`, baseline
|
- **Authenticated, unlike the artifact route.** Requires `core.modules.ui.read`, baseline
|
||||||
`authenticated` — any signed-in user, and nobody else. The artifact bytes are anonymous because
|
`authenticated` — any signed-in user, and nobody else. The artifact bytes are anonymous because
|
||||||
fetching them confers no authority, but that route is deliberately not an enumeration oracle, and
|
fetching them confers no authority, but that route is deliberately not an enumeration oracle, and
|
||||||
@@ -1027,7 +1031,9 @@ GET /api/v1/module-ui/<module-id>/<version>
|
|||||||
joined, resolved, or concatenated into a filesystem path, and the route declares no wildcard
|
joined, resolved, or concatenated into a filesystem path, and the route declares no wildcard
|
||||||
segment — so traversal, encoded separators, sibling bundle chunks, package-root files, and source
|
segment — so traversal, encoded separators, sibling bundle chunks, package-root files, and source
|
||||||
maps are unreachable by construction. **An installed module directory is never a static file
|
maps are unreachable by construction. **An installed module directory is never a static file
|
||||||
root**, and only the single artifact its manifest declares is ever served.
|
root**, and only the single primary artifact its manifest declares is ever served. A separately
|
||||||
|
declared display artifact uses the exact sibling route described below; neither role can select
|
||||||
|
the other's file.
|
||||||
- **A sibling of `/api/v1/modules/`, never a child.** A module may legitimately register the route
|
- **A sibling of `/api/v1/modules/`, never a child.** A module may legitimately register the route
|
||||||
path `/ui/:version`, so nesting publication inside the module prefix would let a module and the
|
path `/ui/:version`, so nesting publication inside the module prefix would let a module and the
|
||||||
platform compete for one URL with routing order deciding the winner.
|
platform compete for one URL with routing order deciding the winner.
|
||||||
@@ -1058,9 +1064,86 @@ construction of this URL. It accepts identity only and percent-encodes both comp
|
|||||||
root-relative, so a same-origin host uses it unchanged and a split-origin host resolves it against
|
root-relative, so a same-origin host uses it unchanged and a split-origin host resolves it against
|
||||||
the same API base URL it already needs for `ModuleUiApiClient`.
|
the same API base URL it already needs for `ModuleUiApiClient`.
|
||||||
|
|
||||||
**Nothing mounts a module UI yet.** A host can now obtain a browser URL that
|
Ekklesia's Dashboard surface consumes the primary inventory and mounts these URLs through
|
||||||
`@ekklesia/module-ui-host` accepts, which was previously impossible — but Ekklesia has no Dashboard
|
`@ekklesia/module-ui-host`. The dedicated display surface reuses the same loader and ABI but obtains
|
||||||
shell, no navigation, and no code that calls this route outside its own tests.
|
its role-specific URL only from the protected descriptor below.
|
||||||
|
|
||||||
|
## Module display host
|
||||||
|
|
||||||
|
Manifest version 6 adds one independently optional display declaration
|
||||||
|
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entrypoints": {
|
||||||
|
"display": {
|
||||||
|
"artifact": "./dist/display/index.js",
|
||||||
|
"permission": "announcements.display.view"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That permission must be declared by the same module and have an `authenticated` or `restricted`
|
||||||
|
baseline; `public` is rejected because public/device display identity is deferred. The artifact path
|
||||||
|
uses the normal compiled, package-contained entrypoint rules and may not collide with server,
|
||||||
|
schema, migrations, jobs, or primary UI. A module may declare primary UI only, display only, both,
|
||||||
|
or neither.
|
||||||
|
|
||||||
|
For every display-capable module in the immutable eligible/running publication registry, API
|
||||||
|
composition installs three exact routes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /display/<module-id>
|
||||||
|
GET /api/v1/module-displays/<module-id>
|
||||||
|
GET /api/v1/module-ui/<module-id>/<version>/display
|
||||||
|
```
|
||||||
|
|
||||||
|
The first is a stable bookmark independent of the installed version. It serves the same frozen,
|
||||||
|
module-agnostic full-viewport HTML shell for every module, with no Dashboard application or chrome.
|
||||||
|
The second returns the only descriptor that shell needs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"module": {
|
||||||
|
"id": "announcements",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"display": {
|
||||||
|
"url": "/api/v1/module-ui/announcements/0.1.0/display"
|
||||||
|
},
|
||||||
|
"api": { "baseUrl": "/api/v1/modules/announcements" },
|
||||||
|
"realtime": { "baseUrl": "/api/v1/module-realtime/announcements" },
|
||||||
|
"permissions": ["announcements.display.view"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Page and descriptor authorization.** Both run the ordinary cookie principal through Core's one
|
||||||
|
evaluator for exactly the display permission stored in the validated publication record. There
|
||||||
|
is no query token, public exception, device principal, or separate authorization system. The
|
||||||
|
descriptor's `permissions` remains presentation-only; module HTTP and realtime routes still
|
||||||
|
enforce their own declared permissions.
|
||||||
|
- **Artifact publication.** The versioned `/display` suffix publishes exactly the canonical display
|
||||||
|
artifact, byte-for-byte, with the same MIME, `nosniff`, strong ETag, `no-cache`, sanitized `404`/
|
||||||
|
`503`, and anonymous-code policy as primary UI. It never falls back to `entrypoints.ui`, and no
|
||||||
|
request component becomes a filesystem path. Fetching code grants no API, realtime, or data
|
||||||
|
authority.
|
||||||
|
- **Lifecycle and restart.** Unknown, primary-only, disabled, dependency-blocked, and failed-startup
|
||||||
|
modules have no display routes and collapse into the normal sanitized `404`. Production reads the
|
||||||
|
exact display-host HTML and script once before binding, freezes them, and serves fixed paths rather
|
||||||
|
than a directory. Installation, manifest, enablement, and build changes take effect on restart.
|
||||||
|
- **Browser behavior.** The shell fetches principal first, then the protected descriptor, and only
|
||||||
|
then dynamically imports the supplied artifact. It supplies the same role-neutral
|
||||||
|
`ModuleUiHostContext` used by primary UI—API, principal, permissions, and realtime—with no mode
|
||||||
|
flag. Failures become fixed states with manual retry. The host fills the viewport but does not
|
||||||
|
request the browser Fullscreen API.
|
||||||
|
- **Unchanged data paths.** Realtime stays on the permission-gated SSE route above. Signed object
|
||||||
|
delivery URLs remain opaque bearer strings generated by module backend code and fetched directly
|
||||||
|
from storage; the display host neither proxies nor re-signs them.
|
||||||
|
|
||||||
|
There is deliberately no display inventory or management API. Named variants, targeting,
|
||||||
|
public/device identity, a standalone host, a shared stylesheet contract, Announcements migration,
|
||||||
|
and Companion integration remain future work.
|
||||||
|
|
||||||
## Module database access
|
## Module database access
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ Related: [ADR 0006](../architecture/decisions/0006-versioned-capabilities-and-co
|
|||||||
(module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md)
|
(module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md)
|
||||||
(module routing/lifecycle and migration execution),
|
(module routing/lifecycle and migration execution),
|
||||||
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md)
|
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md)
|
||||||
(native module realtime), and
|
(native module realtime),
|
||||||
|
[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)
|
||||||
|
(authenticated display hosting), and
|
||||||
[Module development](../module-development/README.md).
|
[Module development](../module-development/README.md).
|
||||||
|
|
||||||
## Installed modules are trusted compiled backend code
|
## Installed modules are trusted compiled backend code
|
||||||
@@ -47,6 +49,7 @@ cooperative. None of them constrains a module that is not:
|
|||||||
| Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them |
|
| Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them |
|
||||||
| Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds |
|
| Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds |
|
||||||
| Scoped realtime channels, publishers, and subscriptions | Correct module-local state wiring, permission enforcement, and finite fanout — not process isolation |
|
| Scoped realtime channels, publishers, and subscriptions | Correct module-local state wiring, permission enforcement, and finite fanout — not process isolation |
|
||||||
|
| Display declaration, permission, and exact publication record | Correct role selection and normal authorization for an eligible/running module — not browser isolation |
|
||||||
| `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects |
|
| `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects |
|
||||||
| The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL |
|
| The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL |
|
||||||
| Capability registry | Explicit inter-module wiring, not access control |
|
| Capability registry | Explicit inter-module wiring, not access control |
|
||||||
@@ -155,6 +158,27 @@ shell owns the container, the loading state, and every error, so a failing modul
|
|||||||
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
|
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
|
||||||
JavaScript, exactly as described above.
|
JavaScript, exactly as described above.
|
||||||
|
|
||||||
|
### A display page is authorized; its artifact bytes are not authority
|
||||||
|
|
||||||
|
Manifest version 6 binds a display artifact to one permission declared by the same module
|
||||||
|
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
|
||||||
|
a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
|
||||||
|
and public/device identity is not approximated with an anonymous exception. Both the stable
|
||||||
|
`/display/<module-id>` page and `/api/v1/module-displays/<module-id>` descriptor evaluate that exact
|
||||||
|
permission through the normal Core evaluator. Client permission hints remain presentation only.
|
||||||
|
|
||||||
|
The versioned display artifact is anonymous code just like primary UI code. It is served only from
|
||||||
|
the frozen eligible/running publication registry, by exact module identity and version, and never
|
||||||
|
falls back to primary UI or opens the module directory. Fetching it grants no module HTTP, realtime,
|
||||||
|
storage, or data authority. Disabled and dependency-blocked modules receive no page, descriptor, or
|
||||||
|
artifact route; this is operational exposure control, not sandboxing.
|
||||||
|
|
||||||
|
The full-viewport host is a separate same-realm surface, not an iframe or containment boundary. It
|
||||||
|
uses normal cookie authorization, the same scoped API/realtime context, and opaque signed object URLs
|
||||||
|
without logging, proxying, extending, or re-signing them. It does not invoke the browser Fullscreen
|
||||||
|
API. Display inventory, device credentials, public principals, variants, and targeting are deferred
|
||||||
|
rather than silently granted.
|
||||||
|
|
||||||
### Dashboard arrangement is not an access control
|
### Dashboard arrangement is not an access control
|
||||||
|
|
||||||
Dashboard groups and placements are presentation configuration
|
Dashboard groups and placements are presentation configuration
|
||||||
|
|||||||
@@ -275,6 +275,24 @@ enablement. During realtime development, run the focused live Node suite repeate
|
|||||||
`pnpm verify` and `pnpm test:migrations:integration`; consecutive runs are specifically intended to
|
`pnpm verify` and `pnpm test:migrations:integration`; consecutive runs are specifically intended to
|
||||||
catch retained timers, listeners, subscribers, or loopback servers.
|
catch retained timers, listeners, subscribers, or loopback servers.
|
||||||
|
|
||||||
|
Phase 7A pass 6 adds display evidence across the same boundaries
|
||||||
|
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). Contract tests prove the
|
||||||
|
manifest v6 hard cutover, optional role combinations, module-owned non-public display permission,
|
||||||
|
and collision rejection. Discovery tests prove inert compiled-artifact validation, containment,
|
||||||
|
symlink collision detection, deterministic diagnostics, and that modules without a display remain
|
||||||
|
valid. Builder tests prove the exact `src/ui/index.tsx` / `src/display/index.tsx` sources,
|
||||||
|
deterministic role order, preflight/compile atomicity, no runtime-entrypoint overwrite, and identical
|
||||||
|
browser/CSS boundaries.
|
||||||
|
|
||||||
|
Core/API tests prove independently frozen primary and display publication records, exact anonymous
|
||||||
|
artifact routing, permission-gated stable page and descriptor routes, eligible/running lifecycle
|
||||||
|
filtering, sanitized failures, and startup-time frozen host assets. Surface tests prove principal-
|
||||||
|
first descriptor bootstrap, the role-neutral host context, full-viewport mounting without Dashboard
|
||||||
|
chrome or static module imports, late-import cleanup, manual retry, realtime reuse, and unmount.
|
||||||
|
The canonical template builds and mounts both artifacts through `parseModuleUiExport`; contract
|
||||||
|
fixtures also retain no-display and backend-only cases. These are code-only tests: display hosting
|
||||||
|
adds no PostgreSQL, object-store, or Docker requirement beyond the existing suites.
|
||||||
|
|
||||||
### Which kind of timing proof lives where
|
### Which kind of timing proof lives where
|
||||||
|
|
||||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ platform context, the application shell, top-level routing, navigation, the glob
|
|||||||
design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host
|
design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host
|
||||||
application's. A module consumes those; it never redefines or bypasses them.
|
application's. A module consumes those; it never redefines or bypasses them.
|
||||||
|
|
||||||
> **What exists today:** you can author a module UI in React, build it into the compiled artifact your
|
> **What exists today:** you can author independently optional primary and full-viewport display UI
|
||||||
> manifest declares, have Core validate it, have Ekklesia publish it at a browser URL, and have
|
> roles in React, build their declared artifacts, have Core validate and publish them, and have
|
||||||
> Ekklesia's browser shell discover and mount it — the loop closes end to end. **What does not exist
|
> Ekklesia mount each in its proper browser surface. The primary shell has flat configurable
|
||||||
> is the Dashboard**: no navigation, grouping, icons, ordering, theme, or design system, and no
|
> navigation; the display shell has no Dashboard chrome. A finished design system, display
|
||||||
> standalone host. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint),
|
> inventory/management, public/device identity, and the standalone host do not exist. See
|
||||||
|
> [Declaring UI entrypoints](#declaring-ui-entrypoints),
|
||||||
> [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact), and
|
> [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact), and
|
||||||
> [What actually mounts your UI](#what-actually-mounts-your-ui).
|
> [What actually mounts your UI](#what-actually-mounts-your-ui).
|
||||||
|
|
||||||
@@ -38,12 +39,16 @@ modules/example/
|
|||||||
│ └── _journal.json
|
│ └── _journal.json
|
||||||
├── dist/schema/index.js # when the module owns tables
|
├── dist/schema/index.js # when the module owns tables
|
||||||
├── dist/server/index.js
|
├── dist/server/index.js
|
||||||
|
├── dist/ui/index.js # when the module declares primary UI
|
||||||
|
├── dist/display/index.js # when the module declares a display
|
||||||
├── drizzle.config.ts # when generating migrations
|
├── drizzle.config.ts # when generating migrations
|
||||||
├── ekklesia.module.json
|
├── ekklesia.module.json
|
||||||
├── package.json
|
├── package.json
|
||||||
├── src/schema/index.ts # when the module owns tables
|
├── src/schema/index.ts # when the module owns tables
|
||||||
├── src/schema/tables.ts # pure Drizzle declarations used by Drizzle Kit
|
├── src/schema/tables.ts # pure Drizzle declarations used by Drizzle Kit
|
||||||
└── src/server/index.ts
|
├── src/server/index.ts
|
||||||
|
├── src/ui/index.tsx # fixed primary-UI build source, when declared
|
||||||
|
└── src/display/index.tsx # fixed display build source, when declared
|
||||||
```
|
```
|
||||||
|
|
||||||
The schema and migration paths are optional. A tableless module omits `entrypoints.schema`; a
|
The schema and migration paths are optional. A tableless module omits `entrypoints.schema`; a
|
||||||
@@ -68,7 +73,7 @@ without executing module code:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"manifestVersion": 4,
|
"manifestVersion": 6,
|
||||||
"id": "calendar",
|
"id": "calendar",
|
||||||
"name": "Calendar",
|
"name": "Calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -102,6 +107,11 @@ without executing module code:
|
|||||||
"id": "calendar.events.read",
|
"id": "calendar.events.read",
|
||||||
"baseline": "restricted",
|
"baseline": "restricted",
|
||||||
"description": "Read calendar events."
|
"description": "Read calendar events."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "calendar.display.view",
|
||||||
|
"baseline": "authenticated",
|
||||||
|
"description": "View the calendar display."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -115,7 +125,12 @@ without executing module code:
|
|||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"server": "./dist/server/index.js",
|
"server": "./dist/server/index.js",
|
||||||
"schema": "./dist/schema/index.js",
|
"schema": "./dist/schema/index.js",
|
||||||
"migrations": "./drizzle"
|
"migrations": "./drizzle",
|
||||||
|
"ui": "./dist/ui/index.js",
|
||||||
|
"display": {
|
||||||
|
"artifact": "./dist/display/index.js",
|
||||||
|
"permission": "calendar.display.view"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -125,12 +140,12 @@ has its own exact semantic version because its public contract can evolve indepe
|
|||||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||||
may be absent; if it is installed, its capability version must be compatible.
|
may be absent; if it is installed, its capability version must be compatible.
|
||||||
|
|
||||||
`manifestVersion: 4` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||||
version 3 manifest, and any other version, fails validation outright rather than being silently
|
version 5 manifest, and any other version, fails validation outright rather than being silently
|
||||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||||
Version 4 adds one optional field, `entrypoints.ui`, so upgrading a backend-only version 3 manifest
|
Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display
|
||||||
is a one-character edit and changes nothing else
|
changes only the version literal; `ui` remains independently optional
|
||||||
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md)).
|
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||||
@@ -176,14 +191,15 @@ relationship the platform has, and it is what orders discovery and startup. An a
|
|||||||
your module into a different folder changes nothing about your artifact, your API root, your
|
your module into a different folder changes nothing about your artifact, your API root, your
|
||||||
permissions, your dependencies, or when you start.
|
permissions, your dependencies, or when you start.
|
||||||
|
|
||||||
Every entrypoint except `migrations` must be a compiled ESM `.js` or `.mjs` file — `server`,
|
Every artifact entrypoint must be a compiled ESM `.js` or `.mjs` file — `server`, `schema`, `jobs`,
|
||||||
`schema`, `jobs`, and `ui` alike. `migrations` is the one directory entrypoint. All entrypoints begin
|
`ui`, and `display.artifact` alike. `migrations` is the one directory entrypoint. All paths begin
|
||||||
with `./`, remain inside the package, and exist. Production never imports source TypeScript directly.
|
with `./`, remain inside the package, and exist. Production never imports source TypeScript directly.
|
||||||
Modules with no tables omit `entrypoints.schema`; modules with no migration history omit
|
A display artifact must remain distinct from every other configured entrypoint before and after
|
||||||
`entrypoints.migrations`; modules with no UI omit `entrypoints.ui`.
|
symlink resolution. Modules with no tables omit `entrypoints.schema`; modules with no migration
|
||||||
|
history omit `entrypoints.migrations`; modules may omit `ui`, `display`, or both.
|
||||||
|
|
||||||
Unknown manifest properties are still rejected. `ui` is the only entrypoint version 4 adds — a
|
Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or
|
||||||
`dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest
|
`renderers` key fails validation — and the manifest
|
||||||
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
|
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
|
||||||
organization is presentation configuration owned by the Dashboard, never module architecture.
|
organization is presentation configuration owned by the Dashboard, never module architecture.
|
||||||
|
|
||||||
@@ -191,10 +207,10 @@ Storage bucket IDs are logical names scoped to the module. Modules may request m
|
|||||||
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
|
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
|
||||||
module code must not configure SeaweedFS or another S3 client directly.
|
module code must not configure SeaweedFS or another S3 client directly.
|
||||||
|
|
||||||
## Declaring a UI entrypoint
|
## Declaring UI entrypoints
|
||||||
|
|
||||||
A module may declare one optional presentation entrypoint alongside its server, schema, and
|
A module may declare a primary/management UI, a dedicated display UI, both, or neither. The roles
|
||||||
migration entrypoints:
|
are separate artifacts because a full-viewport display is not a Dashboard screen with hidden chrome:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -202,20 +218,25 @@ migration entrypoints:
|
|||||||
"server": "./dist/server/index.js",
|
"server": "./dist/server/index.js",
|
||||||
"schema": "./dist/schema/index.js",
|
"schema": "./dist/schema/index.js",
|
||||||
"migrations": "./drizzle",
|
"migrations": "./drizzle",
|
||||||
"ui": "./dist/ui/index.js"
|
"ui": "./dist/ui/index.js",
|
||||||
|
"display": {
|
||||||
|
"artifact": "./dist/display/index.js",
|
||||||
|
"permission": "calendar.display.view"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`ui` obeys exactly the rules `server` and `schema` already obey. It must be package-relative, stay
|
Both artifacts obey the same compiled-entrypoint rules as `server` and `schema`: package-relative,
|
||||||
inside the module package (checked before *and* after symlink resolution), resolve to a real file,
|
inside the module before *and* after symlink resolution, a real `.js`/`.mjs` file, and never source
|
||||||
and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts` path is rejected at
|
TypeScript. The display path must not collide with any other entrypoint. Its permission must be one
|
||||||
discovery — the platform never imports source TypeScript, and a UI artifact is a build output like
|
of this module's own declarations and must use `authenticated` or `restricted`, never `public`;
|
||||||
any other.
|
anonymous/device display identity is deferred. There is one display role per module for now. Named
|
||||||
|
variants and targeting are later contracts, not ad hoc manifest keys.
|
||||||
|
|
||||||
### What the artifact must export
|
### What the artifact must export
|
||||||
|
|
||||||
The artifact default-exports one definition, built with `defineModuleUi` from
|
Each artifact default-exports one definition, built with `defineModuleUi` from
|
||||||
`@ekklesia/module-ui-sdk` (ADR 0022):
|
`@ekklesia/module-ui-sdk` (ADR 0022):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -244,7 +265,9 @@ return an unmount and release it there, the same discipline your `stop` hook fol
|
|||||||
|
|
||||||
React is the supported way to write module UI. It is deliberately **not** part of the contract: the
|
React is the supported way to write module UI. It is deliberately **not** part of the contract: the
|
||||||
platform hands you an element and a context, so your framework choice and the host's stay
|
platform hands you an element and a context, so your framework choice and the host's stay
|
||||||
independent.
|
independent. Primary and display artifacts use this exact same ABI and context. There is no mode
|
||||||
|
flag, `presentation.kind`, Dashboard boolean, or host identity to branch on; the separately declared
|
||||||
|
artifact is the role selection.
|
||||||
|
|
||||||
### What the host gives you
|
### What the host gives you
|
||||||
|
|
||||||
@@ -297,13 +320,12 @@ API process. Write module UI as trusted first-party code, because that is what i
|
|||||||
|
|
||||||
### Building your UI
|
### Building your UI
|
||||||
|
|
||||||
Put your UI source at `src/ui/index.tsx`, declare the artifact in your manifest, and build:
|
Put each declared role at its fixed source path and build once:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/ui/index.tsx # the compiled entrypoint's source
|
src/ui/index.tsx → entrypoints.ui
|
||||||
src/ui/ExampleApp.tsx # your components
|
src/display/index.tsx → entrypoints.display.artifact
|
||||||
↓ pnpm build
|
pnpm build
|
||||||
dist/ui/index.js # the artifact your manifest declares
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You write no bundler configuration. `@ekklesia/module-ui-build` owns all of it
|
You write no bundler configuration. `@ekklesia/module-ui-build` owns all of it
|
||||||
@@ -328,20 +350,31 @@ script runs both halves of the module:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The output path is **read from your manifest**, not from a build convention, so the artifact you
|
Output paths are **read from your manifest**, not inferred, so declarations and produced artifacts
|
||||||
declare and the artifact you produce cannot drift. Declare it exactly as you would any other
|
cannot drift. Source paths are the exact conventions above; module authors do not configure them.
|
||||||
entrypoint:
|
The builder resolves and preflights all roles in deterministic `ui`, then `display` order, compiles
|
||||||
|
both in memory, and writes only after every declared bundle succeeds. A missing or broken display
|
||||||
|
source therefore cannot leave a newly written primary artifact (or vice versa).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "entrypoints": { "ui": "./dist/ui/index.js" } }
|
{
|
||||||
|
"entrypoints": {
|
||||||
|
"ui": "./dist/ui/index.js",
|
||||||
|
"display": {
|
||||||
|
"artifact": "./dist/display/index.js",
|
||||||
|
"permission": "calendar.display.view"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
A module with no `entrypoints.ui` is not an error — the build reports that there is nothing to build
|
A module may declare either role alone. Declaring neither is not an error — the build reports that
|
||||||
and exits successfully, so the same script works for a backend-only module.
|
there is nothing to build and exits successfully, so the same script works for a backend-only module.
|
||||||
|
|
||||||
The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not
|
The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not
|
||||||
compile, an import cannot be resolved, the declared path is unsafe or is not a `.js`/`.mjs` artifact,
|
compile, an import cannot be resolved, the declared path is unsafe or is not a `.js`/`.mjs` artifact,
|
||||||
or `src/ui/index.tsx` is missing.
|
or a declared role's fixed source is missing. It also refuses any display output that could
|
||||||
|
overwrite `server`, `schema`, `migrations`, `jobs`, or primary UI before invoking esbuild.
|
||||||
|
|
||||||
#### TypeScript projects
|
#### TypeScript projects
|
||||||
|
|
||||||
@@ -352,7 +385,12 @@ not match `.tsx`, and a UI project covers the browser half:
|
|||||||
{
|
{
|
||||||
"extends": "@ekklesia/typescript-config/browser-react.json",
|
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||||
"compilerOptions": { "noEmit": true, "types": [] },
|
"compilerOptions": { "noEmit": true, "types": [] },
|
||||||
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
|
"include": [
|
||||||
|
"src/ui/**/*.tsx",
|
||||||
|
"src/ui/**/*.ts",
|
||||||
|
"src/display/**/*.tsx",
|
||||||
|
"src/display/**/*.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -384,10 +422,11 @@ Style with React's `style` prop:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
|
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
|
||||||
an oversight: esbuild would emit a `dist/ui/index.css` beside your artifact, but no manifest field
|
an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no
|
||||||
declares it and no host loads it — so it would work in your tests and silently do nothing in a real
|
manifest field declares it and no host loads it — so it would work in your tests and silently do
|
||||||
host. A stylesheet contract needs a manifest field, a host loading mechanism, and a style-isolation
|
nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and
|
||||||
decision, none of which exist yet. Inline styles are the whole supported story for this phase.
|
a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for
|
||||||
|
this phase.
|
||||||
|
|
||||||
### What you may and may not use today
|
### What you may and may not use today
|
||||||
|
|
||||||
@@ -407,10 +446,11 @@ Layer rules are unchanged: module UI source may not import Core, an app, a surfa
|
|||||||
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
|
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
|
||||||
is resolved and rejected by its real target.
|
is resolved and rejected by its real target.
|
||||||
|
|
||||||
> **Your artifact is served, and Ekklesia's shell mounts it.** The platform publishes your compiled UI
|
> **Your artifacts are served, and Ekklesia's surfaces mount them.** The platform publishes primary
|
||||||
> at `GET /api/v1/module-ui/<module-id>/<version>`
|
> UI at `GET /api/v1/module-ui/<module-id>/<version>` and display UI at the sibling `/display`
|
||||||
|
> artifact URL
|
||||||
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)) and its browser
|
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)) and its browser
|
||||||
> shell discovers and mounts it
|
> surfaces discover and mount them
|
||||||
> ([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). That shell is a
|
> ([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). That shell is a
|
||||||
> module list and a mount target, not a Dashboard. See
|
> module list and a mount target, not a Dashboard. See
|
||||||
> [How a host loads your UI](#how-a-host-loads-your-ui) and
|
> [How a host loads your UI](#how-a-host-loads-your-ui) and
|
||||||
@@ -418,10 +458,10 @@ is resolved and rejected by its real target.
|
|||||||
|
|
||||||
### What this does and does not do today
|
### What this does and does not do today
|
||||||
|
|
||||||
Discovery parses the manifest as inert JSON, resolves the `ui` path, validates it, and records it on
|
Discovery parses the manifest as inert JSON, resolves each declared browser path, validates it, and
|
||||||
the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or renders a UI
|
records it on the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or
|
||||||
entrypoint** — a UI artifact that throws on evaluation still discovers successfully, because it is
|
renders either UI entrypoint** — an artifact that throws on evaluation still discovers successfully,
|
||||||
never evaluated. Hosts perform their own dynamic import and then validate the result with
|
because it is never evaluated. Hosts perform their own dynamic import and then validate the result with
|
||||||
`parseModuleUiExport`; Core never loads browser code.
|
`parseModuleUiExport`; Core never loads browser code.
|
||||||
|
|
||||||
Core also never *builds* anything. Building is an authoring and packaging step you run; Core
|
Core also never *builds* anything. Building is an authoring and packaging step you run; Core
|
||||||
@@ -435,10 +475,9 @@ You do not write this — a host does — but knowing the shape explains what yo
|
|||||||
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)):
|
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
compiled artifact (dist/ui/index.js)
|
compiled primary or display artifact
|
||||||
↓ asset publication — host-specific; Ekklesia serves it at
|
↓ asset publication — host-specific; Ekklesia serves it at
|
||||||
↓ GET /api/v1/module-ui/<module-id>/<version>, and its shell finds it
|
↓ one exact server-produced artifact URL
|
||||||
↓ through GET /api/v1/module-ui
|
|
||||||
browser-loadable module URL
|
browser-loadable module URL
|
||||||
↓ loadModuleUi({ source, container, context })
|
↓ loadModuleUi({ source, container, context })
|
||||||
dynamic import → parseModuleUiExport → identity checked against the manifest
|
dynamic import → parseModuleUiExport → identity checked against the manifest
|
||||||
@@ -470,24 +509,26 @@ What that means for you as a module author:
|
|||||||
|
|
||||||
### How Ekklesia publishes your artifact
|
### How Ekklesia publishes your artifact
|
||||||
|
|
||||||
Core resolves `entrypoints.ui` to a path on the *server's filesystem*, which a browser cannot import.
|
Core resolves each declared browser artifact to a path on the *server's filesystem*, which a browser
|
||||||
Ekklesia turns that into a URL
|
cannot import. Ekklesia turns each role into one exact URL
|
||||||
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)):
|
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GET /api/v1/module-ui/<module-id>/<version>
|
GET /api/v1/module-ui/<module-id>/<version>
|
||||||
|
GET /api/v1/module-ui/<module-id>/<version>/display
|
||||||
```
|
```
|
||||||
|
|
||||||
Four things about it matter to you as a module author:
|
Four things about it matter to you as a module author:
|
||||||
|
|
||||||
- **Only the artifact your manifest declares is served.** The module id and version in that URL are
|
- **Only the artifact that role declares is served.** The module id and version in either URL are
|
||||||
lookup keys, never path components — there is no way to request a second file from your package.
|
lookup keys, never path components — there is no way to request a second file from your package.
|
||||||
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
|
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
|
||||||
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
|
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
|
||||||
stylesheet import still fails the build.
|
stylesheet import still fails the build.
|
||||||
- **The version must match exactly.** A request for any other version is refused rather than served
|
- **The version must match exactly.** A request for any other version is refused rather than served
|
||||||
your installed artifact, so a stale host cannot mount something it did not ask for.
|
your installed artifact, so a stale host cannot mount something it did not ask for.
|
||||||
- **Your artifact is public.** The route requires no authentication — fetching it grants no
|
- **Your artifact bytes are public.** These two routes require no authentication — fetching code
|
||||||
|
grants no
|
||||||
authority, and your backend routes remain permission-enforced regardless. Never put a secret, a
|
authority, and your backend routes remain permission-enforced regardless. Never put a secret, a
|
||||||
private endpoint, or anything you would not publish in your UI bundle.
|
private endpoint, or anything you would not publish in your UI bundle.
|
||||||
- **It is served byte-for-byte, never executed on the server.** No transformation, no re-bundling, no
|
- **It is served byte-for-byte, never executed on the server.** No transformation, no re-bundling, no
|
||||||
@@ -526,6 +567,40 @@ Three things follow for you:
|
|||||||
> `name`, never its id — and under "Other" until someone arranges it. There is no navigation editor,
|
> `name`, never its id — and under "Other" until someone arranges it. There is no navigation editor,
|
||||||
> no nested folders, no icons, no theme, and no Dashboard Home yet.
|
> no nested folders, no icons, no theme, and no Dashboard Home yet.
|
||||||
|
|
||||||
|
### How Ekklesia hosts your display
|
||||||
|
|
||||||
|
The display is not part of the primary inventory or Dashboard navigation. For a module that is
|
||||||
|
eligible, running, and declares `entrypoints.display`, Ekklesia registers exactly these routes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/display/<module-id> stable full-viewport page
|
||||||
|
/api/v1/module-displays/<module-id> host descriptor
|
||||||
|
/api/v1/module-ui/<module-id>/<version>/display compiled artifact bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
The page and descriptor both require the manifest's display permission through the ordinary Core
|
||||||
|
authorization evaluator and an authenticated cookie session. A `public` display permission is
|
||||||
|
rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are
|
||||||
|
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
|
||||||
|
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
|
||||||
|
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
|
||||||
|
not through hot loading.
|
||||||
|
|
||||||
|
The stable page runs a separate `surfaces/shell` browser application with no Dashboard navigation,
|
||||||
|
theme chrome, or primary UI fallback. It asks for the protected descriptor, then passes the exact
|
||||||
|
artifact URL, module API base, realtime base, principal, and permission hints into the generic
|
||||||
|
loader. The host owns credentials and routes; your display still calls
|
||||||
|
`context.api.request("relative-path")` and `context.realtime.subscribe(...)`. Signed object-delivery
|
||||||
|
URLs remain opaque strings in HTTP or realtime state and should be refreshed according to their
|
||||||
|
expiry exactly as in any primary UI. Nothing in the display ABI identifies Ekklesia, so the same
|
||||||
|
artifact remains compatible with a future standalone host.
|
||||||
|
|
||||||
|
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
|
||||||
|
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
|
||||||
|
feature experience and cleanup inside its container. There is one display per module. Display
|
||||||
|
inventory/management, named variants, targeting, public/device identity, a standalone host, shared
|
||||||
|
CSS, Announcements migration, and Companion integration are deferred.
|
||||||
|
|
||||||
### What a module UI will never own
|
### What a module UI will never own
|
||||||
|
|
||||||
A module UI consumes platform policy and never redefines it.
|
A module UI consumes platform policy and never redefines it.
|
||||||
@@ -835,10 +910,11 @@ letting requests already in flight finish publishing. Reverse-order stop then di
|
|||||||
publisher immediately before that module's own stop hook. Call `close()` during UI unmount as well;
|
publisher immediately before that module's own stop hook. Call `close()` during UI unmount as well;
|
||||||
it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are
|
it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are
|
||||||
forwarded unchanged.
|
forwarded unchanged.
|
||||||
Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Public
|
Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Ekklesia's
|
||||||
display/device identity, durable replay, multi-node fanout, client-to-server commands, and
|
authenticated display host consumes the same interface unchanged; public/device identity, durable
|
||||||
display/kiosk hosting remain future work. See
|
replay, multi-node fanout, and client-to-server commands remain future work. See
|
||||||
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md).
|
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md) and
|
||||||
|
[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md).
|
||||||
|
|
||||||
## Drizzle schema definition
|
## Drizzle schema definition
|
||||||
|
|
||||||
@@ -1340,9 +1416,11 @@ disable or block a running module because a storage call timed out.
|
|||||||
|
|
||||||
### Serving objects to a browser
|
### Serving objects to a browser
|
||||||
|
|
||||||
Module UI gets no storage access and no credentials. Serve bytes through your own authorized route,
|
Module UI gets no storage access and no credentials. Its authorized module route may either stream
|
||||||
as in the example above: `browser → your module API → your scoped storage`. Signed URLs and public
|
through scoped storage or call `createReadUrl(key)` and return that short-lived bearer URL so the
|
||||||
object delivery do not exist yet.
|
browser fetches bytes directly from object storage. Primary and display UI consume that URL the same
|
||||||
|
way: keep it opaque, never persist or log it, and refresh it before expiry. A display host adds no
|
||||||
|
storage authority and realtime transport does not extend a URL's lifetime.
|
||||||
|
|
||||||
### Whether your module runs at all is your host's decision
|
### Whether your module runs at all is your host's decision
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
||||||
import {
|
|
||||||
type ModuleConfigurationDeclaration,
|
|
||||||
moduleConfigurationDeclarationSchema,
|
|
||||||
} from "./module-configuration.js";
|
|
||||||
import {
|
import {
|
||||||
capabilityIdSchema,
|
capabilityIdSchema,
|
||||||
logicalBucketIdSchema,
|
logicalBucketIdSchema,
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
packageRelativePathSchema,
|
packageRelativePathSchema,
|
||||||
|
permissionIdSchema,
|
||||||
} from "./identifiers.js";
|
} from "./identifiers.js";
|
||||||
|
import {
|
||||||
|
type ModuleConfigurationDeclaration,
|
||||||
|
moduleConfigurationDeclarationSchema,
|
||||||
|
} from "./module-configuration.js";
|
||||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||||
@@ -64,17 +65,27 @@ const moduleStorageSchema = z
|
|||||||
.default(() => ({ buckets: [] }));
|
.default(() => ({ buckets: [] }));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The compiled artifacts a module publishes for the platform to resolve. Every entry is a
|
* A module's optional full-viewport display UI. The artifact is kept beside its access policy so a
|
||||||
* package-relative path to a build output, never source: Core resolves and validates these
|
* display can never be declared without saying which one of the module's own permissions protects
|
||||||
* statically during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021).
|
* it. Core validates the reference against `permissions` below without executing module code.
|
||||||
|
*/
|
||||||
|
export const moduleDisplayEntrypointSchema = z
|
||||||
|
.object({
|
||||||
|
artifact: packageRelativePathSchema,
|
||||||
|
permission: permissionIdSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compiled artifacts a module publishes for the platform to resolve. File entries are
|
||||||
|
* package-relative paths to build output, never source: Core resolves and validates them statically
|
||||||
|
* during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021).
|
||||||
*
|
*
|
||||||
* `ui` is a module's optional, host-neutral presentation entrypoint (ADR 0021). It is declared and
|
* `ui` is a module's optional, host-neutral presentation entrypoint (ADR 0021). It is declared and
|
||||||
* statically validated here, exactly like `jobs` already is, but **no runtime consumer exists yet**
|
* statically validated here and mounted by a compatible host. Its name is deliberately not
|
||||||
* — nothing in this repository imports, evaluates, mounts, or renders it. Its name is deliberately
|
* `dashboard`: a module's primary UI must be hostable by Ekklesia's application shell *and* by a
|
||||||
* not `dashboard`: a module's UI must be hostable by Ekklesia's own application shell *and* by a
|
* future standalone host. `display` is separate because full-viewport output and primary management
|
||||||
* future standalone host, so the contract cannot be named after one of them. It is deliberately not
|
* UI are distinct product experiences; both still use the same browser mount ABI.
|
||||||
* `surface` either — `surfaces/*` is a separate ownership layer (ADR 0001), and reusing that word
|
|
||||||
* for a module-owned artifact would conflate two different things.
|
|
||||||
*/
|
*/
|
||||||
const entrypointsSchema = z
|
const entrypointsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -83,6 +94,7 @@ const entrypointsSchema = z
|
|||||||
migrations: packageRelativePathSchema.optional(),
|
migrations: packageRelativePathSchema.optional(),
|
||||||
jobs: packageRelativePathSchema.optional(),
|
jobs: packageRelativePathSchema.optional(),
|
||||||
ui: packageRelativePathSchema.optional(),
|
ui: packageRelativePathSchema.optional(),
|
||||||
|
display: moduleDisplayEntrypointSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -106,10 +118,9 @@ function reportDuplicates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one manifest shape Core accepts. Adding a top-level `configuration` array is an incompatible
|
* The one manifest shape Core accepts. Version 5's strict `entrypoints` object rejects `display`, so
|
||||||
* shape change — version 4's manifest object is `.strict()` and rejects `configuration` outright —
|
* the static display declaration is an incompatible shape change and the canonical manifest moves
|
||||||
* so the canonical manifest moves to `manifestVersion: 5` rather than reinterpreting version 4
|
* to `manifestVersion: 6` rather than silently reinterpreting version 5.
|
||||||
* (ADR 0029).
|
|
||||||
*
|
*
|
||||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||||
* executable module code, for one decisive reason: the platform validates a module's configuration
|
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||||
@@ -118,13 +129,13 @@ function reportDuplicates(
|
|||||||
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||||
* importing a single line of module code, exactly as they always have.
|
* importing a single line of module code, exactly as they always have.
|
||||||
*
|
*
|
||||||
* Version 4, and every other version, fails validation: there is no compatibility adapter, no
|
* Version 5, and every other version, fails validation: there is no compatibility adapter, no
|
||||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||||
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
|
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
|
||||||
* manifest document can therefore never be read as two different versions. Upgrading a version 4
|
* manifest document can therefore never be read as two different versions. Upgrading a version 5
|
||||||
* manifest is a one-character edit: no field added by version 5 is required.
|
* manifest is a one-character edit for a module that declares no display: the new field is optional.
|
||||||
*/
|
*/
|
||||||
export const MODULE_MANIFEST_VERSION = 5;
|
export const MODULE_MANIFEST_VERSION = 6;
|
||||||
|
|
||||||
export const moduleManifestSchema = z
|
export const moduleManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -199,6 +210,39 @@ export const moduleManifestSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const display = manifest.entrypoints.display;
|
||||||
|
if (display !== undefined) {
|
||||||
|
const displayPermission = manifest.permissions.find(
|
||||||
|
(permission) => permission.id === display.permission,
|
||||||
|
);
|
||||||
|
if (displayPermission === undefined) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A display entrypoint must reference a permission declared by this module.",
|
||||||
|
path: ["entrypoints", "display", "permission"],
|
||||||
|
});
|
||||||
|
} else if (displayPermission.baseline === "public") {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A display entrypoint permission must require an authenticated principal.",
|
||||||
|
path: ["entrypoints", "display", "permission"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Object.entries(manifest.entrypoints).some(
|
||||||
|
([entrypointName, configured]) =>
|
||||||
|
entrypointName !== "display" && configured === display.artifact,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"The display UI must use a different path from every other configured entrypoint.",
|
||||||
|
path: ["entrypoints", "display", "artifact"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const dependencyKind of ["required", "optional"] as const) {
|
for (const dependencyKind of ["required", "optional"] as const) {
|
||||||
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
|
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
@@ -253,6 +297,7 @@ export function safeParseModuleManifest(input: unknown) {
|
|||||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||||
|
export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>;
|
||||||
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
||||||
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
||||||
* here for discoverability alongside the manifest's other per-field declaration types. */
|
* here for discoverability alongside the manifest's other per-field declaration types. */
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
isVersionCompatible,
|
|
||||||
isModuleConfigurationBlockedReason,
|
isModuleConfigurationBlockedReason,
|
||||||
|
isVersionCompatible,
|
||||||
MODULE_BLOCKED_REASONS,
|
MODULE_BLOCKED_REASONS,
|
||||||
MODULE_CONFIGURATION_BLOCKED_REASONS,
|
MODULE_CONFIGURATION_BLOCKED_REASONS,
|
||||||
MODULE_DEPENDENCY_KINDS,
|
MODULE_DEPENDENCY_KINDS,
|
||||||
MODULE_STORAGE_BLOCKED_REASON,
|
|
||||||
MODULE_MANIFEST_VERSION,
|
MODULE_MANIFEST_VERSION,
|
||||||
MODULE_RUNTIME_STATES,
|
MODULE_RUNTIME_STATES,
|
||||||
|
MODULE_STORAGE_BLOCKED_REASON,
|
||||||
packageRelativePathSchema,
|
packageRelativePathSchema,
|
||||||
parseModuleManifest,
|
parseModuleManifest,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
|
|
||||||
function validManifest(): Record<string, unknown> {
|
function validManifest(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
name: "Calendar",
|
name: "Calendar",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -226,9 +226,15 @@ describe("module manifest contract", () => {
|
|||||||
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
|
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a version 5 manifest rather than silently interpreting it as version 6", () => {
|
||||||
|
const version5Manifest = { ...validManifest(), manifestVersion: 5 };
|
||||||
|
|
||||||
|
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||||
expect(MODULE_MANIFEST_VERSION).toBe(5);
|
expect(MODULE_MANIFEST_VERSION).toBe(6);
|
||||||
for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) {
|
for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) {
|
||||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
@@ -391,6 +397,7 @@ describe("module manifest contract", () => {
|
|||||||
const manifest = parseModuleManifest(validManifest());
|
const manifest = parseModuleManifest(validManifest());
|
||||||
|
|
||||||
expect(manifest.entrypoints.ui).toBeUndefined();
|
expect(manifest.entrypoints.ui).toBeUndefined();
|
||||||
|
expect(manifest.entrypoints.display).toBeUndefined();
|
||||||
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
|
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -405,6 +412,153 @@ describe("module manifest contract", () => {
|
|||||||
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
|
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts an independently declared display UI protected by a module-owned permission", () => {
|
||||||
|
const manifest = parseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manifest.entrypoints.ui).toBeUndefined();
|
||||||
|
expect(manifest.entrypoints.display).toEqual({
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a display permission that this module did not declare", () => {
|
||||||
|
const result = safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "calendar.display.view",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||||
|
"entrypoints.display.permission",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a display permission whose baseline would admit an anonymous principal", () => {
|
||||||
|
const result = safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: "calendar.display.view",
|
||||||
|
baseline: "public",
|
||||||
|
description: "View the calendar display.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "calendar.display.view",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||||
|
"entrypoints.display.permission",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
|
||||||
|
it(`rejects a display artifact that is also configured as the ${entrypointName} entrypoint`, () => {
|
||||||
|
const sharedPath = "./dist/shared.js";
|
||||||
|
const result = safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: {
|
||||||
|
server: entrypointName === "server" ? sharedPath : "./dist/server/index.js",
|
||||||
|
...(entrypointName === "server" ? {} : { [entrypointName]: sharedPath }),
|
||||||
|
display: {
|
||||||
|
artifact: sharedPath,
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||||
|
"entrypoints.display.artifact",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects primary and display UI declarations that name the same artifact", () => {
|
||||||
|
const result = safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/ui/index.js",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||||
|
"entrypoints.display.artifact",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"../outside/display.js",
|
||||||
|
"/absolute/display.js",
|
||||||
|
"./dist/../../display.js",
|
||||||
|
"file:///dist/display.js",
|
||||||
|
".\\dist\\display.js",
|
||||||
|
"./dist/display.js?raw",
|
||||||
|
"",
|
||||||
|
])("rejects the unsafe display artifact path %s", (artifact) => {
|
||||||
|
expect(
|
||||||
|
safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
display: { artifact, permission: "calendar.events.read" },
|
||||||
|
},
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown properties in the static display declaration", () => {
|
||||||
|
expect(
|
||||||
|
safeParseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
public: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["../outside/ui.js", "escapes the package boundary"],
|
["../outside/ui.js", "escapes the package boundary"],
|
||||||
["/absolute/ui.js", "is an absolute path"],
|
["/absolute/ui.js", "is an absolute path"],
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { packageRelativePathSchema } from "@ekklesia/contracts";
|
import { moduleDisplayEntrypointSchema, packageRelativePathSchema } from "@ekklesia/contracts";
|
||||||
import { build, type Plugin } from "esbuild";
|
import { build, type Plugin } from "esbuild";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one supported way to turn a module's React/TSX UI source into the compiled browser artifact
|
* The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts
|
||||||
* its manifest declares as `entrypoints.ui` (ADR 0021, ADR 0022, ADR 0023).
|
* its manifest declares as `entrypoints.ui` and `entrypoints.display`.
|
||||||
*
|
*
|
||||||
* Module authors do not write bundler configuration. They put their UI at
|
* Module authors do not write bundler configuration. They put their UI at
|
||||||
* {@link MODULE_UI_SOURCE_ENTRYPOINT}, declare `entrypoints.ui` in their manifest, and run the
|
* the fixed role-specific source locations below, declare either or both artifacts, and run one
|
||||||
* build; every option below is owned here so one decision applies to every module.
|
* build; every option below is owned here so one decision applies to every module.
|
||||||
*/
|
*/
|
||||||
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
|
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
|
||||||
|
export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx";
|
||||||
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||||
|
|
||||||
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
|
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
|
||||||
@@ -19,17 +20,21 @@ export const MODULE_UI_BUILD_TARGET = "es2022";
|
|||||||
|
|
||||||
export type ModuleUiBuildErrorCode =
|
export type ModuleUiBuildErrorCode =
|
||||||
| "MODULE_UI_MANIFEST_UNREADABLE"
|
| "MODULE_UI_MANIFEST_UNREADABLE"
|
||||||
| "MODULE_UI_ENTRYPOINT_NOT_DECLARED"
|
|
||||||
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
||||||
|
| "MODULE_UI_ENTRYPOINT_COLLISION"
|
||||||
| "MODULE_UI_SOURCE_MISSING"
|
| "MODULE_UI_SOURCE_MISSING"
|
||||||
| "MODULE_UI_BUILD_FAILED";
|
| "MODULE_UI_BUILD_FAILED";
|
||||||
|
|
||||||
|
export type ModuleUiArtifactKind = "ui" | "display";
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
||||||
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
||||||
MODULE_UI_ENTRYPOINT_NOT_DECLARED: `${MODULE_MANIFEST_FILENAME} declares no entrypoints.ui, so there is no module UI to build.`,
|
MODULE_UI_ENTRYPOINT_UNSAFE:
|
||||||
MODULE_UI_ENTRYPOINT_UNSAFE: `entrypoints.ui must be a package-relative path to a compiled .js or .mjs artifact inside the module.`,
|
"A module UI entrypoint must be a package-relative path to a compiled .js or .mjs artifact inside the module.",
|
||||||
MODULE_UI_SOURCE_MISSING: `Module UI source was not found at ${MODULE_UI_SOURCE_ENTRYPOINT}.`,
|
MODULE_UI_ENTRYPOINT_COLLISION:
|
||||||
MODULE_UI_BUILD_FAILED: "The module UI bundle could not be produced.",
|
"A display UI entrypoint must not overwrite another declared artifact.",
|
||||||
|
MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.",
|
||||||
|
MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.",
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by
|
/** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by
|
||||||
@@ -37,11 +42,17 @@ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.f
|
|||||||
* other Ekklesia error boundary. */
|
* other Ekklesia error boundary. */
|
||||||
export class ModuleUiBuildError extends Error {
|
export class ModuleUiBuildError extends Error {
|
||||||
readonly code: ModuleUiBuildErrorCode;
|
readonly code: ModuleUiBuildErrorCode;
|
||||||
|
/** Safe role vocabulary that lets a developer identify which declared artifact failed without
|
||||||
|
* exposing a filesystem path or raw manifest value. */
|
||||||
|
readonly kind?: ModuleUiArtifactKind;
|
||||||
|
|
||||||
constructor(code: ModuleUiBuildErrorCode) {
|
constructor(code: ModuleUiBuildErrorCode, kind?: ModuleUiArtifactKind) {
|
||||||
super(errorMessages[code]);
|
super(errorMessages[code]);
|
||||||
this.name = "ModuleUiBuildError";
|
this.name = "ModuleUiBuildError";
|
||||||
this.code = code;
|
this.code = code;
|
||||||
|
if (kind !== undefined) {
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
this.stack = `${this.name}: ${this.message}`;
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,9 +94,16 @@ export interface BuildModuleUiOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleUiBuildResult {
|
export interface ModuleUiBuildResult {
|
||||||
/** Absolute path of the artifact actually written, which is exactly what the manifest declares. */
|
/** Built artifacts in stable role order: primary UI first, then display. Empty for backend-only
|
||||||
|
* modules, which makes the canonical command usable unchanged by every module package. */
|
||||||
|
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleUiBuildArtifactResult {
|
||||||
|
readonly kind: ModuleUiArtifactKind;
|
||||||
|
/** Absolute path of the artifact actually written, exactly as the manifest declares. */
|
||||||
readonly outputFile: string;
|
readonly outputFile: string;
|
||||||
/** Absolute path of the source entrypoint that produced it. */
|
/** Absolute path of the fixed source entrypoint that produced it. */
|
||||||
readonly sourceFile: string;
|
readonly sourceFile: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +111,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The declared artifact path, validated the same way module discovery validates it, so the build
|
type DeclaredModuleUiArtifact = ModuleUiBuildArtifactResult;
|
||||||
* can never write somewhere discovery would later reject. */
|
|
||||||
async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string> {
|
/** Reads the inert manifest and returns every declared UI artifact in deterministic role order. */
|
||||||
|
async function resolveDeclaredArtifacts(
|
||||||
|
moduleDirectory: string,
|
||||||
|
): Promise<readonly DeclaredModuleUiArtifact[]> {
|
||||||
let manifest: unknown;
|
let manifest: unknown;
|
||||||
try {
|
try {
|
||||||
manifest = JSON.parse(
|
manifest = JSON.parse(
|
||||||
@@ -106,20 +127,81 @@ async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined;
|
const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined;
|
||||||
const declared = isRecord(entrypoints) ? entrypoints.ui : undefined;
|
if (!isRecord(entrypoints)) {
|
||||||
if (declared === undefined) {
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
|
||||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_NOT_DECLARED");
|
|
||||||
}
|
}
|
||||||
// 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.
|
const display = entrypoints.display;
|
||||||
if (
|
const declarations: readonly {
|
||||||
!packageRelativePathSchema.safeParse(declared).success ||
|
readonly declared: unknown;
|
||||||
typeof declared !== "string" ||
|
readonly kind: ModuleUiArtifactKind;
|
||||||
![".js", ".mjs"].includes(path.extname(declared).toLowerCase())
|
readonly present: boolean;
|
||||||
) {
|
readonly source: string;
|
||||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE");
|
}[] = [
|
||||||
|
{
|
||||||
|
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<string>
|
|||||||
*/
|
*/
|
||||||
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
|
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
|
||||||
const moduleDirectory = path.resolve(options.moduleDirectory);
|
const moduleDirectory = path.resolve(options.moduleDirectory);
|
||||||
const outputFile = await resolveDeclaredArtifact(moduleDirectory);
|
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
|
||||||
const sourceFile = path.join(moduleDirectory, MODULE_UI_SOURCE_ENTRYPOINT);
|
const pendingWrites: Array<readonly { path: string; contents: Uint8Array }[]> = [];
|
||||||
|
|
||||||
try {
|
for (const artifact of artifacts) {
|
||||||
await readFile(sourceFile);
|
try {
|
||||||
} catch {
|
const result = await build({
|
||||||
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING");
|
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).
|
||||||
try {
|
bundle: true,
|
||||||
await build({
|
define: {
|
||||||
absWorkingDir: moduleDirectory,
|
// React reads this at module scope. Without it the bundle would reference `process`, a
|
||||||
// Every dependency, React included, is bundled. The artifact must run in any host, including
|
// Node global that does not exist in a browser, and crash on load.
|
||||||
// one that uses no React at all, so nothing is externalized (ADR 0023).
|
"process.env.NODE_ENV": '"production"',
|
||||||
bundle: true,
|
},
|
||||||
define: {
|
entryPoints: [artifact.sourceFile],
|
||||||
// React reads this at module scope. Without it the bundle would reference `process`, a
|
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
|
||||||
// Node global that does not exist in a browser, and crash on load.
|
// requires a real default export.
|
||||||
"process.env.NODE_ENV": '"production"',
|
format: "esm",
|
||||||
},
|
jsx: "automatic",
|
||||||
entryPoints: [sourceFile],
|
// Unminified deliberately: an operator is expected to review the artifact they install
|
||||||
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
|
// (see docs/development/security.md), and nothing else in this repository minifies.
|
||||||
// requires a real default export.
|
minify: false,
|
||||||
format: "esm",
|
outfile: artifact.outputFile,
|
||||||
jsx: "automatic",
|
platform: "browser",
|
||||||
// Unminified deliberately: an operator is expected to review the artifact they install
|
plugins: [rejectStylesheetImports()],
|
||||||
// (see docs/development/security.md), and nothing else in this repository minifies.
|
sourcemap: true,
|
||||||
minify: false,
|
target: MODULE_UI_BUILD_TARGET,
|
||||||
outfile: outputFile,
|
// Fail rather than silently shipping a partial bundle.
|
||||||
platform: "browser",
|
logLevel: "silent",
|
||||||
plugins: [rejectStylesheetImports()],
|
// Compile every role successfully before writing any role. This makes a two-artifact build
|
||||||
sourcemap: true,
|
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
|
||||||
target: MODULE_UI_BUILD_TARGET,
|
write: false,
|
||||||
// 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,
|
|
||||||
});
|
});
|
||||||
|
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 }))),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,24 +11,28 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
/**
|
/**
|
||||||
* `ekklesia-build-module-ui` — the canonical module UI build command, run from a module directory.
|
* `ekklesia-build-module-ui` — the canonical module UI build command, run from a module directory.
|
||||||
*
|
*
|
||||||
* A module with no `entrypoints.ui` is not an error: backend-only modules stay first-class and this
|
* A module with neither UI role is not an error: backend-only modules stay first-class and this
|
||||||
* command reports that there was nothing to build and exits successfully, so one `build` script
|
* command reports that there was nothing to build and exits successfully. When one or both roles
|
||||||
* works for every module.
|
* are declared, this one command builds exactly that set.
|
||||||
*/
|
*/
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
const moduleDirectory = process.argv[2] ?? process.cwd();
|
const moduleDirectory = process.argv[2] ?? process.cwd();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await buildModuleUi({ moduleDirectory });
|
const result = await buildModuleUi({ moduleDirectory });
|
||||||
process.stdout.write(`Built module UI: ${path.relative(moduleDirectory, result.outputFile)}\n`);
|
if (result.artifacts.length === 0) {
|
||||||
|
process.stdout.write("No module UI entrypoints declared; skipping the module UI build.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (const artifact of result.artifacts) {
|
||||||
|
process.stdout.write(
|
||||||
|
`Built module UI (${artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModuleUiBuildError) {
|
if (error instanceof ModuleUiBuildError) {
|
||||||
if (error.code === "MODULE_UI_ENTRYPOINT_NOT_DECLARED") {
|
process.stderr.write(`${error.kind ? `${error.kind}: ` : ""}${error.message}\n`);
|
||||||
process.stdout.write("No entrypoints.ui declared; skipping the module UI build.\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
process.stderr.write(`${error.message}\n`);
|
|
||||||
// esbuild's own diagnostics carry the file, line, and reason a developer needs. They are
|
// esbuild's own diagnostics carry the file, line, and reason a developer needs. They are
|
||||||
// printed here, never folded into the sanitized error message itself.
|
// printed here, never folded into the sanitized error message itself.
|
||||||
const diagnostics = isRecord(error) ? error.diagnostics : undefined;
|
const diagnostics = isRecord(error) ? error.diagnostics : undefined;
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ afterEach(async () => {
|
|||||||
interface ModuleFixtureOptions {
|
interface ModuleFixtureOptions {
|
||||||
/** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */
|
/** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */
|
||||||
readonly ui?: string;
|
readonly ui?: string;
|
||||||
|
readonly schema?: string;
|
||||||
|
readonly migrations?: string;
|
||||||
|
readonly jobs?: string;
|
||||||
|
readonly server?: string;
|
||||||
|
/** The complete inert `entrypoints.display` value, including its permission policy. */
|
||||||
|
readonly display?: unknown;
|
||||||
readonly manifest?: string;
|
readonly manifest?: string;
|
||||||
readonly files?: Record<string, string>;
|
readonly files?: Record<string, string>;
|
||||||
}
|
}
|
||||||
@@ -28,14 +34,18 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
|
|||||||
const manifest =
|
const manifest =
|
||||||
options.manifest ??
|
options.manifest ??
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 5,
|
manifestVersion: 6,
|
||||||
id: "example",
|
id: "example",
|
||||||
name: "Example",
|
name: "Example",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./dist/server/index.js",
|
server: options.server ?? "./dist/server/index.js",
|
||||||
|
...(options.schema === undefined ? {} : { schema: options.schema }),
|
||||||
|
...(options.migrations === undefined ? {} : { migrations: options.migrations }),
|
||||||
|
...(options.jobs === undefined ? {} : { jobs: options.jobs }),
|
||||||
...(options.ui === undefined ? {} : { ui: options.ui }),
|
...(options.ui === undefined ? {} : { ui: options.ui }),
|
||||||
|
...(options.display === undefined ? {} : { display: options.display }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeFile(path.join(directory, "ekklesia.module.json"), manifest);
|
await writeFile(path.join(directory, "ekklesia.module.json"), manifest);
|
||||||
@@ -73,9 +83,14 @@ describe("module UI build", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
const artifactResult = result.artifacts[0];
|
||||||
|
|
||||||
expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js"));
|
expect(result.artifacts).toHaveLength(1);
|
||||||
const artifact = await readFile(result.outputFile, "utf8");
|
expect(artifactResult).toMatchObject({
|
||||||
|
kind: "ui",
|
||||||
|
outputFile: path.join(directory, "dist", "ui", "index.js"),
|
||||||
|
});
|
||||||
|
const artifact = await readFile(artifactResult?.outputFile ?? "", "utf8");
|
||||||
expect(artifact).toContain("export {");
|
expect(artifact).toContain("export {");
|
||||||
expect(artifact).toContain("as default");
|
expect(artifact).toContain("as default");
|
||||||
});
|
});
|
||||||
@@ -88,7 +103,9 @@ describe("module UI build", () => {
|
|||||||
|
|
||||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
|
||||||
expect(result.outputFile).toBe(path.join(directory, "build", "browser", "ui.mjs"));
|
expect(result.artifacts[0]?.outputFile).toBe(
|
||||||
|
path.join(directory, "build", "browser", "ui.mjs"),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits a source map alongside the artifact", async () => {
|
it("emits a source map alongside the artifact", async () => {
|
||||||
@@ -99,15 +116,15 @@ describe("module UI build", () => {
|
|||||||
|
|
||||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
|
||||||
await expect(readFile(`${result.outputFile}.map`, "utf8")).resolves.toContain("sources");
|
await expect(readFile(`${result.artifacts[0]?.outputFile}.map`, "utf8")).resolves.toContain(
|
||||||
|
"sources",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a backend-only module as having no UI to build rather than failing silently", async () => {
|
it("returns an explicit empty result for a backend-only module", async () => {
|
||||||
const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } });
|
const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } });
|
||||||
|
|
||||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
await expect(buildModuleUi({ moduleDirectory: directory })).resolves.toEqual({ artifacts: [] });
|
||||||
code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails when the declared UI has no source entrypoint", async () => {
|
it("fails when the declared UI has no source entrypoint", async () => {
|
||||||
@@ -115,9 +132,134 @@ describe("module UI build", () => {
|
|||||||
|
|
||||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
code: "MODULE_UI_SOURCE_MISSING",
|
code: "MODULE_UI_SOURCE_MISSING",
|
||||||
|
kind: "ui",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds a display-only module from the fixed display source", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "example.display.view",
|
||||||
|
},
|
||||||
|
files: { "src/display/index.tsx": plainUiSource },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
|
||||||
|
expect(result.artifacts).toEqual([
|
||||||
|
{
|
||||||
|
kind: "display",
|
||||||
|
outputFile: path.join(directory, "dist/display/index.js"),
|
||||||
|
sourceFile: path.join(directory, "src/display/index.tsx"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await expect(readFile(result.artifacts[0]?.outputFile ?? "", "utf8")).resolves.toContain(
|
||||||
|
"as default",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds primary and display artifacts in deterministic role order", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.mjs",
|
||||||
|
permission: "example.display.view",
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": plainUiSource.replaceAll("example", "primary"),
|
||||||
|
"src/display/index.tsx": plainUiSource.replaceAll("example", "display"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
|
|
||||||
|
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
|
||||||
|
await expect(readFile(path.join(directory, "dist/ui/index.js"), "utf8")).resolves.toContain(
|
||||||
|
'"primary"',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
readFile(path.join(directory, "dist/display/index.mjs"), "utf8"),
|
||||||
|
).resolves.toContain('"display"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preflights every declared source before writing either artifact", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "example.display.view",
|
||||||
|
},
|
||||||
|
files: { "src/ui/index.tsx": plainUiSource },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_SOURCE_MISSING",
|
||||||
|
kind: "display",
|
||||||
|
});
|
||||||
|
await expect(readFile(path.join(directory, "dist/ui/index.js"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes neither role when the second declared bundle fails to compile", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "example.display.view",
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": plainUiSource,
|
||||||
|
"src/display/index.tsx": "export default { broken:",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_BUILD_FAILED",
|
||||||
|
kind: "display",
|
||||||
|
});
|
||||||
|
await expect(readFile(path.join(directory, "dist/ui/index.js"))).rejects.toThrow();
|
||||||
|
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects colliding primary and display output paths before building", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/ui/index.js",
|
||||||
|
permission: "example.display.view",
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
"src/ui/index.tsx": plainUiSource,
|
||||||
|
"src/display/index.tsx": plainUiSource,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_ENTRYPOINT_COLLISION",
|
||||||
|
kind: "display",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entrypointName of ["server", "schema", "migrations", "jobs"] as const) {
|
||||||
|
it(`rejects a display output that would overwrite the declared ${entrypointName} artifact before building`, async () => {
|
||||||
|
const sharedPath = "./dist/display/index.js";
|
||||||
|
const directory = await createModule({
|
||||||
|
[entrypointName]: sharedPath,
|
||||||
|
display: {
|
||||||
|
artifact: sharedPath,
|
||||||
|
permission: "example.display.view",
|
||||||
|
},
|
||||||
|
files: { "src/display/index.tsx": plainUiSource },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_ENTRYPOINT_COLLISION",
|
||||||
|
kind: "display",
|
||||||
|
});
|
||||||
|
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it("fails when the manifest cannot be read", async () => {
|
it("fails when the manifest cannot be read", async () => {
|
||||||
const directory = await createModule({ manifest: "{ not json" });
|
const directory = await createModule({ manifest: "{ not json" });
|
||||||
|
|
||||||
@@ -138,9 +280,38 @@ describe("module UI build", () => {
|
|||||||
|
|
||||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
|
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
|
||||||
|
kind: "ui",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies the same safe compiled-output rules to display artifacts", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
display: {
|
||||||
|
artifact: "./src/display/index.tsx",
|
||||||
|
permission: "example.display.view",
|
||||||
|
},
|
||||||
|
files: { "src/display/index.tsx": plainUiSource },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
|
||||||
|
kind: "display",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reinterpret a bare display path as the version 6 display declaration", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
display: "./dist/display/index.js",
|
||||||
|
files: { "src/display/index.tsx": plainUiSource },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_MANIFEST_UNREADABLE",
|
||||||
|
kind: "display",
|
||||||
|
});
|
||||||
|
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces a build failure with esbuild's diagnostics attached", async () => {
|
it("surfaces a build failure with esbuild's diagnostics attached", async () => {
|
||||||
const directory = await createModule({
|
const directory = await createModule({
|
||||||
ui: "./dist/ui/index.js",
|
ui: "./dist/ui/index.js",
|
||||||
@@ -212,6 +383,25 @@ describe("module UI build", () => {
|
|||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enforces the stylesheet boundary for display UI too", async () => {
|
||||||
|
const directory = await createModule({
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "example.display.view",
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
"src/display/index.tsx": 'import "./styles.css";\nexport default {};',
|
||||||
|
"src/display/styles.css": ".display { position: fixed; }",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_UI_BUILD_FAILED",
|
||||||
|
kind: "display",
|
||||||
|
});
|
||||||
|
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => {
|
it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => {
|
||||||
const directory = await createModule({
|
const directory = await createModule({
|
||||||
ui: "./dist/ui/index.js",
|
ui: "./dist/ui/index.js",
|
||||||
@@ -222,7 +412,7 @@ describe("module UI build", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||||
const artifact = await readFile(result.outputFile, "utf8");
|
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
|
||||||
|
|
||||||
expect(artifact).toContain('"production"');
|
expect(artifact).toContain('"production"');
|
||||||
expect(artifact).not.toContain("process.env.NODE_ENV");
|
expect(artifact).not.toContain("process.env.NODE_ENV");
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
* How a host obtains a module's UI artifact.
|
* How a host obtains a module's UI artifact.
|
||||||
*
|
*
|
||||||
* `url` is a **browser-loadable module URL**, not a filesystem path. That distinction is the whole
|
* `url` is a **browser-loadable module URL**, not a filesystem path. That distinction is the whole
|
||||||
* reason this type exists. Core's module discovery resolves `entrypoints.ui` to an absolute path on
|
* reason this type exists. Core's module discovery resolves each primary or display entrypoint to an
|
||||||
* the server's disk (`ResolvedModuleEntrypoints.ui`); a browser cannot import that, and a host that
|
* absolute path on the server's disk; a browser cannot import that, and a host that passed one here
|
||||||
* passed one here would fail at run time in a way no test on a server would catch. Turning an
|
* would fail at run time in a way no test on a server would catch. Turning an installed artifact
|
||||||
* installed artifact into a URL a browser may fetch is **asset publication**, a separate concern
|
* into a URL a browser may fetch is **asset publication**, a separate concern this generic package
|
||||||
* this package deliberately does not own and Ekklesia does not implement yet (ADR 0024).
|
* deliberately does not own. Ekklesia implements that concern for both roles at its API edge (ADR
|
||||||
|
* 0025, ADR 0033).
|
||||||
*
|
*
|
||||||
* `moduleId` and `version` are the identity the host already read from the module's validated
|
* `moduleId` and `version` are the identity the host already read from the module's validated
|
||||||
* manifest. They are checked against the artifact before anything is mounted.
|
* manifest. They are checked against the artifact before anything is mounted.
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 5,
|
"manifestVersion": 6,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example",
|
"name": "Example",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ function standaloneContext(requested: string[]): ModuleUiHostContext {
|
|||||||
describe("loading a real compiled artifact", () => {
|
describe("loading a real compiled artifact", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const result = await buildModuleUi({ moduleDirectory: fixtureDirectory });
|
const result = await buildModuleUi({ moduleDirectory: fixtureDirectory });
|
||||||
artifactSource = await readFile(result.outputFile, "utf8");
|
const primaryArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
|
||||||
|
if (!primaryArtifact) {
|
||||||
|
throw new Error("The real-artifact fixture must declare a primary UI artifact.");
|
||||||
|
}
|
||||||
|
artifactSource = await readFile(primaryArtifact.outputFile, "utf8");
|
||||||
artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`;
|
artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`;
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export type ModuleUiPrincipal = ModuleUiAnonymousPrincipal | ModuleUiAuthenticat
|
|||||||
* than to add. See ADR 0022.
|
* than to add. See ADR 0022.
|
||||||
*
|
*
|
||||||
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
|
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
|
||||||
* host that never runs Core, which is what makes one module UI hostable by both. This package has
|
* host that never runs Core, which is what makes one module UI hostable by both. The same context is
|
||||||
|
* supplied to primary and display artifacts: there is deliberately no role or presentation-mode
|
||||||
|
* flag, because the separately declared artifact already selects the experience. This package has
|
||||||
* no dependencies at all, so importing it cannot pull a backend type graph into a browser program.
|
* no dependencies at all, so importing it cannot pull a backend type graph into a browser program.
|
||||||
*/
|
*/
|
||||||
export interface ModuleUiHostContext {
|
export interface ModuleUiHostContext {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export type Awaitable<Value> = Value | PromiseLike<Value>;
|
|||||||
export type ModuleUiUnmount = () => Awaitable<void>;
|
export type ModuleUiUnmount = () => Awaitable<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a module's compiled `entrypoints.ui` artifact default-exports (ADR 0021, ADR 0022).
|
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
|
||||||
|
* default-exports (ADR 0021, ADR 0022, ADR 0033).
|
||||||
*
|
*
|
||||||
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
|
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
|
||||||
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
|
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
|
||||||
@@ -115,8 +116,9 @@ export interface ModuleUiIdentity {
|
|||||||
* Deliberately does **not** import anything itself. Each host performs its own dynamic import — a
|
* Deliberately does **not** import anything itself. Each host performs its own dynamic import — a
|
||||||
* browser host from a served URL, a test host from a file URL — and passes the resulting module
|
* browser host from a served URL, a test host from a file URL — and passes the resulting module
|
||||||
* namespace here. That keeps this package free of any loader, bundler, or URL assumption, and keeps
|
* namespace here. That keeps this package free of any loader, bundler, or URL assumption, and keeps
|
||||||
* Core out of the business of evaluating browser code entirely: module discovery still only
|
* Core out of the business of evaluating browser code entirely: module discovery only resolves
|
||||||
* resolves and validates the `entrypoints.ui` path and never executes it (ADR 0021).
|
* and validates each declared browser artifact path and never executes either role (ADR 0021,
|
||||||
|
* ADR 0033).
|
||||||
*/
|
*/
|
||||||
export function parseModuleUiExport(
|
export function parseModuleUiExport(
|
||||||
namespace: unknown,
|
namespace: unknown,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ async function sourceFiles(): Promise<string[]> {
|
|||||||
* This package is bundled into a browser. `@ekklesia/module-sdk` re-exports all of
|
* This package is bundled into a browser. `@ekklesia/module-sdk` re-exports all of
|
||||||
* `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel export, so a single
|
* `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel export, so a single
|
||||||
* value import from a backend package here would drag that whole graph into every module UI bundle
|
* value import from a backend package here would drag that whole graph into every module UI bundle
|
||||||
* (ADR 0022). The dependency on `@ekklesia/contracts` is deliberately type-only and must stay that
|
* (ADR 0022). ADR 0024 removed even the former type-only contracts dependency because its type graph
|
||||||
* way; `import type` is erased at build time, a plain `import` is not.
|
* leaked Node globals into browser programs; this package must remain completely dependency-free.
|
||||||
*/
|
*/
|
||||||
describe("module UI SDK stays browser-safe", () => {
|
describe("module UI SDK stays browser-safe", () => {
|
||||||
it("imports nothing from another package at all, at type level or runtime", async () => {
|
it("imports nothing from another package at all, at type level or runtime", async () => {
|
||||||
|
|||||||
Generated
+3
-3
@@ -42,6 +42,9 @@ importers:
|
|||||||
'@ekklesia/core':
|
'@ekklesia/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../core
|
version: link:../../core
|
||||||
|
'@ekklesia/shell':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../surfaces/shell
|
||||||
'@hono/node-server':
|
'@hono/node-server':
|
||||||
specifier: 2.0.10
|
specifier: 2.0.10
|
||||||
version: 2.0.10([email protected])
|
version: 2.0.10([email protected])
|
||||||
@@ -55,9 +58,6 @@ importers:
|
|||||||
'@ekklesia/module-ui-host':
|
'@ekklesia/module-ui-host':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/module-ui-host
|
version: link:../../packages/module-ui-host
|
||||||
'@ekklesia/shell':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../../surfaces/shell
|
|
||||||
'@ekklesia/typescript-config':
|
'@ekklesia/typescript-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/typescript-config
|
version: link:../../packages/typescript-config
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" style="height: 100%; margin: 0; overflow: hidden">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
|
<title>Display</title>
|
||||||
|
</head>
|
||||||
|
<body style="height: 100%; margin: 0; overflow: hidden">
|
||||||
|
<!-- Structural canvas only. The module owns every visible pixel after mounting. -->
|
||||||
|
<div id="ekklesia-display" style="height: 100%; min-height: 100dvh; overflow: hidden"></div>
|
||||||
|
<!-- Fixed host asset, never a module-derived path and never an inline bootstrap script. -->
|
||||||
|
<script src="/assets/ekklesia-display/app.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./display-assets": {
|
||||||
|
"types": "./dist/display-assets.d.ts",
|
||||||
|
"import": "./dist/display-assets.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -41,14 +41,17 @@ const buildOptions = {
|
|||||||
// React reads this at module scope and a browser has no `process`.
|
// React reads this at module scope and a browser has no `process`.
|
||||||
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
||||||
},
|
},
|
||||||
entryPoints: [path.join(packageDirectory, "src", "browser", "main.tsx")],
|
entryPoints: {
|
||||||
|
app: path.join(packageDirectory, "src", "browser", "main.tsx"),
|
||||||
|
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
|
||||||
|
},
|
||||||
format: "esm",
|
format: "esm",
|
||||||
jsx: "automatic",
|
jsx: "automatic",
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
// Unminified, matching the module UI build: an operator is expected to be able to read what their
|
// Unminified, matching the module UI build: an operator is expected to be able to read what their
|
||||||
// installation actually serves, and nothing else in this repository minifies.
|
// installation actually serves, and nothing else in this repository minifies.
|
||||||
minify: false,
|
minify: false,
|
||||||
outfile: path.join(outputDirectory, "app.js"),
|
outdir: outputDirectory,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
// Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree.
|
// Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree.
|
||||||
@@ -57,7 +60,12 @@ const buildOptions = {
|
|||||||
|
|
||||||
await rm(outputDirectory, { force: true, recursive: true });
|
await rm(outputDirectory, { force: true, recursive: true });
|
||||||
await mkdir(outputDirectory, { recursive: true });
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
|
||||||
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
|
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
|
||||||
|
await copyFile(
|
||||||
|
path.join(packageDirectory, "display.html"),
|
||||||
|
path.join(outputDirectory, "display", "index.html"),
|
||||||
|
);
|
||||||
|
|
||||||
if (serve) {
|
if (serve) {
|
||||||
const context = await esbuild.context(buildOptions);
|
const context = await esbuild.context(buildOptions);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||||
|
import { type ReactElement, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
bootstrapDisplayHost,
|
||||||
|
type DisplayHostBootstrap,
|
||||||
|
type EkklesiaHostConfig,
|
||||||
|
HostProtocolError,
|
||||||
|
} from "../host-protocol.js";
|
||||||
|
import { DisplayUiFrame } from "./DisplayUiFrame.js";
|
||||||
|
|
||||||
|
type DisplayState =
|
||||||
|
| { readonly status: "loading"; readonly generation: number }
|
||||||
|
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
|
||||||
|
| { readonly status: "signed-out" }
|
||||||
|
| { readonly status: "unavailable" }
|
||||||
|
| { readonly status: "failed" };
|
||||||
|
|
||||||
|
export interface DisplayAppProps {
|
||||||
|
readonly config: EkklesiaHostConfig;
|
||||||
|
readonly moduleId: string;
|
||||||
|
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
|
||||||
|
readonly importModule?: ModuleUiImporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayStyles = {
|
||||||
|
host: {
|
||||||
|
height: "100%",
|
||||||
|
minHeight: "100dvh",
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
alignItems: "center",
|
||||||
|
color: "#57534e",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
gap: "0.75rem",
|
||||||
|
height: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
margin: 0,
|
||||||
|
minHeight: "100dvh",
|
||||||
|
padding: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid currentColor",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
color: "inherit",
|
||||||
|
cursor: "pointer",
|
||||||
|
font: "inherit",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function DisplayMessage(props: {
|
||||||
|
readonly children: string;
|
||||||
|
readonly testId: string;
|
||||||
|
readonly onRetry?: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<div aria-live="polite" data-testid={props.testId} style={displayStyles.message}>
|
||||||
|
<p style={{ margin: 0 }}>{props.children}</p>
|
||||||
|
{props.onRetry ? (
|
||||||
|
<button onClick={props.onRetry} style={displayStyles.retry} type="button">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host for one display-capable module, with no Dashboard application mounted around it.
|
||||||
|
*
|
||||||
|
* Bootstrap is principal-first, so an anonymous browser never probes a module descriptor. The
|
||||||
|
* descriptor itself is permission-protected server-side; only after it succeeds can an artifact URL
|
||||||
|
* reach the generic loader. A changed `moduleId` or retry aborts the old fetch and keys a new frame,
|
||||||
|
* so neither a late descriptor nor a late import can replace the current display.
|
||||||
|
*/
|
||||||
|
export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||||
|
const [state, setState] = useState<DisplayState>({ generation: 0, status: "loading" });
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const { config, importModule, moduleId } = props;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
setState({ generation: attempt, status: "loading" });
|
||||||
|
void bootstrapDisplayHost(config, moduleId, abort.signal)
|
||||||
|
.then((bootstrap) => {
|
||||||
|
if (abort.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(
|
||||||
|
bootstrap.status === "signed-out"
|
||||||
|
? { status: "signed-out" }
|
||||||
|
: { bootstrap, status: "ready" },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (abort.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[ekklesia] display host bootstrap failed", error);
|
||||||
|
setState(
|
||||||
|
error instanceof HostProtocolError &&
|
||||||
|
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "DISPLAY_NOT_AVAILABLE")
|
||||||
|
? { status: "unavailable" }
|
||||||
|
: error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED"
|
||||||
|
? { status: "signed-out" }
|
||||||
|
: { status: "failed" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [attempt, config, moduleId]);
|
||||||
|
|
||||||
|
const retry = (): void => setAttempt((current) => current + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="display-host" style={displayStyles.host}>
|
||||||
|
{state.status === "loading" ? (
|
||||||
|
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
||||||
|
) : undefined}
|
||||||
|
{state.status === "signed-out" ? (
|
||||||
|
<DisplayMessage onRetry={retry} testId="display-host-signed-out">
|
||||||
|
Sign in to use this display.
|
||||||
|
</DisplayMessage>
|
||||||
|
) : undefined}
|
||||||
|
{state.status === "unavailable" ? (
|
||||||
|
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
|
||||||
|
This display is unavailable.
|
||||||
|
</DisplayMessage>
|
||||||
|
) : undefined}
|
||||||
|
{state.status === "failed" ? (
|
||||||
|
<DisplayMessage onRetry={retry} testId="display-host-error">
|
||||||
|
This display could not be reached.
|
||||||
|
</DisplayMessage>
|
||||||
|
) : undefined}
|
||||||
|
{state.status === "ready" ? (
|
||||||
|
<DisplayUiFrame
|
||||||
|
config={config}
|
||||||
|
descriptor={state.bootstrap.descriptor}
|
||||||
|
{...(importModule ? { importModule } : {})}
|
||||||
|
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
|
||||||
|
principal={state.bootstrap.principal}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import {
|
||||||
|
loadModuleUi,
|
||||||
|
ModuleUiHostError,
|
||||||
|
type ModuleUiHostErrorCode,
|
||||||
|
type ModuleUiImporter,
|
||||||
|
} from "@ekklesia/module-ui-host";
|
||||||
|
import {
|
||||||
|
ModuleUiContractError,
|
||||||
|
type ModuleUiContractErrorCode,
|
||||||
|
type ModuleUiPrincipal,
|
||||||
|
} from "@ekklesia/module-ui-sdk";
|
||||||
|
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||||
|
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
|
||||||
|
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
|
||||||
|
|
||||||
|
type FrameStatus = "loading" | "ready" | "failed";
|
||||||
|
|
||||||
|
type DisplayUiFailureCode =
|
||||||
|
| ModuleUiContractErrorCode
|
||||||
|
| ModuleUiHostErrorCode
|
||||||
|
| "DISPLAY_UI_LOAD_FAILED"
|
||||||
|
| "DISPLAY_UI_RUNTIME_ERROR"
|
||||||
|
| "DISPLAY_UI_UNHANDLED_REJECTION";
|
||||||
|
|
||||||
|
function loadFailureCode(error: unknown): DisplayUiFailureCode {
|
||||||
|
if (error instanceof ModuleUiHostError || error instanceof ModuleUiContractError) {
|
||||||
|
return error.code;
|
||||||
|
}
|
||||||
|
return "DISPLAY_UI_LOAD_FAILED";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayUiFrameProps {
|
||||||
|
readonly config: EkklesiaHostConfig;
|
||||||
|
readonly descriptor: HostModuleUiDescriptor;
|
||||||
|
readonly principal: ModuleUiPrincipal;
|
||||||
|
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
|
||||||
|
readonly importModule?: ModuleUiImporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
|
||||||
|
readonly onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameStyles = {
|
||||||
|
container: {
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
alignItems: "center",
|
||||||
|
color: "#57534e",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
gap: "0.75rem",
|
||||||
|
inset: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
position: "absolute",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
frame: {
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
message: { margin: 0 },
|
||||||
|
retry: {
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid currentColor",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
color: "inherit",
|
||||||
|
cursor: "pointer",
|
||||||
|
font: "inherit",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One disposable display mount attempt.
|
||||||
|
*
|
||||||
|
* Its container is never reused by another attempt. A dynamic import cannot be aborted, so this is
|
||||||
|
* what makes a late result harmless: cleanup detaches this whole attempt; if the import later
|
||||||
|
* resolves, it mounts only into that detached element and is immediately unmounted. A failed mount
|
||||||
|
* may have written partial DOM before throwing, and the generic loader intentionally leaves that to
|
||||||
|
* its host, so this boundary always clears the container before exposing its fixed failure state.
|
||||||
|
*/
|
||||||
|
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [status, setStatus] = useState<FrameStatus>("loading");
|
||||||
|
const { config, descriptor, importModule, onRetry, principal } = props;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let failed = false;
|
||||||
|
let listening = true;
|
||||||
|
let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined;
|
||||||
|
setStatus("loading");
|
||||||
|
|
||||||
|
const stopListening = (): void => {
|
||||||
|
if (!listening) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listening = false;
|
||||||
|
window.removeEventListener("error", handlePageError);
|
||||||
|
window.removeEventListener("unhandledrejection", handleUnhandledRejection);
|
||||||
|
};
|
||||||
|
|
||||||
|
const failSafely = (code: DisplayUiFailureCode): void => {
|
||||||
|
if (cancelled || failed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failed = true;
|
||||||
|
stopListening();
|
||||||
|
container.replaceChildren();
|
||||||
|
console.error(`[ekklesia] display UI failure module=${descriptor.id} code=${code}`);
|
||||||
|
setStatus("failed");
|
||||||
|
const cleanup = mounted?.unmount();
|
||||||
|
if (cleanup) {
|
||||||
|
void cleanup.catch(() => undefined).finally(() => container.replaceChildren());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A display route is a dedicated, single-module page. Treat any uncaught page exception or
|
||||||
|
// rejection as fatal for that display attempt: browser frameworks may report render failures
|
||||||
|
// after their mount call has already returned, beyond the generic loader's synchronous/awaited
|
||||||
|
// boundary. This is intentionally page-wide rather than error attribution; there is no robust
|
||||||
|
// cross-framework way to map a global exception back to a particular component tree.
|
||||||
|
function handlePageError(event: ErrorEvent): void {
|
||||||
|
// Element-targeted `error` events represent resource/media failures. Those are module state,
|
||||||
|
// not a crashed runtime, and must not blank a whole display.
|
||||||
|
if (event.target !== event.currentTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
failSafely("DISPLAY_UI_RUNTIME_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnhandledRejection(event: PromiseRejectionEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
failSafely("DISPLAY_UI_UNHANDLED_REJECTION");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("error", handlePageError);
|
||||||
|
window.addEventListener("unhandledrejection", handleUnhandledRejection);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const instance = await loadModuleUi({
|
||||||
|
container,
|
||||||
|
context: createModuleUiContext({ config, descriptor, principal }),
|
||||||
|
...(importModule ? { importModule } : {}),
|
||||||
|
source: {
|
||||||
|
moduleId: descriptor.id,
|
||||||
|
url: moduleUiSourceUrl(config, descriptor),
|
||||||
|
version: descriptor.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (cancelled || failed) {
|
||||||
|
await instance.unmount().catch(() => undefined);
|
||||||
|
container.replaceChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mounted = instance;
|
||||||
|
setStatus("ready");
|
||||||
|
} catch (error) {
|
||||||
|
failSafely(loadFailureCode(error));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
stopListening();
|
||||||
|
const cleanup = mounted?.unmount();
|
||||||
|
if (cleanup) {
|
||||||
|
void cleanup.catch(() => undefined).finally(() => container.replaceChildren());
|
||||||
|
} else {
|
||||||
|
container.replaceChildren();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [config, descriptor, importModule, principal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-live="polite" data-testid="display-frame" style={frameStyles.frame}>
|
||||||
|
<div
|
||||||
|
data-testid="display-module-container"
|
||||||
|
ref={containerRef}
|
||||||
|
style={frameStyles.container}
|
||||||
|
/>
|
||||||
|
{status === "loading" ? (
|
||||||
|
<div data-testid="display-loading" style={frameStyles.fallback}>
|
||||||
|
<p style={frameStyles.message}>Loading display…</p>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
{status === "failed" ? (
|
||||||
|
<div data-testid="display-error" style={frameStyles.fallback}>
|
||||||
|
<p style={frameStyles.message}>This display could not be loaded.</p>
|
||||||
|
<button
|
||||||
|
data-testid="display-retry"
|
||||||
|
onClick={onRetry}
|
||||||
|
style={frameStyles.retry}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-viewport module frame with a fresh container for every manual retry. */
|
||||||
|
export function DisplayUiFrame(props: DisplayUiFrameProps): ReactElement {
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
return (
|
||||||
|
<DisplayUiAttempt
|
||||||
|
{...props}
|
||||||
|
key={attempt}
|
||||||
|
onRetry={() => setAttempt((current) => current + 1)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import type { EkklesiaHostConfig } from "../host-protocol.js";
|
||||||
|
import { parseDisplayRoute } from "../host-protocol.js";
|
||||||
|
import { DisplayApp } from "./DisplayApp.js";
|
||||||
|
|
||||||
|
declare const __EKKLESIA_API_BASE_URL__: string;
|
||||||
|
|
||||||
|
const config: EkklesiaHostConfig = {
|
||||||
|
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = document.getElementById("ekklesia-display");
|
||||||
|
if (!container) {
|
||||||
|
throw new Error("The Ekklesia display host requires a #ekklesia-display element.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let moduleId: string | undefined;
|
||||||
|
try {
|
||||||
|
moduleId = parseDisplayRoute(window.location.pathname);
|
||||||
|
} catch {
|
||||||
|
moduleId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
moduleId ? (
|
||||||
|
<DisplayApp config={config} moduleId={moduleId} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
data-testid="display-route-invalid"
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
color: "#57534e",
|
||||||
|
display: "flex",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
height: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "100dvh",
|
||||||
|
padding: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This display is unavailable.
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -35,6 +35,12 @@ export type HostEventSourceFactory = (url: string, options: HostEventSourceOptio
|
|||||||
* code for a consumer that does not exist. */
|
* code for a consumer that does not exist. */
|
||||||
const PRINCIPAL_PATH = "/api/v1/principal";
|
const PRINCIPAL_PATH = "/api/v1/principal";
|
||||||
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
||||||
|
const MODULE_DISPLAYS_PATH = "/api/v1/module-displays";
|
||||||
|
|
||||||
|
const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||||
|
const MODULE_ID_MAX_LENGTH = 64;
|
||||||
|
const SEMANTIC_VERSION_PATTERN =
|
||||||
|
/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/u;
|
||||||
|
|
||||||
export type HostProtocolErrorCode =
|
export type HostProtocolErrorCode =
|
||||||
/** The caller is not signed in, or lacks `core.modules.ui.read`. */
|
/** The caller is not signed in, or lacks `core.modules.ui.read`. */
|
||||||
@@ -46,7 +52,13 @@ export type HostProtocolErrorCode =
|
|||||||
/** A module UI asked for something outside its own API root. */
|
/** A module UI asked for something outside its own API root. */
|
||||||
| "MODULE_API_PATH_INVALID"
|
| "MODULE_API_PATH_INVALID"
|
||||||
/** A module UI asked for a live channel outside the public local-channel grammar. */
|
/** A module UI asked for a live channel outside the public local-channel grammar. */
|
||||||
| "MODULE_REALTIME_CHANNEL_INVALID";
|
| "MODULE_REALTIME_CHANNEL_INVALID"
|
||||||
|
/** The browser was not opened at one exact, valid display route. */
|
||||||
|
| "DISPLAY_ROUTE_INVALID"
|
||||||
|
/** The requested module does not currently publish a display. */
|
||||||
|
| "DISPLAY_NOT_AVAILABLE"
|
||||||
|
/** The signed-in principal may not mount the requested display. */
|
||||||
|
| "DISPLAY_ACCESS_DENIED";
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
|
const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
|
||||||
HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
|
HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
|
||||||
@@ -54,6 +66,9 @@ const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.fr
|
|||||||
HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.",
|
HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.",
|
||||||
MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.",
|
MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.",
|
||||||
MODULE_REALTIME_CHANNEL_INVALID: "A module asked for an invalid live channel.",
|
MODULE_REALTIME_CHANNEL_INVALID: "A module asked for an invalid live channel.",
|
||||||
|
DISPLAY_ACCESS_DENIED: "You do not have access to this display.",
|
||||||
|
DISPLAY_NOT_AVAILABLE: "This display is not available.",
|
||||||
|
DISPLAY_ROUTE_INVALID: "This is not a valid display address.",
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +118,23 @@ function nonEmptyString(value: unknown): string | undefined {
|
|||||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasExactKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModuleId(value: string): boolean {
|
||||||
|
return value.length <= MODULE_ID_MAX_LENGTH && MODULE_ID_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSemanticVersion(value: string): boolean {
|
||||||
|
return SEMANTIC_VERSION_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
function nestedString(value: unknown, key: string): string | undefined {
|
function nestedString(value: unknown, key: string): string | undefined {
|
||||||
return isRecord(value) ? nonEmptyString(value[key]) : undefined;
|
return isRecord(value) ? nonEmptyString(value[key]) : undefined;
|
||||||
}
|
}
|
||||||
@@ -129,6 +161,26 @@ function rootRelativePath(value: unknown, key: string): string | undefined {
|
|||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const segments = candidate.slice(1).split("/");
|
||||||
|
if (segments.some((segment) => segment.length === 0)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const segment of segments) {
|
||||||
|
const decoded = decodeURIComponent(segment);
|
||||||
|
if (
|
||||||
|
decoded === "." ||
|
||||||
|
decoded === ".." ||
|
||||||
|
decoded.includes("/") ||
|
||||||
|
decoded.includes("\\") ||
|
||||||
|
decoded.includes("\0")
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +218,92 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display host's one-module response.
|
||||||
|
*
|
||||||
|
* The wire names the artifact `display`, not `ui`, so a server cannot accidentally hand the
|
||||||
|
* Dashboard artifact to the full-viewport host and still pass validation. Once validated, the
|
||||||
|
* result deliberately narrows to the same generic mount descriptor every host adapter already
|
||||||
|
* consumes: the generic loader neither knows nor needs to know which outer shell selected it.
|
||||||
|
*/
|
||||||
|
export interface HostModuleDisplayResponse {
|
||||||
|
readonly module: HostModuleUiDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strictly validates the display descriptor response, including every nested URL container. */
|
||||||
|
export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayResponse {
|
||||||
|
if (!hasExactKeys(payload, ["module"])) {
|
||||||
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
|
}
|
||||||
|
const entry = payload.module;
|
||||||
|
if (
|
||||||
|
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
|
||||||
|
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
||||||
|
!hasExactKeys(entry.display, ["url"]) ||
|
||||||
|
!hasExactKeys(entry.realtime, ["baseUrl"])
|
||||||
|
) {
|
||||||
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = nonEmptyString(entry.id);
|
||||||
|
const version = nonEmptyString(entry.version);
|
||||||
|
const apiBaseUrl = rootRelativePath(entry.api, "baseUrl");
|
||||||
|
const uiUrl = rootRelativePath(entry.display, "url");
|
||||||
|
const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl");
|
||||||
|
const permissions = entry.permissions;
|
||||||
|
if (
|
||||||
|
id === undefined ||
|
||||||
|
!isModuleId(id) ||
|
||||||
|
version === undefined ||
|
||||||
|
!isSemanticVersion(version) ||
|
||||||
|
apiBaseUrl === undefined ||
|
||||||
|
uiUrl === undefined ||
|
||||||
|
realtimeBaseUrl === undefined ||
|
||||||
|
!Array.isArray(permissions) ||
|
||||||
|
permissions.some((permission) => typeof permission !== "string" || permission.length === 0) ||
|
||||||
|
new Set(permissions).size !== permissions.length
|
||||||
|
) {
|
||||||
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
module: Object.freeze({
|
||||||
|
apiBaseUrl,
|
||||||
|
id,
|
||||||
|
permissions: Object.freeze([...(permissions as string[])]),
|
||||||
|
realtimeBaseUrl,
|
||||||
|
uiUrl,
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads one exact `/display/<module-id>` pathname without accepting a trailing path or traversal. */
|
||||||
|
export function parseDisplayRoute(pathname: string): string {
|
||||||
|
const match = /^\/display\/([^/]+)$/u.exec(pathname);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
|
||||||
|
}
|
||||||
|
let moduleId: string;
|
||||||
|
try {
|
||||||
|
moduleId = decodeURIComponent(match[1]);
|
||||||
|
} catch {
|
||||||
|
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
|
||||||
|
}
|
||||||
|
if (!isModuleId(moduleId)) {
|
||||||
|
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
|
||||||
|
}
|
||||||
|
return moduleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The only platform path a display browser builds, from a module id already validated above. */
|
||||||
|
export function moduleDisplayDescriptorPath(moduleId: string): string {
|
||||||
|
if (!isModuleId(moduleId)) {
|
||||||
|
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
|
||||||
|
}
|
||||||
|
return `${MODULE_DISPLAYS_PATH}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One module as the Dashboard presents it.
|
* One module as the Dashboard presents it.
|
||||||
*
|
*
|
||||||
@@ -271,6 +409,10 @@ async function requestPlatformJson(
|
|||||||
config: EkklesiaHostConfig,
|
config: EkklesiaHostConfig,
|
||||||
rootRelativePath: string,
|
rootRelativePath: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
statusCodes: {
|
||||||
|
readonly forbidden?: HostProtocolErrorCode;
|
||||||
|
readonly notFound?: HostProtocolErrorCode;
|
||||||
|
} = {},
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const performRequest = config.fetch ?? fetch;
|
const performRequest = config.fetch ?? fetch;
|
||||||
let response: Response;
|
let response: Response;
|
||||||
@@ -283,9 +425,15 @@ async function requestPlatformJson(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
|
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
|
||||||
}
|
}
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401) {
|
||||||
throw new HostProtocolError("HOST_UNAUTHENTICATED");
|
throw new HostProtocolError("HOST_UNAUTHENTICATED");
|
||||||
}
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new HostProtocolError(statusCodes.forbidden ?? "HOST_UNAUTHENTICATED");
|
||||||
|
}
|
||||||
|
if (response.status === 404 && statusCodes.notFound) {
|
||||||
|
throw new HostProtocolError(statusCodes.notFound);
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new HostProtocolError("HOST_REQUEST_FAILED");
|
throw new HostProtocolError("HOST_REQUEST_FAILED");
|
||||||
}
|
}
|
||||||
@@ -316,6 +464,57 @@ export async function loadDashboardNavigation(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the one display the outer host route named.
|
||||||
|
*
|
||||||
|
* The module id chooses only this host-owned descriptor resource. It never chooses a file: the
|
||||||
|
* response supplies the validated display artifact URL, API root, and realtime root, and the
|
||||||
|
* strict parser above rejects anything absolute, protocol-relative, or structurally ambiguous.
|
||||||
|
*/
|
||||||
|
export async function loadModuleDisplay(
|
||||||
|
config: EkklesiaHostConfig,
|
||||||
|
moduleId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<HostModuleUiDescriptor> {
|
||||||
|
const payload = await requestPlatformJson(config, moduleDisplayDescriptorPath(moduleId), signal, {
|
||||||
|
forbidden: "DISPLAY_ACCESS_DENIED",
|
||||||
|
notFound: "DISPLAY_NOT_AVAILABLE",
|
||||||
|
});
|
||||||
|
const descriptor = parseHostModuleDisplay(payload).module;
|
||||||
|
if (descriptor.id !== moduleId) {
|
||||||
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
|
}
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DisplayHostBootstrap =
|
||||||
|
| {
|
||||||
|
readonly status: "signed-out";
|
||||||
|
readonly principal: Extract<ModuleUiPrincipal, { readonly kind: "anonymous" }>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly status: "ready";
|
||||||
|
readonly descriptor: HostModuleUiDescriptor;
|
||||||
|
readonly principal: Extract<ModuleUiPrincipal, { readonly kind: "authenticated" }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Principal first, then protected display metadata; anonymous browsers never probe a module id. */
|
||||||
|
export async function bootstrapDisplayHost(
|
||||||
|
config: EkklesiaHostConfig,
|
||||||
|
moduleId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<DisplayHostBootstrap> {
|
||||||
|
const principal = await loadHostPrincipal(config, signal);
|
||||||
|
if (principal.kind === "anonymous") {
|
||||||
|
return Object.freeze({ principal, status: "signed-out" });
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
descriptor: await loadModuleDisplay(config, moduleId, signal),
|
||||||
|
principal,
|
||||||
|
status: "ready",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostBootstrap {
|
export interface HostBootstrap {
|
||||||
readonly principal: ModuleUiPrincipal;
|
readonly principal: ModuleUiPrincipal;
|
||||||
readonly navigation: DashboardNavigation;
|
readonly navigation: DashboardNavigation;
|
||||||
|
|||||||
@@ -11,19 +11,26 @@
|
|||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
|
bootstrapDisplayHost,
|
||||||
type DashboardNavigation,
|
type DashboardNavigation,
|
||||||
type DashboardNavigationEntry,
|
type DashboardNavigationEntry,
|
||||||
type DashboardNavigationGroup,
|
type DashboardNavigationGroup,
|
||||||
|
type DisplayHostBootstrap,
|
||||||
type EkklesiaHostConfig,
|
type EkklesiaHostConfig,
|
||||||
type HostBootstrap,
|
type HostBootstrap,
|
||||||
type HostEventSourceFactory,
|
type HostEventSourceFactory,
|
||||||
type HostEventSourceOptions,
|
type HostEventSourceOptions,
|
||||||
|
type HostModuleDisplayResponse,
|
||||||
type HostModuleUiDescriptor,
|
type HostModuleUiDescriptor,
|
||||||
HostProtocolError,
|
HostProtocolError,
|
||||||
type HostProtocolErrorCode,
|
type HostProtocolErrorCode,
|
||||||
loadDashboardNavigation,
|
loadDashboardNavigation,
|
||||||
loadHostPrincipal,
|
loadHostPrincipal,
|
||||||
|
loadModuleDisplay,
|
||||||
|
moduleDisplayDescriptorPath,
|
||||||
parseDashboardNavigation,
|
parseDashboardNavigation,
|
||||||
|
parseDisplayRoute,
|
||||||
|
parseHostModuleDisplay,
|
||||||
parseHostPrincipal,
|
parseHostPrincipal,
|
||||||
resolveApiUrl,
|
resolveApiUrl,
|
||||||
} from "./host-protocol.js";
|
} from "./host-protocol.js";
|
||||||
|
|||||||
@@ -0,0 +1,710 @@
|
|||||||
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { act, Component, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { HostApp } from "../src/browser/HostApp.js";
|
||||||
|
import { DisplayApp } from "../src/display/DisplayApp.js";
|
||||||
|
import type {
|
||||||
|
EkklesiaHostConfig,
|
||||||
|
HostEventSourceFactory,
|
||||||
|
HostEventSourceOptions,
|
||||||
|
} from "../src/host-protocol.js";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBaseUrl = "https://ekklesia.example.test";
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
function descriptorPayload(id = "announcements", version = "1.2.0") {
|
||||||
|
return {
|
||||||
|
module: {
|
||||||
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||||
|
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
|
||||||
|
id,
|
||||||
|
permissions: [`${id}.slideshow.view`],
|
||||||
|
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformOptions {
|
||||||
|
readonly descriptorStatus?: number;
|
||||||
|
readonly principal?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function platform(options: PlatformOptions = {}) {
|
||||||
|
const calls: Array<{ readonly init: RequestInit | undefined; readonly url: string }> = [];
|
||||||
|
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
calls.push({ init, url });
|
||||||
|
if (url.endsWith("/api/v1/principal")) {
|
||||||
|
return json(options.principal ?? { kind: "authenticated", userId: "user-1" });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/module-displays/announcements")) {
|
||||||
|
return json(descriptorPayload(), options.descriptorStatus ?? 200);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/modules/announcements/slides")) {
|
||||||
|
return json([{ id: "slide-1" }]);
|
||||||
|
}
|
||||||
|
return json({ code: "NOT_FOUND" }, 404);
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
return { calls, fetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeEventSource {
|
||||||
|
readonly CLOSED = 2 as const;
|
||||||
|
readonly CONNECTING = 0 as const;
|
||||||
|
readonly OPEN = 1 as const;
|
||||||
|
readonly listeners = new Map<string, Set<EventListener>>();
|
||||||
|
closeCalls = 0;
|
||||||
|
readyState: number = this.CONNECTING;
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: EventListener): void {
|
||||||
|
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
|
||||||
|
listeners.add(listener);
|
||||||
|
this.listeners.set(type, listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: EventListener): void {
|
||||||
|
this.listeners.get(type)?.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.closeCalls += 1;
|
||||||
|
this.readyState = this.CLOSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(type: string, data?: string): void {
|
||||||
|
if (type === "open") {
|
||||||
|
this.readyState = this.OPEN;
|
||||||
|
}
|
||||||
|
const event = Object.freeze({ data, type }) as unknown as Event;
|
||||||
|
for (const listener of this.listeners.get(type) ?? []) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventSources() {
|
||||||
|
const calls: Array<{
|
||||||
|
readonly options: HostEventSourceOptions;
|
||||||
|
readonly source: FakeEventSource;
|
||||||
|
readonly url: string;
|
||||||
|
}> = [];
|
||||||
|
const factory = ((url: string, options: HostEventSourceOptions): EventSource => {
|
||||||
|
const source = new FakeEventSource();
|
||||||
|
calls.push({ options, source, url });
|
||||||
|
return source as unknown as EventSource;
|
||||||
|
}) as HostEventSourceFactory;
|
||||||
|
return { calls, factory };
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifact(
|
||||||
|
id = "announcements",
|
||||||
|
version = "1.2.0",
|
||||||
|
options: {
|
||||||
|
readonly cleanup?: () => void;
|
||||||
|
readonly mount?: (container: Element, context: ModuleUiHostContext) => undefined | (() => void);
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
id,
|
||||||
|
mount(element: Element, context: ModuleUiHostContext) {
|
||||||
|
if (options.mount) {
|
||||||
|
return options.mount(element, context);
|
||||||
|
}
|
||||||
|
element.textContent = `${context.moduleId}:display`;
|
||||||
|
return () => {
|
||||||
|
options.cleanup?.();
|
||||||
|
element.replaceChildren();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(
|
||||||
|
config: EkklesiaHostConfig,
|
||||||
|
importModule?: (url: string) => Promise<unknown>,
|
||||||
|
moduleId = "announcements",
|
||||||
|
): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<DisplayApp
|
||||||
|
config={config}
|
||||||
|
{...(importModule ? { importModule } : {})}
|
||||||
|
moduleId={moduleId}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(testId: string): HTMLElement | null {
|
||||||
|
return container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleUrl(source: string): string {
|
||||||
|
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("display host — full-viewport boundary", () => {
|
||||||
|
it("mounts only the display artifact with no Dashboard chrome", async () => {
|
||||||
|
const server = platform();
|
||||||
|
const imported: string[] = [];
|
||||||
|
await render({ apiBaseUrl, fetch: server.fetch }, async (url) => {
|
||||||
|
imported.push(url);
|
||||||
|
return artifact();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`]);
|
||||||
|
expect(query("display-module-container")?.textContent).toBe("announcements:display");
|
||||||
|
expect(container.querySelector("header")).toBeNull();
|
||||||
|
expect(container.querySelector("nav")).toBeNull();
|
||||||
|
expect(container.querySelector("aside")).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("Ekklesia");
|
||||||
|
expect(query("display-host")?.style.height).toBe("100%");
|
||||||
|
expect(query("display-host")?.style.minHeight).toBe("100dvh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs the same browser-safe API, principal, permission, and realtime context", async () => {
|
||||||
|
const server = platform();
|
||||||
|
const live = eventSources();
|
||||||
|
let seenContext: ModuleUiHostContext | undefined;
|
||||||
|
await render({ apiBaseUrl, eventSourceFactory: live.factory, fetch: server.fetch }, async () =>
|
||||||
|
artifact("announcements", "1.2.0", {
|
||||||
|
mount(element, context) {
|
||||||
|
seenContext = context;
|
||||||
|
void context.api.request("slides");
|
||||||
|
const subscription = context.realtime.subscribe("state", {});
|
||||||
|
element.textContent = context.permissions.has("announcements.slideshow.view")
|
||||||
|
? "permitted"
|
||||||
|
: "denied";
|
||||||
|
return () => subscription.close();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
|
||||||
|
"api",
|
||||||
|
"moduleId",
|
||||||
|
"permissions",
|
||||||
|
"principal",
|
||||||
|
"realtime",
|
||||||
|
]);
|
||||||
|
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||||
|
expect(query("display-module-container")?.textContent).toBe("permitted");
|
||||||
|
expect(
|
||||||
|
server.calls.some((call) => call.url.endsWith("/api/v1/modules/announcements/slides")),
|
||||||
|
).toBe(true);
|
||||||
|
expect(live.calls[0]).toMatchObject({
|
||||||
|
options: { withCredentials: true },
|
||||||
|
url: `${apiBaseUrl}/api/v1/module-realtime/announcements/state`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a genuine dynamic import and renders an Announcements snapshot with its direct object URL unchanged", async () => {
|
||||||
|
const server = platform();
|
||||||
|
const live = eventSources();
|
||||||
|
const requestedArtifacts: string[] = [];
|
||||||
|
const source = `
|
||||||
|
export default {
|
||||||
|
id: "announcements",
|
||||||
|
version: "1.2.0",
|
||||||
|
mount(container, context) {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.setAttribute("data-testid", "announcement-slide");
|
||||||
|
container.append(image);
|
||||||
|
const subscription = context.realtime.subscribe("state", {
|
||||||
|
onSnapshot(state) {
|
||||||
|
image.src = state.mode === "on" ? state.slides[state.currentSlide].url : "";
|
||||||
|
},
|
||||||
|
onUpdate(state) {
|
||||||
|
image.src = state.mode === "on" ? state.slides[state.currentSlide].url : "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => { subscription.close(); container.replaceChildren(); };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
await render(
|
||||||
|
{ apiBaseUrl, eventSourceFactory: live.factory, fetch: server.fetch },
|
||||||
|
async (url) => {
|
||||||
|
requestedArtifacts.push(url);
|
||||||
|
return import(moduleUrl(source));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const deliveryUrl =
|
||||||
|
"https://objects.example.test/ekklesia-announcements-assets/slides%2Fwelcome.jpg?X-Amz-Signature=opaque";
|
||||||
|
await act(async () => {
|
||||||
|
live.calls[0]?.source.emit("open");
|
||||||
|
live.calls[0]?.source.emit(
|
||||||
|
"snapshot",
|
||||||
|
JSON.stringify({
|
||||||
|
payload: {
|
||||||
|
currentSlide: 0,
|
||||||
|
mode: "on",
|
||||||
|
slideSeconds: 7,
|
||||||
|
slides: [{ url: deliveryUrl }],
|
||||||
|
},
|
||||||
|
sequence: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requestedArtifacts).toEqual([
|
||||||
|
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
|
||||||
|
]);
|
||||||
|
expect(container.querySelector('[data-testid="announcement-slide"]')?.getAttribute("src")).toBe(
|
||||||
|
deliveryUrl,
|
||||||
|
);
|
||||||
|
expect(container.innerHTML).not.toContain("socket.io");
|
||||||
|
expect(container.innerHTML).not.toContain("mqtt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one Announcements-shaped module's management and slideshow roots distinct", async () => {
|
||||||
|
const live = eventSources();
|
||||||
|
const requestedArtifacts: string[] = [];
|
||||||
|
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/v1/principal")) {
|
||||||
|
return json({ kind: "authenticated", userId: "user-1" });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/dashboard/navigation")) {
|
||||||
|
return json({
|
||||||
|
groups: [],
|
||||||
|
ungrouped: [
|
||||||
|
{
|
||||||
|
module: {
|
||||||
|
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||||
|
id: "announcements",
|
||||||
|
permissions: ["announcements.slideshow.view"],
|
||||||
|
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
|
||||||
|
ui: { url: "/api/v1/module-ui/announcements/1.2.0" },
|
||||||
|
version: "1.2.0",
|
||||||
|
},
|
||||||
|
name: "Announcements",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/module-displays/announcements")) {
|
||||||
|
return json(descriptorPayload());
|
||||||
|
}
|
||||||
|
return json({ code: "NOT_FOUND" }, 404);
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
const config = { apiBaseUrl, eventSourceFactory: live.factory, fetch };
|
||||||
|
const importer = async (url: string): Promise<unknown> => {
|
||||||
|
requestedArtifacts.push(url);
|
||||||
|
if (!url.endsWith("/display")) {
|
||||||
|
return artifact("announcements", "1.2.0", {
|
||||||
|
mount(element) {
|
||||||
|
element.textContent = "announcement management";
|
||||||
|
return () => element.replaceChildren();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return artifact("announcements", "1.2.0", {
|
||||||
|
mount(element, context) {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.dataset.testid = "both-role-slide";
|
||||||
|
element.append(image);
|
||||||
|
const subscription = context.realtime.subscribe<{
|
||||||
|
readonly currentSlide: number;
|
||||||
|
readonly mode: "off" | "on";
|
||||||
|
readonly slides: readonly { readonly url: string }[];
|
||||||
|
}>("state", {
|
||||||
|
onSnapshot(state) {
|
||||||
|
image.src = state.mode === "on" ? (state.slides[state.currentSlide]?.url ?? "") : "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
subscription.close();
|
||||||
|
element.replaceChildren();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
|
||||||
|
expect(container.textContent).toContain("announcement management");
|
||||||
|
expect(container.querySelector("nav")).not.toBeNull();
|
||||||
|
expect(requestedArtifacts).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`]);
|
||||||
|
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () =>
|
||||||
|
root.render(<DisplayApp config={config} importModule={importer} moduleId="announcements" />),
|
||||||
|
);
|
||||||
|
const deliveryUrl =
|
||||||
|
"https://objects.example.test/module-assets/slide.jpg?X-Amz-Signature=opaque";
|
||||||
|
await act(async () => {
|
||||||
|
live.calls[0]?.source.emit("open");
|
||||||
|
live.calls[0]?.source.emit(
|
||||||
|
"snapshot",
|
||||||
|
JSON.stringify({
|
||||||
|
payload: { currentSlide: 0, mode: "on", slides: [{ url: deliveryUrl }] },
|
||||||
|
sequence: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requestedArtifacts).toEqual([
|
||||||
|
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`,
|
||||||
|
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
|
||||||
|
]);
|
||||||
|
expect(container.querySelector('[data-testid="both-role-slide"]')?.getAttribute("src")).toBe(
|
||||||
|
deliveryUrl,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("nav")).toBeNull();
|
||||||
|
expect(container.querySelector("header")).toBeNull();
|
||||||
|
expect(JSON.stringify(requestedArtifacts)).not.toMatch(/socket\.io|mqtt|10\.|localhost/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("display host — authentication and controlled failures", () => {
|
||||||
|
it("mounts nothing and never requests a descriptor for an anonymous browser", async () => {
|
||||||
|
const server = platform({ principal: { kind: "anonymous" } });
|
||||||
|
const importer = vi.fn(async () => artifact());
|
||||||
|
|
||||||
|
await render({ apiBaseUrl, fetch: server.fetch }, importer);
|
||||||
|
|
||||||
|
expect(query("display-host-signed-out")).not.toBeNull();
|
||||||
|
expect(importer).not.toHaveBeenCalled();
|
||||||
|
expect(server.calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not import protected display code when the descriptor is denied or unavailable", async () => {
|
||||||
|
for (const status of [403, 404]) {
|
||||||
|
const server = platform({ descriptorStatus: status });
|
||||||
|
const importer = vi.fn(async () => artifact());
|
||||||
|
await render({ apiBaseUrl, fetch: server.fetch }, importer);
|
||||||
|
|
||||||
|
expect(query("display-host-unavailable"), String(status)).not.toBeNull();
|
||||||
|
expect(importer, String(status)).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears partial mount DOM, renders no caught detail, and retries in a fresh container", async () => {
|
||||||
|
const server = platform();
|
||||||
|
let attempts = 0;
|
||||||
|
const containers: Element[] = [];
|
||||||
|
const importer = vi.fn(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
return artifact("announcements", "1.2.0", {
|
||||||
|
mount(element) {
|
||||||
|
containers.push(element);
|
||||||
|
if (attempts === 1) {
|
||||||
|
element.textContent = "secret at /srv/modules/announcements";
|
||||||
|
throw new Error("mount exploded with token=secret");
|
||||||
|
}
|
||||||
|
element.textContent = "recovered";
|
||||||
|
return () => element.replaceChildren();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await render({ apiBaseUrl, fetch: server.fetch }, importer);
|
||||||
|
|
||||||
|
expect(query("display-error")).not.toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("/srv/");
|
||||||
|
expect(container.textContent).not.toContain("secret");
|
||||||
|
await act(async () => query("display-retry")?.click());
|
||||||
|
|
||||||
|
expect(query("display-error")).toBeNull();
|
||||||
|
expect(query("display-module-container")?.textContent).toBe("recovered");
|
||||||
|
expect(containers).toHaveLength(2);
|
||||||
|
expect(containers[0]).not.toBe(containers[1]);
|
||||||
|
expect(containers[0]?.isConnected).toBe(false);
|
||||||
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
|
"[ekklesia] display UI failure module=announcements code=MODULE_UI_MOUNT_FAILED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"error",
|
||||||
|
"unhandledrejection",
|
||||||
|
] as const)("turns a post-mount page %s into one sanitized failure with manual retry", async (eventType) => {
|
||||||
|
const server = platform();
|
||||||
|
const cleanups: Element[] = [];
|
||||||
|
const mountedInto: Element[] = [];
|
||||||
|
const importer = vi.fn(async () =>
|
||||||
|
artifact("announcements", "1.2.0", {
|
||||||
|
mount(element) {
|
||||||
|
mountedInto.push(element);
|
||||||
|
element.textContent = "private render at /srv/modules/announcements";
|
||||||
|
return () => {
|
||||||
|
cleanups.push(element);
|
||||||
|
element.replaceChildren();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await render({ apiBaseUrl, fetch: server.fetch }, importer);
|
||||||
|
expect(query("display-module-container")?.textContent).toContain("/srv/modules");
|
||||||
|
consoleError.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const event =
|
||||||
|
eventType === "error"
|
||||||
|
? new ErrorEvent("error", {
|
||||||
|
error: new Error("token=secret-render-error"),
|
||||||
|
message: "token=secret-render-error",
|
||||||
|
})
|
||||||
|
: new Event("unhandledrejection");
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
// A second signal from the same failed render must not run cleanup or transition twice.
|
||||||
|
window.dispatchEvent(new Event(eventType));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query("display-error")).not.toBeNull();
|
||||||
|
expect(query("display-module-container")?.textContent).toBe("");
|
||||||
|
expect(container.textContent).toBe("This display could not be loaded.Retry");
|
||||||
|
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
|
||||||
|
expect(importer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanups).toEqual([mountedInto[0]]);
|
||||||
|
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
|
`[ekklesia] display UI failure module=announcements code=${
|
||||||
|
eventType === "error" ? "DISPLAY_UI_RUNTIME_ERROR" : "DISPLAY_UI_UNHANDLED_REJECTION"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => query("display-retry")?.click());
|
||||||
|
|
||||||
|
expect(importer).toHaveBeenCalledTimes(2);
|
||||||
|
expect(query("display-error")).toBeNull();
|
||||||
|
expect(query("display-module-container")?.textContent).toContain("/srv/modules");
|
||||||
|
expect(mountedInto[0]?.isConnected).toBe(false);
|
||||||
|
expect(mountedInto[1]?.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a React render failure reported after the artifact mount has returned", async () => {
|
||||||
|
const server = platform();
|
||||||
|
const cleanup = vi.fn();
|
||||||
|
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
|
||||||
|
artifact("announcements", "1.2.0", {
|
||||||
|
mount(element) {
|
||||||
|
const moduleRoot = createRoot(element);
|
||||||
|
class ReportRenderFailure extends Component<{ readonly children: ReactNode }> {
|
||||||
|
override componentDidCatch(error: unknown): void {
|
||||||
|
// React's browser default reports an uncaught render through the page's global error
|
||||||
|
// facility. This test boundary makes that report deterministic under happy-dom,
|
||||||
|
// whose test runner otherwise intercepts the global exception first.
|
||||||
|
window.dispatchEvent(
|
||||||
|
new ErrorEvent("error", {
|
||||||
|
cancelable: true,
|
||||||
|
error,
|
||||||
|
message: error instanceof Error ? error.message : "display render failure",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render(): ReactNode {
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const BrokenDisplay = (): ReactElement => {
|
||||||
|
throw new Error("late component secret at /srv/modules/announcements");
|
||||||
|
};
|
||||||
|
moduleRoot.render(
|
||||||
|
<ReportRenderFailure>
|
||||||
|
<BrokenDisplay />
|
||||||
|
</ReportRenderFailure>,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cleanup();
|
||||||
|
moduleRoot.unmount();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => undefined);
|
||||||
|
|
||||||
|
expect(query("display-error")).not.toBeNull();
|
||||||
|
expect(container.textContent).toBe("This display could not be loaded.Retry");
|
||||||
|
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a module media/resource error as a crashed display runtime", async () => {
|
||||||
|
const server = platform();
|
||||||
|
const cleanup = vi.fn();
|
||||||
|
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
|
||||||
|
artifact("announcements", "1.2.0", {
|
||||||
|
mount(element) {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.dataset.testid = "display-media";
|
||||||
|
element.append(image);
|
||||||
|
return () => cleanup();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
container.querySelector('[data-testid="display-media"]')?.dispatchEvent(
|
||||||
|
new ErrorEvent("error", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
message: "a signed media URL expired",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query("display-error")).toBeNull();
|
||||||
|
expect(query("display-media")).not.toBeNull();
|
||||||
|
expect(cleanup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the page failure listeners when a display attempt is torn down", async () => {
|
||||||
|
const server = platform();
|
||||||
|
const cleanup = vi.fn();
|
||||||
|
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
|
||||||
|
artifact("announcements", "1.2.0", { cleanup }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => root.render(null));
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
consoleError.mockClear();
|
||||||
|
|
||||||
|
window.dispatchEvent(new ErrorEvent("error", { message: "after teardown" }));
|
||||||
|
window.dispatchEvent(new Event("unhandledrejection"));
|
||||||
|
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(consoleError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("display host — independent instances and stale work", () => {
|
||||||
|
it("mounts and unmounts two browser instances independently", async () => {
|
||||||
|
const server = platform();
|
||||||
|
const cleanups: string[] = [];
|
||||||
|
const importer = async () =>
|
||||||
|
artifact("announcements", "1.2.0", {
|
||||||
|
cleanup: () => cleanups.push("cleanup"),
|
||||||
|
});
|
||||||
|
const config = { apiBaseUrl, fetch: server.fetch };
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<>
|
||||||
|
<DisplayApp
|
||||||
|
config={config}
|
||||||
|
importModule={importer}
|
||||||
|
key="first"
|
||||||
|
moduleId="announcements"
|
||||||
|
/>
|
||||||
|
<DisplayApp
|
||||||
|
config={config}
|
||||||
|
importModule={importer}
|
||||||
|
key="second"
|
||||||
|
moduleId="announcements"
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(2);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<DisplayApp
|
||||||
|
config={config}
|
||||||
|
importModule={importer}
|
||||||
|
key="second"
|
||||||
|
moduleId="announcements"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cleanups).toHaveLength(1);
|
||||||
|
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(1);
|
||||||
|
expect(query("display-module-container")?.textContent).toBe("announcements:display");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards a late import into its detached container without disturbing the newer route", async () => {
|
||||||
|
let releaseAnnouncements: (() => void) | undefined;
|
||||||
|
const pendingAnnouncements = new Promise<void>((resolve) => {
|
||||||
|
releaseAnnouncements = resolve;
|
||||||
|
});
|
||||||
|
const mountedInto: Element[] = [];
|
||||||
|
const staleUnmounts: Element[] = [];
|
||||||
|
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/v1/principal")) {
|
||||||
|
return json({ kind: "authenticated", userId: "user-1" });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/module-displays/calendar")) {
|
||||||
|
return json(descriptorPayload("calendar", "2.0.0"));
|
||||||
|
}
|
||||||
|
return json(descriptorPayload());
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
const config = { apiBaseUrl, fetch };
|
||||||
|
const importer = async (url: string): Promise<unknown> => {
|
||||||
|
const isCalendar = url.includes("/calendar/");
|
||||||
|
if (!isCalendar) {
|
||||||
|
await pendingAnnouncements;
|
||||||
|
}
|
||||||
|
return artifact(isCalendar ? "calendar" : "announcements", isCalendar ? "2.0.0" : "1.2.0", {
|
||||||
|
mount(element) {
|
||||||
|
mountedInto.push(element);
|
||||||
|
element.textContent = isCalendar ? "calendar-live" : "announcements-stale";
|
||||||
|
return () => {
|
||||||
|
if (!isCalendar) {
|
||||||
|
staleUnmounts.push(element);
|
||||||
|
}
|
||||||
|
element.replaceChildren();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(config, importer);
|
||||||
|
await render(config, importer, "calendar");
|
||||||
|
expect(query("display-module-container")?.textContent).toBe("calendar-live");
|
||||||
|
const liveContainer = query("display-module-container");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
releaseAnnouncements?.();
|
||||||
|
await pendingAnnouncements;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query("display-module-container")).toBe(liveContainer);
|
||||||
|
expect(liveContainer?.textContent).toBe("calendar-live");
|
||||||
|
const staleContainer = mountedInto.find((element) => element !== liveContainer);
|
||||||
|
expect(staleContainer?.isConnected).toBe(false);
|
||||||
|
expect(staleUnmounts).toEqual([staleContainer]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { displayHostAssetUrls } from "../src/display-assets.js";
|
||||||
|
|
||||||
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||||
|
|
||||||
|
describe("display host asset locator", () => {
|
||||||
|
it("exports only frozen immutable href strings for the two exact built files", () => {
|
||||||
|
expect(Object.isFrozen(displayHostAssetUrls)).toBe(true);
|
||||||
|
expect(displayHostAssetUrls).toEqual({
|
||||||
|
html: new URL("dist/browser/display/index.html", `file://${packageDirectory}/`).href,
|
||||||
|
script: new URL("dist/browser/display/app.js", `file://${packageDirectory}/`).href,
|
||||||
|
});
|
||||||
|
expect(typeof displayHostAssetUrls.html).toBe("string");
|
||||||
|
expect(typeof displayHostAssetUrls.script).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||||
|
) as { exports?: Record<string, unknown> };
|
||||||
|
expect(manifest.exports?.["./display-assets"]).toEqual({
|
||||||
|
import: "./dist/display-assets.js",
|
||||||
|
types: "./dist/display-assets.d.ts",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootSource = await readFile(path.join(packageDirectory, "src/index.ts"), "utf8");
|
||||||
|
expect(rootSource).not.toContain("displayHostAssetUrls");
|
||||||
|
expect(rootSource).not.toContain("display-assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a fixed root script asset and only structural full-viewport page styles", async () => {
|
||||||
|
const html = await readFile(path.join(packageDirectory, "display.html"), "utf8");
|
||||||
|
|
||||||
|
expect(html).toContain('id="ekklesia-display"');
|
||||||
|
expect(html).toContain('src="/assets/ekklesia-display/app.js"');
|
||||||
|
expect(html).toContain('name="viewport"');
|
||||||
|
expect(html).toContain("height: 100%");
|
||||||
|
expect(html).toContain("margin: 0");
|
||||||
|
expect(html).not.toContain("<nav");
|
||||||
|
expect(html).not.toContain("<header");
|
||||||
|
expect(html).not.toContain("<aside");
|
||||||
|
expect(html).not.toContain("fullscreen");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
bootstrapDisplayHost,
|
||||||
|
loadModuleDisplay,
|
||||||
|
moduleDisplayDescriptorPath,
|
||||||
|
parseDisplayRoute,
|
||||||
|
parseHostModuleDisplay,
|
||||||
|
} from "../src/host-protocol.js";
|
||||||
|
|
||||||
|
const apiBaseUrl = "https://ekklesia.example.test";
|
||||||
|
|
||||||
|
function displayPayload(overrides: Record<string, unknown> = {}): {
|
||||||
|
readonly module: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
module: {
|
||||||
|
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||||
|
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
|
||||||
|
id: "announcements",
|
||||||
|
permissions: ["announcements.slideshow.view"],
|
||||||
|
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
|
||||||
|
version: "1.2.0",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("display host protocol — strict descriptor", () => {
|
||||||
|
it("parses the exact display role into the generic mount descriptor", () => {
|
||||||
|
const parsed = parseHostModuleDisplay(displayPayload());
|
||||||
|
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
module: {
|
||||||
|
apiBaseUrl: "/api/v1/modules/announcements",
|
||||||
|
id: "announcements",
|
||||||
|
permissions: ["announcements.slideshow.view"],
|
||||||
|
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
|
||||||
|
uiUrl: "/api/v1/module-ui/announcements/1.2.0/display",
|
||||||
|
version: "1.2.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(parsed)).toBe(true);
|
||||||
|
expect(Object.isFrozen(parsed.module)).toBe(true);
|
||||||
|
expect(Object.isFrozen(parsed.module.permissions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed identities, shapes, permissions, and any non-root-relative URL", () => {
|
||||||
|
const invalid: unknown[] = [
|
||||||
|
{},
|
||||||
|
{ ...displayPayload(), extra: true },
|
||||||
|
displayPayload({ id: "Announcements" }),
|
||||||
|
displayPayload({ id: "announcements/other" }),
|
||||||
|
displayPayload({ id: "a".repeat(65) }),
|
||||||
|
displayPayload({ version: "v1.2.0" }),
|
||||||
|
displayPayload({ version: "01.2.0" }),
|
||||||
|
displayPayload({ permissions: "announcements.slideshow.view" }),
|
||||||
|
displayPayload({ permissions: [""] }),
|
||||||
|
displayPayload({
|
||||||
|
permissions: ["announcements.slideshow.view", "announcements.slideshow.view"],
|
||||||
|
}),
|
||||||
|
displayPayload({ api: { baseUrl: "https://evil.example.test/api" } }),
|
||||||
|
displayPayload({ display: { url: "//evil.example.test/display.js" } }),
|
||||||
|
displayPayload({ display: { url: "/display.js?token=secret" } }),
|
||||||
|
displayPayload({ display: { url: "/api/%2e%2e/grants" } }),
|
||||||
|
displayPayload({ display: { url: "/api/%2Fgrants" } }),
|
||||||
|
displayPayload({ display: { url: "/api//display.js" } }),
|
||||||
|
displayPayload({ realtime: { baseUrl: "/\\evil.example.test/live" } }),
|
||||||
|
displayPayload({ display: { url: "/display.js", extra: true } }),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const payload of invalid) {
|
||||||
|
expect(() => parseHostModuleDisplay(payload), JSON.stringify(payload)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept a Dashboard artifact in place of a display artifact", () => {
|
||||||
|
const module = (displayPayload() as { module: Record<string, unknown> }).module;
|
||||||
|
const { display: _display, ...withoutDisplay } = module;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseHostModuleDisplay({
|
||||||
|
module: {
|
||||||
|
...withoutDisplay,
|
||||||
|
ui: { url: "/api/v1/module-ui/announcements/1.2.0" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("display host protocol — outer route", () => {
|
||||||
|
it("accepts one exact stable version-independent module route", () => {
|
||||||
|
expect(parseDisplayRoute("/display/announcements")).toBe("announcements");
|
||||||
|
expect(moduleDisplayDescriptorPath("announcements")).toBe(
|
||||||
|
"/api/v1/module-displays/announcements",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects extra segments, trailing slashes, malformed escapes, and path escapes", () => {
|
||||||
|
for (const pathname of [
|
||||||
|
"/display",
|
||||||
|
"/display/",
|
||||||
|
"/display/announcements/",
|
||||||
|
"/display/announcements/settings",
|
||||||
|
"/display/%2fapi",
|
||||||
|
"/display/..",
|
||||||
|
"/display/%",
|
||||||
|
"/modules/announcements/display",
|
||||||
|
]) {
|
||||||
|
expect(() => parseDisplayRoute(pathname), pathname).toThrowError(
|
||||||
|
expect.objectContaining({ code: "DISPLAY_ROUTE_INVALID" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("display host protocol — authenticated bootstrap", () => {
|
||||||
|
it("loads principal first, then the exact descriptor, with host-owned credentials", async () => {
|
||||||
|
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
|
||||||
|
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
calls.push({ init, url });
|
||||||
|
return url.endsWith("/api/v1/principal")
|
||||||
|
? json({ kind: "authenticated", userId: "user-1" })
|
||||||
|
: json(displayPayload());
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
|
||||||
|
|
||||||
|
expect(bootstrap.status).toBe("ready");
|
||||||
|
expect(calls.map((call) => call.url)).toEqual([
|
||||||
|
`${apiBaseUrl}/api/v1/principal`,
|
||||||
|
`${apiBaseUrl}/api/v1/module-displays/announcements`,
|
||||||
|
]);
|
||||||
|
expect(calls.every((call) => call.init?.credentials === "include")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not probe a display descriptor for an anonymous principal", async () => {
|
||||||
|
const fetch = vi.fn(async () =>
|
||||||
|
json({ kind: "anonymous" }),
|
||||||
|
) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
|
||||||
|
|
||||||
|
expect(bootstrap).toEqual({ principal: { kind: "anonymous" }, status: "signed-out" });
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes denied, unavailable, unauthenticated, and infrastructure failures safely", async () => {
|
||||||
|
for (const [status, code] of [
|
||||||
|
[401, "HOST_UNAUTHENTICATED"],
|
||||||
|
[403, "DISPLAY_ACCESS_DENIED"],
|
||||||
|
[404, "DISPLAY_NOT_AVAILABLE"],
|
||||||
|
[503, "HOST_REQUEST_FAILED"],
|
||||||
|
] as const) {
|
||||||
|
const fetch = vi.fn(async () =>
|
||||||
|
json({ detail: "/srv/modules/announcements secret-token" }, status),
|
||||||
|
) as unknown as typeof globalThis.fetch;
|
||||||
|
const error = await loadModuleDisplay({ apiBaseUrl, fetch }, "announcements").catch(
|
||||||
|
(caught: unknown) => caught,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error).toMatchObject({ code });
|
||||||
|
expect((error as Error).message).not.toContain("/srv/");
|
||||||
|
expect((error as Error).message).not.toContain("secret-token");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stale or misrouted descriptor whose identity differs from the requested display", async () => {
|
||||||
|
const fetch = vi.fn(async () =>
|
||||||
|
json(displayPayload({ id: "calendar" })),
|
||||||
|
) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
await expect(loadModuleDisplay({ apiBaseUrl, fetch }, "announcements")).rejects.toMatchObject({
|
||||||
|
code: "HOST_RESPONSE_INVALID",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -170,3 +170,53 @@ describe("the shell builds no module knowledge into its bundle", () => {
|
|||||||
expect(bundle).not.toContain("createObjectURL");
|
expect(bundle).not.toContain("createObjectURL");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the display host is a separate generic browser bundle", () => {
|
||||||
|
let bundle: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const result = await build({
|
||||||
|
absWorkingDir: packageDirectory,
|
||||||
|
bundle: true,
|
||||||
|
define: {
|
||||||
|
__EKKLESIA_API_BASE_URL__: '""',
|
||||||
|
"process.env.NODE_ENV": '"production"',
|
||||||
|
},
|
||||||
|
entryPoints: [path.join(sourceDirectory, "display", "main.tsx")],
|
||||||
|
format: "esm",
|
||||||
|
jsx: "automatic",
|
||||||
|
logLevel: "silent",
|
||||||
|
minify: false,
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2022",
|
||||||
|
write: false,
|
||||||
|
});
|
||||||
|
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("contains the generic loader's one dynamic import and no statically known module", () => {
|
||||||
|
expect([...bundle.matchAll(/\bimport\s*\(/gu)]).toHaveLength(1);
|
||||||
|
expect(bundle).toContain("(url) => import(url)");
|
||||||
|
expect(bundle).not.toContain("ekklesia.module.json");
|
||||||
|
expect(bundle).not.toContain("/api/v1/modules");
|
||||||
|
for (const moduleId of ["announcements", "calendar", "livestream", "signage"]) {
|
||||||
|
expect(bundle.toLowerCase()).not.toContain(moduleId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("knows only its host route resources, with no Dashboard application or transport coupling", () => {
|
||||||
|
expect(bundle).toContain('"/api/v1/principal"');
|
||||||
|
expect(bundle).toContain('"/api/v1/module-displays"');
|
||||||
|
expect(bundle).not.toContain('"/api/v1/dashboard/navigation"');
|
||||||
|
expect(bundle).not.toContain("Loading Ekklesia");
|
||||||
|
expect(bundle).not.toContain("Sign in to Ekklesia");
|
||||||
|
expect(bundle.toLowerCase()).not.toContain("socket.io");
|
||||||
|
expect(bundle.toLowerCase()).not.toContain("mqtt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses neither eval nor a blob URL to load display code", () => {
|
||||||
|
expect(bundle).not.toContain("new Function(");
|
||||||
|
expect(bundle).not.toMatch(/\beval\s*\(/u);
|
||||||
|
expect(bundle).not.toContain("createObjectURL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,5 +8,10 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/index.ts", "src/host-protocol.ts", "src/module-context.ts"]
|
"include": [
|
||||||
|
"src/index.ts",
|
||||||
|
"src/display-assets.ts",
|
||||||
|
"src/host-protocol.ts",
|
||||||
|
"src/module-context.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-13
@@ -1,6 +1,6 @@
|
|||||||
# Ekklesia module template
|
# Ekklesia module template
|
||||||
|
|
||||||
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
|
This is the smallest supported Ekklesia module product. It proves the static manifest,
|
||||||
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
|
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
|
||||||
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
|
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
|
||||||
route (`GET /records`, declared in `register`) that reads that table through
|
route (`GET /records`, declared in `register`) that reads that table through
|
||||||
@@ -8,18 +8,25 @@ route (`GET /records`, declared in `register`) that reads that table through
|
|||||||
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
|
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
|
||||||
provisioning are intentionally not included.
|
provisioning are intentionally not included.
|
||||||
|
|
||||||
It also ships an **optional** React UI (`src/ui/`), compiled to the `entrypoints.ui` artifact its
|
It also ships two independently optional React roles: a primary/management UI at `src/ui/index.tsx`
|
||||||
manifest declares by `pnpm build`
|
and a full-viewport display at `src/display/index.tsx`. One invocation of `pnpm build` compiles each
|
||||||
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). UI is a choice, not
|
declared role to the exact artifact in `entrypoints.ui` and `entrypoints.display.artifact`
|
||||||
a requirement: to keep a module backend-only, delete `src/ui/`, `tsconfig.ui.json`,
|
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). Both default-export
|
||||||
`tsconfig.ui-test.json`, `tests/module-ui.test.ts`, the `ui` entrypoint from the manifest, and the
|
the same role-neutral `defineModuleUi({ id, version, mount })` ABI and receive the same
|
||||||
React and module-UI dependencies. Everything else keeps working, and `ekklesia-build-module-ui`
|
`ModuleUiHostContext`; there is deliberately no mode flag. The manifest entrypoint and the host route
|
||||||
simply reports that there is nothing to build.
|
choose the role.
|
||||||
|
|
||||||
No host mounts a module UI yet — the Dashboard shell does not exist. `tests/module-ui.test.ts` builds
|
Either role can be omitted independently. To remove only the display example, delete `src/display/`
|
||||||
the artifact from source, validates it with `parseModuleUiExport`, mounts it into a real DOM, and
|
and `entrypoints.display` (and remove `example.display.view` if nothing else uses it). To keep the
|
||||||
unmounts it, which is what proves the pipeline today. See
|
module backend-only, also delete `src/ui/`, `tsconfig.ui.json`, `tsconfig.ui-test.json`,
|
||||||
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint).
|
`tests/module-ui.test.ts`, the `ui` entrypoint, and the React/module-UI dependencies. The builder
|
||||||
|
succeeds with an empty artifact list when neither role is declared.
|
||||||
|
|
||||||
|
`tests/module-ui.test.ts` builds both artifacts from source, validates them with
|
||||||
|
`parseModuleUiExport`, mounts each into a real DOM, and unmounts it. Ekklesia mounts the primary UI
|
||||||
|
inside its Dashboard shell and serves the display without Dashboard chrome at the stable
|
||||||
|
`/display/example` page. See
|
||||||
|
[Declaring UI entrypoints](../../docs/module-development/README.md#declaring-ui-entrypoints).
|
||||||
|
|
||||||
To create a module inside this repository:
|
To create a module inside this repository:
|
||||||
|
|
||||||
@@ -46,7 +53,11 @@ To create a module inside this repository:
|
|||||||
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
|
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
|
||||||
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
|
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
|
||||||
|
|
||||||
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
|
The module owns its feature screens and display renderer. Do not add platform navigation, the
|
||||||
|
containing shell, global theme, authentication screens, or top-level routing; those belong to
|
||||||
|
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
|
||||||
|
hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page
|
||||||
|
or descriptor; `context.permissions` remains a presentation-only hint.
|
||||||
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
|
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
|
||||||
and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a
|
and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a
|
||||||
raw `pg` client — `context.services.database` is the only supported way to query this module's own
|
raw `pg` client — `context.services.database` is the only supported way to query this module's own
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 5,
|
"manifestVersion": 6,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example Module",
|
"name": "Example Module",
|
||||||
"description": "A minimal Ekklesia module template with backend behavior and optional UI.",
|
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.1.0",
|
||||||
@@ -19,6 +19,11 @@
|
|||||||
"id": "example.records.read",
|
"id": "example.records.read",
|
||||||
"baseline": "restricted",
|
"baseline": "restricted",
|
||||||
"description": "Read example records."
|
"description": "Read example records."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "example.display.view",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "View the example full-screen display."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configuration": [
|
"configuration": [
|
||||||
@@ -34,6 +39,10 @@
|
|||||||
"server": "./dist/server/index.js",
|
"server": "./dist/server/index.js",
|
||||||
"schema": "./dist/schema/index.js",
|
"schema": "./dist/schema/index.js",
|
||||||
"migrations": "./drizzle",
|
"migrations": "./drizzle",
|
||||||
"ui": "./dist/ui/index.js"
|
"ui": "./dist/ui/index.js",
|
||||||
|
"display": {
|
||||||
|
"artifact": "./dist/display/index.js",
|
||||||
|
"permission": "example.display.view"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
|
||||||
|
const mayView = props.context.permissions.has("example.display.view");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-testid="example-display"
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f9fafb",
|
||||||
|
display: "flex",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
height: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
padding: "2rem",
|
||||||
|
textAlign: "center",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ margin: 0 }}>
|
||||||
|
{mayView
|
||||||
|
? `Example full-screen display (${props.context.moduleId})`
|
||||||
|
: "Display unavailable"}
|
||||||
|
</h1>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A display uses the same role-neutral ABI as the primary UI. Its separate manifest entrypoint —
|
||||||
|
* not a mode flag in `ModuleUiHostContext` — tells a host which artifact it is mounting.
|
||||||
|
*/
|
||||||
|
export default defineModuleUi({
|
||||||
|
id: "example",
|
||||||
|
version: "0.1.0",
|
||||||
|
mount(container, context) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<ExampleDisplay context={context} />);
|
||||||
|
return () => root.unmount();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -59,7 +59,7 @@ async function registerAndCaptureRoutes(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("module template", () => {
|
describe("module template", () => {
|
||||||
it("ships a valid backend-only manifest", async () => {
|
it("ships a valid product manifest with independently declared primary and display UI", async () => {
|
||||||
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
||||||
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown;
|
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown;
|
||||||
const manifest = parseModuleManifest(manifestInput);
|
const manifest = parseModuleManifest(manifestInput);
|
||||||
@@ -72,6 +72,11 @@ describe("module template", () => {
|
|||||||
baseline: "restricted",
|
baseline: "restricted",
|
||||||
description: "Read example records.",
|
description: "Read example records.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "example.display.view",
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the example full-screen display.",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
// The template declares **no** logical storage bucket, deliberately. Since ADR 0030 a declared
|
// The template declares **no** logical storage bucket, deliberately. Since ADR 0030 a declared
|
||||||
// bucket is a real requirement: a module that declares one is blocked on an installation with no
|
// bucket is a real requirement: a module that declares one is blocked on an installation with no
|
||||||
@@ -80,6 +85,11 @@ describe("module template", () => {
|
|||||||
expect(manifest.storage.buckets).toEqual([]);
|
expect(manifest.storage.buckets).toEqual([]);
|
||||||
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
||||||
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
||||||
|
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
|
||||||
|
expect(manifest.entrypoints.display).toEqual({
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "example.display.view",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("packages a generated stable Drizzle migration and matching policy", async () => {
|
it("packages a generated stable Drizzle migration and matching policy", async () => {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
|
||||||
import { parseModuleManifest } from "@ekklesia/module-sdk";
|
import { parseModuleManifest } from "@ekklesia/module-sdk";
|
||||||
|
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||||
import {
|
import {
|
||||||
type ModuleUiApiClient,
|
type ModuleUiApiClient,
|
||||||
type ModuleUiHostContext,
|
type ModuleUiHostContext,
|
||||||
@@ -14,7 +14,7 @@ import { beforeAll, describe, expect, it } from "vitest";
|
|||||||
/**
|
/**
|
||||||
* The end-to-end proof for the module UI build pipeline (ADR 0023):
|
* The end-to-end proof for the module UI build pipeline (ADR 0023):
|
||||||
*
|
*
|
||||||
* React/TSX source -> `pnpm build` -> compiled artifact at the manifest's `entrypoints.ui`
|
* React/TSX source -> `pnpm build` -> compiled artifacts at the manifest's UI entrypoints
|
||||||
* -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()`
|
* -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()`
|
||||||
*
|
*
|
||||||
* The artifact is rebuilt from source here rather than read from a checked-in file, so the test can
|
* The artifact is rebuilt from source here rather than read from a checked-in file, so the test can
|
||||||
@@ -26,7 +26,10 @@ const moduleDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)
|
|||||||
|
|
||||||
let artifactHref: string;
|
let artifactHref: string;
|
||||||
let artifactPath: string;
|
let artifactPath: string;
|
||||||
|
let displayArtifactHref: string;
|
||||||
|
let displayArtifactPath: string;
|
||||||
let manifestUi: string;
|
let manifestUi: string;
|
||||||
|
let manifestDisplay: string;
|
||||||
let manifestIdentity: { id: string; version: string };
|
let manifestIdentity: { id: string; version: string };
|
||||||
|
|
||||||
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
|
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
|
||||||
@@ -72,30 +75,49 @@ describe("module UI build pipeline", () => {
|
|||||||
if (!manifest.entrypoints.ui) {
|
if (!manifest.entrypoints.ui) {
|
||||||
throw new Error("The template manifest must declare entrypoints.ui for this test.");
|
throw new Error("The template manifest must declare entrypoints.ui for this test.");
|
||||||
}
|
}
|
||||||
|
if (!manifest.entrypoints.display) {
|
||||||
|
throw new Error("The template manifest must declare entrypoints.display for this test.");
|
||||||
|
}
|
||||||
manifestUi = manifest.entrypoints.ui;
|
manifestUi = manifest.entrypoints.ui;
|
||||||
|
manifestDisplay = manifest.entrypoints.display.artifact;
|
||||||
manifestIdentity = { id: manifest.id, version: manifest.version };
|
manifestIdentity = { id: manifest.id, version: manifest.version };
|
||||||
|
|
||||||
const result = await buildModuleUi({ moduleDirectory });
|
const result = await buildModuleUi({ moduleDirectory });
|
||||||
artifactPath = result.outputFile;
|
const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
|
||||||
artifactHref = pathToFileURL(result.outputFile).href;
|
const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display");
|
||||||
|
if (!uiArtifact || !displayArtifact) {
|
||||||
|
throw new Error("The template build must produce both declared UI roles.");
|
||||||
|
}
|
||||||
|
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
|
||||||
|
artifactPath = uiArtifact.outputFile;
|
||||||
|
artifactHref = pathToFileURL(uiArtifact.outputFile).href;
|
||||||
|
displayArtifactPath = displayArtifact.outputFile;
|
||||||
|
displayArtifactHref = pathToFileURL(displayArtifact.outputFile).href;
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
it("writes the artifact exactly where the manifest declares it", () => {
|
it("writes both artifacts exactly where the manifest declares them", () => {
|
||||||
expect(manifestUi).toBe("./dist/ui/index.js");
|
expect(manifestUi).toBe("./dist/ui/index.js");
|
||||||
expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true);
|
expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true);
|
||||||
|
expect(manifestDisplay).toBe("./dist/display/index.js");
|
||||||
|
expect(displayArtifactPath.endsWith(path.join("dist", "display", "index.js"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("produces a browser-safe ESM artifact that needs no host-provided React", async () => {
|
it("produces browser-safe ESM artifacts that need no host-provided React", async () => {
|
||||||
const artifact = await readFile(artifactPath, "utf8");
|
const artifacts = await Promise.all([
|
||||||
|
readFile(artifactPath, "utf8"),
|
||||||
|
readFile(displayArtifactPath, "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
// React is bundled, so the artifact resolves nothing at run time (ADR 0023). A bare top-level
|
for (const artifact of artifacts) {
|
||||||
// import would mean the host had to supply that module.
|
// React is bundled, so each artifact resolves nothing at run time (ADR 0023). A bare
|
||||||
expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
|
// top-level import would mean the host had to supply that module.
|
||||||
expect(artifact).toContain("export {");
|
expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
|
||||||
// No Ekklesia deployment detail may be baked into a portable artifact.
|
expect(artifact).toContain("export {");
|
||||||
expect(artifact).not.toContain("/api/v1/modules");
|
// No Ekklesia deployment detail may be baked into a portable artifact.
|
||||||
// `process.env` must be substituted at build time; a browser has no `process`.
|
expect(artifact).not.toContain("/api/v1/modules");
|
||||||
expect(artifact).not.toContain("process.env.NODE_ENV");
|
// `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 () => {
|
it("satisfies parseModuleUiExport and matches the manifest identity", async () => {
|
||||||
@@ -139,6 +161,26 @@ describe("module UI build pipeline", () => {
|
|||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("mounts the display artifact through the same ABI, then unmounts it", async () => {
|
||||||
|
const namespace = (await import(displayArtifactHref)) as unknown;
|
||||||
|
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const requested: string[] = [];
|
||||||
|
|
||||||
|
const unmount = await definition.mount(
|
||||||
|
container,
|
||||||
|
recordingHost({ canRead: true, records: [], requested, resolve: () => undefined }),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.querySelector("[data-testid='example-display']")?.textContent).toContain(
|
||||||
|
"Example full-screen display (example)",
|
||||||
|
);
|
||||||
|
expect(requested).toEqual([]);
|
||||||
|
await (unmount as () => Promise<void>)();
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("asks its host for a relative path, never Ekklesia's module API prefix", async () => {
|
it("asks its host for a relative path, never Ekklesia's module API prefix", async () => {
|
||||||
const namespace = (await import(artifactHref)) as unknown;
|
const namespace = (await import(artifactHref)) as unknown;
|
||||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
|
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts", "src/display/**/*.tsx", "src/display/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user