feat: define the module UI authoring model and host contract #16
@@ -16,10 +16,12 @@ monorepo:
|
||||
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
|
||||
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
|
||||
rejects UI source files and UI framework dependencies inside a module package.
|
||||
- **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client
|
||||
contracts only, never Core or module internals directly.
|
||||
`entrypoints.ui` artifact, authored in React per ADR 0022 against `@ekklesia/module-ui-sdk`. No
|
||||
host mounts that entrypoint yet, and `pnpm boundaries` still rejects every non-React UI framework
|
||||
and non-`.tsx` presentation source inside a module package.
|
||||
- **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
|
||||
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
|
||||
private source paths.
|
||||
- Use `workspace:` ranges for internal package dependencies.
|
||||
- Module packages cannot contain React, mobile, stylesheet, or other UI source files, and
|
||||
`pnpm boundaries` enforces this. ADR 0021 establishes that a module may own its own UI, but the
|
||||
authoring model — and therefore which parts of that rule change — is a later Phase 6 decision.
|
||||
Until it lands, a module's optional `entrypoints.ui` artifact must be framework-neutral compiled
|
||||
output, and nothing hosts it yet.
|
||||
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
||||
`pnpm boundaries`; module styling and the module UI build pipeline are not designed yet, and no
|
||||
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 host application, never to a module.
|
||||
- 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
|
||||
[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
|
||||
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
|
||||
@@ -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
|
||||
[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
|
||||
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
|
||||
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
|
||||
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,
|
||||
and every feature module do not exist.** `pnpm boundaries` also still enforces
|
||||
`modules-do-not-own-ui`, so module UI cannot yet be authored with a UI framework — choosing that
|
||||
authoring model is the next pass's first question. Restart-based discovery, explicit
|
||||
operator-invoked migrations, and the trusted compiled-module model are unchanged.
|
||||
and every feature module do not exist.** 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
|
||||
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
|
||||
|
||||
> **Cross-reference — see [ADR 0022](0022-module-ui-authoring-and-host-contract.md).**
|
||||
> The open question this section records — the module UI authoring model, and which parts of the
|
||||
> `modules-do-not-own-ui` guard change — is now answered. ADR 0022 selects React as the supported
|
||||
> authoring model, narrows the guard to allow `.tsx` plus `react`/`react-dom` while keeping every
|
||||
> other framework rejected, and defines what `entrypoints.ui` exports and what a host provides.
|
||||
> Everything else in this ADR remains accepted and unchanged: `entrypoints.ui` is still statically
|
||||
> validated and never executed by discovery, and Core still never loads browser code.
|
||||
|
||||
|
||||
A module author can now express the complete product they own, and the platform has one contract for
|
||||
discovering that a module offers presentation at all. Backend-only modules are unaffected beyond a
|
||||
one-character manifest version bump. The dependency graph stays flat and meaningful, and Dashboard
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# 0022: Module UI authoring model and host contract
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-21
|
||||
- Extends: [0021](0021-modules-as-hostable-products.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0021 established that a module is a product which may own its UI, and added `entrypoints.ui` —
|
||||
a statically validated, never-executed path to a compiled artifact. It deliberately answered none of
|
||||
the questions that make that field usable, and recorded the gap explicitly: the authoring model was
|
||||
"the first question the next pass must answer," and `pnpm boundaries` still rejected UI source and UI
|
||||
framework dependencies inside a module.
|
||||
|
||||
Five questions block every later Phase 6 pass:
|
||||
|
||||
1. What does a UI artifact export?
|
||||
2. What does a host give it?
|
||||
3. How does one artifact run under Ekklesia *and* a standalone host?
|
||||
4. What is the browser-side trust model?
|
||||
5. How does module UI avoid being welded to a Dashboard that does not exist?
|
||||
|
||||
The repository already leans one way on the first. `packages/typescript-config` ships a `browser.json`
|
||||
preset (DOM libs, JSX) and a `react-native.json` preset (`"jsx": "react-jsx"`), and ADR 0002 names
|
||||
"separate Node, browser, and React Native base configurations." No frontend framework is installed
|
||||
anywhere — the only React or Preact in the tree is inside boundary fixtures that exist to prove the
|
||||
guard rejects them.
|
||||
|
||||
## Decision
|
||||
|
||||
### React is the supported module UI authoring model
|
||||
|
||||
A module author writes module UI in React and TypeScript (`.tsx`). This is a real choice, not a
|
||||
neutrality dodge: the repository's own TypeScript presets already anticipate React, Ekklesia's future
|
||||
first-party UI will be React, and pretending otherwise would push every module author into an
|
||||
abstraction layer that serves nobody. One framework is supported. Vue, Svelte, Angular, Preact,
|
||||
Solid, Qwik, Lit, Astro, Marko, and React Native remain unsupported and remain rejected by
|
||||
`pnpm boundaries`.
|
||||
|
||||
**React is the supported authoring model. It is not part of the host contract.** That distinction is
|
||||
the whole design, and the next section is where it is enforced.
|
||||
|
||||
### The host contract is a mount function, not a component
|
||||
|
||||
A module's compiled `entrypoints.ui` artifact default-exports a `ModuleUiDefinition`
|
||||
(`packages/module-ui-sdk`):
|
||||
|
||||
```ts
|
||||
export default defineModuleUi({
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<Editor context={context} />);
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition` exactly
|
||||
(ADR 0006): a host proves the bundle it imported was built from the module it thinks it is, and a
|
||||
stale artifact is caught rather than mounted. They duplicate nothing else from the manifest.
|
||||
|
||||
`mount` receives a container `Element` and the host context, and returns an unmount function or
|
||||
nothing. A module that acquires anything — a React root, a subscription, a timer, a listener —
|
||||
returns an unmount and releases it there, exactly as a backend module releases resources in `stop`.
|
||||
|
||||
React appears nowhere in that contract. A host needs an element and a context; it does not need to
|
||||
know, share, or match the module's rendering library. That keeps three things independent that would
|
||||
otherwise be welded together: the module's framework, the host's framework, and the platform
|
||||
boundary between them. React can be the supported way to *write* module UI while the *contract*
|
||||
outlives any particular version of it.
|
||||
|
||||
### `@ekklesia/module-ui-sdk` is a separate, browser-safe package
|
||||
|
||||
The contract lives in a new shared package with role `module-ui-sdk`, not in `@ekklesia/contracts` or
|
||||
`@ekklesia/module-sdk`. The reason is concrete rather than organizational: `@ekklesia/module-sdk`
|
||||
re-exports all of `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel
|
||||
export. Routing module UI through it would drag a backend dependency graph into a browser bundle.
|
||||
|
||||
The package therefore has no runtime dependencies. Its one dependency on `@ekklesia/contracts` is
|
||||
type-only (`import type { EkklesiaPrincipal }`), erased at build time, so a host and a module UI
|
||||
provably share Core's principal type without shipping any of Core's runtime.
|
||||
|
||||
Both hosts consume this same package. It is not a Dashboard framework and must not become one: no
|
||||
components, no layout, no design system, no router.
|
||||
|
||||
### The host context is deliberately four members
|
||||
|
||||
```ts
|
||||
interface ModuleUiHostContext {
|
||||
readonly moduleId: string;
|
||||
readonly api: ModuleUiApiClient;
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
readonly permissions: ModuleUiPermissions;
|
||||
}
|
||||
```
|
||||
|
||||
This is the smallest set that proves the architecture, not the set a finished screen will want.
|
||||
Navigation, host theme and design tokens, module configuration, organization/platform context, and
|
||||
diagnostics reporting are **not** included. Each needs a design that does not exist, and a
|
||||
speculative field frozen into a public contract costs more to remove than to add later. A host passes
|
||||
this object and nothing else; there is no platform singleton, no global, and no escape hatch to Core.
|
||||
|
||||
### Module UI addresses its own API relatively, never by URL
|
||||
|
||||
`ModuleUiApiClient.request(path, init)` takes a path **relative to the module's own API root** and
|
||||
returns a `Response`. The module asks for `posts`. The host decides what that means.
|
||||
|
||||
This is the single most important portability rule here. Ekklesia mounts module routes under
|
||||
`/api/v1/modules/<module-id>` (`apps/api/src/module-http-routes.ts`), but that prefix is Ekklesia's
|
||||
private deployment detail. A standalone host serving one module may expose the same routes at `/api`,
|
||||
at another origin, or behind a proxy. A module UI that builds
|
||||
`/api/v1/modules/announcements/posts` itself is silently welded to Ekklesia and can never be hosted
|
||||
anywhere else — and the failure is invisible until someone tries.
|
||||
|
||||
`Request`/`Response` are the same Fetch vocabulary a module's backend routes already speak (ADR
|
||||
0015), so one module speaks one HTTP shape on both sides of the wire. Credentials, base URL, and any
|
||||
platform headers are the host's responsibility; a module UI neither supplies nor sees a session
|
||||
token. No backend routing changed in this pass.
|
||||
|
||||
### Module UI is trusted first-party code in the host's realm
|
||||
|
||||
Module UI runs in the same browser JavaScript realm as the host shell. There is no iframe, no
|
||||
sandbox, no cross-origin isolation, and no capability confinement. A module's UI can reach the host
|
||||
page, the DOM, `window`, and any global the browser exposes, exactly like any other script the
|
||||
application loads.
|
||||
|
||||
**This is a trusted-code model, stated plainly rather than dressed up.** It matches, and does not
|
||||
extend, the backend model ADR 0021 preserved: installing a module already means running its code in
|
||||
the API process with the API's PostgreSQL authority. A module whose server code is trusted with the
|
||||
database is not meaningfully constrained by sandboxing its browser code.
|
||||
|
||||
Introducing an iframe or a separate origin now would add real cost — postMessage plumbing, styling
|
||||
seams, focus and accessibility breakage — to buy theoretical separation from code that already has
|
||||
server-side authority. That trade only becomes worthwhile for genuinely untrusted third-party
|
||||
modules, which this platform does not accept (see
|
||||
[Security model and deployment trust boundaries](../../development/security.md)). Nothing in this
|
||||
contract prevents a future sandboxed tier: a host that wanted one could implement `mount` against an
|
||||
isolated realm without changing this interface, because the contract is a mount function and a
|
||||
plain-data context rather than shared object graphs.
|
||||
|
||||
### Client-side permission checks are presentation, never enforcement
|
||||
|
||||
`ModuleUiPermissions.has(permissionId)` tells a module UI what the host believes, so it can hide an
|
||||
action, disable a control, or pick an empty state. It is not a security boundary and must never be
|
||||
described as one.
|
||||
|
||||
Server-side authorization remains the only authority. Every module route is enforced by Core's
|
||||
`requirePermission` middleware against the composed platform permission registry (ADR 0013/0014),
|
||||
and that enforcement neither knows nor cares what a browser decided. A module UI that consults
|
||||
`has()` and then calls its API is still fully authorized server-side; one that skips the check and
|
||||
calls anyway is still denied. The value is that a user does not see a button that would fail.
|
||||
|
||||
`permissionId` is a plain `string`, not Core's branded `PermissionId`, deliberately: branding would
|
||||
force a module UI to run Zod in the browser to produce a value for a call that decides nothing
|
||||
security-relevant. The branded type remains the server-side type, where it matters.
|
||||
|
||||
### Discovery still never executes UI, and Core still never loads it
|
||||
|
||||
`parseModuleUiExport(namespace, expected)` validates an already-imported module namespace. It does
|
||||
not import anything. Each host performs its own dynamic import — a browser host from a served URL, a
|
||||
test host from a file URL — and passes the resulting namespace in.
|
||||
|
||||
That keeps this package free of any loader, bundler, or URL assumption, and it keeps Core out of the
|
||||
business of evaluating browser code entirely. Module discovery continues to resolve and validate the
|
||||
`entrypoints.ui` path and never execute it (ADR 0021), and this pass adds no UI loading to
|
||||
`core/`, to `apps/api`, or to any startup path. Nothing about restart-based discovery changed, and
|
||||
nothing about explicit operator-invoked migrations changed.
|
||||
|
||||
### Dashboard organization stays outside this contract
|
||||
|
||||
The contract carries no navigation entry, route, title, icon, folder, group, or ordering field, and
|
||||
none may be added as a hierarchy mechanism. ADR 0021 established that Dashboard grouping is
|
||||
presentation configuration an administrator reorganizes, never module architecture; adding any of it
|
||||
here would quietly reverse that, because a field in the UI contract is a field a module owns.
|
||||
|
||||
### The boundary guard is narrowed precisely, not removed
|
||||
|
||||
`pnpm boundaries` keeps enforcing `modules-do-not-own-ui`. Exactly three things changed:
|
||||
|
||||
- `.tsx` is no longer a rejected source extension. `.jsx` stays rejected (this repository is
|
||||
strict-TypeScript throughout) and `.css` stays rejected (module styling and the module UI build
|
||||
pipeline are not designed yet, and a stylesheet would imply a bundling contract that does not
|
||||
exist).
|
||||
- `react` and `react-dom` are no longer rejected dependencies. Every other framework stays rejected,
|
||||
including `react-router-dom` (routing belongs to the host), `react-native` (this pass supports
|
||||
React on the DOM only), and `@vitejs/plugin-react` (the build pipeline is undesigned).
|
||||
- Modules may depend on the new `module-ui-sdk` shared role, which itself may depend only on
|
||||
`@ekklesia/contracts`.
|
||||
|
||||
Layer rules are untouched. Module UI source is subject to the identical import boundaries as module
|
||||
backend source: it cannot import Core, an app, a surface, a builder, another module, or a shared
|
||||
package outside the roles a module is allowed. An unsupported framework aliased behind an allowed
|
||||
name (`"react": "npm:vue@3"`) is resolved and rejected by target, so permitting React did not create
|
||||
a way to smuggle anything else in.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A React component as the export** (`export default function Ui(props)`). Rejected: it would put
|
||||
React into the platform contract, force every host — including standalone — onto the same React
|
||||
major, and make a framework upgrade a breaking change to the module boundary. A mount function
|
||||
costs a module author three lines and buys independence for all three parties.
|
||||
- **Web Components as the authoring model.** Rejected as the *authoring* model: it would mean every
|
||||
module author writes to a lower-level API than the one Ekklesia's own UI will use, and styling,
|
||||
form handling, and state would all need re-solving per module. Notably, the mount contract does not
|
||||
forbid a module from using them internally — it only declines to require them.
|
||||
- **A serialized UI description** (`{"type": "form", "fields": [...]}`) rendered by the host.
|
||||
Rejected outright. It is a frontend framework in disguise, it can only ever express what its schema
|
||||
anticipated, and the Announcements editor that motivated ADR 0021 is exactly the kind of screen
|
||||
such a system cannot express. Ekklesia is not building its own frontend framework.
|
||||
- **Putting the contract in `@ekklesia/module-sdk`.** Rejected: that package re-exports all of
|
||||
`@ekklesia/contracts`, so a module UI bundle would pull Zod, Drizzle, and semver into the browser.
|
||||
- **Passing a single platform object** (`context.platform`, `context.core`, a store). Rejected: it
|
||||
makes every internal a de facto public API, and nothing that reaches a module can be changed
|
||||
afterwards without breaking one. Four narrow members can grow; a platform object can only leak.
|
||||
- **An absolute API base URL** (`context.apiBaseUrl`) instead of a request function. Rejected: a
|
||||
module that concatenates a base URL still assumes a path shape underneath it, and it puts URL
|
||||
construction — and therefore credential and header policy — back inside the module.
|
||||
- **Iframe or cross-origin sandboxing now.** Rejected as premature and misleading: real cost today,
|
||||
no real containment for code whose backend half already holds the API's database authority, and a
|
||||
sandbox claim this repository would not be able to substantiate.
|
||||
- **Waiting for the Dashboard before defining the contract.** Rejected: the Dashboard would then
|
||||
define the contract by accident, and every Ekklesia-specific assumption it happened to make would
|
||||
become the standalone host's problem later.
|
||||
- **Allowing `.css` in modules now.** Rejected: it implies a bundling and style-isolation contract
|
||||
that this pass does not design. Deferred deliberately rather than half-answered.
|
||||
|
||||
## Consequences
|
||||
|
||||
A module author can now write a real screen in React, against a documented four-member host context,
|
||||
and know exactly what the artifact must export. Two hosts can consume that artifact with no shared
|
||||
code beyond one small package, and the test suite proves it by mounting one fixture artifact under
|
||||
two hosts whose URL shapes and origins differ.
|
||||
|
||||
The cost is that this is still a contract without a production consumer. No Dashboard mounts a module
|
||||
UI, no module ships one, and the build pipeline that turns a module's `.tsx` into the compiled
|
||||
`entrypoints.ui` artifact is not designed — `browser.json` uses `"jsx": "preserve"`, which needs a
|
||||
bundler that no module has, and `@vitejs/plugin-react` remains denied. Choosing between a `tsc`-only
|
||||
JSX preset and a real bundler, together with the styling question, is the next pass's work and is the
|
||||
reason `.css` stays rejected today.
|
||||
|
||||
A second cost is deliberate duplication of effort in the browser: if both the host and a module
|
||||
bundle React, a page can load React twice. Sharing one copy through peer dependencies, bundler
|
||||
externals, or an import map is a build-pipeline optimization, not a contract change, and the mount
|
||||
contract is unaffected by whichever is chosen.
|
||||
|
||||
## Deferred
|
||||
|
||||
Not implemented, designed, or promised here: the Dashboard application shell, homepage, or
|
||||
navigation UI; module navigation registration; Dashboard folders, groups, or drag-and-drop
|
||||
organization; a design system, component library, or theme/design-token contract; the module UI build
|
||||
and styling pipeline; a standalone application host; a module installer, marketplace, or manager;
|
||||
hot-loading or runtime unloading; automatic migrations; sandboxed or untrusted module tiers; and
|
||||
every real feature module. `entrypoints.jobs` still accepts source TypeScript where other executable
|
||||
entrypoints require compiled output — that inconsistency is unchanged and remains open, because this
|
||||
pass did not centralize executable-entrypoint validation and fixing it here would have been unrelated
|
||||
scope.
|
||||
@@ -30,3 +30,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0019: Durable authorization history and grant management](0019-durable-authorization-history-and-grant-management.md) — Accepted
|
||||
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
|
||||
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
|
||||
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
|
||||
|
||||
@@ -30,7 +30,9 @@ The four ownership layers are:
|
||||
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
|
||||
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
|
||||
2. **Modules** own domain behavior, data schemas, resources, actions, routes, events, permissions,
|
||||
jobs — and, per [ADR 0021](decisions/0021-modules-as-hostable-products.md), their own UI. A module
|
||||
jobs — and, per [ADR 0021](decisions/0021-modules-as-hostable-products.md), their own UI, authored
|
||||
in React against `@ekklesia/module-ui-sdk`
|
||||
([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A module
|
||||
is an independently versioned product, not only a backend capability. It does not own the
|
||||
containing application shell or any platform policy.
|
||||
3. **Surfaces** own the presentation concerns a module does not: the containing application shell,
|
||||
@@ -163,20 +165,40 @@ blocks Phase 6. Read
|
||||
does and does not protect against, and the repository root
|
||||
[README](../../README.md#current-status) for the current status of every phase.
|
||||
|
||||
**Phase 6A establishes the module product contract.**
|
||||
**Phase 6A established the module product contract.**
|
||||
[ADR 0021](decisions/0021-modules-as-hostable-products.md) replaces the backend-only module model:
|
||||
a module is an independently versioned product that may own its own UI, hostable inside Ekklesia and
|
||||
— later, from the same implementation — as a standalone application. Core keeps the host shell and
|
||||
every platform policy, the module graph stays flat, and Dashboard grouping stays presentation
|
||||
configuration.
|
||||
|
||||
What this pass actually implements is the **static contract only**: `manifestVersion: 4` adds one
|
||||
optional `entrypoints.ui` field, validated during discovery exactly like the server and schema
|
||||
entrypoints — package-relative, inside the package, compiled ESM. Discovery remains inert; nothing
|
||||
imports, mounts, or renders that entrypoint. The Dashboard shell, the UI runtime, the standalone
|
||||
host, module UI authoring (including the still-enforced `modules-do-not-own-ui` boundary rule), and
|
||||
every feature module remain future work. Restart-based discovery, explicit operator-invoked
|
||||
migrations, and the trusted compiled-module model are all unchanged.
|
||||
What that pass implemented is the **static contract only**: `manifestVersion: 4` adds one optional
|
||||
`entrypoints.ui` field, validated during discovery exactly like the server and schema entrypoints —
|
||||
package-relative, inside the package, compiled ESM. Discovery remains inert; nothing imports, mounts,
|
||||
or renders that entrypoint.
|
||||
|
||||
**Phase 6B pass 1 defines the module UI authoring model and host contract.**
|
||||
[ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md) selects React and TypeScript as
|
||||
the supported way to author module UI, and defines what a compiled `entrypoints.ui` artifact exports
|
||||
and what a host provides it. React is the authoring model but deliberately not the contract: an
|
||||
artifact default-exports `defineModuleUi({ id, version, mount })`, and `mount(container, context)`
|
||||
takes a DOM element and returns an unmount function, so a module's framework and a host's stay
|
||||
independent.
|
||||
|
||||
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
|
||||
dependencies, consumed by module authors and hosts alike. Its host context is four members —
|
||||
`moduleId`, `api`, `principal`, `permissions`. Module UI reaches its own backend through
|
||||
`api.request(path)` with a path relative to the module's own API root, never by constructing
|
||||
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
|
||||
Client-side permission hints are presentation only; server-side enforcement remains the sole
|
||||
authority. Module UI is trusted code in the host's browser realm — there is no sandbox, matching the
|
||||
backend trust model rather than overstating it.
|
||||
|
||||
Core is untouched by this: discovery still validates the `entrypoints.ui` path and never evaluates
|
||||
it, and hosts perform their own dynamic import. The Dashboard shell, a UI runtime, the module UI
|
||||
build and styling pipeline, the standalone host, and every feature module remain future work.
|
||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||
are all unchanged.
|
||||
|
||||
Target architecture not yet implemented includes production approval-provider
|
||||
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
|
||||
|
||||
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
| --- | --- | --- |
|
||||
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
||||
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
||||
| `modules/*` | Domain capability, data, and (per ADR 0021) the module's own UI | Module SDK, public contracts, and external backend libraries |
|
||||
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries |
|
||||
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
|
||||
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
||||
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
||||
@@ -17,14 +17,18 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
|
||||
- Core never imports a feature module, surface, Builder implementation, or app.
|
||||
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
|
||||
- A module cannot contain presentation source, including JSX/TSX, single-file components,
|
||||
Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI
|
||||
frameworks or shared UI-role packages. **This rule is still enforced, but it is no longer the
|
||||
architectural direction.** [ADR 0021](decisions/0021-modules-as-hostable-products.md) establishes
|
||||
that a module may own its own UI and declare a compiled `entrypoints.ui` artifact. The deny list
|
||||
above stays in force until a later Phase 6 pass chooses the module UI authoring model and decides
|
||||
what replaces it; until then a module UI must be a framework-neutral compiled artifact. Core still
|
||||
owns the containing application shell and all platform policy.
|
||||
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
|
||||
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
|
||||
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
|
||||
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
|
||||
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
||||
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
|
||||
packages. An unsupported framework aliased behind an allowed name is resolved and rejected by its
|
||||
real target.
|
||||
- Module UI source is subject to the identical layer rules as module backend source: it may not
|
||||
import Core, an app, a surface, a builder, or another module. Core still owns the containing
|
||||
application shell and all platform policy, and module UI reaches the host only through
|
||||
`@ekklesia/module-ui-sdk`.
|
||||
- A surface never imports Core or a feature module. It uses public API/client contracts.
|
||||
- Separate surfaces do not import each other's internals.
|
||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||
@@ -44,8 +48,9 @@ The boundary test suite includes intentionally invalid fixture graphs. This prov
|
||||
rejects every currently machine-checkable repository rule even before production packages fill
|
||||
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
|
||||
common UI-framework dependencies inside modules. This deny list is defense in depth; package
|
||||
review remains required when adopting a new frontend technology — and revisiting the guard itself is
|
||||
an explicit prerequisite of the module UI authoring pass (ADR 0021).
|
||||
review remains required when adopting a new frontend technology. ADR 0022 narrowed the guard
|
||||
precisely rather than removing it: React was un-denied, every other framework stayed denied, and the
|
||||
layer rules were untouched.
|
||||
|
||||
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
|
||||
targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation
|
||||
|
||||
@@ -54,11 +54,36 @@ schema-permission, table-ACL, row-level-security, tenant, or resource sandbox be
|
||||
Core.** A module is not isolated from Core, from other modules, or from the host.
|
||||
|
||||
[ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) lets a module declare its
|
||||
own UI entrypoint. **That changes nothing on this page.** A module that ships UI is exactly as
|
||||
trusted as one that does not: its server entrypoint still runs in the API process with the authority
|
||||
described above, and declaring `entrypoints.ui` neither adds nor implies any isolation. No host
|
||||
loads a UI entrypoint yet; when one does, the trust model for whatever it executes must be decided
|
||||
explicitly and recorded — it does not inherit a boundary from this section, because none exists.
|
||||
own UI entrypoint. **That changes nothing about the authority described above.** A module that ships
|
||||
UI is exactly as trusted as one that does not: its server entrypoint still runs in the API process
|
||||
with that authority, and declaring `entrypoints.ui` neither adds nor implies any isolation.
|
||||
|
||||
### Module UI is trusted browser code, in the host's own realm
|
||||
|
||||
[ADR 0022](../architecture/decisions/0022-module-ui-authoring-and-host-contract.md) decides the
|
||||
browser-side model that ADR 0021 deliberately left open. Module UI runs **in the same browser
|
||||
JavaScript realm as the host shell**: no iframe, no sandbox, no cross-origin isolation, no capability
|
||||
confinement. A module's UI can reach the host page, the DOM, `window`, and any global the browser
|
||||
exposes, exactly like any other script the application loads.
|
||||
|
||||
That is a deliberate choice for a first-party, trusted-module platform, and it is consistent rather
|
||||
than lax: a module whose server code already runs in the API process with the API's full PostgreSQL
|
||||
authority is not meaningfully constrained by sandboxing its browser half. Do not describe module UI
|
||||
as sandboxed, isolated, or confined — none of those are true, and no mechanism in this repository
|
||||
implements them.
|
||||
|
||||
The contract does leave room for a future sandboxed tier without a redesign: `mount(container,
|
||||
context)` passes an element and a plain-data context rather than shared object graphs, so a host
|
||||
could implement it against an isolated realm. Nothing does today.
|
||||
|
||||
### Client-side permission checks are not a security boundary
|
||||
|
||||
A host may tell a module UI what permissions it believes the viewer holds
|
||||
(`ModuleUiPermissions.has`), so the UI can hide an action or disable a control. **This is
|
||||
presentation only.** Server-side authorization remains the sole authority: every module route is
|
||||
enforced by `requirePermission` against the composed platform permission registry, and that
|
||||
enforcement neither knows nor cares what a browser decided. Hiding a control is a usability
|
||||
improvement, never an access control. Treat any claim that a UI check protects anything as a bug.
|
||||
|
||||
## What this platform must not claim
|
||||
|
||||
|
||||
@@ -190,33 +190,118 @@ and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts`
|
||||
discovery — the platform never imports source TypeScript, and a UI artifact is a build output like
|
||||
any other.
|
||||
|
||||
### What the artifact must export
|
||||
|
||||
The artifact default-exports one definition, built with `defineModuleUi` from
|
||||
`@ekklesia/module-ui-sdk` (ADR 0022):
|
||||
|
||||
```tsx
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Editor } from "./editor.js";
|
||||
|
||||
export default defineModuleUi({
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<Editor context={context} />);
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`id` and `version` are build-artifact identity markers and must match your manifest, exactly like the
|
||||
ones in `defineModuleRuntime`. A host verifies both against the manifest it already validated, so a
|
||||
stale UI bundle is caught rather than mounted.
|
||||
|
||||
`mount` receives a container element and the host context, and returns an unmount function or
|
||||
nothing. If your mount acquires anything — a React root, a subscription, a timer, an event listener —
|
||||
return an unmount and release it there, the same discipline your `stop` hook follows.
|
||||
|
||||
React is the supported way to write module UI. It is deliberately **not** part of the contract: the
|
||||
platform hands you an element and a context, so your framework choice and the host's stay
|
||||
independent.
|
||||
|
||||
### What the host gives you
|
||||
|
||||
`mount` receives exactly four members, and nothing else is available:
|
||||
|
||||
| Member | What it is |
|
||||
| --- | --- |
|
||||
| `moduleId` | Your module's id, so you never re-derive it from a URL |
|
||||
| `api` | `request(path, init)` against **your own** module API — see below |
|
||||
| `principal` | Who is viewing (`anonymous` or `authenticated`), for presentation |
|
||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
||||
|
||||
Navigation, host theme and design tokens, module configuration, organization context, and
|
||||
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a
|
||||
speculative field frozen into a public contract is harder to remove than to add.
|
||||
|
||||
### Never build your own API URL
|
||||
|
||||
Ask the host for a path relative to your own module's API root:
|
||||
|
||||
```ts
|
||||
const response = await context.api.request("posts");
|
||||
```
|
||||
|
||||
Do **not** write `/api/v1/modules/announcements/posts`. That prefix is Ekklesia's private deployment
|
||||
detail. A standalone host serving only your module may expose the same routes at `/api`, at a
|
||||
different origin, or behind a proxy — and a module that builds the URL itself silently stops being
|
||||
hostable anywhere but Ekklesia. The host resolves the path and owns credentials, base URL, and
|
||||
platform headers; your UI never sees a session token.
|
||||
|
||||
`request` returns a `Response`, the same Fetch vocabulary your backend routes already speak.
|
||||
|
||||
### Client-side permission checks are UX, not security
|
||||
|
||||
`context.permissions.has("announcements.posts.publish")` tells you what the host believes, so you can
|
||||
hide an action or disable a control. **It is not a security boundary.** Server-side authorization is
|
||||
the only authority: your routes are enforced by Core's `requirePermission` middleware regardless of
|
||||
what any browser decided. Checking `has()` and then calling your API is still fully authorized
|
||||
server-side; skipping the check is still denied. The point is that a user does not see a button that
|
||||
would fail.
|
||||
|
||||
### Your UI is trusted code in the host's page
|
||||
|
||||
Module UI runs in the same browser JavaScript realm as the host shell. There is no iframe, no
|
||||
sandbox, and no capability confinement — your UI can reach the host page and any global the browser
|
||||
exposes. This matches the backend model: installing a module already means running its code in the
|
||||
API process. Write module UI as trusted first-party code, because that is what it is. See
|
||||
[Security model and deployment trust boundaries](../development/security.md).
|
||||
|
||||
### What you may and may not use today
|
||||
|
||||
`pnpm boundaries` still enforces `modules-do-not-own-ui`, narrowed precisely (ADR 0022):
|
||||
|
||||
| Allowed in a module | Still rejected |
|
||||
| --- | --- |
|
||||
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
||||
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
|
||||
| `@ekklesia/module-ui-sdk` | `@ekklesia/ui` and other shared UI-role packages |
|
||||
|
||||
Layer rules are unchanged: module UI source may not import Core, an app, a surface, a builder, or
|
||||
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
|
||||
is resolved and rejected by its real target.
|
||||
|
||||
> **Not yet buildable end to end.** The pipeline that compiles a module's `.tsx` into the compiled
|
||||
> `entrypoints.ui` artifact is not designed: `browser.json` uses `"jsx": "preserve"`, which needs a
|
||||
> bundler, and `@vitejs/plugin-react` is still denied. Module styling is unresolved for the same
|
||||
> reason, which is why `.css` stays rejected. No host mounts a module UI yet either. The contract
|
||||
> above is stable and tested; shipping a module UI is not yet possible end to end.
|
||||
|
||||
### What this does and does not do today
|
||||
|
||||
Discovery parses the manifest as inert JSON, resolves the `ui` path, validates it, and records it on
|
||||
the discovered module. That is all. **Nothing in this repository imports, evaluates, mounts, or
|
||||
renders a UI entrypoint** — a UI artifact that throws on evaluation still discovers successfully,
|
||||
because it is never evaluated. The field follows the same established pattern as `entrypoints.jobs`,
|
||||
which has been declared and validated since ADR 0006 with no runtime consumer.
|
||||
|
||||
Declaring `ui` therefore has no observable runtime effect yet. It exists so the host shell, the UI
|
||||
runtime, and a future standalone host have a fixed, tested contract to build against.
|
||||
|
||||
### The authoring constraint you will hit
|
||||
|
||||
`pnpm boundaries` enforces `modules-do-not-own-ui`, which rejects UI *source* extensions (`.tsx`,
|
||||
`.vue`, `.svelte`, `.css`, `.svg`, and others) and UI *framework dependencies* (React, Vue, Svelte,
|
||||
Tailwind, and others) inside a module package. **That rule is still enforced.** ADR 0021 changed the
|
||||
architectural direction — a module may own UI — but the authoring model for module UI, including
|
||||
which parts of that deny list are relaxed and what replaces them, is a later Phase 6 decision that
|
||||
has not been made.
|
||||
|
||||
In practice this means a module can declare `entrypoints.ui` today only for a framework-neutral
|
||||
compiled artifact authored in plain `.ts`. Do not add a UI framework dependency to a module package
|
||||
expecting it to pass; it will not, and that is deliberate rather than an oversight.
|
||||
the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or renders a UI
|
||||
entrypoint** — a UI artifact that throws on evaluation still discovers successfully, because it is
|
||||
never evaluated. Hosts perform their own dynamic import and then validate the result with
|
||||
`parseModuleUiExport`; Core never loads browser code.
|
||||
|
||||
### What a module UI will never own
|
||||
|
||||
Whatever authoring model is chosen, a module UI consumes platform policy and never redefines it.
|
||||
A module UI consumes platform policy and never redefines it.
|
||||
Authentication and session establishment, authorization enforcement, organization/platform context,
|
||||
the containing application shell, top-level routing and mount boundaries, the platform navigation
|
||||
container, the global theme and design-token contract, platform error boundaries, and
|
||||
|
||||
@@ -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
|
||||
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: {}
|
||||
|
||||
templates/module:
|
||||
|
||||
+39
-13
@@ -47,6 +47,11 @@ export interface BoundaryResult {
|
||||
}
|
||||
|
||||
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
|
||||
// Presentation formats a module may NOT contain. `.tsx` is deliberately absent: React is the
|
||||
// supported module UI authoring model (ADR 0022), so a module's own `.tsx` is legitimate source.
|
||||
// `.jsx` stays rejected because this repository is strict-TypeScript throughout, and `.css` stays
|
||||
// rejected because module styling and the module UI build pipeline are not designed yet — allowing
|
||||
// a stylesheet would imply a bundling contract that does not exist.
|
||||
const MODULE_UI_EXTENSIONS = new Set([
|
||||
".astro",
|
||||
".css",
|
||||
@@ -64,9 +69,14 @@ const MODULE_UI_EXTENSIONS = new Set([
|
||||
".stylus",
|
||||
".svelte",
|
||||
".svg",
|
||||
".tsx",
|
||||
".vue",
|
||||
]);
|
||||
// UI packages a module may NOT depend on. `react` and `react-dom` are deliberately absent: they are
|
||||
// the one supported module UI authoring framework (ADR 0022). Everything else stays denied, so
|
||||
// choosing React does not quietly open every other frontend technology. Three neighbours are worth
|
||||
// naming: `react-router-dom` stays denied because routing belongs to the host, `react-native` stays
|
||||
// denied because this pass supports React on the DOM only, and `@vitejs/plugin-react` stays denied
|
||||
// because the module UI build pipeline is not designed yet.
|
||||
const MODULE_UI_PACKAGES = new Set([
|
||||
"@angular/core",
|
||||
"@builder.io/qwik",
|
||||
@@ -91,8 +101,6 @@ const MODULE_UI_PACKAGES = new Set([
|
||||
"nuxt",
|
||||
"preact",
|
||||
"qwik",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-native",
|
||||
"react-router-dom",
|
||||
"remix",
|
||||
@@ -144,6 +152,7 @@ const SHARED_PACKAGE_ROLES = new Set([
|
||||
"contracts",
|
||||
"design-tokens",
|
||||
"module-sdk",
|
||||
"module-ui-sdk",
|
||||
"shared",
|
||||
"surface-sdk",
|
||||
"testing",
|
||||
@@ -212,7 +221,7 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
||||
}
|
||||
if (
|
||||
to.layer === "shared" &&
|
||||
!["contracts", "module-sdk", "testing", "tooling"].includes(to.role ?? "")
|
||||
!["contracts", "module-sdk", "module-ui-sdk", "testing", "tooling"].includes(to.role ?? "")
|
||||
) {
|
||||
return "modules-use-public-platform-contracts";
|
||||
}
|
||||
@@ -245,7 +254,7 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
||||
|
||||
if (
|
||||
from.layer === "shared" &&
|
||||
["builder-sdk", "module-sdk", "surface-sdk"].includes(from.role ?? "") &&
|
||||
["builder-sdk", "module-sdk", "module-ui-sdk", "surface-sdk"].includes(from.role ?? "") &&
|
||||
["app", "builder", "core", "module", "surface"].includes(to.layer)
|
||||
) {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
@@ -255,7 +264,10 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
||||
if (from.role === "contracts" && to.role !== "contracts") {
|
||||
return "contracts-do-not-depend-upward";
|
||||
}
|
||||
if (["module-sdk", "surface-sdk"].includes(from.role ?? "") && to.role !== "contracts") {
|
||||
if (
|
||||
["module-sdk", "module-ui-sdk", "surface-sdk"].includes(from.role ?? "") &&
|
||||
to.role !== "contracts"
|
||||
) {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
}
|
||||
if (from.role === "builder-sdk" && !["contracts", "surface-sdk"].includes(to.role ?? "")) {
|
||||
@@ -293,9 +305,19 @@ function npmAliasTarget(range: string): string | undefined {
|
||||
return alias.split("@")[0];
|
||||
}
|
||||
|
||||
function isModuleUiDependency(packageName: string, range: string): boolean {
|
||||
/**
|
||||
* The forbidden UI framework a module dependency actually resolves to, or `undefined` when it
|
||||
* resolves to none. Returns the *alias target* when the alias is what matched, so a dependency
|
||||
* declared as `"react": "npm:vue@3"` is reported as Vue rather than as the allowed name it hides
|
||||
* behind. Now that React is permitted (ADR 0022), that distinction is what keeps an allowed name
|
||||
* from becoming a way to smuggle a denied framework past the guard.
|
||||
*/
|
||||
function moduleUiDependencyTarget(packageName: string, range: string): string | undefined {
|
||||
if (isModuleUiPackage(packageName)) {
|
||||
return packageName;
|
||||
}
|
||||
const aliasTarget = npmAliasTarget(range);
|
||||
return isModuleUiPackage(packageName) || (aliasTarget ? isModuleUiPackage(aliasTarget) : false);
|
||||
return aliasTarget && isModuleUiPackage(aliasTarget) ? aliasTarget : undefined;
|
||||
}
|
||||
|
||||
async function isFile(filePath: string): Promise<boolean> {
|
||||
@@ -694,13 +716,17 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
||||
for (const [dependencyName, range] of Object.entries(
|
||||
workspacePackage.manifest[dependencyField] ?? {},
|
||||
)) {
|
||||
if (
|
||||
workspacePackage.location.layer === "module" &&
|
||||
isModuleUiDependency(dependencyName, range)
|
||||
) {
|
||||
const uiDependencyTarget =
|
||||
workspacePackage.location.layer === "module"
|
||||
? moduleUiDependencyTarget(dependencyName, range)
|
||||
: undefined;
|
||||
if (uiDependencyTarget) {
|
||||
violations.push({
|
||||
from: workspacePackage.location.relativePath,
|
||||
message: `Module package depends on UI framework ${dependencyName}.`,
|
||||
message:
|
||||
uiDependencyTarget === dependencyName
|
||||
? `Module package depends on UI framework ${dependencyName}.`
|
||||
: `Module package depends on UI framework ${uiDependencyTarget} aliased as ${dependencyName}.`,
|
||||
rule: "modules-do-not-own-ui",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ describe("repository dependency boundaries", () => {
|
||||
["package-cycle", "no-circular-package-dependencies"],
|
||||
["module-has-ui", "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"],
|
||||
["cross-package-relative", "workspace-imports-use-public-exports"],
|
||||
["cycle", "no-circular-source-dependencies"],
|
||||
@@ -55,18 +57,72 @@ describe("repository dependency boundaries", () => {
|
||||
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"));
|
||||
expect(
|
||||
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"),
|
||||
).toHaveLength(3);
|
||||
const uiViolations = result.violations.filter(
|
||||
(violation) => violation.rule === "modules-do-not-own-ui",
|
||||
);
|
||||
|
||||
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"));
|
||||
expect(
|
||||
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"),
|
||||
).toHaveLength(4);
|
||||
const uiViolations = result.violations.filter(
|
||||
(violation) => violation.rule === "modules-do-not-own-ui",
|
||||
);
|
||||
|
||||
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 () => {
|
||||
|
||||
+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