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
+39 -13
View File
@@ -47,6 +47,11 @@ export interface BoundaryResult {
}
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
// Presentation formats a module may NOT contain. `.tsx` is deliberately absent: React is the
// supported module UI authoring model (ADR 0022), so a module's own `.tsx` is legitimate source.
// `.jsx` stays rejected because this repository is strict-TypeScript throughout, and `.css` stays
// rejected because module styling and the module UI build pipeline are not designed yet — allowing
// a stylesheet would imply a bundling contract that does not exist.
const MODULE_UI_EXTENSIONS = new Set([
".astro",
".css",
@@ -64,9 +69,14 @@ const MODULE_UI_EXTENSIONS = new Set([
".stylus",
".svelte",
".svg",
".tsx",
".vue",
]);
// UI packages a module may NOT depend on. `react` and `react-dom` are deliberately absent: they are
// the one supported module UI authoring framework (ADR 0022). Everything else stays denied, so
// choosing React does not quietly open every other frontend technology. Three neighbours are worth
// naming: `react-router-dom` stays denied because routing belongs to the host, `react-native` stays
// denied because this pass supports React on the DOM only, and `@vitejs/plugin-react` stays denied
// because the module UI build pipeline is not designed yet.
const MODULE_UI_PACKAGES = new Set([
"@angular/core",
"@builder.io/qwik",
@@ -91,8 +101,6 @@ const MODULE_UI_PACKAGES = new Set([
"nuxt",
"preact",
"qwik",
"react",
"react-dom",
"react-native",
"react-router-dom",
"remix",
@@ -144,6 +152,7 @@ const SHARED_PACKAGE_ROLES = new Set([
"contracts",
"design-tokens",
"module-sdk",
"module-ui-sdk",
"shared",
"surface-sdk",
"testing",
@@ -212,7 +221,7 @@ function boundaryRule(from: Location, to: Location): string | undefined {
}
if (
to.layer === "shared" &&
!["contracts", "module-sdk", "testing", "tooling"].includes(to.role ?? "")
!["contracts", "module-sdk", "module-ui-sdk", "testing", "tooling"].includes(to.role ?? "")
) {
return "modules-use-public-platform-contracts";
}
@@ -245,7 +254,7 @@ function boundaryRule(from: Location, to: Location): string | undefined {
if (
from.layer === "shared" &&
["builder-sdk", "module-sdk", "surface-sdk"].includes(from.role ?? "") &&
["builder-sdk", "module-sdk", "module-ui-sdk", "surface-sdk"].includes(from.role ?? "") &&
["app", "builder", "core", "module", "surface"].includes(to.layer)
) {
return "sdks-do-not-depend-on-implementations";
@@ -255,7 +264,10 @@ function boundaryRule(from: Location, to: Location): string | undefined {
if (from.role === "contracts" && to.role !== "contracts") {
return "contracts-do-not-depend-upward";
}
if (["module-sdk", "surface-sdk"].includes(from.role ?? "") && to.role !== "contracts") {
if (
["module-sdk", "module-ui-sdk", "surface-sdk"].includes(from.role ?? "") &&
to.role !== "contracts"
) {
return "sdks-do-not-depend-on-implementations";
}
if (from.role === "builder-sdk" && !["contracts", "surface-sdk"].includes(to.role ?? "")) {
@@ -293,9 +305,19 @@ function npmAliasTarget(range: string): string | undefined {
return alias.split("@")[0];
}
function isModuleUiDependency(packageName: string, range: string): boolean {
/**
* The forbidden UI framework a module dependency actually resolves to, or `undefined` when it
* resolves to none. Returns the *alias target* when the alias is what matched, so a dependency
* declared as `"react": "npm:vue@3"` is reported as Vue rather than as the allowed name it hides
* behind. Now that React is permitted (ADR 0022), that distinction is what keeps an allowed name
* from becoming a way to smuggle a denied framework past the guard.
*/
function moduleUiDependencyTarget(packageName: string, range: string): string | undefined {
if (isModuleUiPackage(packageName)) {
return packageName;
}
const aliasTarget = npmAliasTarget(range);
return isModuleUiPackage(packageName) || (aliasTarget ? isModuleUiPackage(aliasTarget) : false);
return aliasTarget && isModuleUiPackage(aliasTarget) ? aliasTarget : undefined;
}
async function isFile(filePath: string): Promise<boolean> {
@@ -694,13 +716,17 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
for (const [dependencyName, range] of Object.entries(
workspacePackage.manifest[dependencyField] ?? {},
)) {
if (
workspacePackage.location.layer === "module" &&
isModuleUiDependency(dependencyName, range)
) {
const uiDependencyTarget =
workspacePackage.location.layer === "module"
? moduleUiDependencyTarget(dependencyName, range)
: undefined;
if (uiDependencyTarget) {
violations.push({
from: workspacePackage.location.relativePath,
message: `Module package depends on UI framework ${dependencyName}.`,
message:
uiDependencyTarget === dependencyName
? `Module package depends on UI framework ${dependencyName}.`
: `Module package depends on UI framework ${uiDependencyTarget} aliased as ${dependencyName}.`,
rule: "modules-do-not-own-ui",
});
}