Merge pull request 'feat: define the module UI authoring model and host contract' (#16) from phase-6b/module-ui-contract into main
CI / verify (push) Successful in 36s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 43s

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/16
This commit was merged in pull request #16.
This commit is contained in:
The_River
2026-08-22 02:02:07 +00:00
34 changed files with 1318 additions and 83 deletions
+6 -4
View File
@@ -16,10 +16,12 @@ monorepo:
packages. They own their own data and migrations, declare capabilities/permissions through packages. They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a `@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
surface directly. Per ADR 0021 a module may also own its own UI and declare a compiled surface directly. Per ADR 0021 a module may also own its own UI and declare a compiled
`entrypoints.ui` artifact; no runtime hosts that entrypoint yet, and `pnpm boundaries` still `entrypoints.ui` artifact, authored in React per ADR 0022 against `@ekklesia/module-ui-sdk`. No
rejects UI source files and UI framework dependencies inside a module package. host mounts that entrypoint yet, and `pnpm boundaries` still rejects every non-React UI framework
- **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client and non-`.tsx` presentation source inside a module package.
contracts only, never Core or module internals directly. - **Surfaces** (future `surfaces/*`) own the containing application shell, navigation, and every
presentation concern a module does not. They consume public API/client contracts only, never Core
or module internals directly.
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and documented. Read `docs/architecture/repository-boundaries.md` and
+8 -5
View File
@@ -33,11 +33,14 @@ pnpm infrastructure:config
- Expose supported entry points through package `exports`; do not import another package's - Expose supported entry points through package `exports`; do not import another package's
private source paths. private source paths.
- Use `workspace:` ranges for internal package dependencies. - Use `workspace:` ranges for internal package dependencies.
- Module packages cannot contain React, mobile, stylesheet, or other UI source files, and - Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
`pnpm boundaries` enforces this. ADR 0021 establishes that a module may own its own UI, but the contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
authoring model — and therefore which parts of that rule change — is a later Phase 6 decision. framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
Until it lands, a module's optional `entrypoints.ui` artifact must be framework-neutral compiled `pnpm boundaries`; module styling and the module UI build pipeline are not designed yet, and no
output, and nothing hosts it yet. host mounts a UI entrypoint yet.
- Module UI reaches its host only through `@ekklesia/module-ui-sdk`, and asks the host for its own
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
private deployment detail and would break standalone hosting.
- The containing application shell, navigation, global theme, and platform policy belong to Core and - The containing application shell, navigation, global theme, and platform policy belong to Core and
the host application, never to a module. the host application, never to a module.
- Add or update tests when changing a contract or architectural rule. - Add or update tests when changing a contract or architectural rule.
+32 -7
View File
@@ -218,7 +218,7 @@ development proceeds under the current trusted-contributor, not-yet-deployed mod
and job-token authority remain unverified and are not described as isolated. See and job-token authority remain unverified and are not described as isolated. See
[Security model and deployment trust boundaries](docs/development/security.md). [Security model and deployment trust boundaries](docs/development/security.md).
Phase 6A establishes the **module product contract** and nothing else yet. Ekklesia's modules are no Phase 6A established the **module product contract**. Ekklesia's modules are no
longer backend-only capability packages: a module is an independently versioned *product* that owns longer backend-only capability packages: a module is an independently versioned *product* that owns
its domain behavior, data, permissions, routes, migrations, and — new in this pass — **its own UI**. its domain behavior, data, permissions, routes, migrations, and — new in this pass — **its own UI**.
Core keeps the containing application shell and every platform policy (authentication, authorization Core keeps the containing application shell and every platform policy (authentication, authorization
@@ -230,18 +230,43 @@ never a dependency. The long-term goal is one module implementation hosted by Ek
by a standalone application — never two codebases for one domain. See by a standalone application — never two codebases for one domain. See
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md). [ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
**What this pass actually ships is the static contract only.** `manifestVersion: 4` adds one **What that pass shipped is the static contract only.** `manifestVersion: 4` adds one
optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
during discovery exactly like `server` and `schema` — inside the package, real file, no source during discovery exactly like `server` and `schema` — inside the package, real file, no source
TypeScript. Discovery stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly TypeScript. Discovery stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly
like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a
one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was
before it. **The Dashboard shell, a UI runtime or renderer, any UI framework choice, navigation before it. **The Dashboard shell, a UI runtime or renderer, navigation
registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading, registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading,
and every feature module do not exist.** `pnpm boundaries` also still enforces and every feature module do not exist.** Restart-based discovery, explicit operator-invoked
`modules-do-not-own-ui`, so module UI cannot yet be authored with a UI framework — choosing that migrations, and the trusted compiled-module model are unchanged.
authoring model is the next pass's first question. Restart-based discovery, explicit
operator-invoked migrations, and the trusted compiled-module model are unchanged. Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is,
and what a host gives it.** Module UI is authored in **React and TypeScript**, and a module package
may now contain `.tsx` and depend on `react`/`react-dom` — every other UI framework stays rejected by
`pnpm boundaries`, and the layer rules are untouched, so module UI still cannot import Core, an app,
a surface, or another module. React is the supported *authoring* model but is deliberately **not** in
the contract: a module's compiled `entrypoints.ui` artifact default-exports
`defineModuleUi({ id, version, mount })`, and `mount(container, context)` receives a DOM element and
returns an unmount function. A host needs an element and a context, never a matching framework.
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
host context is deliberately four members — `moduleId`, `api`, `principal`, `permissions` — and
nothing else. **A module UI asks its host for its own API by relative path** (`api.request("posts")`)
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
hints are presentation only** — server-side `requirePermission` remains the sole authority — and
**module UI is trusted code in the host's own browser realm**: no iframe, no sandbox, matching the
backend trust model rather than pretending to improve on it. See
[ADR 0022](docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md).
**This is a contract, not a running feature.** No Dashboard mounts a module UI, no module ships one,
and the pipeline that compiles a module's `.tsx` into the compiled artifact is not designed —
`.css` and bundler plugins stay rejected for that reason. Core still never loads browser code:
discovery validates the `entrypoints.ui` path and never evaluates it, and hosts do their own dynamic
import.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary **Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
@@ -205,6 +205,15 @@ one that does not, and an operator installing either is deploying trusted code.
## Consequences ## 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 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 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 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 - [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 - [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 - [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, 1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle. configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
2. **Modules** own domain behavior, data schemas, resources, actions, routes, events, permissions, 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 is an independently versioned product, not only a backend capability. It does not own the
containing application shell or any platform policy. containing application shell or any platform policy.
3. **Surfaces** own the presentation concerns a module does not: the containing application shell, 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 does and does not protect against, and the repository root
[README](../../README.md#current-status) for the current status of every phase. [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: [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 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 — 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 every platform policy, the module graph stays flat, and Dashboard grouping stays presentation
configuration. configuration.
What this pass actually implements is the **static contract only**: `manifestVersion: 4` adds one What that pass implemented is the **static contract only**: `manifestVersion: 4` adds one optional
optional `entrypoints.ui` field, validated during discovery exactly like the server and schema `entrypoints.ui` field, validated during discovery exactly like the server and schema entrypoints —
entrypoints — package-relative, inside the package, compiled ESM. Discovery remains inert; nothing package-relative, inside the package, compiled ESM. Discovery remains inert; nothing imports, mounts,
imports, mounts, or renders that entrypoint. The Dashboard shell, the UI runtime, the standalone or renders that entrypoint.
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 **Phase 6B pass 1 defines the module UI authoring model and host contract.**
migrations, and the trusted compiled-module model are all unchanged. [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 Target architecture not yet implemented includes production approval-provider
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource 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 | | `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 | | `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 | | `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 | | `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 | | `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. - 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 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, - A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
frameworks or shared UI-role packages. **This rule is still enforced, but it is no longer the module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
architectural direction.** [ADR 0021](decisions/0021-modules-as-hostable-products.md) establishes `@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
that a module may own its own UI and declare a compiled `entrypoints.ui` artifact. The deny list Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
above stays in force until a later Phase 6 pass chooses the module UI authoring model and decides (including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
what replaces it; until then a module UI must be a framework-neutral compiled artifact. Core still packages. An unsupported framework aliased behind an allowed name is resolved and rejected by its
owns the containing application shell and all platform policy. 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. - A surface never imports Core or a feature module. It uses public API/client contracts.
- Separate surfaces do not import each other's internals. - Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or - 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 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 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 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 review remains required when adopting a new frontend technology. ADR 0022 narrowed the guard
an explicit prerequisite of the module UI authoring pass (ADR 0021). 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 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 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. 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 [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 own UI entrypoint. **That changes nothing about the authority described above.** A module that ships
trusted as one that does not: its server entrypoint still runs in the API process with the authority UI is exactly as trusted as one that does not: its server entrypoint still runs in the API process
described above, and declaring `entrypoints.ui` neither adds nor implies any isolation. No host with that authority, and declaring `entrypoints.ui` neither adds nor implies any isolation.
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. ### 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 ## 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 discovery — the platform never imports source TypeScript, and a UI artifact is a build output like
any other. 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 ### 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 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 the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or renders a UI
renders a UI entrypoint** — a UI artifact that throws on evaluation still discovers successfully, entrypoint** — a UI artifact that throws on evaluation still discovers successfully, because it is
because it is never evaluated. The field follows the same established pattern as `entrypoints.jobs`, never evaluated. Hosts perform their own dynamic import and then validate the result with
which has been declared and validated since ADR 0006 with no runtime consumer. `parseModuleUiExport`; Core never loads browser code.
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.
### What a module UI will never own ### 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, Authentication and session establishment, authorization enforcement, organization/platform context,
the containing application shell, top-level routing and mount boundaries, the platform navigation 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 container, the global theme and design-token contract, platform error boundaries, and
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@ekklesia/module-ui-sdk",
"version": "0.1.0",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-sdk",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "module-ui-sdk"
}
}
@@ -0,0 +1,12 @@
import { type ModuleUiDefinition, parseModuleUiDefinition } from "./module-ui.js";
/**
* The module author's entry point, mirroring `defineModuleRuntime` (ADR 0006): validate at authoring
* time, freeze, and preserve the literal type. A malformed definition fails where it was written
* rather than in a host, at mount time, in a browser.
*/
export function defineModuleUi<const Definition extends ModuleUiDefinition>(
definition: Definition,
): Readonly<Definition> {
return parseModuleUiDefinition(definition) as Readonly<Definition>;
}
@@ -0,0 +1,75 @@
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
/**
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
*
* This is deliberately the smallest set that proves the architecture rather than everything a real
* screen will eventually want. Navigation, host theme and design tokens, module configuration,
* organization/platform context, and diagnostics reporting are **not** here yet: each needs a design
* that does not exist, and a speculative field frozen into a public contract is harder to remove
* than to add. See ADR 0022.
*
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
* host that never runs Core, which is what makes one module UI hostable by both.
*/
export interface ModuleUiHostContext {
/** The module this UI belongs to, as the host knows it. Supplied so a module never has to hardcode
* or re-derive its own identity from a URL. */
readonly moduleId: string;
/** The only supported way for module UI to reach its own backend. See {@link ModuleUiApiClient}. */
readonly api: ModuleUiApiClient;
/** Who is viewing, for presentation decisions only. Reuses Core's own principal type so a host and
* a module UI cannot disagree about its shape. */
readonly principal: EkklesiaPrincipal;
/** Presentation-only permission hints. See {@link ModuleUiPermissions}. */
readonly permissions: ModuleUiPermissions;
}
/**
* A module UI's access to its own module's HTTP API, addressed **relative to the module's own API
* root** — never as an absolute path.
*
* This is the single most important portability rule in this contract. 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 a different origin entirely, or through 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.
*
* So the module asks for `posts`, and the host resolves it. `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.
*
* A host is responsible for credentials, base URL, and any platform headers. A module UI neither
* supplies nor sees a session token.
*/
export interface ModuleUiApiClient {
/**
* @param path A path relative to this module's own API root, with or without a leading `/`.
* Must not be an absolute URL: a host is free to reject one rather than silently allowing a
* module UI to address something other than its own backend.
*/
request(path: string, init?: RequestInit): Promise<Response>;
}
/**
* Presentation-only permission hints.
*
* **This is not a security boundary and must never be used as one.** A module UI may use it to hide
* an action, disable a control, or choose which empty state to render. 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 is unchanged by, and unaware of, anything a browser believes.
*
* A module UI that "checks" a permission here and then calls its API is still fully authorized
* server-side. A module UI that skips the check and calls anyway is still denied server-side. The
* value of this interface is that the user does not see a button that would fail.
*
* `permissionId` is a plain `string`, deliberately, and not the branded `PermissionId` Core uses:
* branding would require a module UI to run Zod parsing in the browser to produce a value for a
* call that decides nothing security-relevant. The branded type remains the server-side type.
*/
export interface ModuleUiPermissions {
/** Whether the host believes the current principal holds this permission. A hint, not a grant. */
has(permissionId: string): boolean;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./define-module-ui.js";
export * from "./host-context.js";
export * from "./module-ui.js";
+133
View File
@@ -0,0 +1,133 @@
import type { ModuleUiHostContext } from "./host-context.js";
export type Awaitable<Value> = Value | PromiseLike<Value>;
/**
* Released by a host when the module UI should be torn down. A module that acquires anything during
* `mount` — a framework root, a subscription, a timer, an event listener — must return one of these
* and release that work here, exactly as a backend module releases resources in its `stop` hook
* (ADR 0006). Returning nothing declares that the mount acquired nothing to release.
*/
export type ModuleUiUnmount = () => Awaitable<void>;
/**
* What a module's compiled `entrypoints.ui` artifact default-exports (ADR 0021, ADR 0022).
*
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
* thinks it is, and catch a stale artifact. They do not duplicate anything else from the manifest.
*
* `mount` is intentionally the whole behavioral contract. The host supplies a container element and
* the host context; what happens inside is the module's business. React is the supported way to
* write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function,
* so a module's internal framework choice, and a host's, can each change without renegotiating the
* platform boundary.
*/
export interface ModuleUiDefinition {
readonly id: string;
readonly version: string;
readonly mount: (
container: Element,
context: ModuleUiHostContext,
) => Awaitable<ModuleUiUnmount | void>;
}
export type ModuleUiContractErrorCode =
| "MODULE_UI_EXPORT_MISSING"
| "MODULE_UI_DEFINITION_INVALID"
| "MODULE_UI_IDENTITY_MISMATCH";
const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({
MODULE_UI_EXPORT_MISSING:
"A module UI entrypoint must have a default export produced by defineModuleUi.",
MODULE_UI_DEFINITION_INVALID:
"A module UI definition must declare a non-empty id and version and a mount function, and nothing else.",
MODULE_UI_IDENTITY_MISMATCH:
"The module UI artifact's id and version do not match the module it was loaded for.",
});
/**
* Carries a stable code and a fixed message, never the offending value, the artifact URL, a
* filesystem path, or a caught cause — matching the sanitization rule every other Ekklesia error
* boundary follows.
*/
export class ModuleUiContractError extends Error {
readonly code: ModuleUiContractErrorCode;
constructor(code: ModuleUiContractErrorCode) {
super(errorMessages[code]);
this.name = "ModuleUiContractError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
const definitionKeys = new Set(["id", "mount", "version"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Structural check only. The identity a definition claims is verified against the manifest by
* {@link parseModuleUiExport}, so this never needs a copy of Core's module-id or semantic-version
* grammar — there is exactly one place each of those is defined, and it is not here.
*/
export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition {
if (!isRecord(value)) {
return false;
}
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
return false;
}
return (
isNonEmptyString(value.id) &&
isNonEmptyString(value.version) &&
typeof value.mount === "function"
);
}
export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition {
if (!isModuleUiDefinition(value)) {
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
}
return Object.freeze({
id: value.id,
mount: value.mount,
version: value.version,
});
}
/** The identity a host already knows from the module's validated manifest. */
export interface ModuleUiIdentity {
readonly id: string;
readonly version: string;
}
/**
* The one entry point a host uses after importing a module's UI artifact.
*
* Deliberately does **not** import anything itself. 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 module
* namespace here. That keeps this package free of any loader, bundler, or URL assumption, and keeps
* Core out of the business of evaluating browser code entirely: module discovery still only
* resolves and validates the `entrypoints.ui` path and never executes it (ADR 0021).
*/
export function parseModuleUiExport(
namespace: unknown,
expected: ModuleUiIdentity,
): ModuleUiDefinition {
if (!isRecord(namespace) || !("default" in namespace)) {
throw new ModuleUiContractError("MODULE_UI_EXPORT_MISSING");
}
const definition = parseModuleUiDefinition(namespace.default);
if (definition.id !== expected.id || definition.version !== expected.version) {
throw new ModuleUiContractError("MODULE_UI_IDENTITY_MISMATCH");
}
return definition;
}
@@ -0,0 +1,65 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const sourceDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "src");
async function sourceFiles(): Promise<string[]> {
const entries = await readdir(sourceDirectory);
return entries.filter((entry) => entry.endsWith(".ts")).sort();
}
/**
* This package is bundled into a browser. `@ekklesia/module-sdk` re-exports all of
* `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel export, so a single
* value import from a backend package here would drag that whole graph into every module UI bundle
* (ADR 0022). The dependency on `@ekklesia/contracts` is deliberately type-only and must stay that
* way; `import type` is erased at build time, a plain `import` is not.
*/
describe("module UI SDK stays browser-safe", () => {
it("imports nothing from another workspace package at runtime", async () => {
for (const file of await sourceFiles()) {
const source = await readFile(path.join(sourceDirectory, file), "utf8");
const workspaceImports = [
...source.matchAll(/^import\s+(.*?)\s+from\s+"(@ekklesia\/[^"]+)"/gmu),
];
for (const [, clause, specifier] of workspaceImports) {
expect(
clause?.startsWith("type "),
`${file} imports ${specifier} at runtime; it must be a type-only import`,
).toBe(true);
}
}
});
it("declares no runtime dependency other than the type-only contracts package", async () => {
const manifestPath = path.join(sourceDirectory, "..", "package.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
dependencies?: Record<string, string>;
};
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/contracts"]);
});
it("does not depend on a UI framework, so the contract stays framework-neutral", async () => {
const manifestPath = path.join(sourceDirectory, "..", "package.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const declared = [
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.devDependencies ?? {}),
...Object.keys(manifest.peerDependencies ?? {}),
];
// React is the supported way to *author* module UI. It is deliberately absent from the host
// contract, so a host never has to match a module's rendering library.
for (const framework of ["react", "react-dom", "vue", "svelte", "preact"]) {
expect(declared).not.toContain(framework);
}
});
});
@@ -0,0 +1,47 @@
import { defineModuleUi } from "../../src/index.js";
import type { ModuleUiHostContext } from "../../src/index.js";
/**
* A stand-in for a real module's compiled `entrypoints.ui` artifact. It is deliberately tiny and
* deliberately host-neutral: it never constructs an API URL, never imports Core, an app, or a
* surface, and never reads a global. Everything it needs arrives in the host context.
*
* This is the artifact both fake hosts in `module-ui-contract.test.ts` mount, unchanged.
*/
export interface AnnouncementsUiTrace {
readonly mounted: string[];
readonly unmounted: string[];
readonly requestedPaths: string[];
readonly canPublish: boolean[];
}
export const trace: AnnouncementsUiTrace = {
mounted: [],
unmounted: [],
requestedPaths: [],
canPublish: [],
};
export default defineModuleUi({
id: "announcements",
version: "0.1.0",
async mount(container: Element, context: ModuleUiHostContext) {
trace.mounted.push(context.moduleId);
// Presentation-only gating: the module asks the host whether to render the publish control.
// Whatever the answer, the server decides what actually happens.
const canPublish = context.permissions.has("announcements.posts.publish");
trace.canPublish.push(canPublish);
// The whole portability point: the module asks for its own resource by relative path and has
// no idea what URL the host turns it into.
const response = await context.api.request("posts");
trace.requestedPaths.push("posts");
container.textContent = `announcements:${response.status}:${canPublish ? "editor" : "reader"}`;
return () => {
trace.unmounted.push(context.moduleId);
container.textContent = "";
};
},
});
@@ -0,0 +1,239 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
defineModuleUi,
isModuleUiDefinition,
ModuleUiContractError,
type ModuleUiApiClient,
type ModuleUiDefinition,
type ModuleUiHostContext,
parseModuleUiDefinition,
parseModuleUiExport,
} from "../src/index.js";
function validDefinition(): ModuleUiDefinition {
return { id: "announcements", version: "0.1.0", mount: (): void => undefined };
}
/** The contract treats the container as an opaque host-supplied mount target, so a plain object
* with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the
* contract, and this package deliberately does not take a DOM dependency to obtain one. */
const noop = (): void => undefined;
function fakeContainer(): Element & { textContent: string } {
return { textContent: "" } as unknown as Element & { textContent: string };
}
describe("module UI definition contract", () => {
it("accepts and freezes a valid definition", () => {
const definition = defineModuleUi(validDefinition());
expect(definition.id).toBe("announcements");
expect(definition.version).toBe("0.1.0");
expect(Object.isFrozen(definition)).toBe(true);
});
it.each<[string, unknown]>([
["a missing id", { version: "0.1.0", mount: noop }],
["a blank id", { id: " ", version: "0.1.0", mount: noop }],
["a non-string id", { id: 7, version: "0.1.0", mount: noop }],
["a missing version", { id: "announcements", mount: noop }],
["a blank version", { id: "announcements", version: "", mount: noop }],
["a missing mount", { id: "announcements", version: "0.1.0" }],
["a non-function mount", { id: "announcements", version: "0.1.0", mount: "render" }],
["an unknown property", { id: "announcements", version: "0.1.0", mount: noop, routes: [] }],
])("rejects a definition with %s", (_label, candidate) => {
expect(isModuleUiDefinition(candidate)).toBe(false);
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
});
it.each<unknown>([
null,
undefined,
42,
"ui",
[],
noop,
])("rejects a non-object definition %s", (candidate) => {
expect(isModuleUiDefinition(candidate)).toBe(false);
});
it("reports a malformed definition with a stable, sanitized code", () => {
try {
parseModuleUiDefinition({ id: "announcements" });
expect.unreachable("expected a contract error");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiContractError);
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_DEFINITION_INVALID");
expect((error as ModuleUiContractError).stack).not.toContain("announcements");
}
});
it("rejects an artifact namespace with no default export", () => {
expect(() =>
parseModuleUiExport({ ui: validDefinition() }, { id: "a", version: "1.0.0" }),
).toThrowError(ModuleUiContractError);
try {
parseModuleUiExport({}, { id: "a", version: "1.0.0" });
} catch (error) {
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_EXPORT_MISSING");
}
});
it.each([
["id", { id: "calendar", version: "0.1.0" }],
["version", { id: "announcements", version: "0.2.0" }],
])("rejects an artifact whose %s does not match the manifest", (_label, expected) => {
try {
parseModuleUiExport({ default: defineModuleUi(validDefinition()) }, expected);
expect.unreachable("expected an identity mismatch");
} catch (error) {
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_IDENTITY_MISMATCH");
}
});
it("accepts an artifact whose identity matches the manifest exactly", () => {
const definition = parseModuleUiExport(
{ default: defineModuleUi(validDefinition()) },
{ id: "announcements", version: "0.1.0" },
);
expect(definition.id).toBe("announcements");
});
});
/**
* The portability proof. One artifact, imported once, mounted by two hosts that share nothing but
* this package's public interface. If module UI ever hardcoded Ekklesia's `/api/v1/modules/<id>`
* prefix, the standalone host below could not serve it.
*/
describe("the same module UI artifact hosted by two different hosts", () => {
interface RecordingHost {
readonly name: string;
readonly requestedUrls: string[];
readonly context: ModuleUiHostContext;
}
/** Mirrors how Ekklesia mounts module routes today: `/api/v1/modules/<module-id>/<path>`. */
function ekklesiaHost(moduleId: string, canPublish: boolean): RecordingHost {
const requestedUrls: string[] = [];
const api: ModuleUiApiClient = {
async request(path) {
const url = `/api/v1/modules/${moduleId}/${path.replace(/^\//u, "")}`;
requestedUrls.push(url);
return new Response(null, { status: 200 });
},
};
return {
name: "ekklesia",
requestedUrls,
context: {
moduleId,
api,
principal: { kind: "anonymous" },
permissions: { has: () => canPublish },
},
};
}
/** A standalone host that serves exactly one module from a different origin and a different
* path shape, and never runs Core at all. */
function standaloneHost(moduleId: string, canPublish: boolean): RecordingHost {
const requestedUrls: string[] = [];
const api: ModuleUiApiClient = {
async request(path) {
const url = `https://announcements.example.org/api/${path.replace(/^\//u, "")}`;
requestedUrls.push(url);
return new Response(null, { status: 200 });
},
};
return {
name: "standalone",
requestedUrls,
context: {
moduleId,
api,
principal: { kind: "anonymous" },
permissions: { has: () => canPublish },
},
};
}
async function loadFixtureUi(): Promise<ModuleUiDefinition> {
// Each host performs its own dynamic import; this package never imports an artifact itself.
const namespace = (await import("./fixtures/announcements-ui.js")) as unknown;
return parseModuleUiExport(namespace, { id: "announcements", version: "0.1.0" });
}
beforeEach(async () => {
const { trace } = await import("./fixtures/announcements-ui.js");
trace.mounted.length = 0;
trace.unmounted.length = 0;
trace.requestedPaths.length = 0;
trace.canPublish.length = 0;
});
it("validates a real imported artifact's export against its manifest identity", async () => {
const definition = await loadFixtureUi();
expect(definition.id).toBe("announcements");
expect(definition.version).toBe("0.1.0");
expect(typeof definition.mount).toBe("function");
});
it("mounts and unmounts identically under both hosts, and resolves URLs only in the host", async () => {
const definition = await loadFixtureUi();
const { trace } = await import("./fixtures/announcements-ui.js");
for (const host of [
ekklesiaHost("announcements", true),
standaloneHost("announcements", true),
]) {
const container = fakeContainer();
const unmount = await definition.mount(container, host.context);
expect(container.textContent).toBe("announcements:200:editor");
expect(typeof unmount).toBe("function");
await (unmount as () => Promise<void>)();
expect(container.textContent).toBe("");
}
// The module asked for the same relative path both times and never built a URL itself.
expect(trace.requestedPaths).toEqual(["posts", "posts"]);
expect(trace.mounted).toEqual(["announcements", "announcements"]);
expect(trace.unmounted).toEqual(["announcements", "announcements"]);
});
it("lets each host resolve the module's relative path to its own URL shape", async () => {
const definition = await loadFixtureUi();
const ekklesia = ekklesiaHost("announcements", true);
const standalone = standaloneHost("announcements", true);
await definition.mount(fakeContainer(), ekklesia.context);
await definition.mount(fakeContainer(), standalone.context);
expect(ekklesia.requestedUrls).toEqual(["/api/v1/modules/announcements/posts"]);
expect(standalone.requestedUrls).toEqual(["https://announcements.example.org/api/posts"]);
});
it("renders a different presentation when the host reports a permission is absent", async () => {
const definition = await loadFixtureUi();
const container = fakeContainer();
await definition.mount(container, ekklesiaHost("announcements", false).context);
expect(container.textContent).toBe("announcements:200:reader");
});
it("treats a denied permission hint as presentation only, never as enforcement", async () => {
// The host reports the permission as absent. The module still reaches its API — nothing in this
// contract blocks the request, and nothing in it could. Server-side `requirePermission` remains
// the only authority, exactly as it is for any other caller.
const definition = await loadFixtureUi();
const host = ekklesiaHost("announcements", false);
await definition.mount(fakeContainer(), host.context);
expect(host.context.permissions.has("announcements.posts.publish")).toBe(false);
expect(host.requestedUrls).toEqual(["/api/v1/modules/announcements/posts"]);
});
});
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+16
View File
@@ -161,6 +161,22 @@ importers:
specifier: 4.1.10 specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])) version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-ui-sdk:
dependencies:
'@ekklesia/contracts':
specifier: workspace:*
version: link:../contracts
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/typescript-config: {} packages/typescript-config: {}
templates/module: templates/module:
+39 -13
View File
@@ -47,6 +47,11 @@ export interface BoundaryResult {
} }
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]); 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([ const MODULE_UI_EXTENSIONS = new Set([
".astro", ".astro",
".css", ".css",
@@ -64,9 +69,14 @@ const MODULE_UI_EXTENSIONS = new Set([
".stylus", ".stylus",
".svelte", ".svelte",
".svg", ".svg",
".tsx",
".vue", ".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([ const MODULE_UI_PACKAGES = new Set([
"@angular/core", "@angular/core",
"@builder.io/qwik", "@builder.io/qwik",
@@ -91,8 +101,6 @@ const MODULE_UI_PACKAGES = new Set([
"nuxt", "nuxt",
"preact", "preact",
"qwik", "qwik",
"react",
"react-dom",
"react-native", "react-native",
"react-router-dom", "react-router-dom",
"remix", "remix",
@@ -144,6 +152,7 @@ const SHARED_PACKAGE_ROLES = new Set([
"contracts", "contracts",
"design-tokens", "design-tokens",
"module-sdk", "module-sdk",
"module-ui-sdk",
"shared", "shared",
"surface-sdk", "surface-sdk",
"testing", "testing",
@@ -212,7 +221,7 @@ function boundaryRule(from: Location, to: Location): string | undefined {
} }
if ( if (
to.layer === "shared" && 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"; return "modules-use-public-platform-contracts";
} }
@@ -245,7 +254,7 @@ function boundaryRule(from: Location, to: Location): string | undefined {
if ( if (
from.layer === "shared" && 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) ["app", "builder", "core", "module", "surface"].includes(to.layer)
) { ) {
return "sdks-do-not-depend-on-implementations"; 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") { if (from.role === "contracts" && to.role !== "contracts") {
return "contracts-do-not-depend-upward"; 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"; return "sdks-do-not-depend-on-implementations";
} }
if (from.role === "builder-sdk" && !["contracts", "surface-sdk"].includes(to.role ?? "")) { 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]; 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); const aliasTarget = npmAliasTarget(range);
return isModuleUiPackage(packageName) || (aliasTarget ? isModuleUiPackage(aliasTarget) : false); return aliasTarget && isModuleUiPackage(aliasTarget) ? aliasTarget : undefined;
} }
async function isFile(filePath: string): Promise<boolean> { 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( for (const [dependencyName, range] of Object.entries(
workspacePackage.manifest[dependencyField] ?? {}, workspacePackage.manifest[dependencyField] ?? {},
)) { )) {
if ( const uiDependencyTarget =
workspacePackage.location.layer === "module" && workspacePackage.location.layer === "module"
isModuleUiDependency(dependencyName, range) ? moduleUiDependencyTarget(dependencyName, range)
) { : undefined;
if (uiDependencyTarget) {
violations.push({ violations.push({
from: workspacePackage.location.relativePath, 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", rule: "modules-do-not-own-ui",
}); });
} }
+64 -8
View File
@@ -47,6 +47,8 @@ describe("repository dependency boundaries", () => {
["package-cycle", "no-circular-package-dependencies"], ["package-cycle", "no-circular-package-dependencies"],
["module-has-ui", "modules-do-not-own-ui"], ["module-has-ui", "modules-do-not-own-ui"],
["module-ui-framework", "modules-do-not-own-ui"], ["module-ui-framework", "modules-do-not-own-ui"],
["module-ui-aliased-framework", "modules-do-not-own-ui"],
["module-ui-imports-core", "modules-use-public-platform-contracts"],
["module-imports-ui-package", "modules-use-public-platform-contracts"], ["module-imports-ui-package", "modules-use-public-platform-contracts"],
["cross-package-relative", "workspace-imports-use-public-exports"], ["cross-package-relative", "workspace-imports-use-public-exports"],
["cycle", "no-circular-source-dependencies"], ["cycle", "no-circular-source-dependencies"],
@@ -55,18 +57,72 @@ describe("repository dependency boundaries", () => {
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule); expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
}); });
it("rejects component, stylesheet, and case-insensitive view files inside a module", async () => { it("rejects stylesheet and case-insensitive view files inside a module, but not TSX", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-has-ui")); const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
expect( const uiViolations = result.violations.filter(
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"), (violation) => violation.rule === "modules-do-not-own-ui",
).toHaveLength(3); );
expect(uiViolations).toHaveLength(2);
expect(uiViolations.map((violation) => violation.from)).toEqual(
expect.arrayContaining([
"modules/calendar/src/calendar.css",
"modules/calendar/src/view.ASTRO",
]),
);
// React is the supported module UI authoring model (ADR 0022), so a module's own `.tsx` is
// legitimate source and must no longer be reported.
expect(uiViolations.map((violation) => violation.from)).not.toContain(
"modules/calendar/src/calendar.tsx",
);
}); });
it("rejects UI framework dependencies, imports, and view formats inside a module", async () => { it("rejects unsupported UI frameworks, their imports, and view formats inside a module", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-ui-framework")); const result = await checkBoundaries(path.join(fixtures, "module-ui-framework"));
expect( const uiViolations = result.violations.filter(
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"), (violation) => violation.rule === "modules-do-not-own-ui",
).toHaveLength(4); );
expect(uiViolations).toHaveLength(3);
expect(uiViolations.map((violation) => violation.message)).toEqual(
expect.arrayContaining([
"Module package depends on UI framework preact.",
"Module source imports UI framework preact.",
]),
);
// The fixture also aliases `compat` to react, which is now the supported framework and is
// therefore no longer a violation.
expect(uiViolations.map((violation) => violation.message).join(" ")).not.toContain("compat");
});
it("still rejects module UI source that reaches into Core internals", async () => {
// Allowing `.tsx` inside a module changed which *formats* a module may contain. It did not
// change which layers a module may import: module UI is subject to the identical layer rules
// as module backend source.
const result = await checkBoundaries(path.join(fixtures, "module-ui-imports-core"));
expect(result.violations).toEqual([
{
from: "modules/announcements/src/ui/editor.tsx",
message: "module code may not import core code directly.",
rule: "modules-use-public-platform-contracts",
to: "core/src/index.ts",
},
]);
});
it("accepts a module that owns React UI through the public module UI SDK", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-owns-react-ui"));
expect(result.violations).toEqual([]);
});
it("rejects an unsupported framework aliased behind the supported one", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-ui-aliased-framework"));
expect(result.violations.map((violation) => violation.message)).toEqual([
"Module package depends on UI framework vue aliased as react.",
]);
}); });
it("requires workspace: ranges in every dependency field", async () => { it("requires workspace: ranges in every dependency field", async () => {
@@ -0,0 +1,16 @@
{
"name": "@ekklesia/module-announcements",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@ekklesia/module-ui-sdk": "workspace:*",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module"
}
}
@@ -0,0 +1 @@
export const moduleId = "announcements";
@@ -0,0 +1,12 @@
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import { createRoot } from "react-dom/client";
export default defineModuleUi({
id: "announcements",
version: "0.0.0",
mount(container, context) {
const root = createRoot(container);
root.render(context.moduleId);
return () => root.unmount();
},
});
@@ -0,0 +1,11 @@
{
"name": "@ekklesia/module-ui-sdk",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module-ui-sdk"
}
}
@@ -0,0 +1,3 @@
export function defineModuleUi<Definition>(definition: Definition): Definition {
return definition;
}
@@ -0,0 +1,14 @@
{
"name": "@ekklesia/module-calendar",
"version": "0.0.0",
"type": "module",
"dependencies": {
"react": "npm:[email protected]"
},
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module"
}
}
@@ -0,0 +1 @@
export const moduleId = "calendar";
@@ -0,0 +1 @@
export const corePool = "core-internal-pool";
@@ -0,0 +1,11 @@
{
"name": "@ekklesia/module-announcements",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module"
}
}
@@ -0,0 +1 @@
export const moduleId = "announcements";
@@ -0,0 +1,3 @@
import { corePool } from "../../../../core/src/index.js";
export const leakedCoreInternal = corePool;