Files
Ekklesia/docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md
T
ekklesiaandClaude Opus 5 3f9b5e3cb0
CI / verify (pull_request) Successful in 31s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 42s
fix: require a compiled artifact for entrypoints.jobs
Module discovery gated the compiled-ESM rule on an allowlist,
`["schema", "server", "ui"]`, so `entrypoints.jobs` accepted
`./src/jobs/index.ts`. Every other executable entrypoint required a built
`.js` or `.mjs`; `jobs` did not, purely because it was never added to
that list. It is the second entrypoint to slip through the same way —
`ui` did too, until Phase 6A added it explicitly — which is the argument
against the allowlist rather than against `jobs`.

The rule is now expressed as "every entrypoint except a directory
entrypoint", with `migrations` the only member of that set. A new
manifest entrypoint therefore defaults to the stricter validation instead
of silently escaping it. The same constant replaces the two existing
`=== "migrations"` special cases for the file/directory kind check and
its message, so the one thing that distinguishes `migrations` is stated
once. The directory message is now templated and produces byte-identical
text.

Every other entrypoint guarantee is untouched: package-relative paths,
no traversal, inside-package before and after symlink resolution, the
artifact must exist, and it must be a file.

No runtime was added. `entrypoints.jobs` still has no consumer — it is
declared, validated, and never executed — exactly as it has been since
ADR 0006, and as `ui` was before its host loader existed. The manifest
schema is unchanged and this stays within v4: the field, its type, and
its optionality are all the same, and only discovery's validation of the
declared path tightened.

Compatibility impact is nil in first-party code: no module, template, or
fixture declares a `jobs` entrypoint at all. A third-party module
declaring a source-TypeScript path would now fail discovery, which is the
intended correction.

Tests cover accepted `.js` and `.mjs`, rejected `.ts`/`.tsx`/`.cjs`, a
missing artifact, a directory, a symlink escaping the package, and that
discovery still never evaluates the artifact. The three rejection cases
were confirmed to fail against the previous implementation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 22:07:54 -05:00

13 KiB

0024: Browser-safe module UI SDK and the generic host loader

  • Status: Accepted
  • Date: 2026-08-21
  • Extends: 0022, 0023
  • Supersedes (partial): 0022 — 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.

Correction — this deferred item is closed. entrypoints.jobs now requires a compiled .js or .mjs artifact, exactly like server, schema, and ui. Module discovery expresses the rule as "every entrypoint except the directory entrypoint (migrations)", so the omission that let jobs escape it cannot recur. No jobs runtime was added: the entrypoint is still declared, validated, and never executed. Every other item in this list remains deferred.