From 44e676f0df640ac7fce30f2086e36af1e6a07655 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 21 Aug 2026 21:44:42 -0500 Subject: [PATCH] feat: close the browser boundary and add the generic UI host loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps stood between the module UI contract and a host that could use it. ADR 0023 recorded the first honestly but never diagnosed it: a module UI file compiled with `"types": []` could still typecheck `process.env`. The second was simply missing — nothing imported a compiled artifact, checked its identity, mounted it, or tore it down. The leak was one type-only import, bisected against the real type graph rather than guessed. `@ekklesia/module-ui-sdk` imported `type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing contracts/identity alone is clean and importing Zod alone is clean, but contracts exposes a single barrel entry, so that one import reached module-database, whose Drizzle types re-introduce Node's globals. The runtime was never affected — the import was erased and host-context.js is `export {}` — but a type-graph leak is still a real boundary failure, because it silently permits code that cannot run in its target environment. The fix is not a narrower import path. The SDK now has no dependencies at all, so there is no graph to leak through and no future contracts change can reopen this. It owns `ModuleUiPrincipal`: the same shape ADR 0022 chose, with the Zod brand dropped. That brand encodes a server-side parsing guarantee a browser cannot produce, and it is the same reason `permissions.has` already took a plain string. An Ekklesia host still passes its own `EkklesiaPrincipal` straight through — a branded string is assignable to `string` — while a standalone host builds the literal. Proven the way an author experiences it: two fixtures compiled under the exact project a module uses, one supported and one deliberately reaching for `process`, with the second required to fail on that specific name. `@ekklesia/module-ui-host` is the generic loader, in its own package because the SDK is bundled into every module artifact and host-only loader code has no business shipping there. The boundary checker enforces both directions: modules may not depend on the loader, and the loader may depend only on the SDK plus dev-time tooling. The distinction that shaped the design: Core resolves `entrypoints.ui` to an absolute path on the server's disk, and a browser needs a URL. A loader that accepted the path would test green on a server and fail in every real host, and would quietly own asset publication. So the loader takes a browser-loadable URL and knows nothing about how one is obtained. Ekklesia has no route that publishes installed artifacts, so this is precisely: loader complete, publication deferred. Nothing can mount an installed module UI yet, and the docs say so rather than implying otherwise. Dynamic import is `(url) => import(url)` with a non-analyzable specifier, kept as a runtime import so a host does not statically bundle every installed module or need a hardcoded registry. It is injectable for bundlers needing their own escape hatch, and deliberately not `eval` or `new Function`, which would cost a host `unsafe-eval` in its CSP. Identity is validated through the SDK's existing parser before `mount` runs, so a stale or mismatched bundle never renders. Contract violations propagate unwrapped; the loader adds only import and mount failure codes, sanitized, with the cause attached. Unmount is idempotent, safe when a module returned no cleanup, and marks an instance unmounted before running cleanup so a throwing cleanup surfaces once and never re-runs. A failed mount registers no cleanup. Each instance owns its own state, so two mounts coexist. The loader never mutates the host's container — not on success, failure, or unmount. Tests prove all of it against real module URLs (`data:`, which both browsers and Node import natively) rather than filesystem paths, including one artifact built by the canonical Pass 2 pipeline and run under both an Ekklesia-shaped and a standalone-shaped context. Core, apps/api, and the contracts package are untouched; discovery remains inert. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 3 +- README.md | 21 +- ...2-module-ui-authoring-and-host-contract.md | 8 + .../0023-module-ui-build-pipeline.md | 7 + ...024-browser-safe-ui-sdk-and-host-loader.md | 199 +++++++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 14 + docs/architecture/repository-boundaries.md | 7 +- docs/development/security.md | 17 + docs/module-development/README.md | 66 +++- packages/module-ui-host/package.json | 35 ++ packages/module-ui-host/src/index.ts | 1 + packages/module-ui-host/src/load-module-ui.ts | 156 +++++++++ .../tests/browser-safety.test.ts | 38 +++ .../artifact-module/ekklesia.module.json | 14 + .../fixtures/artifact-module/src/ui/index.tsx | 21 ++ .../tests/load-module-ui.test.ts | 309 ++++++++++++++++++ .../tests/real-artifact.test.ts | 121 +++++++ packages/module-ui-host/tsconfig.build.json | 13 + packages/module-ui-host/tsconfig.json | 8 + packages/module-ui-host/tsconfig.test.json | 8 + packages/module-ui-sdk/package.json | 6 +- packages/module-ui-sdk/src/host-context.ts | 40 ++- .../tests/browser-boundary.test.ts | 48 +++ .../tests/browser-boundary/node-global.tsx | 6 + .../tests/browser-boundary/supported.tsx | 24 ++ .../tsconfig.node-global.json | 5 + .../browser-boundary/tsconfig.supported.json | 5 + .../tests/browser-safety.test.ts | 29 +- packages/module-ui-sdk/tsconfig.build.json | 3 +- packages/module-ui-sdk/tsconfig.json | 4 +- packages/module-ui-sdk/tsconfig.test.json | 8 + pnpm-lock.yaml | 30 +- scripts/check-boundaries.ts | 12 +- tests/boundaries.test.ts | 13 + .../modules/calendar/package.json | 14 + .../modules/calendar/src/index.ts | 1 + .../modules/calendar/src/ui/editor.tsx | 3 + .../packages/module-ui-host/package.json | 11 + .../packages/module-ui-host/src/index.ts | 1 + 40 files changed, 1286 insertions(+), 44 deletions(-) create mode 100644 docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md create mode 100644 packages/module-ui-host/package.json create mode 100644 packages/module-ui-host/src/index.ts create mode 100644 packages/module-ui-host/src/load-module-ui.ts create mode 100644 packages/module-ui-host/tests/browser-safety.test.ts create mode 100644 packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json create mode 100644 packages/module-ui-host/tests/fixtures/artifact-module/src/ui/index.tsx create mode 100644 packages/module-ui-host/tests/load-module-ui.test.ts create mode 100644 packages/module-ui-host/tests/real-artifact.test.ts create mode 100644 packages/module-ui-host/tsconfig.build.json create mode 100644 packages/module-ui-host/tsconfig.json create mode 100644 packages/module-ui-host/tsconfig.test.json create mode 100644 packages/module-ui-sdk/tests/browser-boundary.test.ts create mode 100644 packages/module-ui-sdk/tests/browser-boundary/node-global.tsx create mode 100644 packages/module-ui-sdk/tests/browser-boundary/supported.tsx create mode 100644 packages/module-ui-sdk/tests/browser-boundary/tsconfig.node-global.json create mode 100644 packages/module-ui-sdk/tests/browser-boundary/tsconfig.supported.json create mode 100644 packages/module-ui-sdk/tsconfig.test.json create mode 100644 tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/package.json create mode 100644 tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/src/ui/editor.tsx create mode 100644 tests/fixtures/boundaries/module-imports-ui-host/packages/module-ui-host/package.json create mode 100644 tests/fixtures/boundaries/module-imports-ui-host/packages/module-ui-host/src/index.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19dab94..284dc63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//...`, 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 diff --git a/README.md b/README.md index 58f4867..4c27d34 100644 --- a/README.md +++ b/README.md @@ -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 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 index f0d8566..54d1b92 100644 --- a/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md +++ b/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md @@ -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; diff --git a/docs/architecture/decisions/0023-module-ui-build-pipeline.md b/docs/architecture/decisions/0023-module-ui-build-pipeline.md index 90c27f7..e95a596 100644 --- a/docs/architecture/decisions/0023-module-ui-build-pipeline.md +++ b/docs/architecture/decisions/0023-module-ui-build-pipeline.md @@ -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 diff --git a/docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md b/docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md new file mode 100644 index 0000000..6d97f7d --- /dev/null +++ b/docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md @@ -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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 89d612e..37ce4db 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 1d0bb54..137bbeb 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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. diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index 2c77a08..8b51acd 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -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. diff --git a/docs/development/security.md b/docs/development/security.md index 04ab04e..679fc3f 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -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 diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 0036462..ff93873 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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. diff --git a/packages/module-ui-host/package.json b/packages/module-ui-host/package.json new file mode 100644 index 0000000..a584e1f --- /dev/null +++ b/packages/module-ui-host/package.json @@ -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" + } +} diff --git a/packages/module-ui-host/src/index.ts b/packages/module-ui-host/src/index.ts new file mode 100644 index 0000000..0cc9f60 --- /dev/null +++ b/packages/module-ui-host/src/index.ts @@ -0,0 +1 @@ +export * from "./load-module-ui.js"; diff --git a/packages/module-ui-host/src/load-module-ui.ts b/packages/module-ui-host/src/load-module-ui.ts new file mode 100644 index 0000000..1aef373 --- /dev/null +++ b/packages/module-ui-host/src/load-module-ui.ts @@ -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; + +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; +} + +export type ModuleUiHostErrorCode = "MODULE_UI_IMPORT_FAILED" | "MODULE_UI_MOUNT_FAILED"; + +const errorMessages: Readonly> = 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 { + 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?.(); + }, + }; +} diff --git a/packages/module-ui-host/tests/browser-safety.test.ts b/packages/module-ui-host/tests/browser-safety.test.ts new file mode 100644 index 0000000..25805bf --- /dev/null +++ b/packages/module-ui-host/tests/browser-safety.test.ts @@ -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; peerDependencies?: Record }; + + 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); + }); +}); diff --git a/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json b/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json new file mode 100644 index 0000000..30a704c --- /dev/null +++ b/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json @@ -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" + } +} diff --git a/packages/module-ui-host/tests/fixtures/artifact-module/src/ui/index.tsx b/packages/module-ui-host/tests/fixtures/artifact-module/src/ui/index.tsx new file mode 100644 index 0000000..120dfed --- /dev/null +++ b/packages/module-ui-host/tests/fixtures/artifact-module/src/ui/index.tsx @@ -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 = ""; + }; + }, +}); diff --git a/packages/module-ui-host/tests/load-module-ui.test.ts b/packages/module-ui-host/tests/load-module-ui.test.ts new file mode 100644 index 0000000..db3d798 --- /dev/null +++ b/packages/module-ui-host/tests/load-module-ui.test.ts @@ -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); + }); +}); diff --git a/packages/module-ui-host/tests/real-artifact.test.ts b/packages/module-ui-host/tests/real-artifact.test.ts new file mode 100644 index 0000000..7feb617 --- /dev/null +++ b/packages/module-ui-host/tests/real-artifact.test.ts @@ -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(); + }); +}); diff --git a/packages/module-ui-host/tsconfig.build.json b/packages/module-ui-host/tsconfig.build.json new file mode 100644 index 0000000..3fdc831 --- /dev/null +++ b/packages/module-ui-host/tsconfig.build.json @@ -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"] +} diff --git a/packages/module-ui-host/tsconfig.json b/packages/module-ui-host/tsconfig.json new file mode 100644 index 0000000..a317ea5 --- /dev/null +++ b/packages/module-ui-host/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../typescript-config/browser.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/module-ui-host/tsconfig.test.json b/packages/module-ui-host/tsconfig.test.json new file mode 100644 index 0000000..8c786ea --- /dev/null +++ b/packages/module-ui-host/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../typescript-config/browser.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["tests/**/*.ts"] +} diff --git a/packages/module-ui-sdk/package.json b/packages/module-ui-sdk/package.json index 83b7a82..63a3a8f 100644 --- a/packages/module-ui-sdk/package.json +++ b/packages/module-ui-sdk/package.json @@ -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" }, diff --git a/packages/module-ui-sdk/src/host-context.ts b/packages/module-ui-sdk/src/host-context.ts index cfe35b6..c082780 100644 --- a/packages/module-ui-sdk/src/host-context.ts +++ b/packages/module-ui-sdk/src/host-context.ts @@ -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; } diff --git a/packages/module-ui-sdk/tests/browser-boundary.test.ts b/packages/module-ui-sdk/tests/browser-boundary.test.ts new file mode 100644 index 0000000..de464e3 --- /dev/null +++ b/packages/module-ui-sdk/tests/browser-boundary.test.ts @@ -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); +}); diff --git a/packages/module-ui-sdk/tests/browser-boundary/node-global.tsx b/packages/module-ui-sdk/tests/browser-boundary/node-global.tsx new file mode 100644 index 0000000..12204b4 --- /dev/null +++ b/packages/module-ui-sdk/tests/browser-boundary/node-global.tsx @@ -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; diff --git a/packages/module-ui-sdk/tests/browser-boundary/supported.tsx b/packages/module-ui-sdk/tests/browser-boundary/supported.tsx new file mode 100644 index 0000000..8efcb04 --- /dev/null +++ b/packages/module-ui-sdk/tests/browser-boundary/supported.tsx @@ -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 = ""; + }; + }, +}); diff --git a/packages/module-ui-sdk/tests/browser-boundary/tsconfig.node-global.json b/packages/module-ui-sdk/tests/browser-boundary/tsconfig.node-global.json new file mode 100644 index 0000000..2e8419f --- /dev/null +++ b/packages/module-ui-sdk/tests/browser-boundary/tsconfig.node-global.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../typescript-config/browser-react.json", + "compilerOptions": { "noEmit": true, "types": [] }, + "include": ["node-global.tsx"] +} diff --git a/packages/module-ui-sdk/tests/browser-boundary/tsconfig.supported.json b/packages/module-ui-sdk/tests/browser-boundary/tsconfig.supported.json new file mode 100644 index 0000000..cfc82cf --- /dev/null +++ b/packages/module-ui-sdk/tests/browser-boundary/tsconfig.supported.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../typescript-config/browser-react.json", + "compilerOptions": { "noEmit": true, "types": [] }, + "include": ["supported.tsx"] +} diff --git a/packages/module-ui-sdk/tests/browser-safety.test.ts b/packages/module-ui-sdk/tests/browser-safety.test.ts index e91635e..7bd0983 100644 --- a/packages/module-ui-sdk/tests/browser-safety.test.ts +++ b/packages/module-ui-sdk/tests/browser-safety.test.ts @@ -18,29 +18,30 @@ async function sourceFiles(): Promise { * 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; + peerDependencies?: Record; }; - 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); + } }); }); diff --git a/packages/module-ui-sdk/tsconfig.build.json b/packages/module-ui-sdk/tsconfig.build.json index 64398a6..3fdc831 100644 --- a/packages/module-ui-sdk/tsconfig.build.json +++ b/packages/module-ui-sdk/tsconfig.build.json @@ -6,8 +6,7 @@ "noEmit": false, "outDir": "dist", "rootDir": "src", - "sourceMap": true, - "types": [] + "sourceMap": true }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.test.ts", "tests"] diff --git a/packages/module-ui-sdk/tsconfig.json b/packages/module-ui-sdk/tsconfig.json index 582aaf5..a317ea5 100644 --- a/packages/module-ui-sdk/tsconfig.json +++ b/packages/module-ui-sdk/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../typescript-config/browser.json", "compilerOptions": { "noEmit": true, - "types": ["vitest/globals"] + "types": [] }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": ["src/**/*.ts"] } diff --git a/packages/module-ui-sdk/tsconfig.test.json b/packages/module-ui-sdk/tsconfig.test.json new file mode 100644 index 0000000..8c786ea --- /dev/null +++ b/packages/module-ui-sdk/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../typescript-config/browser.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["tests/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e2ca1b..fa2ecde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,15 +183,39 @@ importers: specifier: 4.1.10 version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(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: + 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/node@24.13.3)(happy-dom@20.0.11)(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: + 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 diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index cf69394..d07048b 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -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"; } diff --git a/tests/boundaries.test.ts b/tests/boundaries.test.ts index 554de9c..08b2fc8 100644 --- a/tests/boundaries.test.ts +++ b/tests/boundaries.test.ts @@ -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")); diff --git a/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/package.json b/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/package.json new file mode 100644 index 0000000..cbfac02 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/package.json @@ -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" + } +} diff --git a/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/src/index.ts b/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/src/index.ts new file mode 100644 index 0000000..1f0c5a0 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/src/index.ts @@ -0,0 +1 @@ +export const moduleId = "calendar"; diff --git a/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/src/ui/editor.tsx b/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/src/ui/editor.tsx new file mode 100644 index 0000000..0e4c000 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/src/ui/editor.tsx @@ -0,0 +1,3 @@ +import { loadModuleUi } from "@ekklesia/module-ui-host"; + +export const misuse = loadModuleUi; diff --git a/tests/fixtures/boundaries/module-imports-ui-host/packages/module-ui-host/package.json b/tests/fixtures/boundaries/module-imports-ui-host/packages/module-ui-host/package.json new file mode 100644 index 0000000..d385ef1 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-ui-host/packages/module-ui-host/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/module-ui-host", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "module-ui-host" + } +} diff --git a/tests/fixtures/boundaries/module-imports-ui-host/packages/module-ui-host/src/index.ts b/tests/fixtures/boundaries/module-imports-ui-host/packages/module-ui-host/src/index.ts new file mode 100644 index 0000000..cf13ac8 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-ui-host/packages/module-ui-host/src/index.ts @@ -0,0 +1 @@ +export function loadModuleUi(): void {}