# Module development An Ekklesia module is an independently versioned, first-class product. It owns its domain behavior, data declarations, permissions, capabilities, HTTP routes, migrations — and, as of [ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md), its own UI. It does **not** own the application that contains it. Authentication, authorization enforcement, platform context, the application shell, top-level routing, navigation, the global theme and design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host application's. A module consumes those; it never redefines or bypasses them. > **What exists today:** you can author independently optional primary and full-viewport display UI > roles in React, build their declared artifacts, have Core validate and publish them, and have > Ekklesia mount each in its proper browser surface. The primary shell has flat configurable > navigation; the display shell has no Dashboard chrome. A finished design system, display > inventory/management, public/device identity, and the standalone host do not exist. See > [Declaring UI entrypoints](#declaring-ui-entrypoints), > [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact), and > [What actually mounts your UI](#what-actually-mounts-your-ui). > **Before you publish or install a module:** modules are trusted compiled backend code. Your > module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and > nothing in this guide — manifests, permission declarations, typed facades, route validation, > frozen contexts — isolates it. An operator installing your module is deploying trusted server > code. See [Security model and deployment trust boundaries](../development/security.md). ## Required package shape A module is developed as an ESM workspace package in a checkout's `modules/` directory, and it is installed, self-contained, into an installation's module storage ([An installed module is self-contained](#an-installed-module-is-self-contained)). In a checkout, each immediate child of `modules/` must be an ESM workspace package with a unique package name, a strict semantic version, and the module layer marker: ```text modules/example/ ├── drizzle/ # when the module has migration history │ ├── 0000_initial.sql │ └── meta/ │ ├── 0000_snapshot.json │ ├── _ekklesia.json │ └── _journal.json ├── dist/schema/index.js # when the module owns tables ├── dist/server/index.js ├── dist/ui/index.js # when the module declares primary UI ├── dist/display/index.js # when the module declares a display ├── drizzle.config.ts # when generating migrations ├── ekklesia.module.json ├── package.json ├── src/schema/index.ts # when the module owns tables ├── src/schema/tables.ts # pure Drizzle declarations used by Drizzle Kit ├── src/server/index.ts ├── src/ui/index.tsx # fixed primary-UI build source, when declared └── src/display/index.tsx # fixed display build source, when declared ``` The schema and migration paths are optional. A tableless module omits `entrypoints.schema`; a module with no migration history omits `entrypoints.migrations` and needs no placeholder files. ```json { "type": "module", "ekklesia": { "layer": "module" } } ``` The package version must match the manifest and runtime-definition versions. Inside this repository, Ekklesia package dependencies use `workspace:` ranges and supported public package entrypoints. A module in its own repository depends on the published packages instead ([Developing a module in its own repository](#developing-a-module-in-its-own-repository)). ### Developing a module in its own repository > **Status:** the packages are published by release > ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md), > [Releases and the public package train](../development/releases.md)). The first published train > is 0.2.0, the platform release after 0.1.2. A module does not need an Ekklesia checkout. Do not clone Ekklesia and put your module in its `modules/` directory: your module is its own repository, and it depends on Ekklesia's module-development packages like any other npm packages. The packages are **publicly readable** ([ADR 0054](../architecture/decisions/0054-public-package-reads-before-launch.md)): installing them needs no account, no token, and no access to Ekklesia's Gitea. That is not a public launch, and it promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's release workflow can publish them. Point the `@ekklesia` scope at the registry in your repository's committed `.npmrc`; that is all the configuration there is: ```ini @ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ ``` Then the module builds and packages like any other: ```bash pnpm install pnpm build pnpm exec ekklesia-pack-module --module . --target any --out ../packages \ --license-file ./LICENSE --source-revision "$(git rev-parse HEAD)" ``` Before 1.0, a minor version may change these APIs (the project's SemVer policy). Pin the train you build against with ranges in `package.json` and exact versions in your lockfile. | Package | Where it goes | What it is | | --- | --- | --- | | `@ekklesia/module-sdk` | `dependencies` | `defineModuleRuntime`, routes, configuration, storage, realtime, principals | | `@ekklesia/module-database` | `dependencies` | Module Database ABI v1 types, `sql`, schema metadata; `/drizzle` for Drizzle | | `@ekklesia/module-ui-sdk` | `dependencies` | `defineModuleUi` and the host context | | `@ekklesia/design-tokens` | `dependencies` | design-token names and helpers | | `@ekklesia/ui` | `dependencies` | shared elements; a pre-1.0 API that may still change | | `react`, `react-dom`, your ORM if any | `dependencies` | your own copies, bundled into your artifacts | | `@ekklesia/module-ui-build` | `devDependencies` | `ekklesia-build-module-ui` | | `@ekklesia/module-pack` | `devDependencies`, **exact version** | `ekklesia-pack-module` | | `@ekklesia/typescript-config` | `devDependencies` | `node.json`, `browser-react.json`, ... | `@ekklesia/contracts` is not a direct dependency. The packages above install it; never add or import it yourself, because what it exports is the platform's, not a supported author API. ```json { "name": "@acme/notes", "version": "0.1.0", "license": "AGPL-3.0-or-later", "private": true, "type": "module", "engines": { "node": ">=24.11.0 <25" }, "scripts": { "build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui" }, "dependencies": { "@ekklesia/module-database": "^0.2.0", "@ekklesia/module-sdk": "^0.2.0", "@ekklesia/module-ui-sdk": "^0.2.0", "@ekklesia/ui": "^0.2.0", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { "@ekklesia/module-pack": "0.2.0", "@ekklesia/module-ui-build": "^0.2.0", "@ekklesia/typescript-config": "^0.2.0", "typescript": "7.0.2" } } ``` - **One coordinated version.** Every Ekklesia module-development package has the same version, equal to the Ekklesia release it belongs to, and the platform checks your manifest's `compatibility.moduleSdk` against it. Ranges in your `package.json` are fine; your lockfile pins exact artifacts, and you install with a frozen lockfile. - **Pin `@ekklesia/module-pack` exactly** in `package.json`, not only in the lockfile: its version decides the package it produces. - **`engines.node` is required.** The packager records your module's supported Node.js range from `engines.node` in your `package.json`, and refuses to package without it (`MODULE_PACK_RUNTIME_UNKNOWN`). It never looks for a workspace file. - **No Drizzle unless you use it.** The Module SDK does not install Drizzle. A module that uses the Drizzle adapter, `@ekklesia/module-database/drizzle`, adds `drizzle-orm` to its own dependencies; it is an optional peer of that adapter only. - **One React.** `@ekklesia/ui` takes React as a peer, so your bundle contains one React, your own. Your UI artifacts still bundle it ([ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)). - **esbuild's install script.** The UI build and the packager use esbuild, which runs an install script. With pnpm, allow it in your repository's `pnpm-workspace.yaml`: ```yaml allowBuilds: esbuild: true ``` - **Lockfiles name the registry.** pnpm records Gitea's tarball URLs in your lockfile. If the packages ever move to another registry, change `@ekklesia:registry` and regenerate the lockfile; nothing else changes, and the integrity of every package stays the same. - **TypeScript.** Extend the shared configurations by package name, for example `"extends": "@ekklesia/typescript-config/node.json"` for the backend and `@ekklesia/typescript-config/browser-react.json` for the UI. Packaging and verification work exactly as for a module in this repository ([Packaging your module](#packaging-your-module)). The packager recognizes Ekklesia's packages by their identity, not by where they are installed, so they are recorded under `incorporates` and never credited as third-party software, wherever they came from. Ekklesia proves this on every CI run: `pnpm test:package-release` builds the public packages of a release as one train of tarballs, proves two builds identical, and installs exactly those tarballs into a module outside the repository (`tests/fixtures/standalone-module`), with no workspace link and no registry, then typechecks, builds, packages, and verifies it. Those exact artifacts are what the release publishes ([Releases and the public package train](../development/releases.md)). ### An installed module is self-contained Ekklesia's image contains no modules ([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)). An installation keeps installed modules in its own module storage, the directory `EKKLESIA_MODULES_DIR` names (`/var/lib/ekklesia/modules` in the production image, the checkout's `modules/` when it is unset). Discovery reads it only when a process starts. Nothing is hot loaded, and an installed module is read-only to the platform. An installed module directory is **self-contained for runtime use**: everything the platform reads or loads from it, and everything those files import at run time, resolves inside it. You never assemble that directory by hand. You build your module, and the canonical packager turns it into an **Ekklesia Module Package v1** ([ADR 0049](../architecture/decisions/0049-module-package-v1.md)): one deterministic archive and its SHA-256, which an installation verifies and extracts into module storage. **Copying `dist/` is not an installed module.** Your compiled server code imports `@ekklesia/module-sdk` and `drizzle-orm` by name, and Node resolves those only from your module's own directory upwards. The platform's `node_modules` is never on that path, and Ekklesia adds it through no link, `NODE_PATH`, loader hook, or alias. The packager therefore bundles your backend. #### Packaging your module Build your module, then run the packager from your module's directory. It is a development dependency like `@ekklesia/module-ui-build`, and it takes your module's supported Node.js range from `engines.node` in its `package.json`: ```bash pnpm exec ekklesia-pack-module --module . --target any --out ../packages \ --license-file ./LICENSE \ --source-revision "$(git rev-parse HEAD)" ``` It writes `--.tar.gz` and its `.sha256` line, and nothing else anywhere: it never installs, links, or deploys, so your workspace is left exactly as it was. Never run `pnpm deploy` in a workspace instead: it rewrites that workspace's install state to production-only. The archive holds one directory named with your module id: ```text / ├── package.json generated: name, version, private, "type": "module", the layer marker ├── ekklesia.module.json your manifest, byte for byte ├── ekklesia.package.json package metadata: license, target, runtime, provenance, file digests ├── LICENSE your explicitly supplied module license material, byte for byte ├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled ├── dist/server/index.js your backend entrypoints, bundled ├── dist/schema/index.js ├── dist/chunks/ code your backend entrypoints share ├── dist/ui/ … dist/display/ … dist/contributions/ your browser artifacts, exactly as built ├── drizzle/ your migrations └── node_modules/ a native target only: native runtime dependencies, as real files ``` - **Bundled backend.** Your server, schema, and jobs entrypoints are bundled with every pure-JavaScript dependency they use, the Module SDK, the contracts, and Drizzle included. The only imports left are Node.js built-ins. A dependency the bundle cannot resolve fails packaging; nothing is left to be found at run time. - **Never shipped:** `src/`, tests, TypeScript declarations, source maps, development dependencies, your development `package.json`, any `workspace:` range, and links of any kind. - **Browser artifacts must be current.** The packager rebuilds them in memory and refuses an artifact on disk that differs, so run your build first. - **Targets.** - **`any`** is for pure-JavaScript modules. A dependency that is native or platform-specific (a compiled addon, a package that declares an operating system or CPU, or one that loads a per-platform binary such as `sharp`) cannot be in an `any` package, and packaging fails. - **`linux-x64-glibc`** is for modules with such a dependency, on Linux, x86-64, and glibc. Pack with `--target linux-x64-glibc` on a matching host, after installing your dependencies. - **What a `linux-x64-glibc` package contains.** - **Native packages stay outside the bundle.** Your ordinary JavaScript dependencies are bundled as before. Each native package is copied instead, with exactly its production runtime dependencies, as real files flat under `node_modules/`. - **Only this target's platform packages are copied.** Optional platform packages for other systems, development dependencies, declarations, and source maps are left out. Two versions of one package, a link inside a native package, or a required dependency for another platform fail packaging. - **`runtime` is read from the binaries themselves.** It records the Node-API version your addons declare (or the exact Node.js ABI of an ABI-specific addon) and the newest glibc symbol version any copied binary needs. A native target for a module with no native addon is refused: use `any`. - **A native package fits one environment.** A `linux-x64-glibc` artifact runs only on Linux, x86-64, glibc hosts that meet its recorded glibc and Node-API minimums. The verifier checks all of that before it loads anything. Other operating systems and CPUs, including ARM, are not supported yet. - **Your module's licence.** Your source `package.json` must contain a non-empty string SPDX expression. The packager validates its syntax, rejects `UNLICENSED`, and copies it unchanged to the required `ekklesia.package.json.license`. `--license-file` is also required: it names the regular file whose exact bytes become the root `LICENSE` payload and inventory entry. The packager never searches parent directories, guesses text from the expression, or downloads it. - **Third-party licences.** `THIRD_PARTY_NOTICES` separately credits every third-party package your bundles contain, with its declared licence and the licence and notice files it ships. A dependency that declares no licence fails packaging, and so does one that ships no licence text, unless its licence's standard text is complete without a copyright line (Apache-2.0, which the packager then supplies and says so). The module itself is not added to this notice file. Bundled Ekklesia packages remain listed in `ekklesia.package.json` under `incorporates`; one applicable AGPL text is sufficient for current official modules. An Ekklesia package is one named `@ekklesia/*` whose `package.json` also carries `"ekklesia": { "distribution": "module-development" }`, whether it is linked from this repository or installed from a registry; a package with only the name is third-party. - **Reviewed licence supplements.** Some dependencies omit material that redistribution requires. For one of those, the packager adds material from a reviewed supplement checked in with it, after the package's own material and labelled as such ([ADR 0052](../architecture/decisions/0052-reviewed-license-supplements.md)). - **Exact match.** A supplement is used only for an exact package name, version, and content digest. The digest covers the redistributed files' relative paths and bytes, and is documented in `packages/module-pack/src/supplements.ts`. Any mismatch fails. - **What it does not do.** A supplement never changes the licence the package declares. The supplements cover `@img/sharp-libvips-linux-x64` 1.3.3 and 1.3.4, the libvips binaries that `sharp` 0.35.4 and 0.35.5 load; each matches only its own version and digest, and the packager's native proof builds with sharp 0.35.5. A supplement identifies and verifies licence and notice material only. Source availability for copyleft native code is a separate requirement, met by a corresponding-source artifact ([Distributing a native package publicly](#distributing-a-native-package-publicly)). - **Deterministic.** The same packager version and the same built inputs produce the same archive, byte for byte, wherever the checkout is. **Database ABI v1 is the current path.** A module that declares `"database": { "abi": 1 }` keeps Drizzle and its exact version inside its own bundle as an implementation detail. Its package omits `transitional.databaseRuntime`; Core validates the module's plain schema metadata and never compares the bundled Drizzle version with its own. The canonical template demonstrates this path with `@ekklesia/module-database/drizzle`. A manifest-v8 module that omits `database` remains on the temporary legacy facade. Only that legacy package records bundled Drizzle under `transitional.databaseRuntime`, and the verifier continues to require exact agreement with Core. The legacy facade has not been removed. #### Verifying a package `node core/dist/cli/verify-module-package.js --sha256-file .sha256`, run from an Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without installing it. Pass `--extract-to ` to receive the verified module directory. [deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation. The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An installed package normally has no source, so the platform cannot check it the same way. Your module's own CI is where its boundary rules, its production dependencies, and its behaviour inside Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)). #### Distributing a native package publicly Building and testing a native package needs nothing beyond the packager. **Distributing it publicly** does, when it redistributes native code under the GNU LGPL, the MPL, or a similar licence with source obligations ([ADR 0055](../architecture/decisions/0055-corresponding-source-artifacts.md)). - **Covered dependencies.** A native dependency is covered when its declared licence, or any component its reviewed supplement records inside it, carries source obligations. libvips is one: it declares `LGPL-3.0-or-later`, and cairo and several Rust crates inside it are under the MPL. - **The policy.** A package with covered dependencies may be distributed publicly only together with a corresponding-source artifact. The artifact holds the exact source, patches, build definitions, and rebuild and relink instructions for those dependencies, as bytes rather than links. If the artifact cannot be produced and verified, the package is not approved for public distribution. This is Ekklesia's project compliance policy, not legal advice or certification. A package that passes the gate meets Ekklesia's redistribution policy, and nothing more is claimed. - **Source recipes.** For each covered dependency version, a reviewed recipe is checked in beside the supplements, under `packages/module-pack/corresponding-sources///`. It holds `recipe.json` and its documents, notably `BUILDING.md`. The recipe names the exact package (name, version, content digest) and the exact supplement it was prepared with. It lists every component with its role (`compiled`, `build-input`, or `build-definition`), licence, licence material, and relationship to the binary. Every source file and patch is recorded by origin, size, and SHA-256, and every exclusion has a reason. Like supplements, recipes are reviewed data, and the mechanism knows no particular library. Releasing a native package publicly takes three steps after packaging: ```bash # 1. Acquisition: the only step that uses the network. It fetches each recorded file once into a # cache keyed by SHA-256, refusing any response that differs from the recipe. pnpm exec ekklesia-corresponding-source acquire --package ../packages/--linux-x64-glibc.tar.gz \ --cache ~/.cache/ekklesia/corresponding-sources # 2. Construction: offline. Every byte is verified against the recipe before anything is written. pnpm exec ekklesia-corresponding-source build --package ../packages/--linux-x64-glibc.tar.gz \ --cache ~/.cache/ekklesia/corresponding-sources --out ../packages # 3. The public-distribution gate. Exit status 0 means approved. pnpm exec ekklesia-corresponding-source verify --package ../packages/--linux-x64-glibc.tar.gz \ --source ../packages/--linux-x64-glibc-corresponding-source.tar.gz \ --metadata ../packages/--linux-x64-glibc.redistribution.json ``` Construction writes four sibling release artifacts beside the package. The source archive never goes inside the Module Package, and `THIRD_PARTY_NOTICES` is unchanged. ```text --linux-x64-glibc.tar.gz the Module Package (packager) --linux-x64-glibc.tar.gz.sha256 --linux-x64-glibc-corresponding-source.tar.gz the corresponding source --linux-x64-glibc-corresponding-source.tar.gz.sha256 --linux-x64-glibc.redistribution.json binary ↔ source, by digest --linux-x64-glibc.source-notice.txt the release notice ``` - **The source archive** is deterministic: sorted entries, fixed modes, ownership, and timestamps, and no machine-specific path. The same package and recipe produce the same bytes in any directory. - `corresponding-source.json` records every file with its SHA-256, and `SHA256SUMS` repeats them. - Each covered dependency has its own `dependencies///` directory, with `BUILDING.md`, the recipe, `PROVENANCE.md`, the supplement, and `sources/` and `patches/`. - **The redistribution metadata** answers "which exact source corresponds to this exact binary?" from digests. It records: - the package file and its SHA-256, the module identity, and the target; - each covered dependency's version and content digest, and the supplement and recipe it was assembled from; - the source archive's file name and SHA-256, and the SHA-256 of its manifest. - **The release notice** must be shown alongside the binary wherever it is offered. It states that the release includes third-party software under licences including the GNU LGPL and the MPL, and that corresponding source and build and relink materials for the covered native dependencies are in the accompanying artifact. It names that artifact by its exact file name and SHA-256. - **Fail-closed.** The gate refuses a package when: - a covered dependency has no recipe, or a recipe or supplement does not match its exact bytes; - the source archive is missing, non-canonical, or differs from its manifest; - the archive holds a file the manifest does not record, or a generated file names a local path; - the metadata or the notice does not name these exact files. It also refuses bundled (not copied) copyleft code, which this mechanism does not cover. A package with no covered native code is approved without an artifact. - **Retention.** Keep a release's source artifact for as long as its binary release is distributed or supported. Never delete one automatically while the binary remains available. Never replace its bytes under the same name. A correction is a new release, or an explicitly versioned compliance artifact. **Available today:** a recipe for `@img/sharp-libvips-linux-x64` 1.3.4, used by sharp 0.35.5. Its artifact is about 200 MB: 27 library archives, 5 patches, 353 Rust crates, and the Rust standard library source of the recorded toolchain. Following its `BUILDING.md` rebuilds `libvips-cpp.so.8.18.7` from that source alone. libvips 1.3.3 (sharp 0.35.4) has a supplement but no recipe, so a package with it builds and tests as before but is not approved for public distribution. `pnpm test:corresponding-source` runs the whole proof for the native proof module: acquisition into a verified cache, two byte-identical builds, the gate, and sharp running from the package. **Adding a native dependency** follows the same path as libvips: - curate and review its supplement (ADR 0052); - curate and review its source recipe and `BUILDING.md`, recording every source input the dependency's build fetches; - prove both with the native packaging tests and `pnpm test:corresponding-source`. ### License Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any later version, `AGPL-3.0-or-later` ([LICENSE](../../LICENSE)), and official Ekklesia modules use the same license. Declare it in your module's development `package.json` (`"license": "AGPL-3.0-or-later"`), as the module template does. A Module Package v1 records that validated expression in its required `ekklesia.package.json.license` and contains the exact explicitly supplied bytes at `LICENSE` ([ADR 0051](../architecture/decisions/0051-module-package-license-material.md)). The generated runtime `package.json` is not authoritative for licensing. `THIRD_PARTY_NOTICES` remains only for redistributed third-party dependencies, while bundled Ekklesia components remain represented by `incorporates`. This strict-reader addition keeps `packageFormat` at `1`; older pre-0051 artifacts without the field and payload fail closed. ### Removing a module keeps its data Removing your module's directory from module storage is not deleting its data: - your tables and rows, buckets and objects stay; - the platform keeps its enablement decision, your configuration, its Dashboard placement, and permission grants; - your applied migration history stays, and the migration plan reports it as retained history for a module that is not installed. Reinstalled with the same module id, your module finds all of it again. Its history is validated strictly again, so a reinstalled package must still contain every migration that was ever applied, unchanged. Never delete or edit a released migration, and never ship an older version over newer data. Disabling is different from removing: a disabled module stays installed and is simply not loaded. Deleting a module's data (purge) does not exist yet. ## Canonical manifest `ekklesia.module.json` is inert and authoritative, so Core can validate every installed module without executing module code: ```json { "manifestVersion": 8, "id": "calendar", "name": "Calendar", "version": "0.1.0", "compatibility": { "platform": "^0.2.0", "moduleSdk": "^0.2.0" }, "dependencies": { "modules": { "required": { "people": "^0.1.0" } }, "capabilities": { "required": { "people.records": "^2.0.0" }, "optional": { "media.items": ">=1.1.0 <2.0.0" } } }, "capabilities": [ { "id": "calendar.events", "version": "1.0.0" } ], "permissions": [ { "id": "calendar.events.read", "baseline": "restricted", "description": "Read calendar events." }, { "id": "calendar.display.view", "baseline": "authenticated", "description": "View the calendar display." } ], "storage": { "buckets": [ { "id": "assets", "description": "Private event assets owned by Calendar." } ] }, "contributions": [ { "id": "calendar.signage-slide", "version": "1.0.0", "capability": "signage.slide-renderer", "capabilityRange": "^1.0.0", "artifact": "./dist/contributions/signage-slide/index.js", "description": "Renders an upcoming-events slide for a signage timeline." } ], "entrypoints": { "server": "./dist/server/index.js", "schema": "./dist/schema/index.js", "migrations": "./drizzle", "ui": "./dist/ui/index.js", "display": { "artifact": "./dist/display/index.js", "permission": "calendar.display.view" } } } ``` Module, capability, and permission identifiers are lowercase and namespaced. A provided capability has its own exact semantic version because its public contract can evolve independently from the module release. Capability dependencies use semantic-version ranges. Optional means the provider may be absent; if it is installed, its capability version must be compatible. `manifestVersion: 8` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a version 7 manifest, and any other version, fails validation outright rather than being silently reinterpreted; there is no compatibility adapter or automatic migration between manifest versions. Already-built version 7 packages must be rebuilt. Version 7 added the optional `contributions` array ([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that contributes nothing changes only the version literal; every browser artifact remains independently optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin with `.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or `restricted` — see [Authorization](../development/authorization.md#permission-registry) for what each baseline means to the evaluator), and a required, non-blank `description` up to 240 characters for operators and audit review only — Core's authorization layer never reads or interprets it. Declaring a permission here is necessary but not sufficient for it to be enforceable: the manifest only proves the declaration is well-formed and namespaced correctly, and does not itself register anything with Core's authorization registry (see [Authorization](../development/authorization.md#module-permission-composition) for how Core composes every discovered module's declarations, alongside its own, into the one platform permission registry). A duplicate permission id within one manifest, or a permission id outside the module's own namespace, fails validation the same way a duplicate or misnamed capability id does. `apps/api` composes this registry once, at startup, from every module installed at that time (see [Authorization](../development/authorization.md#api-startup-discovery-composition-and-enforcement)); installing, removing, or editing a module's manifest takes effect only on the next API restart, not while it is running. Once installed and discovered, a module's declared permission is enforceable and grantable through exactly the same mechanism as any Core-owned permission — the same `requirePermission` middleware and the same `/api/v1/grants` explicit-grant HTTP API a platform owner (or anyone they delegate `core.authorization.grants.manage` to) already uses. Mounting a module's own HTTP routes, and creating relationship-derived grants automatically from a module's own business logic, are both still later work; this only makes a declared permission *usable* through Core's existing, generic surface. ### Your module's display name `name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees in Ekklesia's Dashboard navigation. It is your module's **product identity**: what the product is called, which you own. Set it to something a church staff member would recognize ("Livestream"), not your package name or module id. **You do not declare where your module appears.** Dashboard groups — "Media", "Planning", "Sunday Production" — are presentation configuration an administrator owns, stored by the platform and changed at any time without touching your module ([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). There is no manifest field for a folder, a group, an order, an icon, or a category, and adding one would make a rendering preference into module structure. That separation also means grouping never affects you. Two modules sharing a folder have no relationship: no dependency, no ordering, no shared anything. If your module genuinely needs another module's functionality, declare it in `dependencies.modules` — that is the only kind of module relationship the platform has, and it is what orders discovery and startup. An administrator moving your module into a different folder changes nothing about your artifact, your API root, your permissions, your dependencies, or when you start. Every artifact entrypoint must be a compiled ESM `.js` or `.mjs` file — `server`, `schema`, `jobs`, `ui`, and `display.artifact` alike. `migrations` is the one directory entrypoint. All paths begin with `./`, remain inside the package, and exist. Production never imports source TypeScript directly. A display artifact must remain distinct from every other configured entrypoint before and after symlink resolution. Modules with no tables omit `entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`; modules may omit `ui`, `display`, or both. Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard organization is presentation configuration owned by the Dashboard, never module architecture. Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets. Core will later map them to deployment-specific physical S3 buckets and provide scoped access; module code must not configure SeaweedFS or another S3 client directly. ## Declaring UI entrypoints A module may declare a primary/management UI, a dedicated display UI, both, or neither. The roles are separate artifacts because a full-viewport display is not a Dashboard screen with hidden chrome: ```json { "contributions": [ { "id": "calendar.signage-slide", "version": "1.0.0", "capability": "signage.slide-renderer", "capabilityRange": "^1.0.0", "artifact": "./dist/contributions/signage-slide/index.js", "description": "Renders an upcoming-events slide for a signage timeline." } ], "entrypoints": { "server": "./dist/server/index.js", "schema": "./dist/schema/index.js", "migrations": "./drizzle", "ui": "./dist/ui/index.js", "display": { "artifact": "./dist/display/index.js", "permission": "calendar.display.view" } } } ``` Both artifacts obey the same compiled-entrypoint rules as `server` and `schema`: package-relative, inside the module before *and* after symlink resolution, a real `.js`/`.mjs` file, and never source TypeScript. The display path must not collide with any other entrypoint. Its permission must be one of this module's own declarations and must use `authenticated` or `restricted`, never `public`; anonymous/device display identity is deferred. There is one display role per module for now. Named variants and targeting are later contracts, not ad hoc manifest keys. ### What the artifact must export Each artifact default-exports one definition, built with `defineModuleUi` from `@ekklesia/module-ui-sdk` (ADR 0022): ```tsx import { defineModuleUi } from "@ekklesia/module-ui-sdk"; import { createRoot } from "react-dom/client"; import { Editor } from "./editor.js"; export default defineModuleUi({ id: "announcements", version: "0.1.0", mount(container, context) { const root = createRoot(container); root.render(); return () => root.unmount(); }, }); ``` `id` and `version` are build-artifact identity markers and must match your manifest, exactly like the ones in `defineModuleRuntime`. A host verifies both against the manifest it already validated, so a stale UI bundle is caught rather than mounted. `mount` receives a container element and the host context, and returns an unmount function or nothing. If your mount acquires anything — a React root, a subscription, a timer, an event listener — return an unmount and release it there, the same discipline your `stop` hook follows. React is the supported way to write module UI. It is deliberately **not** part of the contract: the platform hands you an element and a context, so your framework choice and the host's stay independent. Primary and display artifacts use this exact same ABI and context. There is no mode flag, `presentation.kind`, Dashboard boolean, or host identity to branch on; the separately declared artifact is the role selection. ### What the host gives you `mount` receives exactly six members, and nothing else is available: | Member | What it is | | --- | --- | | `moduleId` | Your module's id, so you never re-derive it from a URL | | `api` | `request(path, init)` against **your own** module API — see below | | `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` | | `permissions` | `has(permissionId)` — a presentation hint, never a security check | | `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) | | `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) | Navigation, module configuration, organization context, and diagnostics reporting are **not** in the contract. They need designs that do not exist, and a speculative field frozen into a public contract is harder to remove than to add. The host **theme** is deliberately not a context member either, and that is not a gap: design tokens reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import `@ekklesia/design-tokens` and write `var(--ekklesia-…)`; the host installs the values at its own root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that can forget to pass one. ### Never build your own API URL Ask the host for a path relative to your own module's API root: ```ts const response = await context.api.request("posts"); ``` Do **not** write `/api/v1/modules/announcements/posts`. That prefix is Ekklesia's private deployment detail. A standalone host serving only your module may expose the same routes at `/api`, at a different origin, or behind a proxy — and a module that builds the URL itself silently stops being hostable anywhere but Ekklesia. The host resolves the path and owns credentials, base URL, and platform headers; your UI never sees a session token. `request` returns a `Response`, the same Fetch vocabulary your backend routes already speak. ### Client-side permission checks are UX, not security `context.permissions.has("announcements.posts.publish")` tells you what the host believes, so you can hide an action or disable a control. **It is not a security boundary.** Server-side authorization is the only authority: your routes are enforced by Core's `requirePermission` middleware regardless of what any browser decided. Checking `has()` and then calling your API is still fully authorized server-side; skipping the check is still denied. The point is that a user does not see a button that would fail. ### Your UI is trusted code in the host's page Module UI runs in the same browser JavaScript realm as the host shell. There is no iframe, no sandbox, and no capability confinement — your UI can reach the host page and any global the browser exposes. This matches the backend model: installing a module already means running its code in the API process. Write module UI as trusted first-party code, because that is what it is. See [Security model and deployment trust boundaries](../development/security.md). ### Building your UI Put each declared role at its fixed source path and build once: ```text src/ui/index.tsx → entrypoints.ui src/display/index.tsx → entrypoints.display.artifact pnpm build ``` You write no bundler configuration. `@ekklesia/module-ui-build` owns all of it ([ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)), and your package's build script runs both halves of the module: ```json { "scripts": { "build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui" }, "dependencies": { "@ekklesia/module-ui-sdk": "workspace:*", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { "@ekklesia/module-ui-build": "workspace:*", "@types/react": "19.2.18", "@types/react-dom": "19.2.4" } } ``` Output paths are **read from your manifest**, not inferred, so declarations and produced artifacts cannot drift. Source paths are the exact conventions above; module authors do not configure them. The builder resolves and preflights all roles in deterministic `ui`, then `display` order, compiles both in memory, and writes only after every declared bundle succeeds. A missing or broken display source therefore cannot leave a newly written primary artifact (or vice versa). ```json { "entrypoints": { "ui": "./dist/ui/index.js", "display": { "artifact": "./dist/display/index.js", "permission": "calendar.display.view" } } } ``` A module may declare either role alone. Declaring neither is not an error — the build reports that there is nothing to build and exits successfully, so the same script works for a backend-only module. The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not compile, an import cannot be resolved, the declared path is unsafe or is not a `.js`/`.mjs` artifact, or a declared role's fixed source is missing. It also refuses any display output that could overwrite `server`, `schema`, `migrations`, `jobs`, or primary UI before invoking esbuild. #### TypeScript projects Keep UI and backend TypeScript separate. Your Node project already includes `src/**/*.ts`, which does not match `.tsx`, and a UI project covers the browser half: ```json { "extends": "@ekklesia/typescript-config/browser-react.json", "compilerOptions": { "noEmit": true, "types": [] }, "include": [ "src/ui/**/*.tsx", "src/ui/**/*.ts", "src/display/**/*.tsx", "src/display/**/*.ts" ] } ``` `"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. #### What is in your artifact **React is bundled into it.** Your artifact is self-contained: it resolves nothing at run time and needs no React from its host, which is what lets the same file run under Ekklesia and under a future standalone host that may not use React at all. The cost is that a page hosting several module UIs loads several React copies, and artifacts are large (the template's is roughly 570 KB, unminified so an operator can review it). That is accepted for now and can be optimized later **without changing the contract**, because the contract is a mount function rather than a component. ### Design tokens and shared elements Your UI may import two shared design packages, and only those two ([ADR 0039](../architecture/decisions/0039-shared-design-tokens-and-elements.md)): | Package | What it is | | --- | --- | | `@ekklesia/design-tokens` | The platform's global semantic theme — a validated, enumerable registry of named values and the `var(--ekklesia-…)` references for them | | `@ekklesia/ui` | The foundational Ekklesia elements, every one of them built from those tokens | ```tsx import { designToken } from "@ekklesia/design-tokens"; import { Badge, Button, EkklesiaUiStyles, Panel } from "@ekklesia/ui"; export function Slides({ context }: { readonly context: ModuleUiHostContext }) { return ( <> Add slide} title="Slides">

Nothing scheduled yet.

Paused
); } ``` Render `` once, near the top of your tree. It puts the element stylesheet into whichever root your artifact is mounted in, so you never have to know whether that is a document or an isolated root. **Use semantic names, not colours.** `designToken("text.danger")` says what the text *means*; `#9f1239` says what it looks like today. An installation that changes its theme changes the first and not the second. `designToken` only accepts registered names, so a typo is a compile error. **Never define an `--ekklesia-*` custom property.** Those are the platform's global theme variables, and inventing one would create a global a future theme editor could not enumerate and another module could unknowingly depend on. You may derive as many *private* aliases as you like inside your own artifact, under your own prefix: ```tsx
``` The host installs the default token values at its own root, and CSS custom properties inherit through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at run time and nothing for the host to hand you. Every other shared package stays rejected inside a module: `@ekklesia/api-client`, `@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to import, and `pnpm boundaries` says so. ### Styling: a stylesheet compiled into your artifact Author plain `.css` beside your UI source, import it, and declare it ([ADR 0040](../architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)): ```text src/ui/index.tsx src/ui/styles.css ``` ```tsx import { defineModuleUi } from "@ekklesia/module-ui-sdk"; import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui"; import styles from "./styles.css"; export default defineModuleUi({ id: "announcements", version: "0.1.0", styles: `${EKKLESIA_UI_STYLESHEET}\n${styles}`, mount(container, context) { /* … */ }, }); ``` TypeScript needs to know what a `.css` import is. Copy the template's `src/module-styles.d.ts` into your module and include it in your UI TypeScript project: ```ts declare module "*.css" { const stylesheet: string; export default stylesheet; } ``` **There is no sidecar file.** `@ekklesia/module-ui-build` compiles each stylesheet — resolving local `@import`s — into your declared JavaScript artifact as text. One declared file still contains everything, so there is nothing extra to publish, nothing for a host to fetch, and no way for your CSS to become an asset nobody serves. That was the exact hazard that kept stylesheets out until now. Two rules are enforced by the build, each with its own error: - **Reference Ekklesia's global theme variables; never declare one.** `--ekklesia-*` belongs to the registry a future theme editor enumerates (ADR 0039). Derive private aliases under your own prefix freely: `--announcements-slide-gap: var(--ekklesia-spacing-sm)`. - **Every `@import` must resolve to a local file.** A remote import would make your artifact fetch a stylesheet at run time, and a declared artifact has to be self-contained. Plain `.css` only. `.scss`, `.less`, and other dialects stay rejected — adding a preprocessor is a toolchain decision that needs its own ADR. A CSS `url()` pointing at a local image or font also fails the build: no asset loader is configured. Use a data URL, or serve real media from your module's object storage. `pnpm boundaries` allows `.css` inside `src/ui/` and `src/display/` only. A stylesheet anywhere else in your module is a file no build reads and no host loads. ### Your artifact renders in its own isolated root Every browser artifact — primary UI, display UI, and any renderer you contribute — is mounted inside a Shadow DOM root the host creates, with your stylesheet installed in it: ```text host container └─
#shadow-root (open) ├─