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
+39
-13
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user