Merge pull request 'feat: define the module UI authoring model and host contract' (#16) from phase-6b/module-ui-contract into main
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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./define-module-ui.js";
|
||||||
|
export * from "./host-context.js";
|
||||||
|
export * from "./module-ui.js";
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../typescript-config/browser.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
Generated
+16
@@ -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
@@ -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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const moduleId = "announcements";
|
||||||
+12
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-ui-sdk",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module-ui-sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export function defineModuleUi<Definition>(definition: Definition): Definition {
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
+14
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const moduleId = "calendar";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const corePool = "core-internal-pool";
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-announcements",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const moduleId = "announcements";
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { corePool } from "../../../../core/src/index.js";
|
||||||
|
|
||||||
|
export const leakedCoreInternal = corePool;
|
||||||
Reference in New Issue
Block a user