ADR 0022 defined what a module UI is and selected React and TypeScript as the authoring model, but closed by recording that it was not buildable end to end: `browser.json` preserves JSX, bundler plugins are denied, and nothing turned a module's `.tsx` into the artifact its manifest declares. This closes exactly that gap. esbuild produces the artifact. It was already the right answer before it was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`, so the pinned dependency adds no second copy and no new trust decision. It is also the smallest tool that covers TSX, ESM, browser platform, bundling, source maps, and a define-time constant with no plugin ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which the module boundary denies on purpose; `tsc` alone cannot bundle, so its output would carry bare `react/jsx-runtime` imports no browser resolves. React is bundled, not externalized. That follows from ADR 0022 rather than from convenience: `mount()` was made the ABI precisely so React would not be part of the host contract, and externalizing it would reverse that through the build system — every host would have to supply a compatible React, and a standalone host might not use React at all. The cost is real and accepted: duplicate runtimes and a ~570 KB unminified artifact. Sharing one runtime later is a build optimization that cannot change the contract, because the contract is a mount function. Output is browser ESM at `es2022`, unminified so an operator can review what they install, with `process.env.NODE_ENV` substituted — React reads it at module scope and would otherwise touch a Node global that does not exist in a browser. The output path is read from the module's own manifest rather than a build convention, so declared and produced artifacts cannot drift, and it is validated with the same `packageRelativePathSchema` discovery enforces, so a build can never write where discovery would later reject. `@ekklesia/module-ui-build` owns every option; modules own none. It uses the existing `tooling` role that modules could already depend on, so this pass required no boundary relaxation at all. Its `bin` points at a committed launcher rather than compiled output because pnpm links bins during install, before Turborepo builds anything — a bin targeting `dist/cli.js` fails to link on a fresh clone and breaks every module's UI build in CI. A module with no `entrypoints.ui` is not an error; the CLI reports nothing to build and exits zero, so one script serves both kinds of module. Styling is React inline style objects. A stylesheet import fails the build rather than emitting a sidecar `.css` that no manifest declares and no host loads — an undeclared asset would appear to work in tests and silently do nothing in a real host. `templates/module` is the canonical example and now ships optional UI. Its tests build the artifact from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered output, and unmount it; the artifact is rebuilt per run so the proof can never pass against stale output, and `dist/` stays gitignored. Because verify runs both `pnpm test` and `pnpm build`, a UI that cannot be produced fails CI through two independent paths. One correction worth stating plainly: the UI TypeScript project declares `"types": []`, and a UI file referencing `process` does fail in isolation, but TypeScript still pulls Node globals in transitively — a file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts` and through its Zod and Drizzle types `process.env` typechecks again. The docs and ADR describe this as the convention it is rather than the guard it is not. Core is untouched. Building is an authoring step; discovery still only validates the artifact a module shipped and never executes or produces one. Co-Authored-By: Claude Opus 5 <[email protected]>
267 lines
16 KiB
Markdown
267 lines
16 KiB
Markdown
# 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
|
|
|
|
> **Cross-reference — see [ADR 0023](0023-module-ui-build-pipeline.md).**
|
|
> The gap this section records — that module UI was not buildable end to end, because no pipeline
|
|
> turned `.tsx` into the compiled `entrypoints.ui` artifact — is now closed. ADR 0023 selects
|
|
> esbuild, bundles React rather than externalizing it (preserving this ADR's mount-function ABI),
|
|
> emits browser ESM at the manifest-declared path, and keeps stylesheet imports unsupported by
|
|
> failing the build on one. Everything else in this ADR remains accepted and unchanged.
|
|
|
|
|
|
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.
|