feat: close the browser boundary and add the generic UI host loader #18

Merged
Zayin_Admin merged 1 commits from phase-6b/module-ui-host into main 2026-08-22 03:00:57 +00:00
40 changed files with 1286 additions and 44 deletions
Showing only changes of commit 44e676f0df - Show all commits
+2 -1
View File
@@ -40,7 +40,8 @@ pnpm infrastructure:config
artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write
no bundler configuration. Styling is React inline style objects only for now, 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
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
private deployment detail and would break standalone hosting.
- The containing application shell, navigation, global theme, and platform policy belong to Core and
+18 -3
View File
@@ -287,9 +287,24 @@ from source, validate it with `parseModuleUiExport`, mount it into a real DOM, a
output, and unmount it — so a UI that cannot be produced fails CI, and no generated file is ever
committed. See [ADR 0023](docs/architecture/decisions/0023-module-ui-build-pipeline.md).
**Still no host.** A module UI can be authored, built, and tested; **nothing serves one to a user
yet.** The Dashboard shell, navigation, a design system, a stylesheet loading contract, and the
standalone host do not exist.
Phase 6B pass 3 closes the last two foundational gaps. **The browser boundary now actually holds**:
`@ekklesia/module-ui-sdk` had one type-only import of `@ekklesia/contracts`, whose barrel pulled
Drizzle's types — and with them Node's globals — into every module UI program, so `process.env`
typechecked inside browser-only code. The SDK now has **no dependencies at all**, and owns a plain
browser `ModuleUiPrincipal` instead of Core's Zod-branded server type; a compile test proves that
supported UI source compiles and that `process` does not. **And there is now a generic host loader**:
`@ekklesia/module-ui-host` imports a published artifact from a browser URL, validates its id and
version against the manifest *before* mounting, mounts it into a host-owned container, and returns an
idempotent `unmount()`. A module may never depend on it — it is host infrastructure, and the boundary
checker enforces that. See
[ADR 0024](docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md).
**Still no host, and one specific reason why.** Core resolves `entrypoints.ui` to a path on the
*server's filesystem*; a browser cannot import that. **Ekklesia has no route that serves an installed
module's artifact at a browser URL**, so nothing can mount an installed module UI yet — the loader is
complete, asset publication is not, and that is the next pass's first job. The Dashboard shell,
navigation, a design system, a stylesheet loading contract, and the standalone host also do not
exist.
**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
@@ -87,6 +87,14 @@ components, no layout, no design system, no router.
### The host context is deliberately four members
> **Partial correction — see [ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md).**
> `principal` is no longer typed as Core's `EkklesiaPrincipal`. That import made this package depend
> on `@ekklesia/contracts`, whose barrel pulled Drizzle's types — and Node's globals — into every
> browser program that imported the SDK. The context now uses a browser-owned `ModuleUiPrincipal`
> with the same shape and an unbranded `userId`, and the SDK has no dependencies at all. The four
> members and everything else in this section are unchanged.
```ts
interface ModuleUiHostContext {
readonly moduleId: string;
@@ -170,6 +170,13 @@ through two independent paths.
## Consequences
> **Cross-reference — see [ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md).**
> The Node-globals leak this ADR recorded under "Browser TypeScript stays scoped to UI source" is
> now diagnosed and closed: it was a single type-only import of `@ekklesia/contracts` in the UI SDK,
> whose barrel reached Drizzle's types. The SDK now has no dependencies, and a compile test proves a
> module UI file importing it cannot use `process`. Everything else in this ADR is unchanged.
A module author can now write React and TypeScript, run their normal build, and get exactly the
artifact their manifest declares — with no bundler configuration of their own. The end-to-end path
from ADR 0021's static field through ADR 0022's contract to a mounted, unmountable UI is closed and
@@ -0,0 +1,199 @@
# 0024: Browser-safe module UI SDK and the generic host loader
- Status: Accepted
- Date: 2026-08-21
- Extends: [0022](0022-module-ui-authoring-and-host-contract.md),
[0023](0023-module-ui-build-pipeline.md)
- Supersedes (partial): [0022](0022-module-ui-authoring-and-host-contract.md) — only its decision to
type `ModuleUiHostContext.principal` as Core's `EkklesiaPrincipal`. Every other part of that ADR —
the mount-function ABI, the four-member context, module-relative API access, and the trust model —
remains accepted and unchanged.
## Context
Two gaps stand between the module UI contract and a host that could actually use it.
**The browser boundary had a hole.** ADR 0023 recorded it honestly: a module UI file compiled with
`"types": []` should not see Node globals, yet `process.env` typechecked. The cause was never
diagnosed beyond "transitively, through dependency type graphs."
**Nothing loads a module UI.** ADR 0022 defined `parseModuleUiExport` for a host to call, and ADR
0023 made the artifact buildable, but no code imports an artifact, checks its identity, mounts it, or
tears it down. Every future host — the Ekklesia Dashboard and a standalone host alike — needs that
lifecycle, and it must not be invented twice.
## Decision
### The leak was one type-only import, and it is removed by deleting the dependency
Bisected against the real type graph rather than inferred: `@ekklesia/module-ui-sdk` imported
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing `contracts/identity` alone is
clean, and importing Zod alone is clean — but `@ekklesia/contracts` exposes a single barrel entry,
so that one import pulled in `module-database`, whose Drizzle types re-introduce Node's global
declarations. A browser file importing the SDK therefore inherited `process`.
The runtime was never affected: the import was type-only and the emitted `host-context.js` is
`export {};`. This was purely a type-graph leak — and a type-graph leak is still a real boundary
failure, because it silently permits code that cannot run in the target environment.
The fix is not a narrower import path. `@ekklesia/module-ui-sdk` now has **no dependencies at all**,
so there is no graph to leak through and no future contracts change can reopen this. That is the
property worth having: the boundary holds because the package is empty of external references, not
because today's import happens to resolve to something clean.
### A browser principal is not a server principal
`ModuleUiPrincipal` is now defined by the SDK: `{ kind: "anonymous" }` or
`{ kind: "authenticated", userId: string }`. The shape ADR 0022 chose is preserved; only the brand is
dropped.
That is a deliberate contract change, not a convenience. Core's `EkklesiaUserId` is a Zod-branded
string, and the brand encodes a *server-side parsing* guarantee that a browser cannot produce without
running Zod — which ADR 0022 already identified as the reason `permissions.has` takes a plain string.
A browser-facing identity should be plain and serializable; a server type that drags an ORM's types
behind it is not a browser contract merely because it is convenient to reuse.
The two remain structurally compatible: a branded string is assignable to `string`, so an Ekklesia
host passes its own `EkklesiaPrincipal` straight into the context with no conversion, while a
standalone host builds the same object literal from whatever identity system it has. No field was
added. Nothing server-internal is exposed to gain type reuse.
### The generic loader lives in its own package
`@ekklesia/module-ui-host` (new shared role `module-ui-host`) owns the load-and-mount lifecycle. It
is not part of `@ekklesia/module-ui-sdk`, because the two have opposite audiences: the SDK is bundled
into **every module artifact**, so putting host-only loader code there would ship it inside every
module that never runs it and blur the author/host split. `pnpm boundaries` enforces the separation
in both directions — the `module-ui-host` role is deliberately absent from the roles a module may
depend on, and the loader itself may depend only on the SDK (plus dev-time `tooling`), never on a
module, an app, a surface, or the contracts barrel.
Dependency direction is therefore: module UI → SDK; host → loader → SDK. Never module → loader, and
never SDK → anything.
### A browser module URL is not a filesystem path, and this is the pass's central distinction
Core's discovery resolves `entrypoints.ui` to an **absolute path on the server's disk**
(`ResolvedModuleEntrypoints.ui`). A browser cannot import that. A loader that accepted one would look
correct in a server-side test and fail in every real host.
So `loadModuleUi` takes a `url` — a browser-loadable module URL — and this package deliberately does
not know how one is obtained. Turning an installed artifact into a URL a browser may fetch is **asset
publication**: a separate, host-specific concern. Ekklesia will need a trusted route that serves an
installed module's compiled artifact; a standalone host may publish the same file from a CDN, a
static directory, or its own bundle. Making the loader own publication would weld it to one host and
recreate exactly the coupling ADR 0022 exists to prevent.
**Ekklesia has no such route today.** The loader is complete; publication is deferred and is the
first thing the next pass must build. Nothing here should be read as "a host can mount an installed
module now" — it cannot, because it has no way to obtain a URL.
### Dynamic import is injectable, and never `eval`
The default importer is `(url) => import(url)` with a non-analyzable specifier, which esbuild and
Rollup leave as a run-time import rather than resolving at build time. That is required, not
incidental: installed modules are unknown when a host is built, and a statically-resolved import
would either fail or force a hardcoded registry of module imports — defeating independently
installable modules entirely.
`importModule` is injectable so a host whose bundler needs a specific escape hatch (Vite's
`@vite-ignore`, webpack's `webpackIgnore`) supplies a one-line importer instead of this package
guessing. It is deliberately **not** implemented with `new Function` or `eval`: those defeat static
analysis reliably but require `unsafe-eval` in a Content-Security-Policy, and loading a module should
never cost a host its CSP.
### Identity is verified before anything mounts
The loader calls the SDK's existing `parseModuleUiExport(namespace, { id, version })` — it does not
reimplement shape or identity checking. A missing default export, a malformed definition, a wrong
module id, or a wrong version throws `ModuleUiContractError` with its specific code, **before**
`mount` is called, so a stale or mismatched bundle can never render or execute module logic.
Contract violations propagate unwrapped; the loader adds only two codes of its own,
`MODULE_UI_IMPORT_FAILED` and `MODULE_UI_MOUNT_FAILED`, whose messages carry no URL or caught text.
The originating error is attached as `cause` for host diagnostics.
### Lifecycle: idempotent unmount, no hidden state, host owns the container
`loadModuleUi` returns a `MountedModuleUi` that owns its own cleanup. There is no cache, registry, or
singleton, so a module may be mounted into several containers at once and unmounting one leaves the
others untouched.
`unmount()` is idempotent: the first call runs the module's cleanup, later calls do nothing, and it
is safe when the module returned no cleanup at all. An instance is marked unmounted *before* cleanup
runs, so a throwing cleanup surfaces to the host exactly once and never re-runs.
A failed `mount` registers no cleanup, so no handle can claim a failed module is mounted. The loader
never mutates the container — not on success, not on failure, not on unmount. The host owns the
container; the module owns its contents; deciding what a half-rendered failure should look like is
the host's call, not the loader's.
### Mounting executes trusted code in the host's page
Importing a module UI artifact runs ordinary same-realm JavaScript: it can reach the DOM, globals,
and anything else the page can. **The loader is not a sandbox and does not become one by validating
identity.** Identity validation prevents mounting the *wrong* module; it says nothing about whether a
module is safe.
Publishing an artifact must never be read as evidence that it is safe. Only installed, reviewed
modules may be published or mounted under the current trust model, and asset publication must be
restricted accordingly when it is built. Server APIs remain the sole authority for authorization: a
mounted module UI has gained no privilege it did not already have.
## Alternatives
- **Narrowing the import to a `@ekklesia/contracts` subpath.** Rejected. It would work today —
`identity` alone is clean — but it makes the browser boundary depend on which types a backend
package happens to co-locate, and any future edit could reopen the hole silently.
- **A new `@ekklesia/client-contracts` package.** Rejected: only one package originates these types,
and the loader receives them transitively through the SDK. A package for two interfaces would be
structure without a second consumer to justify it.
- **Keeping `EkklesiaPrincipal` and adding Node types to the UI tsconfig.** Rejected outright: that
hides the error instead of fixing the graph, and hands browser code a `process` that does not
exist at run time.
- **Putting the loader in `@ekklesia/module-ui-sdk`.** Rejected: it would ship host-only code inside
every module artifact and erase the author/host distinction the roles exist to express.
- **A loader that accepts Core's resolved filesystem path.** Rejected as the most tempting wrong
answer: it would test green on a server and fail in every browser, and it would quietly make the
loader responsible for asset publication.
- **Implementing publication here to make the loader "complete".** Rejected: it is host-specific, it
needs an authorization decision about who may fetch an artifact, and bundling it in would couple
the generic loader to Ekklesia. Deferred explicitly instead of half-built.
- **`new Function("return import(u)")` to defeat bundler analysis.** Rejected: requires `unsafe-eval`
in a CSP. An injectable importer achieves the same result without that cost.
- **Throwing on a second `unmount()`.** Rejected: teardown races are ordinary in UI hosts, and an
idempotent unmount lets a host clean up without tracking whether it already did.
- **Clearing the container on mount failure.** Rejected: the loader promises never to mutate the
host's container, and a single unconditional rule is easier to reason about than one with an
exception.
## Consequences
A module author importing the supported SDK now provably cannot reach Node globals — proven by
compiling both a supported fixture and a deliberately-failing one under the exact project a module
uses. And a host implementer has one narrow, tested lifecycle to call instead of writing dynamic
import, identity checking, and teardown themselves.
The cost is that this is still infrastructure without a consumer. **Ekklesia cannot mount an
installed module UI**, because nothing serves an installed artifact at a browser URL. The loader is
proven against `data:` URLs built from real artifacts, which is a genuine module URL rather than a
filesystem path — but a real host needs a real route.
`ModuleUiPrincipal` is also a public type change. Nothing outside this repository consumes it yet, so
the migration cost is zero today, and an Ekklesia host's `EkklesiaPrincipal` remains assignable to it
without conversion.
## Deferred
Not implemented or promised here: **module UI asset publication** — the trusted route that turns an
installed module's compiled artifact into a browser URL, including who may fetch it — which is the
prerequisite for any real host; the Dashboard shell, navigation, or grouping; a design system, theme
tokens, or component library; stylesheet and asset loading; React runtime sharing; a standalone
application host; module installation, marketplace, or hot loading; and every real feature module.
`entrypoints.jobs` still accepts source TypeScript where other executable entrypoints require
compiled output. This pass did not centralize compiled-entrypoint validation, so that inconsistency
is unchanged and still open. The permission-id typing gap (`permissions.has` takes a plain string, so
a typo silently hides a control) is likewise unchanged: the browser-safe split did not produce a
clean answer for it, and inventing one would have meant shipping generated constants this phase does
not need.
+1
View File
@@ -32,3 +32,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [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
- [0023: Module UI build pipeline](0023-module-ui-build-pipeline.md) — Accepted
- [0024: Browser-safe module UI SDK and the generic host loader](0024-browser-safe-ui-sdk-and-host-loader.md) — Accepted
@@ -206,6 +206,20 @@ styling is inline style objects only, and a stylesheet import fails the build ra
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
only validates the artifact a module shipped. The Dashboard shell, a UI runtime, a design system, a
stylesheet loading contract, the standalone host, and every feature module remain future work.
**Phase 6B pass 3 makes the browser boundary real and adds the generic host loader.**
[ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md) removes the UI SDK's last
dependency — one type-only import of the contracts barrel that pulled Drizzle's types, and Node's
globals, into browser programs — so the SDK now depends on nothing and defines its own plain
`ModuleUiPrincipal`. `@ekklesia/module-ui-host` is the loader both Ekklesia and a future standalone
host will use: dynamic import from a browser URL, identity validated against the manifest before
mounting, a host-owned container the loader never mutates, and an idempotent unmount. Modules may not
depend on it.
The distinction that shapes the next pass: Core resolves `entrypoints.ui` to a **server filesystem
path**, and a browser needs a **URL**. Turning an installed artifact into a browser URL is asset
publication — host-specific, deliberately outside the loader, and not implemented. Until it exists,
no host can mount an installed module UI.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged.
+6 -1
View File
@@ -28,7 +28,12 @@ These rules map platform ownership to filesystem and package boundaries. The exe
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). 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
import Core, an app, a surface, a builder, or another module. It also may not import
`@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader
would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)).
- `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only
on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it
Node's global declarations — into browser code. 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.
+17
View File
@@ -76,6 +76,23 @@ The contract does leave room for a future sandboxed tier without a redesign: `mo
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.
### Loading a module UI executes trusted code in the host page
`@ekklesia/module-ui-host` dynamically imports a module's compiled artifact from a URL and mounts it
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)). Once imported,
that artifact is ordinary same-realm JavaScript: it can reach the DOM, `window`, and anything else
the page can. **The loader is not a sandbox.** It validates that the artifact is the module the host
expected — the right id, the right version, a well-formed default export — and refuses to mount
anything else, but that is an integrity check against a *stale or mismatched* bundle. It is not a
judgement about whether a module is safe, and it must never be described as one.
The publication side carries the real obligation, and it is not built yet. When Ekklesia gains a
route that serves installed module artifacts as browser URLs, **the existence of an artifact on disk
must not be what makes it servable.** Only installed, reviewed modules may be published or mounted
under this trust model. A module UI that mounts has gained no privilege it did not already have —
its server half already runs in the API process with the API's database authority — and server APIs
remain the sole authority for authorization regardless of what a browser loaded.
### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds
+56 -10
View File
@@ -231,7 +231,7 @@ independent.
| --- | --- |
| `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 |
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
Navigation, host theme and design tokens, module configuration, organization context, and
@@ -332,12 +332,13 @@ not match `.tsx`, and a UI project covers the browser half:
}
```
`"types": []` is deliberate: UI source should not reach for Node globals. Be aware this is a
convention rather than an airtight guard — TypeScript still pulls global declarations in
transitively through dependency type graphs, so `process.env` currently typechecks in a UI file that
imports `@ekklesia/module-ui-sdk`. Nothing catches it at build time either: only
`process.env.NODE_ENV` is substituted, so any other Node global reaches the browser and fails there.
Treat "no Node globals in UI source" as a rule you keep, not one the compiler keeps for you.
`"types": []` is deliberate, and as of
[ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md) it actually holds:
`process.env` in a UI file fails to compile even though the file imports `@ekklesia/module-ui-sdk`.
It did not hold previously — the SDK imported `@ekklesia/contracts`, whose barrel pulled Drizzle's
types and with them Node's globals into every module UI program. The SDK now has **no dependencies at
all**, so importing it cannot drag a backend type graph into your browser code, and a compile test in
that package proves both directions.
`noEmit` is also deliberate — TypeScript only checks types here; esbuild produces the artifact.
@@ -382,9 +383,11 @@ Layer rules are unchanged: module UI source may not import Core, an app, a surfa
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
is resolved and rejected by its real target.
> **Still no host.** You can now author, build, and test a module UI end to end. **Nothing serves it
> to a user yet** — the Dashboard shell does not exist, and neither does the standalone host. Your
> artifact is proven by your own tests, not mounted in production.
> **Still no host.** You can author, build, and test a module UI end to end, and
> `@ekklesia/module-ui-host` can load and mount a built artifact from a browser URL. **Nothing serves
> your artifact at such a URL yet** — Ekklesia has no module-asset publication route, and the
> Dashboard shell and standalone host do not exist. Your artifact is proven by tests, not mounted in
> production. See [How a host loads your UI](#how-a-host-loads-your-ui).
### What this does and does not do today
@@ -398,6 +401,49 @@ Core also never *builds* anything. Building is an authoring and packaging step y
discovers the compiled artifact you shipped and validates it. Installing a module does not trigger a
build, and neither does starting the API.
### How a host loads your UI
You do not write this — a host does — but knowing the shape explains what your artifact must satisfy.
`@ekklesia/module-ui-host` is the generic loader both Ekklesia and a future standalone host use
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)):
```text
compiled artifact (dist/ui/index.js)
↓ asset publication — host-specific, NOT YET IMPLEMENTED for Ekklesia
browser-loadable module URL
↓ loadModuleUi({ source, container, context })
dynamic import → parseModuleUiExport → identity checked against the manifest
↓
mount(container, context)
↓
MountedModuleUi.unmount() // idempotent
```
```ts
const mounted = await loadModuleUi({
source: { moduleId: "example", version: "0.1.0", url: publishedUrl },
container,
context,
});
await mounted.unmount();
```
What that means for you as a module author:
- **Your `id` and `version` must match your manifest.** They are checked *before* `mount` runs, so a
stale artifact is rejected rather than rendered.
- **Return an unmount function if you acquire anything.** The host calls it once; calling it again is
a no-op. Returning nothing is fine if you acquired nothing.
- **If your `mount` throws, nothing is registered as mounted** and the host is told the mount failed.
The loader does not clear your container — the host owns it and decides what a failure looks like.
- **Your UI may be mounted more than once**, into different containers, if your implementation
supports it. Each mount gets its own cleanup.
> **Asset publication is the missing piece.** Core resolves `entrypoints.ui` to a path on the
> *server's filesystem*; a browser cannot import that. Something must serve your compiled artifact at
> a URL first, and Ekklesia has no such route yet. Until it does, no host can mount an installed
> module's UI — the loader is ready, the plumbing to reach it is not.
### What a module UI will never own
A module UI consumes platform policy and never redefines it.
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@ekklesia/module-ui-host",
"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-host",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"dependencies": {
"@ekklesia/module-ui-sdk": "workspace:*"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-build": "workspace:*",
"@types/node": "24.13.3",
"happy-dom": "20.0.11",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "module-ui-host"
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./load-module-ui.js";
@@ -0,0 +1,156 @@
import {
type ModuleUiDefinition,
type ModuleUiHostContext,
type ModuleUiUnmount,
parseModuleUiExport,
} from "@ekklesia/module-ui-sdk";
/**
* How a host obtains a module's UI artifact.
*
* `url` is a **browser-loadable module URL**, not a filesystem path. That distinction is the whole
* reason this type exists. Core's module discovery resolves `entrypoints.ui` to an absolute path on
* the server's disk (`ResolvedModuleEntrypoints.ui`); a browser cannot import that, and a host that
* passed one here would fail at run time in a way no test on a server would catch. Turning an
* installed artifact into a URL a browser may fetch is **asset publication**, a separate concern
* this package deliberately does not own and Ekklesia does not implement yet (ADR 0024).
*
* `moduleId` and `version` are the identity the host already read from the module's validated
* manifest. They are checked against the artifact before anything is mounted.
*/
export interface ModuleUiSource {
readonly moduleId: string;
readonly version: string;
readonly url: string;
}
/**
* How the artifact is actually imported.
*
* Injectable because bundlers disagree about dynamic `import()`. The default is a plain
* `import(url)` with a non-analyzable specifier, which esbuild and Rollup leave as a run-time import
* rather than trying to resolve at build time — the behaviour a host needs, since installed modules
* are not known when the host is built. A host whose bundler needs a specific escape hatch (Vite's
* `/* @vite-ignore *\/`, webpack's `webpackIgnore`) supplies its own one-line importer instead of
* this package guessing.
*
* Deliberately not implemented with `new Function` or `eval`: those defeat static analysis reliably
* but require `unsafe-eval` in a Content-Security-Policy, and a host should not have to weaken its
* CSP to load a module.
*/
export type ModuleUiImporter = (url: string) => Promise<unknown>;
const defaultImporter: ModuleUiImporter = (url) => import(url);
export interface LoadModuleUiOptions {
readonly source: ModuleUiSource;
/** The element the module renders into. The host owns it; the module owns its contents. */
readonly container: Element;
/** Supplied whole by the host. This package forwards it and never adds to it. */
readonly context: ModuleUiHostContext;
readonly importModule?: ModuleUiImporter;
}
/** One mounted instance. Each `loadModuleUi` call returns its own; nothing is shared or cached
* here, so a module may be mounted into several containers at once if its own implementation
* supports it. */
export interface MountedModuleUi {
readonly moduleId: string;
readonly version: string;
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
readonly mounted: boolean;
/**
* Runs the module's cleanup, if it returned one. Idempotent: the first call cleans up, later
* calls do nothing. Safe when the module returned no cleanup function.
*/
unmount(): Promise<void>;
}
export type ModuleUiHostErrorCode = "MODULE_UI_IMPORT_FAILED" | "MODULE_UI_MOUNT_FAILED";
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
});
/**
* Carries a stable code and a fixed message, never the artifact URL or a caught cause, matching
* every other Ekklesia error boundary. The originating error is attached as `cause` for a host's
* own diagnostics, and is never part of the message.
*/
export class ModuleUiHostError extends Error {
readonly code: ModuleUiHostErrorCode;
constructor(code: ModuleUiHostErrorCode, options?: { readonly cause?: unknown }) {
super(errorMessages[code], options);
this.name = "ModuleUiHostError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
/**
* Imports a published module UI artifact, verifies it is the module the host expected, and mounts
* it into the host's container.
*
* **This executes trusted first-party code in the host's own page.** A module UI artifact is
* ordinary same-realm JavaScript once imported: it can reach the DOM, globals, and anything else the
* page can. Nothing here is a sandbox, and publishing an artifact must never be read as evidence
* that it is safe — only installed, reviewed modules should ever be published or mounted. Server
* APIs remain the sole authority for authorization; a module UI that is mounted has gained no
* privilege it did not already have (ADR 0022, ADR 0024).
*
* Nothing is mounted until the artifact's declared identity matches the manifest identity the host
* passed in, so a stale or mismatched bundle fails before it can render or run `mount`.
*/
export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> {
const { container, context, source } = options;
const importModule = options.importModule ?? defaultImporter;
let namespace: unknown;
try {
namespace = await importModule(source.url);
} catch (error) {
throw new ModuleUiHostError("MODULE_UI_IMPORT_FAILED", { cause: error });
}
// The one contract parser, reused rather than reimplemented: it validates the default export's
// shape and that its id/version match what the host expected, and throws `ModuleUiContractError`
// with a specific code when either fails. That error propagates unwrapped — a malformed artifact
// is a contract violation, not a host failure.
const definition: ModuleUiDefinition = parseModuleUiExport(namespace, {
id: source.moduleId,
version: source.version,
});
let cleanup: ModuleUiUnmount | undefined;
try {
const result = await definition.mount(container, context);
if (typeof result === "function") {
cleanup = result;
}
} catch (error) {
// No cleanup is registered for a mount that never completed, so the returned handle can never
// claim a failed module is mounted. The container is deliberately left untouched: the host owns
// it and decides what a failed mount should look like.
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
}
let mounted = true;
return {
moduleId: definition.id,
version: definition.version,
get mounted() {
return mounted;
},
async unmount() {
if (!mounted) {
return;
}
// Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once
// and a second call never re-runs it.
mounted = false;
await cleanup?.();
},
};
}
@@ -0,0 +1,38 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
/**
* The host loader runs in a browser too, so it inherits the same rule as the authoring SDK: it must
* not reach a backend type graph. Its only dependency is the module-author contract (ADR 0024).
*/
describe("module UI host loader stays browser-safe", () => {
it("declares exactly one runtime dependency: the module UI SDK", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/module-ui-sdk"]);
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual([]);
});
it("imports nothing beyond the module UI SDK in its source", async () => {
const source = await readFile(path.join(packageDirectory, "src", "load-module-ui.ts"), "utf8");
const external = [...source.matchAll(/from\s+"([^"]+)"/gmu)]
.map(([, specifier]) => specifier)
.filter((specifier) => specifier !== undefined && !specifier.startsWith("."));
expect([...new Set(external)]).toEqual(["@ekklesia/module-ui-sdk"]);
});
it("never names a Node built-in or an Ekklesia URL prefix", async () => {
const source = await readFile(path.join(packageDirectory, "src", "load-module-ui.ts"), "utf8");
expect(source).not.toContain('"node:');
// The loader forwards a host-supplied URL; it must never construct one.
expect(source).not.toMatch(/["'`]\/api\/v1\//u);
});
});
@@ -0,0 +1,14 @@
{
"manifestVersion": 4,
"id": "example",
"name": "Example",
"version": "0.1.0",
"compatibility": {
"platform": "^0.1.0",
"moduleSdk": "^0.1.0"
},
"entrypoints": {
"server": "./dist/server/index.js",
"ui": "./dist/ui/index.js"
}
}
@@ -0,0 +1,21 @@
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
/**
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
*
* React is deliberately absent — the module template already proves the React path end to end, and
* omitting it keeps this artifact small enough to serve as a `data:` URL.
*/
export default defineModuleUi({
id: "example",
version: "0.1.0",
async mount(container, context) {
const response = await context.api.request("records");
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
container.textContent = `${context.moduleId}:${response.status}:${label}`;
return () => {
container.textContent = "";
};
},
});
@@ -0,0 +1,309 @@
// @vitest-environment happy-dom
import { ModuleUiContractError, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { describe, expect, it } from "vitest";
import { loadModuleUi, ModuleUiHostError, type ModuleUiImporter } from "../src/index.js";
/**
* A browser-loadable URL for a module built from source text.
*
* `data:` URLs are genuine module URLs that both browsers and Node import natively, so these tests
* exercise the loader's **default** `import(url)` against a real URL rather than a filesystem path.
* That matters: Core resolves `entrypoints.ui` to a server filesystem path, and a loader proven only
* against such a path would not prove anything a browser host could rely on (ADR 0024).
*/
function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
}
const validArtifact = (options: { id?: string; version?: string; cleanup?: boolean } = {}) => `
export default {
id: ${JSON.stringify(options.id ?? "announcements")},
version: ${JSON.stringify(options.version ?? "1.2.0")},
mount(container, context) {
globalThis.__mounts ??= [];
globalThis.__mounts.push({ moduleId: context.moduleId, container });
container.textContent = "mounted:" + context.moduleId;
void context.api.request("posts");
${
options.cleanup === false
? ""
: `return () => { globalThis.__unmounts ??= []; globalThis.__unmounts.push(context.moduleId); container.textContent = ""; };`
}
},
};
`;
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
return {
moduleId,
api: {
async request(path) {
requested.push(path);
return new Response(null, { status: 200 });
},
},
principal: { kind: "anonymous" },
permissions: { has: () => true },
};
}
function container(): Element {
const element = document.createElement("div");
document.body.append(element);
return element;
}
const source = { moduleId: "announcements", version: "1.2.0" };
describe("generic module UI host loader", () => {
it("imports a browser URL with the default importer, mounts, and reports identity", async () => {
const element = container();
const requested: string[] = [];
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext(requested),
});
expect(mounted.moduleId).toBe("announcements");
expect(mounted.version).toBe("1.2.0");
expect(mounted.mounted).toBe(true);
expect(element.textContent).toBe("mounted:announcements");
});
it("passes the host's own container and context straight through", async () => {
const element = container();
const requested: string[] = [];
const context = hostContext(requested);
await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context,
});
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
.__mounts;
expect(mounts?.at(-1)?.container).toBe(element);
expect(mounts?.at(-1)?.moduleId).toBe("announcements");
});
it("keeps the module's API calls relative — the loader adds no URL knowledge", async () => {
const element = container();
const requested: string[] = [];
await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext(requested),
});
expect(requested).toEqual(["posts"]);
expect(requested.join(" ")).not.toContain("/api/v1/modules");
});
it("runs the module's cleanup on unmount without touching the host container", async () => {
const element = container();
const parent = element.parentElement;
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
await mounted.unmount();
expect(mounted.mounted).toBe(false);
expect(element.textContent).toBe("");
// The host owns the container; unmounting a module never removes it.
expect(element.parentElement).toBe(parent);
expect(document.body.contains(element)).toBe(true);
});
it("is idempotent: a second unmount does nothing", async () => {
const element = container();
const before = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length;
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
await mounted.unmount();
await mounted.unmount();
await mounted.unmount();
const after = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length;
expect(after - before).toBe(1);
expect(mounted.mounted).toBe(false);
});
it("unmounts safely when the module returned no cleanup function", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact({ cleanup: false })) },
container: element,
context: hostContext([]),
});
await expect(mounted.unmount()).resolves.toBeUndefined();
expect(mounted.mounted).toBe(false);
});
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
const element = container();
const artifact = `
export default {
id: "announcements", version: "1.2.0",
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
};`;
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(artifact) },
container: element,
context: hostContext([]),
});
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
expect(mounted.mounted).toBe(false);
await expect(mounted.unmount()).resolves.toBeUndefined();
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
});
it("supports two independent instances in two containers", async () => {
const first = container();
const second = container();
const url = moduleUrl(validArtifact());
const a = await loadModuleUi({
source: { ...source, url },
container: first,
context: hostContext([]),
});
const b = await loadModuleUi({
source: { ...source, url },
container: second,
context: hostContext([]),
});
expect(first.textContent).toBe("mounted:announcements");
expect(second.textContent).toBe("mounted:announcements");
await a.unmount();
// Each handle owns its own cleanup state; unmounting one leaves the other alone.
expect(a.mounted).toBe(false);
expect(b.mounted).toBe(true);
expect(first.textContent).toBe("");
expect(second.textContent).toBe("mounted:announcements");
});
describe("rejects an artifact before mounting it", () => {
it("when the module id does not match the manifest", async () => {
const element = container();
const url = moduleUrl(validArtifact({ id: "calendar" }));
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.textContent).toBe("");
});
it("when the version does not match the manifest", async () => {
const element = container();
const url = moduleUrl(validArtifact({ version: "1.1.0" }));
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.textContent).toBe("");
});
it("when there is no default export", async () => {
const element = container();
const url = moduleUrl("export const ui = {};");
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_EXPORT_MISSING" });
});
it("when the default export is not a module UI definition", async () => {
const element = container();
const url = moduleUrl('export default { id: "announcements", version: "1.2.0" };');
const failure = loadModuleUi({
source: { ...source, url },
container: element,
context: hostContext([]),
});
await expect(failure).rejects.toBeInstanceOf(ModuleUiContractError);
await expect(failure).rejects.toMatchObject({ code: "MODULE_UI_DEFINITION_INVALID" });
});
});
it("reports an unreachable artifact as an import failure, with the cause attached", async () => {
const element = container();
try {
await loadModuleUi({
source: { ...source, url: "data:text/javascript,this is not(valid javascript" },
container: element,
context: hostContext([]),
});
expect.unreachable("expected the import to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("data:");
expect((error as ModuleUiHostError).cause).toBeDefined();
}
});
it("reports a throwing mount as a mount failure and registers no cleanup", async () => {
const element = container();
const url = moduleUrl(`
export default {
id: "announcements", version: "1.2.0",
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
};`);
try {
await loadModuleUi({
source: { ...source, url },
container: element,
context: hostContext([]),
});
expect.unreachable("expected mount to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_MOUNT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("exploded");
expect((error as { cause?: Error }).cause?.message).toBe("mount exploded");
}
// No handle exists, so nothing can claim the module is mounted. The container is left exactly
// as the module left it: the host owns it and decides what a failed mount looks like.
expect(element.textContent).toBe("partial");
});
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
const element = container();
const seen: string[] = [];
const importModule: ModuleUiImporter = async (url) => {
seen.push(url);
return { default: { id: "announcements", version: "1.2.0", mount: () => undefined } };
};
const mounted = await loadModuleUi({
source: { ...source, url: "https://assets.example.test/modules/announcements/ui.js" },
container: element,
context: hostContext([]),
importModule,
});
expect(seen).toEqual(["https://assets.example.test/modules/announcements/ui.js"]);
expect(mounted.mounted).toBe(true);
});
});
@@ -0,0 +1,121 @@
// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { beforeAll, describe, expect, it } from "vitest";
import { loadModuleUi } from "../src/index.js";
const fixtureDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"fixtures",
"artifact-module",
);
let artifactUrl: string;
let artifactSource: string;
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
return {
moduleId: "example",
api: {
async request(pathname) {
requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`);
return new Response(null, { status: 200 });
},
},
principal: { kind: "authenticated", userId: "user-123" },
permissions: { has: () => true },
};
}
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
function standaloneContext(requested: string[]): ModuleUiHostContext {
return {
moduleId: "example",
api: {
async request(pathname) {
requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`);
return new Response(null, { status: 200 });
},
},
principal: { kind: "anonymous" },
permissions: { has: () => false },
};
}
/**
* Proves the loader consumes exactly the browser ESM artifact `@ekklesia/module-ui-build` produces
* (ADR 0023) — no second output format, no checked-in build output. The artifact is rebuilt from
* source each run and served through a `data:` URL, a real module URL rather than a filesystem path.
*/
describe("loading a real compiled artifact", () => {
beforeAll(async () => {
const result = await buildModuleUi({ moduleDirectory: fixtureDirectory });
artifactSource = await readFile(result.outputFile, "utf8");
artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`;
}, 60_000);
it("produces a self-contained artifact with no bare imports for a host to resolve", () => {
expect(artifactSource).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
expect(artifactSource).toContain("export {");
expect(artifactSource).not.toContain("/api/v1/modules");
});
it("mounts the real artifact through the generic loader and unmounts it", async () => {
const container = document.createElement("div");
const requested: string[] = [];
const mounted = await loadModuleUi({
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
container,
context: ekklesiaContext(requested),
});
expect(container.textContent).toBe("example:200:user-123");
await mounted.unmount();
expect(container.textContent).toBe("");
expect(mounted.mounted).toBe(false);
});
it("still rejects the real artifact when the host expects a different version", async () => {
await expect(
loadModuleUi({
source: { moduleId: "example", version: "0.2.0", url: artifactUrl },
container: document.createElement("div"),
context: ekklesiaContext([]),
}),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
});
it("runs the same artifact under an Ekklesia host and a standalone host", async () => {
const ekklesiaRequests: string[] = [];
const standaloneRequests: string[] = [];
const ekklesiaContainer = document.createElement("div");
const standaloneContainer = document.createElement("div");
const source = { moduleId: "example", version: "0.1.0", url: artifactUrl };
const inEkklesia = await loadModuleUi({
source,
container: ekklesiaContainer,
context: ekklesiaContext(ekklesiaRequests),
});
const inStandalone = await loadModuleUi({
source,
container: standaloneContainer,
context: standaloneContext(standaloneRequests),
});
// One artifact, one loader, two hosts. Every difference lives in the context the host supplied:
// the module asked for "records" both times and resolved nothing itself.
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
expect(ekklesiaContainer.textContent).toBe("example:200:user-123");
expect(standaloneContainer.textContent).toBe("example:200:guest");
await inEkklesia.unmount();
await inStandalone.unmount();
});
});
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["tests/**/*.ts"]
}
+2 -4
View File
@@ -16,13 +16,11 @@
"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:*"
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+35 -5
View File
@@ -1,4 +1,33 @@
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
/**
* Who is viewing, as a browser sees it.
*
* This is deliberately **not** Core's `EkklesiaPrincipal`, and the difference is the point. That
* type's `userId` is a Zod-branded string whose brand only means something to server-side parsing,
* and reaching for it made this package import `@ekklesia/contracts` — a backend barrel whose type
* graph pulls Drizzle, and with it Node's global declarations, back into every module UI program
* (measured: a UI file importing this package could typecheck `process.env`). A browser contract
* that drags a database ORM's types behind it is not a browser contract.
*
* So the shape is preserved and the brand is dropped: plain, serializable, and exactly what a UI
* needs to decide what to render. An Ekklesia host can pass its own `EkklesiaPrincipal` straight in
* — a branded string is assignable to `string`, so the two remain structurally compatible without
* this package knowing anything about the server's representation. A standalone host builds the
* same object literal from whatever identity system it has.
*
* Nothing server-internal belongs here. A field is added only when module UI provably needs it to
* render something.
*/
export interface ModuleUiAnonymousPrincipal {
readonly kind: "anonymous";
}
export interface ModuleUiAuthenticatedPrincipal {
readonly kind: "authenticated";
/** Opaque to module UI: an identifier to correlate with, never to parse or display verbatim. */
readonly userId: string;
}
export type ModuleUiPrincipal = ModuleUiAnonymousPrincipal | ModuleUiAuthenticatedPrincipal;
/**
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
@@ -10,7 +39,8 @@ import type { EkklesiaPrincipal } from "@ekklesia/contracts";
* 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.
* host that never runs Core, which is what makes one module UI hostable by both. This package has
* no dependencies at all, so importing it cannot pull a backend type graph into a browser program.
*/
export interface ModuleUiHostContext {
/** The module this UI belongs to, as the host knows it. Supplied so a module never has to hardcode
@@ -18,9 +48,9 @@ export interface ModuleUiHostContext {
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;
/** Who is viewing, for presentation decisions only. See {@link ModuleUiPrincipal} for why this is
* a browser-owned shape rather than Core's server principal type. */
readonly principal: ModuleUiPrincipal;
/** Presentation-only permission hints. See {@link ModuleUiPermissions}. */
readonly permissions: ModuleUiPermissions;
}
@@ -0,0 +1,48 @@
import { execFile } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
const run = promisify(execFile);
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const fixtureDirectory = path.join(packageDirectory, "tests", "browser-boundary");
const typescript = path.join(packageDirectory, "node_modules", ".bin", "tsc");
async function typecheck(project: string): Promise<{ code: number; output: string }> {
try {
await run(typescript, ["--noEmit", "--project", path.join(fixtureDirectory, project)]);
return { code: 0, output: "" };
} catch (error) {
const failure = error as { code?: number; stdout?: string; stderr?: string };
return { code: failure.code ?? 1, output: `${failure.stdout ?? ""}${failure.stderr ?? ""}` };
}
}
/**
* The browser boundary, tested the way a module author experiences it rather than by inspecting the
* dependency graph.
*
* Both fixtures compile under the *exact* project a module uses for UI source —
* `browser-react.json` with `"types": []`. Before this package dropped its `@ekklesia/contracts`
* dependency, the negative fixture below compiled successfully: that import reached a backend
* barrel whose Drizzle types re-introduced Node's global declarations, so `process.env` typechecked
* inside browser-only code (ADR 0024).
*/
describe("module UI SDK browser boundary", () => {
it("compiles supported UI source that imports the public SDK", async () => {
const result = await typecheck("tsconfig.supported.json");
expect(result.output).toBe("");
expect(result.code).toBe(0);
}, 60_000);
it("refuses Node globals in UI source that imports the public SDK", async () => {
const result = await typecheck("tsconfig.node-global.json");
expect(result.code).not.toBe(0);
// Specifically because `process` does not exist — not because of an unrelated error.
expect(result.output).toContain("Cannot find name 'process'");
expect(result.output).toContain("node-global.tsx");
}, 60_000);
});
@@ -0,0 +1,6 @@
// The negative case. Importing the public SDK must NOT make Node globals visible. If this file
// ever typechecks, the browser boundary has regressed and `browser-boundary.test.ts` fails.
import type { ModuleUiHostContext } from "../../src/index.js";
export type Context = ModuleUiHostContext;
export const leaked: string | undefined = process.env.NODE_ENV;
@@ -0,0 +1,24 @@
// A module author's supported UI source: it imports the public SDK and must typecheck cleanly
// under the exact browser project a module uses (`browser-react.json`, `"types": []`).
import {
defineModuleUi,
type ModuleUiHostContext,
type ModuleUiPrincipal,
} from "../../src/index.js";
function describePrincipal(principal: ModuleUiPrincipal): string {
return principal.kind === "authenticated" ? principal.userId : "anonymous";
}
export default defineModuleUi({
id: "example",
version: "0.1.0",
mount(container: Element, context: ModuleUiHostContext) {
container.textContent = describePrincipal(context.principal);
void context.api.request("records");
void context.permissions.has("example.records.read");
return () => {
container.textContent = "";
};
},
});
@@ -0,0 +1,5 @@
{
"extends": "../../../typescript-config/browser-react.json",
"compilerOptions": { "noEmit": true, "types": [] },
"include": ["node-global.tsx"]
}
@@ -0,0 +1,5 @@
{
"extends": "../../../typescript-config/browser-react.json",
"compilerOptions": { "noEmit": true, "types": [] },
"include": ["supported.tsx"]
}
@@ -18,29 +18,30 @@ async function sourceFiles(): Promise<string[]> {
* 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 () => {
it("imports nothing from another package at all, at type level or runtime", async () => {
// Stronger than "type-only imports are fine". A type-only import is erased at runtime but still
// drags the imported package's *type graph* into every consuming program — which is exactly how
// Node globals reached browser-only module UI before ADR 0024. This package now imports nothing
// external, so there is no graph to drag.
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),
];
const externalImports = [...source.matchAll(/^(?:import|export)\s[^;]*?from\s+"([^"]+)"/gmu)]
.map(([, specifier]) => specifier)
.filter((specifier) => specifier !== undefined && !specifier.startsWith("."));
for (const [, clause, specifier] of workspaceImports) {
expect(
clause?.startsWith("type "),
`${file} imports ${specifier} at runtime; it must be a type-only import`,
).toBe(true);
}
expect(externalImports, `${file} imports outside this package`).toEqual([]);
}
});
it("declares no runtime dependency other than the type-only contracts package", async () => {
it("declares no dependencies whatsoever", async () => {
const manifestPath = path.join(sourceDirectory, "..", "package.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/contracts"]);
expect(Object.keys(manifest.dependencies ?? {})).toEqual([]);
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual([]);
});
it("does not depend on a UI framework, so the contract stays framework-neutral", async () => {
@@ -61,5 +62,9 @@ describe("module UI SDK stays browser-safe", () => {
for (const framework of ["react", "react-dom", "vue", "svelte", "preact"]) {
expect(declared).not.toContain(framework);
}
// Backend graphs must not reappear through a development dependency either.
for (const backend of ["drizzle-orm", "zod", "semver", "@ekklesia/contracts"]) {
expect(declared).not.toContain(backend);
}
});
});
+1 -2
View File
@@ -6,8 +6,7 @@
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": []
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
+2 -2
View File
@@ -2,7 +2,7 @@
"extends": "../typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": ["vitest/globals"]
"types": []
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["tests/**/*.ts"]
}
+27 -3
View File
@@ -183,15 +183,39 @@ importers:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-ui-sdk:
packages/module-ui-host:
dependencies:
'@ekklesia/contracts':
'@ekklesia/module-ui-sdk':
specifier: workspace:*
version: link:../contracts
version: link:../module-ui-sdk
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@ekklesia/module-ui-build':
specifier: workspace:*
version: link:../module-ui-build
'@types/node':
specifier: 24.13.3
version: 24.13.3
happy-dom:
specifier: 20.0.11
version: 20.0.11
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-ui-sdk:
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@types/node':
specifier: 24.13.3
version: 24.13.3
typescript:
specifier: 7.0.2
version: 7.0.2
+11 -1
View File
@@ -152,6 +152,7 @@ const SHARED_PACKAGE_ROLES = new Set([
"contracts",
"design-tokens",
"module-sdk",
"module-ui-host",
"module-ui-sdk",
"shared",
"surface-sdk",
@@ -254,7 +255,9 @@ function boundaryRule(from: Location, to: Location): string | undefined {
if (
from.layer === "shared" &&
["builder-sdk", "module-sdk", "module-ui-sdk", "surface-sdk"].includes(from.role ?? "") &&
["builder-sdk", "module-sdk", "module-ui-host", "module-ui-sdk", "surface-sdk"].includes(
from.role ?? "",
) &&
["app", "builder", "core", "module", "surface"].includes(to.layer)
) {
return "sdks-do-not-depend-on-implementations";
@@ -270,6 +273,13 @@ function boundaryRule(from: Location, to: Location): string | undefined {
) {
return "sdks-do-not-depend-on-implementations";
}
// The host loader consumes the module-author contract — that is its whole job — plus dev-time
// `tooling` (it builds a real artifact in its own tests through the canonical pipeline rather
// than reimplementing one). It may not reach further: never a module, an app, a surface, or the
// backend contracts barrel, which is what keeps it browser-safe (ADR 0024).
if (from.role === "module-ui-host" && !["module-ui-sdk", "tooling"].includes(to.role ?? "")) {
return "sdks-do-not-depend-on-implementations";
}
if (from.role === "builder-sdk" && !["contracts", "surface-sdk"].includes(to.role ?? "")) {
return "sdks-do-not-depend-on-implementations";
}
+13
View File
@@ -50,6 +50,7 @@ describe("repository dependency boundaries", () => {
["module-ui-aliased-framework", "modules-do-not-own-ui"],
["module-ui-imports-core", "modules-use-public-platform-contracts"],
["module-ui-bundler-plugin", "modules-do-not-own-ui"],
["module-imports-ui-host", "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"],
@@ -121,6 +122,18 @@ describe("repository dependency boundaries", () => {
expect(result.violations).toEqual([]);
});
it("keeps the host loader out of module packages", async () => {
// `@ekklesia/module-ui-host` is host infrastructure. A module that depended on it would bundle
// loader code into its own artifact and blur the author/host split (ADR 0024), so the
// `module-ui-host` role is deliberately absent from the roles a module may depend on.
const result = await checkBoundaries(path.join(fixtures, "module-imports-ui-host"));
expect(result.violations.map((violation) => violation.rule)).toEqual([
"modules-use-public-platform-contracts",
"modules-use-public-platform-contracts",
]);
});
it("still rejects a module reaching for a bundler plugin ecosystem", async () => {
// Module authors do not configure a bundler; `@ekklesia/module-ui-build` owns that entirely.
const result = await checkBoundaries(path.join(fixtures, "module-ui-bundler-plugin"));
@@ -0,0 +1,14 @@
{
"name": "@ekklesia/module-calendar",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@ekklesia/module-ui-host": "workspace:*"
},
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module"
}
}
@@ -0,0 +1 @@
export const moduleId = "calendar";
@@ -0,0 +1,3 @@
import { loadModuleUi } from "@ekklesia/module-ui-host";
export const misuse = loadModuleUi;
@@ -0,0 +1,11 @@
{
"name": "@ekklesia/module-ui-host",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module-ui-host"
}
}
@@ -0,0 +1 @@
export function loadModuleUi(): void {}