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
@@ -205,6 +205,15 @@ one that does not, and an operator installing either is deploying trusted code.
## Consequences
> **Cross-reference — see [ADR 0022](0022-module-ui-authoring-and-host-contract.md).**
> The open question this section records — the module UI authoring model, and which parts of the
> `modules-do-not-own-ui` guard change — is now answered. ADR 0022 selects React as the supported
> authoring model, narrows the guard to allow `.tsx` plus `react`/`react-dom` while keeping every
> other framework rejected, and defines what `entrypoints.ui` exports and what a host provides.
> Everything else in this ADR remains accepted and unchanged: `entrypoints.ui` is still statically
> validated and never executed by discovery, and Core still never loads browser code.
A module author can now express the complete product they own, and the platform has one contract for
discovering that a module offers presentation at all. Backend-only modules are unaffected beyond a
one-character manifest version bump. The dependency graph stays flat and meaningful, and Dashboard
@@ -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.
+1
View File
@@ -30,3 +30,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0019: Durable authorization history and grant management](0019-durable-authorization-history-and-grant-management.md) — Accepted
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
+31 -9
View File
@@ -30,7 +30,9 @@ The four ownership layers are:
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
2. **Modules** own domain behavior, data schemas, resources, actions, routes, events, permissions,
jobs — and, per [ADR 0021](decisions/0021-modules-as-hostable-products.md), their own UI. A module
jobs — and, per [ADR 0021](decisions/0021-modules-as-hostable-products.md), their own UI, authored
in React against `@ekklesia/module-ui-sdk`
([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A module
is an independently versioned product, not only a backend capability. It does not own the
containing application shell or any platform policy.
3. **Surfaces** own the presentation concerns a module does not: the containing application shell,
@@ -163,20 +165,40 @@ blocks Phase 6. Read
does and does not protect against, and the repository root
[README](../../README.md#current-status) for the current status of every phase.
**Phase 6A establishes the module product contract.**
**Phase 6A established the module product contract.**
[ADR 0021](decisions/0021-modules-as-hostable-products.md) replaces the backend-only module model:
a module is an independently versioned product that may own its own UI, hostable inside Ekklesia and
— later, from the same implementation — as a standalone application. Core keeps the host shell and
every platform policy, the module graph stays flat, and Dashboard grouping stays presentation
configuration.
What this pass actually implements is the **static contract only**: `manifestVersion: 4` adds one
optional `entrypoints.ui` field, validated during discovery exactly like the server and schema
entrypoints — package-relative, inside the package, compiled ESM. Discovery remains inert; nothing
imports, mounts, or renders that entrypoint. The Dashboard shell, the UI runtime, the standalone
host, module UI authoring (including the still-enforced `modules-do-not-own-ui` boundary rule), and
every feature module remain future work. Restart-based discovery, explicit operator-invoked
migrations, and the trusted compiled-module model are all unchanged.
What that pass implemented is the **static contract only**: `manifestVersion: 4` adds one optional
`entrypoints.ui` field, validated during discovery exactly like the server and schema entrypoints —
package-relative, inside the package, compiled ESM. Discovery remains inert; nothing imports, mounts,
or renders that entrypoint.
**Phase 6B pass 1 defines the module UI authoring model and host contract.**
[ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md) selects React and TypeScript as
the supported way to author module UI, and defines what a compiled `entrypoints.ui` artifact exports
and what a host provides it. React is the authoring model but deliberately not the contract: an
artifact default-exports `defineModuleUi({ id, version, mount })`, and `mount(container, context)`
takes a DOM element and returns an unmount function, so a module's framework and a host's stay
independent.
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
dependencies, consumed by module authors and hosts alike. Its host context is four members —
`moduleId`, `api`, `principal`, `permissions`. Module UI reaches its own backend through
`api.request(path)` with a path relative to the module's own API root, never by constructing
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
Client-side permission hints are presentation only; server-side enforcement remains the sole
authority. Module UI is trusted code in the host's browser realm — there is no sandbox, matching the
backend trust model rather than overstating it.
Core is untouched by this: discovery still validates the `entrypoints.ui` path and never evaluates
it, and hosts perform their own dynamic import. The Dashboard shell, a UI runtime, the module UI
build and styling pipeline, the standalone host, and every feature module remain future work.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged.
Target architecture not yet implemented includes production approval-provider
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
+16 -11
View File
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
| --- | --- | --- |
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
| `modules/*` | Domain capability, data, and (per ADR 0021) the module's own UI | Module SDK, public contracts, and external backend libraries |
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries |
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
@@ -17,14 +17,18 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- Core never imports a feature module, surface, Builder implementation, or app.
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
- A module cannot contain presentation source, including JSX/TSX, single-file components,
Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI
frameworks or shared UI-role packages. **This rule is still enforced, but it is no longer the
architectural direction.** [ADR 0021](decisions/0021-modules-as-hostable-products.md) establishes
that a module may own its own UI and declare a compiled `entrypoints.ui` artifact. The deny list
above stays in force until a later Phase 6 pass chooses the module UI authoring model and decides
what replaces it; until then a module UI must be a framework-neutral compiled artifact. Core still
owns the containing application shell and all platform policy.
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
packages. An unsupported framework aliased behind an allowed name is resolved and rejected by its
real target.
- Module UI source is subject to the identical layer rules as module backend source: it may not
import Core, an app, a surface, a builder, or another module. Core still owns the containing
application shell and all platform policy, and module UI reaches the host only through
`@ekklesia/module-ui-sdk`.
- A surface never imports Core or a feature module. It uses public API/client contracts.
- Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or
@@ -44,8 +48,9 @@ The boundary test suite includes intentionally invalid fixture graphs. This prov
rejects every currently machine-checkable repository rule even before production packages fill
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
common UI-framework dependencies inside modules. This deny list is defense in depth; package
review remains required when adopting a new frontend technology — and revisiting the guard itself is
an explicit prerequisite of the module UI authoring pass (ADR 0021).
review remains required when adopting a new frontend technology. ADR 0022 narrowed the guard
precisely rather than removing it: React was un-denied, every other framework stayed denied, and the
layer rules were untouched.
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation
+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
+106 -21
View File
@@ -190,33 +190,118 @@ and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts`
discovery — the platform never imports source TypeScript, and a UI artifact is a build output like
any other.
### What the artifact must export
The artifact default-exports one definition, built with `defineModuleUi` from
`@ekklesia/module-ui-sdk` (ADR 0022):
```tsx
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import { createRoot } from "react-dom/client";
import { Editor } from "./editor.js";
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 and must match your manifest, exactly like the
ones in `defineModuleRuntime`. A host verifies both against the manifest it already validated, so a
stale UI bundle is caught rather than mounted.
`mount` receives a container element and the host context, and returns an unmount function or
nothing. If your mount acquires anything — a React root, a subscription, a timer, an event listener —
return an unmount and release it there, the same discipline your `stop` hook follows.
React is the supported way to write module UI. It is deliberately **not** part of the contract: the
platform hands you an element and a context, so your framework choice and the host's stay
independent.
### What the host gives you
`mount` receives exactly four members, and nothing else is available:
| Member | What it is |
| --- | --- |
| `moduleId` | Your module's id, so you never re-derive it from a URL |
| `api` | `request(path, init)` against **your own** module API — see below |
| `principal` | Who is viewing (`anonymous` or `authenticated`), for presentation |
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
Navigation, host theme and design tokens, module configuration, organization context, and
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a
speculative field frozen into a public contract is harder to remove than to add.
### Never build your own API URL
Ask the host for a path relative to your own module's API root:
```ts
const response = await context.api.request("posts");
```
Do **not** write `/api/v1/modules/announcements/posts`. That prefix is Ekklesia's private deployment
detail. A standalone host serving only your module may expose the same routes at `/api`, at a
different origin, or behind a proxy — and a module that builds the URL itself silently stops being
hostable anywhere but Ekklesia. The host resolves the path and owns credentials, base URL, and
platform headers; your UI never sees a session token.
`request` returns a `Response`, the same Fetch vocabulary your backend routes already speak.
### Client-side permission checks are UX, not security
`context.permissions.has("announcements.posts.publish")` tells you what the host believes, so you can
hide an action or disable a control. **It is not a security boundary.** Server-side authorization is
the only authority: your routes are enforced by Core's `requirePermission` middleware regardless of
what any browser decided. Checking `has()` and then calling your API is still fully authorized
server-side; skipping the check is still denied. The point is that a user does not see a button that
would fail.
### Your UI is trusted code in the host's page
Module UI runs in the same browser JavaScript realm as the host shell. There is no iframe, no
sandbox, and no capability confinement — your UI can reach the host page and any global the browser
exposes. This matches the backend model: installing a module already means running its code in the
API process. Write module UI as trusted first-party code, because that is what it is. See
[Security model and deployment trust boundaries](../development/security.md).
### What you may and may not use today
`pnpm boundaries` still enforces `modules-do-not-own-ui`, narrowed precisely (ADR 0022):
| Allowed in a module | Still rejected |
| --- | --- |
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
| `@ekklesia/module-ui-sdk` | `@ekklesia/ui` and other shared UI-role packages |
Layer rules are unchanged: module UI source may not import Core, an app, a surface, a builder, or
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
is resolved and rejected by its real target.
> **Not yet buildable end to end.** The pipeline that compiles a module's `.tsx` into the compiled
> `entrypoints.ui` artifact is not designed: `browser.json` uses `"jsx": "preserve"`, which needs a
> bundler, and `@vitejs/plugin-react` is still denied. Module styling is unresolved for the same
> reason, which is why `.css` stays rejected. No host mounts a module UI yet either. The contract
> above is stable and tested; shipping a module UI is not yet possible end to end.
### What this does and does not do today
Discovery parses the manifest as inert JSON, resolves the `ui` path, validates it, and records it on
the discovered module. That is all. **Nothing in this repository imports, evaluates, mounts, or
renders a UI entrypoint** — a UI artifact that throws on evaluation still discovers successfully,
because it is never evaluated. The field follows the same established pattern as `entrypoints.jobs`,
which has been declared and validated since ADR 0006 with no runtime consumer.
Declaring `ui` therefore has no observable runtime effect yet. It exists so the host shell, the UI
runtime, and a future standalone host have a fixed, tested contract to build against.
### The authoring constraint you will hit
`pnpm boundaries` enforces `modules-do-not-own-ui`, which rejects UI *source* extensions (`.tsx`,
`.vue`, `.svelte`, `.css`, `.svg`, and others) and UI *framework dependencies* (React, Vue, Svelte,
Tailwind, and others) inside a module package. **That rule is still enforced.** ADR 0021 changed the
architectural direction — a module may own UI — but the authoring model for module UI, including
which parts of that deny list are relaxed and what replaces them, is a later Phase 6 decision that
has not been made.
In practice this means a module can declare `entrypoints.ui` today only for a framework-neutral
compiled artifact authored in plain `.ts`. Do not add a UI framework dependency to a module package
expecting it to pass; it will not, and that is deliberate rather than an oversight.
the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or renders a UI
entrypoint** — a UI artifact that throws on evaluation still discovers successfully, because it is
never evaluated. Hosts perform their own dynamic import and then validate the result with
`parseModuleUiExport`; Core never loads browser code.
### What a module UI will never own
Whatever authoring model is chosen, a module UI consumes platform policy and never redefines it.
A module UI consumes platform policy and never redefines it.
Authentication and session establishment, authorization enforcement, organization/platform context,
the containing application shell, top-level routing and mount boundaries, the platform navigation
container, the global theme and design-token contract, platform error boundaries, and