From 8d651c427d63cdabf23b2571b47e9ba951011af0 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 21 Aug 2026 20:52:47 -0500 Subject: [PATCH] feat: define the module UI authoring model and host contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0021 established that a module may own UI and added a statically validated, never-executed `entrypoints.ui` path, but deliberately left five questions open: what the artifact exports, what a host provides, how one artifact runs under both Ekklesia and a standalone host, what the browser-side trust model is, and how module UI avoids being welded to a Dashboard that does not exist. This answers them. React and TypeScript are the supported module UI authoring model. The repository already leaned this way: `packages/typescript-config` ships a `browser.json` preset and a `react-native.json` preset with `"jsx": "react-jsx"`, and ADR 0002 names browser and React Native configurations. One framework is supported; every other stays rejected. React is deliberately not part of the contract. A module's compiled artifact default-exports `defineModuleUi({ id, version, mount })`, and `mount(container, context)` takes a DOM element and returns an unmount function. `id` and `version` are build-artifact identity markers a host verifies against the manifest, mirroring `defineModuleRuntime` exactly. A host needs an element and a context, never a matching rendering library, so a module's framework, a host's framework, and the platform boundary between them stay independent. The contract lives in a new `@ekklesia/module-ui-sdk` rather than in `@ekklesia/module-sdk`, for a concrete reason: that package re-exports all of `@ekklesia/contracts`, so routing module UI through it would drag Zod, Drizzle, and semver into every browser bundle. The new package has no runtime dependencies, and its one dependency on contracts is type-only — the emitted `host-context.js` is `export {};`, and a test pins that so it cannot regress. The host context is four members: `moduleId`, `api`, `principal`, and `permissions`. Navigation, theme, module configuration, organization context, and diagnostics are deliberately absent; each needs a design that does not exist. Module UI reaches its backend through `api.request(path)` with a path relative to its own API root and never constructs `/api/v1/modules//...`, which is Ekklesia's private deployment detail — a module that builds that URL is silently unhostable anywhere else. One fixture artifact is mounted under two hosts with different origins and URL shapes to prove it. Client-side permission hints are presentation only, and both the docs and a test say so plainly: a denied hint does not stop the module from calling its API, because server-side `requirePermission` is the only authority. Module UI is trusted code in the host's own browser realm — no iframe, no sandbox — which matches the backend model rather than overstating it, since a module's server half already holds the API's database authority. The boundary guard is narrowed precisely, not removed: `.tsx`, `react`, and `react-dom` are un-denied and modules may depend on the new `module-ui-sdk` role. `.jsx`, `.css`, every other framework, `react-router-dom`, `react-native`, and `@vitejs/plugin-react` stay denied, layer rules are untouched, and an unsupported framework aliased behind an allowed name (`"react": "npm:vue@3"`) is resolved and rejected by its real target. Fixtures prove each case, including that module UI source still cannot import Core. Core, apps/api, and the manifest contract are untouched. Discovery still validates the `entrypoints.ui` path and never evaluates it; hosts perform their own dynamic import and validate the result with `parseModuleUiExport`. No Dashboard shell, UI runtime, navigation, grouping, standalone host, build or styling pipeline, installer, hot-loading, or feature module is added. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 10 +- CONTRIBUTING.md | 13 +- README.md | 39 ++- .../0021-modules-as-hostable-products.md | 9 + ...2-module-ui-authoring-and-host-contract.md | 258 ++++++++++++++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 40 ++- docs/architecture/repository-boundaries.md | 27 +- docs/development/security.md | 35 ++- docs/module-development/README.md | 127 +++++++-- packages/module-ui-sdk/package.json | 32 +++ .../module-ui-sdk/src/define-module-ui.ts | 12 + packages/module-ui-sdk/src/host-context.ts | 75 +++++ packages/module-ui-sdk/src/index.ts | 3 + packages/module-ui-sdk/src/module-ui.ts | 133 +++++++++ .../tests/browser-safety.test.ts | 65 +++++ .../tests/fixtures/announcements-ui.ts | 47 ++++ .../tests/module-ui-contract.test.ts | 239 ++++++++++++++++ packages/module-ui-sdk/tsconfig.build.json | 14 + packages/module-ui-sdk/tsconfig.json | 8 + pnpm-lock.yaml | 16 ++ scripts/check-boundaries.ts | 52 +++- tests/boundaries.test.ts | 72 ++++- .../modules/announcements/package.json | 16 ++ .../modules/announcements/src/index.ts | 1 + .../modules/announcements/src/ui/editor.tsx | 12 + .../packages/module-ui-sdk/package.json | 11 + .../packages/module-ui-sdk/src/index.ts | 3 + .../modules/calendar/package.json | 14 + .../modules/calendar/src/index.ts | 1 + .../module-ui-imports-core/core/src/index.ts | 1 + .../modules/announcements/package.json | 11 + .../modules/announcements/src/index.ts | 1 + .../modules/announcements/src/ui/editor.tsx | 3 + 34 files changed, 1318 insertions(+), 83 deletions(-) create mode 100644 docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md create mode 100644 packages/module-ui-sdk/package.json create mode 100644 packages/module-ui-sdk/src/define-module-ui.ts create mode 100644 packages/module-ui-sdk/src/host-context.ts create mode 100644 packages/module-ui-sdk/src/index.ts create mode 100644 packages/module-ui-sdk/src/module-ui.ts create mode 100644 packages/module-ui-sdk/tests/browser-safety.test.ts create mode 100644 packages/module-ui-sdk/tests/fixtures/announcements-ui.ts create mode 100644 packages/module-ui-sdk/tests/module-ui-contract.test.ts create mode 100644 packages/module-ui-sdk/tsconfig.build.json create mode 100644 packages/module-ui-sdk/tsconfig.json create mode 100644 tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/package.json create mode 100644 tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/src/index.ts create mode 100644 tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/src/ui/editor.tsx create mode 100644 tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-sdk/package.json create mode 100644 tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-sdk/src/index.ts create mode 100644 tests/fixtures/boundaries/module-ui-aliased-framework/modules/calendar/package.json create mode 100644 tests/fixtures/boundaries/module-ui-aliased-framework/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/module-ui-imports-core/core/src/index.ts create mode 100644 tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/package.json create mode 100644 tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/src/index.ts create mode 100644 tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/src/ui/editor.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 0f7b700..273ab7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f43dad..1ff2389 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//...`, 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. diff --git a/README.md b/README.md index c4aee98..ff5913f 100644 --- a/README.md +++ b/README.md @@ -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//...`, 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 diff --git a/docs/architecture/decisions/0021-modules-as-hostable-products.md b/docs/architecture/decisions/0021-modules-as-hostable-products.md index 45a805a..3830ef5 100644 --- a/docs/architecture/decisions/0021-modules-as-hostable-products.md +++ b/docs/architecture/decisions/0021-modules-as-hostable-products.md @@ -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 diff --git a/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md b/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md new file mode 100644 index 0000000..942f09b --- /dev/null +++ b/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md @@ -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(); + 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/` (`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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index f180add..703548b 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index c027423..534f493 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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/` 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 diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index f16c488..c492097 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -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 diff --git a/docs/development/security.md b/docs/development/security.md index f3eedf1..04ab04e 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -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 diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 8e30349..0cdbc22 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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(); + 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 diff --git a/packages/module-ui-sdk/package.json b/packages/module-ui-sdk/package.json new file mode 100644 index 0000000..83b7a82 --- /dev/null +++ b/packages/module-ui-sdk/package.json @@ -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" + } +} diff --git a/packages/module-ui-sdk/src/define-module-ui.ts b/packages/module-ui-sdk/src/define-module-ui.ts new file mode 100644 index 0000000..263e5cb --- /dev/null +++ b/packages/module-ui-sdk/src/define-module-ui.ts @@ -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( + definition: Definition, +): Readonly { + return parseModuleUiDefinition(definition) as Readonly; +} diff --git a/packages/module-ui-sdk/src/host-context.ts b/packages/module-ui-sdk/src/host-context.ts new file mode 100644 index 0000000..cfe35b6 --- /dev/null +++ b/packages/module-ui-sdk/src/host-context.ts @@ -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/` (`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; +} + +/** + * 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; +} diff --git a/packages/module-ui-sdk/src/index.ts b/packages/module-ui-sdk/src/index.ts new file mode 100644 index 0000000..b866232 --- /dev/null +++ b/packages/module-ui-sdk/src/index.ts @@ -0,0 +1,3 @@ +export * from "./define-module-ui.js"; +export * from "./host-context.js"; +export * from "./module-ui.js"; diff --git a/packages/module-ui-sdk/src/module-ui.ts b/packages/module-ui-sdk/src/module-ui.ts new file mode 100644 index 0000000..1d07486 --- /dev/null +++ b/packages/module-ui-sdk/src/module-ui.ts @@ -0,0 +1,133 @@ +import type { ModuleUiHostContext } from "./host-context.js"; + +export type Awaitable = Value | PromiseLike; + +/** + * 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; + +/** + * 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; +} + +export type ModuleUiContractErrorCode = + | "MODULE_UI_EXPORT_MISSING" + | "MODULE_UI_DEFINITION_INVALID" + | "MODULE_UI_IDENTITY_MISMATCH"; + +const errorMessages: Readonly> = 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 { + 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; +} diff --git a/packages/module-ui-sdk/tests/browser-safety.test.ts b/packages/module-ui-sdk/tests/browser-safety.test.ts new file mode 100644 index 0000000..e91635e --- /dev/null +++ b/packages/module-ui-sdk/tests/browser-safety.test.ts @@ -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 { + 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; + }; + + 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; + devDependencies?: Record; + peerDependencies?: Record; + }; + 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); + } + }); +}); diff --git a/packages/module-ui-sdk/tests/fixtures/announcements-ui.ts b/packages/module-ui-sdk/tests/fixtures/announcements-ui.ts new file mode 100644 index 0000000..61cade6 --- /dev/null +++ b/packages/module-ui-sdk/tests/fixtures/announcements-ui.ts @@ -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 = ""; + }; + }, +}); diff --git a/packages/module-ui-sdk/tests/module-ui-contract.test.ts b/packages/module-ui-sdk/tests/module-ui-contract.test.ts new file mode 100644 index 0000000..a8bf4f4 --- /dev/null +++ b/packages/module-ui-sdk/tests/module-ui-contract.test.ts @@ -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([ + 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/` + * 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//`. */ + 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 { + // 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)(); + 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"]); + }); +}); diff --git a/packages/module-ui-sdk/tsconfig.build.json b/packages/module-ui-sdk/tsconfig.build.json new file mode 100644 index 0000000..64398a6 --- /dev/null +++ b/packages/module-ui-sdk/tsconfig.build.json @@ -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"] +} diff --git a/packages/module-ui-sdk/tsconfig.json b/packages/module-ui-sdk/tsconfig.json new file mode 100644 index 0000000..582aaf5 --- /dev/null +++ b/packages/module-ui-sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../typescript-config/browser.json", + "compilerOptions": { + "noEmit": true, + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4a5a23..3f6dc36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,22 @@ importers: specifier: 4.1.10 version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/typescript-config: {} templates/module: diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index e399449..cf69394 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -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 { @@ -694,13 +716,17 @@ export async function checkBoundaries(rootDirectory: string): Promise { ["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 () => { diff --git a/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/package.json b/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/package.json new file mode 100644 index 0000000..b2f61d4 --- /dev/null +++ b/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/package.json @@ -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" + } +} diff --git a/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/src/index.ts b/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/src/index.ts new file mode 100644 index 0000000..bfa83d1 --- /dev/null +++ b/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/src/index.ts @@ -0,0 +1 @@ +export const moduleId = "announcements"; diff --git a/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/src/ui/editor.tsx b/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/src/ui/editor.tsx new file mode 100644 index 0000000..9b0c4fa --- /dev/null +++ b/tests/fixtures/boundaries/module-owns-react-ui/modules/announcements/src/ui/editor.tsx @@ -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(); + }, +}); diff --git a/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-sdk/package.json b/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-sdk/package.json new file mode 100644 index 0000000..acb1490 --- /dev/null +++ b/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-sdk/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/module-ui-sdk", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "module-ui-sdk" + } +} diff --git a/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-sdk/src/index.ts b/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-sdk/src/index.ts new file mode 100644 index 0000000..bff5df0 --- /dev/null +++ b/tests/fixtures/boundaries/module-owns-react-ui/packages/module-ui-sdk/src/index.ts @@ -0,0 +1,3 @@ +export function defineModuleUi(definition: Definition): Definition { + return definition; +} diff --git a/tests/fixtures/boundaries/module-ui-aliased-framework/modules/calendar/package.json b/tests/fixtures/boundaries/module-ui-aliased-framework/modules/calendar/package.json new file mode 100644 index 0000000..84517b6 --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-aliased-framework/modules/calendar/package.json @@ -0,0 +1,14 @@ +{ + "name": "@ekklesia/module-calendar", + "version": "0.0.0", + "type": "module", + "dependencies": { + "react": "npm:vue@3.5.13" + }, + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "module" + } +} diff --git a/tests/fixtures/boundaries/module-ui-aliased-framework/modules/calendar/src/index.ts b/tests/fixtures/boundaries/module-ui-aliased-framework/modules/calendar/src/index.ts new file mode 100644 index 0000000..1f0c5a0 --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-aliased-framework/modules/calendar/src/index.ts @@ -0,0 +1 @@ +export const moduleId = "calendar"; diff --git a/tests/fixtures/boundaries/module-ui-imports-core/core/src/index.ts b/tests/fixtures/boundaries/module-ui-imports-core/core/src/index.ts new file mode 100644 index 0000000..e6905f3 --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-imports-core/core/src/index.ts @@ -0,0 +1 @@ +export const corePool = "core-internal-pool"; diff --git a/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/package.json b/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/package.json new file mode 100644 index 0000000..dd9096a --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/module-announcements", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "module" + } +} diff --git a/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/src/index.ts b/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/src/index.ts new file mode 100644 index 0000000..bfa83d1 --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/src/index.ts @@ -0,0 +1 @@ +export const moduleId = "announcements"; diff --git a/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/src/ui/editor.tsx b/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/src/ui/editor.tsx new file mode 100644 index 0000000..b9e1c1b --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/src/ui/editor.tsx @@ -0,0 +1,3 @@ +import { corePool } from "../../../../core/src/index.js"; + +export const leakedCoreInternal = corePool;