feat: define the module UI authoring model and host contract
CI / verify (pull_request) Successful in 30s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 45s

ADR 0021 established that a module may own UI and added a statically
validated, never-executed `entrypoints.ui` path, but deliberately left
five questions open: what the artifact exports, what a host provides,
how one artifact runs under both Ekklesia and a standalone host, what
the browser-side trust model is, and how module UI avoids being welded
to a Dashboard that does not exist. This answers them.

React and TypeScript are the supported module UI authoring model. The
repository already leaned this way: `packages/typescript-config` ships a
`browser.json` preset and a `react-native.json` preset with
`"jsx": "react-jsx"`, and ADR 0002 names browser and React Native
configurations. One framework is supported; every other stays rejected.

React is deliberately not part of the contract. A module's compiled
artifact default-exports `defineModuleUi({ id, version, mount })`, and
`mount(container, context)` takes a DOM element and returns an unmount
function. `id` and `version` are build-artifact identity markers a host
verifies against the manifest, mirroring `defineModuleRuntime` exactly.
A host needs an element and a context, never a matching rendering
library, so a module's framework, a host's framework, and the platform
boundary between them stay independent.

The contract lives in a new `@ekklesia/module-ui-sdk` rather than in
`@ekklesia/module-sdk`, for a concrete reason: that package re-exports
all of `@ekklesia/contracts`, so routing module UI through it would drag
Zod, Drizzle, and semver into every browser bundle. The new package has
no runtime dependencies, and its one dependency on contracts is
type-only — the emitted `host-context.js` is `export {};`, and a test
pins that so it cannot regress.

The host context is four members: `moduleId`, `api`, `principal`, and
`permissions`. Navigation, theme, module configuration, organization
context, and diagnostics are deliberately absent; each needs a design
that does not exist. Module UI reaches its backend through
`api.request(path)` with a path relative to its own API root and never
constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private
deployment detail — a module that builds that URL is silently unhostable
anywhere else. One fixture artifact is mounted under two hosts with
different origins and URL shapes to prove it.

Client-side permission hints are presentation only, and both the docs
and a test say so plainly: a denied hint does not stop the module from
calling its API, because server-side `requirePermission` is the only
authority. Module UI is trusted code in the host's own browser realm —
no iframe, no sandbox — which matches the backend model rather than
overstating it, since a module's server half already holds the API's
database authority.

The boundary guard is narrowed precisely, not removed: `.tsx`, `react`,
and `react-dom` are un-denied and modules may depend on the new
`module-ui-sdk` role. `.jsx`, `.css`, every other framework,
`react-router-dom`, `react-native`, and `@vitejs/plugin-react` stay
denied, layer rules are untouched, and an unsupported framework aliased
behind an allowed name (`"react": "npm:vue@3"`) is resolved and rejected
by its real target. Fixtures prove each case, including that module UI
source still cannot import Core.

Core, apps/api, and the manifest contract are untouched. Discovery still
validates the `entrypoints.ui` path and never evaluates it; hosts
perform their own dynamic import and validate the result with
`parseModuleUiExport`. No Dashboard shell, UI runtime, navigation,
grouping, standalone host, build or styling pipeline, installer,
hot-loading, or feature module is added.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-21 20:52:47 -05:00
co-authored by Claude Opus 5
parent b5aa4371b6
commit 8d651c427d
34 changed files with 1318 additions and 83 deletions
+30 -5
View File
@@ -54,11 +54,36 @@ schema-permission, table-ACL, row-level-security, tenant, or resource sandbox be
Core.** A module is not isolated from Core, from other modules, or from the host.
[ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) lets a module declare its
own UI entrypoint. **That changes nothing on this page.** A module that ships UI is exactly as
trusted as one that does not: its server entrypoint still runs in the API process with the authority
described above, and declaring `entrypoints.ui` neither adds nor implies any isolation. No host
loads a UI entrypoint yet; when one does, the trust model for whatever it executes must be decided
explicitly and recorded — it does not inherit a boundary from this section, because none exists.
own UI entrypoint. **That changes nothing about the authority described above.** A module that ships
UI is exactly as trusted as one that does not: its server entrypoint still runs in the API process
with that authority, and declaring `entrypoints.ui` neither adds nor implies any isolation.
### Module UI is trusted browser code, in the host's own realm
[ADR 0022](../architecture/decisions/0022-module-ui-authoring-and-host-contract.md) decides the
browser-side model that ADR 0021 deliberately left open. Module UI runs **in the same browser
JavaScript realm as the host shell**: no iframe, no sandbox, no cross-origin isolation, no capability
confinement. A module's UI can reach the host page, the DOM, `window`, and any global the browser
exposes, exactly like any other script the application loads.
That is a deliberate choice for a first-party, trusted-module platform, and it is consistent rather
than lax: a module whose server code already runs in the API process with the API's full PostgreSQL
authority is not meaningfully constrained by sandboxing its browser half. Do not describe module UI
as sandboxed, isolated, or confined — none of those are true, and no mechanism in this repository
implements them.
The contract does leave room for a future sandboxed tier without a redesign: `mount(container,
context)` passes an element and a plain-data context rather than shared object graphs, so a host
could implement it against an isolated realm. Nothing does today.
### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds
(`ModuleUiPermissions.has`), so the UI can hide an action or disable a control. **This is
presentation only.** Server-side authorization remains the sole authority: every module route is
enforced by `requirePermission` against the composed platform permission registry, and that
enforcement neither knows nor cares what a browser decided. Hiding a control is a usability
improvement, never an access control. Treat any claim that a UI check protects anything as a bug.
## What this platform must not claim