feat: define the module UI authoring model and host contract
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:
co-authored by
Claude Opus 5
parent
b5aa4371b6
commit
8d651c427d
@@ -0,0 +1,258 @@
|
||||
# 0022: Module UI authoring model and host contract
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-21
|
||||
- Extends: [0021](0021-modules-as-hostable-products.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0021 established that a module is a product which may own its UI, and added `entrypoints.ui` —
|
||||
a statically validated, never-executed path to a compiled artifact. It deliberately answered none of
|
||||
the questions that make that field usable, and recorded the gap explicitly: the authoring model was
|
||||
"the first question the next pass must answer," and `pnpm boundaries` still rejected UI source and UI
|
||||
framework dependencies inside a module.
|
||||
|
||||
Five questions block every later Phase 6 pass:
|
||||
|
||||
1. What does a UI artifact export?
|
||||
2. What does a host give it?
|
||||
3. How does one artifact run under Ekklesia *and* a standalone host?
|
||||
4. What is the browser-side trust model?
|
||||
5. How does module UI avoid being welded to a Dashboard that does not exist?
|
||||
|
||||
The repository already leans one way on the first. `packages/typescript-config` ships a `browser.json`
|
||||
preset (DOM libs, JSX) and a `react-native.json` preset (`"jsx": "react-jsx"`), and ADR 0002 names
|
||||
"separate Node, browser, and React Native base configurations." No frontend framework is installed
|
||||
anywhere — the only React or Preact in the tree is inside boundary fixtures that exist to prove the
|
||||
guard rejects them.
|
||||
|
||||
## Decision
|
||||
|
||||
### React is the supported module UI authoring model
|
||||
|
||||
A module author writes module UI in React and TypeScript (`.tsx`). This is a real choice, not a
|
||||
neutrality dodge: the repository's own TypeScript presets already anticipate React, Ekklesia's future
|
||||
first-party UI will be React, and pretending otherwise would push every module author into an
|
||||
abstraction layer that serves nobody. One framework is supported. Vue, Svelte, Angular, Preact,
|
||||
Solid, Qwik, Lit, Astro, Marko, and React Native remain unsupported and remain rejected by
|
||||
`pnpm boundaries`.
|
||||
|
||||
**React is the supported authoring model. It is not part of the host contract.** That distinction is
|
||||
the whole design, and the next section is where it is enforced.
|
||||
|
||||
### The host contract is a mount function, not a component
|
||||
|
||||
A module's compiled `entrypoints.ui` artifact default-exports a `ModuleUiDefinition`
|
||||
(`packages/module-ui-sdk`):
|
||||
|
||||
```ts
|
||||
export default defineModuleUi({
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<Editor context={context} />);
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition` exactly
|
||||
(ADR 0006): a host proves the bundle it imported was built from the module it thinks it is, and a
|
||||
stale artifact is caught rather than mounted. They duplicate nothing else from the manifest.
|
||||
|
||||
`mount` receives a container `Element` and the host context, and returns an unmount function or
|
||||
nothing. A module that acquires anything — a React root, a subscription, a timer, a listener —
|
||||
returns an unmount and releases it there, exactly as a backend module releases resources in `stop`.
|
||||
|
||||
React appears nowhere in that contract. A host needs an element and a context; it does not need to
|
||||
know, share, or match the module's rendering library. That keeps three things independent that would
|
||||
otherwise be welded together: the module's framework, the host's framework, and the platform
|
||||
boundary between them. React can be the supported way to *write* module UI while the *contract*
|
||||
outlives any particular version of it.
|
||||
|
||||
### `@ekklesia/module-ui-sdk` is a separate, browser-safe package
|
||||
|
||||
The contract lives in a new shared package with role `module-ui-sdk`, not in `@ekklesia/contracts` or
|
||||
`@ekklesia/module-sdk`. The reason is concrete rather than organizational: `@ekklesia/module-sdk`
|
||||
re-exports all of `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel
|
||||
export. Routing module UI through it would drag a backend dependency graph into a browser bundle.
|
||||
|
||||
The package therefore has no runtime dependencies. Its one dependency on `@ekklesia/contracts` is
|
||||
type-only (`import type { EkklesiaPrincipal }`), erased at build time, so a host and a module UI
|
||||
provably share Core's principal type without shipping any of Core's runtime.
|
||||
|
||||
Both hosts consume this same package. It is not a Dashboard framework and must not become one: no
|
||||
components, no layout, no design system, no router.
|
||||
|
||||
### The host context is deliberately four members
|
||||
|
||||
```ts
|
||||
interface ModuleUiHostContext {
|
||||
readonly moduleId: string;
|
||||
readonly api: ModuleUiApiClient;
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
readonly permissions: ModuleUiPermissions;
|
||||
}
|
||||
```
|
||||
|
||||
This is the smallest set that proves the architecture, not the set a finished screen will want.
|
||||
Navigation, host theme and design tokens, module configuration, organization/platform context, and
|
||||
diagnostics reporting are **not** included. Each needs a design that does not exist, and a
|
||||
speculative field frozen into a public contract costs more to remove than to add later. A host passes
|
||||
this object and nothing else; there is no platform singleton, no global, and no escape hatch to Core.
|
||||
|
||||
### Module UI addresses its own API relatively, never by URL
|
||||
|
||||
`ModuleUiApiClient.request(path, init)` takes a path **relative to the module's own API root** and
|
||||
returns a `Response`. The module asks for `posts`. The host decides what that means.
|
||||
|
||||
This is the single most important portability rule here. Ekklesia mounts module routes under
|
||||
`/api/v1/modules/<module-id>` (`apps/api/src/module-http-routes.ts`), but that prefix is Ekklesia's
|
||||
private deployment detail. A standalone host serving one module may expose the same routes at `/api`,
|
||||
at another origin, or behind a proxy. A module UI that builds
|
||||
`/api/v1/modules/announcements/posts` itself is silently welded to Ekklesia and can never be hosted
|
||||
anywhere else — and the failure is invisible until someone tries.
|
||||
|
||||
`Request`/`Response` are the same Fetch vocabulary a module's backend routes already speak (ADR
|
||||
0015), so one module speaks one HTTP shape on both sides of the wire. Credentials, base URL, and any
|
||||
platform headers are the host's responsibility; a module UI neither supplies nor sees a session
|
||||
token. No backend routing changed in this pass.
|
||||
|
||||
### Module UI is trusted first-party code in the host's realm
|
||||
|
||||
Module UI runs in the same browser JavaScript realm as the host shell. There is no iframe, no
|
||||
sandbox, no cross-origin isolation, and 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.
|
||||
|
||||
**This is a trusted-code model, stated plainly rather than dressed up.** It matches, and does not
|
||||
extend, the backend model ADR 0021 preserved: installing a module already means running its code in
|
||||
the API process with the API's PostgreSQL authority. A module whose server code is trusted with the
|
||||
database is not meaningfully constrained by sandboxing its browser code.
|
||||
|
||||
Introducing an iframe or a separate origin now would add real cost — postMessage plumbing, styling
|
||||
seams, focus and accessibility breakage — to buy theoretical separation from code that already has
|
||||
server-side authority. That trade only becomes worthwhile for genuinely untrusted third-party
|
||||
modules, which this platform does not accept (see
|
||||
[Security model and deployment trust boundaries](../../development/security.md)). Nothing in this
|
||||
contract prevents a future sandboxed tier: a host that wanted one could implement `mount` against an
|
||||
isolated realm without changing this interface, because the contract is a mount function and a
|
||||
plain-data context rather than shared object graphs.
|
||||
|
||||
### Client-side permission checks are presentation, never enforcement
|
||||
|
||||
`ModuleUiPermissions.has(permissionId)` tells a module UI what the host believes, so it can hide an
|
||||
action, disable a control, or pick an empty state. It is not a security boundary and must never be
|
||||
described as one.
|
||||
|
||||
Server-side authorization remains the only authority. Every module route is enforced by Core's
|
||||
`requirePermission` middleware against the composed platform permission registry (ADR 0013/0014),
|
||||
and that enforcement neither knows nor cares what a browser decided. A module UI that consults
|
||||
`has()` and then calls its API is still fully authorized server-side; one that skips the check and
|
||||
calls anyway is still denied. The value is that a user does not see a button that would fail.
|
||||
|
||||
`permissionId` is a plain `string`, not Core's branded `PermissionId`, deliberately: branding would
|
||||
force a module UI to run Zod in the browser to produce a value for a call that decides nothing
|
||||
security-relevant. The branded type remains the server-side type, where it matters.
|
||||
|
||||
### Discovery still never executes UI, and Core still never loads it
|
||||
|
||||
`parseModuleUiExport(namespace, expected)` validates an already-imported module namespace. It does
|
||||
not import anything. 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 namespace in.
|
||||
|
||||
That keeps this package free of any loader, bundler, or URL assumption, and it keeps Core out of the
|
||||
business of evaluating browser code entirely. Module discovery continues to resolve and validate the
|
||||
`entrypoints.ui` path and never execute it (ADR 0021), and this pass adds no UI loading to
|
||||
`core/`, to `apps/api`, or to any startup path. Nothing about restart-based discovery changed, and
|
||||
nothing about explicit operator-invoked migrations changed.
|
||||
|
||||
### Dashboard organization stays outside this contract
|
||||
|
||||
The contract carries no navigation entry, route, title, icon, folder, group, or ordering field, and
|
||||
none may be added as a hierarchy mechanism. ADR 0021 established that Dashboard grouping is
|
||||
presentation configuration an administrator reorganizes, never module architecture; adding any of it
|
||||
here would quietly reverse that, because a field in the UI contract is a field a module owns.
|
||||
|
||||
### The boundary guard is narrowed precisely, not removed
|
||||
|
||||
`pnpm boundaries` keeps enforcing `modules-do-not-own-ui`. Exactly three things changed:
|
||||
|
||||
- `.tsx` is no longer a rejected source extension. `.jsx` stays rejected (this repository is
|
||||
strict-TypeScript throughout) and `.css` stays rejected (module styling and the module UI build
|
||||
pipeline are not designed yet, and a stylesheet would imply a bundling contract that does not
|
||||
exist).
|
||||
- `react` and `react-dom` are no longer rejected dependencies. Every other framework stays rejected,
|
||||
including `react-router-dom` (routing belongs to the host), `react-native` (this pass supports
|
||||
React on the DOM only), and `@vitejs/plugin-react` (the build pipeline is undesigned).
|
||||
- Modules may depend on the new `module-ui-sdk` shared role, which itself may depend only on
|
||||
`@ekklesia/contracts`.
|
||||
|
||||
Layer rules are untouched. Module UI source is subject to the identical import boundaries as module
|
||||
backend source: it cannot import Core, an app, a surface, a builder, another module, or a shared
|
||||
package outside the roles a module is allowed. An unsupported framework aliased behind an allowed
|
||||
name (`"react": "npm:vue@3"`) is resolved and rejected by target, so permitting React did not create
|
||||
a way to smuggle anything else in.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A React component as the export** (`export default function Ui(props)`). Rejected: it would put
|
||||
React into the platform contract, force every host — including standalone — onto the same React
|
||||
major, and make a framework upgrade a breaking change to the module boundary. A mount function
|
||||
costs a module author three lines and buys independence for all three parties.
|
||||
- **Web Components as the authoring model.** Rejected as the *authoring* model: it would mean every
|
||||
module author writes to a lower-level API than the one Ekklesia's own UI will use, and styling,
|
||||
form handling, and state would all need re-solving per module. Notably, the mount contract does not
|
||||
forbid a module from using them internally — it only declines to require them.
|
||||
- **A serialized UI description** (`{"type": "form", "fields": [...]}`) rendered by the host.
|
||||
Rejected outright. It is a frontend framework in disguise, it can only ever express what its schema
|
||||
anticipated, and the Announcements editor that motivated ADR 0021 is exactly the kind of screen
|
||||
such a system cannot express. Ekklesia is not building its own frontend framework.
|
||||
- **Putting the contract in `@ekklesia/module-sdk`.** Rejected: that package re-exports all of
|
||||
`@ekklesia/contracts`, so a module UI bundle would pull Zod, Drizzle, and semver into the browser.
|
||||
- **Passing a single platform object** (`context.platform`, `context.core`, a store). Rejected: it
|
||||
makes every internal a de facto public API, and nothing that reaches a module can be changed
|
||||
afterwards without breaking one. Four narrow members can grow; a platform object can only leak.
|
||||
- **An absolute API base URL** (`context.apiBaseUrl`) instead of a request function. Rejected: a
|
||||
module that concatenates a base URL still assumes a path shape underneath it, and it puts URL
|
||||
construction — and therefore credential and header policy — back inside the module.
|
||||
- **Iframe or cross-origin sandboxing now.** Rejected as premature and misleading: real cost today,
|
||||
no real containment for code whose backend half already holds the API's database authority, and a
|
||||
sandbox claim this repository would not be able to substantiate.
|
||||
- **Waiting for the Dashboard before defining the contract.** Rejected: the Dashboard would then
|
||||
define the contract by accident, and every Ekklesia-specific assumption it happened to make would
|
||||
become the standalone host's problem later.
|
||||
- **Allowing `.css` in modules now.** Rejected: it implies a bundling and style-isolation contract
|
||||
that this pass does not design. Deferred deliberately rather than half-answered.
|
||||
|
||||
## Consequences
|
||||
|
||||
A module author can now write a real screen in React, against a documented four-member host context,
|
||||
and know exactly what the artifact must export. Two hosts can consume that artifact with no shared
|
||||
code beyond one small package, and the test suite proves it by mounting one fixture artifact under
|
||||
two hosts whose URL shapes and origins differ.
|
||||
|
||||
The cost is that this is still a contract without a production consumer. No Dashboard mounts a module
|
||||
UI, no module ships one, and the build pipeline that turns a module's `.tsx` into the compiled
|
||||
`entrypoints.ui` artifact is not designed — `browser.json` uses `"jsx": "preserve"`, which needs a
|
||||
bundler that no module has, and `@vitejs/plugin-react` remains denied. Choosing between a `tsc`-only
|
||||
JSX preset and a real bundler, together with the styling question, is the next pass's work and is the
|
||||
reason `.css` stays rejected today.
|
||||
|
||||
A second cost is deliberate duplication of effort in the browser: if both the host and a module
|
||||
bundle React, a page can load React twice. Sharing one copy through peer dependencies, bundler
|
||||
externals, or an import map is a build-pipeline optimization, not a contract change, and the mount
|
||||
contract is unaffected by whichever is chosen.
|
||||
|
||||
## Deferred
|
||||
|
||||
Not implemented, designed, or promised here: the Dashboard application shell, homepage, or
|
||||
navigation UI; module navigation registration; Dashboard folders, groups, or drag-and-drop
|
||||
organization; a design system, component library, or theme/design-token contract; the module UI build
|
||||
and styling pipeline; a standalone application host; a module installer, marketplace, or manager;
|
||||
hot-loading or runtime unloading; automatic migrations; sandboxed or untrusted module tiers; and
|
||||
every real feature module. `entrypoints.jobs` still accepts source TypeScript where other executable
|
||||
entrypoints require compiled output — that inconsistency is unchanged and remains open, because this
|
||||
pass did not centralize executable-entrypoint validation and fixing it here would have been unrelated
|
||||
scope.
|
||||
Reference in New Issue
Block a user