CI / verify (pull_request) Successful in 1m6s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
Implements ADR 0049's deferred native target and ADR 0052's reviewed license supplements. The proof case is a real module using sharp. packageFormat stays 1, and `any` is unchanged. Packager (@ekklesia/module-pack): - --target linux-x64-glibc. Native or platform-specific packages, found by the same generic rule that refuses them in `any`, stay outside the bundle. - Each native package's production closure is walked through its own dependencies, installed optionalDependencies that match the target, and required peers. It is copied as real files flat under node_modules/<name>, with no links, dev dependencies, declarations, source maps, or other platforms' packages. Missing or foreign-platform required dependencies, version conflicts, links, foreign machines, and Node.js range conflicts fail closed. - runtime.nodeApi, nodeAbi, and libcVersion are read from the binaries by a small ELF reader: the addon's node_api_module_get_api_version_v1 and the newest GLIBC_ symbol version needed. No shelling out. A native target with no addon is refused. - ADR 0052 supplements: package-provided material stays primary. An exact match on name, version, and ekklesia-content-v1 digest (sorted normalized paths with each file's size and SHA-256; no metadata) adds labelled material after the package's own. Any mismatch, a declared-licence conflict, or a malformed or unreviewed supplement file fails. Missing material with no supplement still fails. Supplement for @img/[email protected]: - 184 components and 143 unique texts, assembled from the exact upstream sources sharp-libvips v1.3.3 builds linux-x64 from: 27 C/C++ libraries, glib's gvdb, librsvg's Rust crates (reproduced with cargo tree), the Rust standard library at the rustc commit recorded in the binary, plus the LGPL-3.0, GPL-3.0, and MPL-2.0 texts. Each source is recorded with its SHA-256 in PROVENANCE.md. - It pins digest ekklesia-content-v1:sha256:50647b0b…, which is identical from the installed copy and from the registry tarball. - LGPL/MPL source-availability determinations stay open, as ADR 0052 states. Nothing claims they are met. Verifier (Core): - linux-x64-glibc is accepted only on a host whose platform, arch, glibc family and version, and Node-API version (from Node itself) match. - Every ELF file must be x86-64; every .node must be an ELF object. - `any` packages now also refuse node_modules/ and ELF files. - Contract: native metadata must state nodeApi or nodeAbi, and Linux targets libcVersion (ADR 0049). Proof: - A checked-in AGPL native fixture packs byte-identically from unrelated copies (8.86 MB) and verifies with Core's verifier. - Extracted elsewhere, it runs a sharp resize and PNG round trip in an empty-environment process. Every resolution stays in the package, and only the package's addon and libvips plus host system libraries load. Signage is untouched. Co-Authored-By: Claude Opus 5.5 <[email protected]>
2118 lines
107 KiB
Markdown
2118 lines
107 KiB
Markdown
# 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. Internal package
|
||
dependencies use `workspace:` ranges and supported public package entrypoints.
|
||
|
||
### 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 the workspace your module is built in. It is a
|
||
development dependency like `@ekklesia/module-ui-build`:
|
||
|
||
```bash
|
||
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
|
||
--license-file ./LICENSE \
|
||
--source-revision "$(git rev-parse HEAD)"
|
||
```
|
||
|
||
It writes `<id>-<version>-<target>.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
|
||
<id>/
|
||
├── 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/<name>`.
|
||
- **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.
|
||
- **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 first
|
||
supplement covers `@img/sharp-libvips-linux-x64` 1.3.3, the libvips binary `sharp` loads. It
|
||
records upstream sources, but whether the LGPL and MPL source obligations for that binary are met
|
||
is an open determination. A package that carries it is not yet treated as publicly
|
||
distributable.
|
||
- **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 <archive> --sha256-file <archive>.sha256`, run from an
|
||
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
|
||
installing it. Pass `--extract-to <new directory>` 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)).
|
||
|
||
### 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.1.0",
|
||
"moduleSdk": "^0.1.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 `<module-id>.`, 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(<Editor context={context} />);
|
||
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 (
|
||
<>
|
||
<EkklesiaUiStyles />
|
||
<Panel actions={<Button variant="primary">Add slide</Button>} title="Slides">
|
||
<p style={{ color: designToken("text.secondary") }}>Nothing scheduled yet.</p>
|
||
<Badge tone="warning">Paused</Badge>
|
||
</Panel>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
Render `<EkklesiaUiStyles />` 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
|
||
<div style={{ "--calendar-row-gap": designToken("spacing.sm") } as CSSProperties}>
|
||
```
|
||
|
||
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
|
||
└─ <div data-ekklesia-module-ui>
|
||
#shadow-root (open)
|
||
├─ <style> host base rules, then your declared CSS
|
||
└─ <div> ← the container your mount() receives
|
||
```
|
||
|
||
What that buys you, and what it costs:
|
||
|
||
- **Your selectors cannot leak out**, and the Dashboard's cannot leak in. Write `.slide` without a
|
||
prefix and without worrying about another module's `.slide`.
|
||
- **Design tokens still reach you.** CSS custom properties inherit through the boundary, so
|
||
`var(--ekklesia-…)` resolves from the values the host installed at its root. Nothing to import at
|
||
run time, nothing for the host to pass.
|
||
- **Two artifacts of your own module are isolated from each other too.** Your management UI and your
|
||
display share styles only if they share a source file.
|
||
- **Your DOM is not in the host document's tree.** `document.querySelector` from outside will not
|
||
find your elements — including in your own tests, which should mount the artifact and query inside
|
||
the root the loader hands `mount`.
|
||
- **You get no box by default.** The host's two wrapper elements are `display: contents`, so you lay
|
||
out exactly where the host put you. A full-viewport display that needs a real box overrides it:
|
||
`:host { display: block }`, which works because the host installs its base rules *before* yours.
|
||
- **Mount and unmount are deterministic.** The host installs your stylesheet once per mount and
|
||
removes the whole root on unmount, so repeated mounts never accumulate styles and a cleanup that
|
||
throws still cannot leave your CSS behind. You never install or remove your own `<style>`.
|
||
|
||
**This is not a sandbox.** Your artifact is trusted first-party code in the host's own JavaScript
|
||
realm and can reach `document` if it wants to; the root is `open` for exactly that reason. It is a
|
||
correctness boundary for styles, and nothing more. See
|
||
[Security model and deployment trust boundaries](../development/security.md).
|
||
|
||
### The mount ABI is versioned
|
||
|
||
`defineModuleUi` stamps the browser mount ABI version onto your artifact — you never write it. A host
|
||
verifies it before mounting, and an artifact built against an older SDK fails with
|
||
`MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics.
|
||
|
||
Migrating from ABI 1 is one `pnpm build`. If you declare no stylesheet, no source change is needed at
|
||
all; if your own tests assert on module DOM, they need to look inside the isolated root.
|
||
|
||
### What you may and may not use today
|
||
|
||
`pnpm boundaries` enforces `modules-do-not-own-ui`, narrowed precisely (ADR 0022) and **unchanged by
|
||
the build pipeline** — adding the pipeline required no boundary relaxation at all:
|
||
|
||
| Allowed in a module | Still rejected |
|
||
| --- | --- |
|
||
| `.tsx` source, and plain `.css` inside `src/ui/` or `src/display/` | `.jsx` (strict TypeScript only), `.css` outside a browser source root, `.scss`/`.less`/`.styl`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
||
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
|
||
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build`, `@ekklesia/design-tokens`, `@ekklesia/ui` | Every other shared role: `api-client`, `auth-client`, `surface-sdk`, `module-ui-host` |
|
||
|
||
You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because
|
||
`@ekklesia/module-ui-build` owns the bundler entirely.
|
||
|
||
Layer rules are unchanged: module UI source may not import Core, an app, a surface, a builder, or
|
||
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
|
||
is resolved and rejected by its real target.
|
||
|
||
> **Your artifacts are served, and Ekklesia's surfaces mount them.** The platform publishes primary
|
||
> UI at `GET /api/v1/module-ui/<module-id>/<version>` and display UI at the sibling `/display`
|
||
> artifact URL
|
||
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)) and its browser
|
||
> surfaces discover and mount them
|
||
> ([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). That shell is a
|
||
> module list and a mount target, not a Dashboard. See
|
||
> [How a host loads your UI](#how-a-host-loads-your-ui) and
|
||
> [What actually mounts your UI](#what-actually-mounts-your-ui).
|
||
|
||
### What this does and does not do today
|
||
|
||
Discovery parses the manifest as inert JSON, resolves each declared browser path, validates it, and
|
||
records it on the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or
|
||
renders either UI entrypoint** — an artifact that throws on evaluation still discovers successfully,
|
||
because it is never evaluated. Hosts perform their own dynamic import and then validate the result with
|
||
`parseModuleUiExport`; Core never loads browser code.
|
||
|
||
Core also never *builds* anything. Building is an authoring and packaging step you run; Core
|
||
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 primary or display artifact
|
||
↓ asset publication — host-specific; Ekklesia serves it at
|
||
↓ one exact server-produced artifact URL
|
||
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.
|
||
|
||
### How Ekklesia publishes your artifact
|
||
|
||
Core resolves each declared browser artifact to a path on the *server's filesystem*, which a browser
|
||
cannot import. Ekklesia turns each role into one exact URL
|
||
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)):
|
||
|
||
```text
|
||
GET /api/v1/module-ui/<module-id>/<version>
|
||
GET /api/v1/module-ui/<module-id>/<version>/display
|
||
```
|
||
|
||
Four things about it matter to you as a module author:
|
||
|
||
- **Only the artifact that role declares is served.** The module id and version in either URL are
|
||
lookup keys, never path components — there is no way to request a second file from your package.
|
||
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
|
||
an asset, it must be inside the bundle: that is exactly why your stylesheet is compiled into the
|
||
artifact as text rather than published beside it (ADR 0040), and why a bundled image or font is
|
||
still not supported.
|
||
- **The version must match exactly.** A request for any other version is refused rather than served
|
||
your installed artifact, so a stale host cannot mount something it did not ask for.
|
||
- **Your artifact bytes are public.** These two routes require no authentication — fetching code
|
||
grants no
|
||
authority, and your backend routes remain permission-enforced regardless. Never put a secret, a
|
||
private endpoint, or anything you would not publish in your UI bundle.
|
||
- **It is served byte-for-byte, never executed on the server.** No transformation, no re-bundling, no
|
||
evaluation. What `pnpm build` produced is exactly what a browser receives.
|
||
|
||
The response carries `Content-Type: text/javascript; charset=utf-8`, `X-Content-Type-Options:
|
||
nosniff`, `Cache-Control: no-cache`, and a strong `ETag`, so rebuilding your module at the same
|
||
version is picked up on the next request rather than being cached indefinitely.
|
||
|
||
### What actually mounts your UI
|
||
|
||
Ekklesia's browser shell (`surfaces/shell`) asks the server which module UIs exist, and mounts them
|
||
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)):
|
||
|
||
```text
|
||
GET /api/v1/module-ui → { id, version, ui.url, api.baseUrl, permissions }
|
||
↓
|
||
loadModuleUi(ui.url) → your default export, identity-checked against your manifest
|
||
↓
|
||
mount(container, context)
|
||
```
|
||
|
||
Three things follow for you:
|
||
|
||
- **Your module appears automatically once it is installed and discovered.** The shell has no list of
|
||
modules and imports none; yours arrives from the inventory at run time. You register nothing.
|
||
- **`context.permissions` reflects a real server decision** about *your* module's declared
|
||
permissions for the current viewer — not a default, and not the viewer's permissions elsewhere. It
|
||
is still only a hint; your backend route is what actually protects anything.
|
||
- **`context.api.request(path)` reaches only your own module's API.** The host resolves it onto your
|
||
API root; a path that escapes it (`"../other-module/records"`, an absolute URL) is refused before
|
||
it becomes a request. Keep asking for relative paths and your artifact stays hostable elsewhere.
|
||
|
||
> **This is not the finished Dashboard.** The shell renders one level of grouping and mounts the
|
||
> selected module. Your module appears under whatever group an administrator placed it in — by its
|
||
> `name`, never its id — and under "Other" until someone arranges it. There is no navigation editor,
|
||
> no nested folders, no icons, no theme, and no Dashboard Home yet.
|
||
|
||
### How Ekklesia hosts your display
|
||
|
||
The display is not part of the primary inventory or Dashboard navigation. For a module that is
|
||
eligible, running, and declares `entrypoints.display`, Ekklesia registers exactly these routes:
|
||
|
||
```text
|
||
/display/<module-id> stable full-viewport page
|
||
/api/v1/module-displays/<module-id> host descriptor
|
||
/api/v1/module-ui/<module-id>/<version>/display compiled artifact bytes
|
||
```
|
||
|
||
The page and descriptor both require the manifest's display permission through the ordinary Core
|
||
authorization evaluator and an authenticated cookie session. A `public` display permission is
|
||
rejected; there is no anonymous exception or device token. Artifact bytes are
|
||
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
|
||
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
|
||
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
|
||
not through hot loading.
|
||
|
||
The stable page runs a separate `surfaces/shell` browser application with no Dashboard navigation,
|
||
theme chrome, or primary UI fallback. It asks for the protected descriptor, then passes the exact
|
||
artifact URL, module API base, realtime base, principal, and permission hints into the generic
|
||
loader. The host owns credentials and routes; your display still calls
|
||
`context.api.request("relative-path")` and `context.realtime.subscribe(...)`. Signed object-delivery
|
||
URLs remain opaque strings in HTTP or realtime state and should be refreshed according to their
|
||
expiry exactly as in any primary UI. Nothing in the display ABI identifies Ekklesia, so the same
|
||
artifact remains compatible with a future standalone host.
|
||
|
||
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
|
||
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
|
||
feature experience and cleanup inside its container. There is one display per module. Display
|
||
inventory/management, named variants, targeting, public/device identity, a standalone host,
|
||
Announcements migration, and Companion integration are deferred.
|
||
|
||
### Your display survives a network outage — as code, not as content
|
||
|
||
A display page is controlled by a service worker that caches **the shell and the declared code**: the
|
||
page, the host script, your display artifact, and every contributed renderer that display actually
|
||
mounted ([ADR 0043](../architecture/decisions/0043-display-offline-code-fallback.md)). Caching is
|
||
network-first, so a screen picks up a new build the moment the server returns and can never be
|
||
trapped on an obsolete one.
|
||
|
||
What that means for you, concretely:
|
||
|
||
- **Your artifact will be served from cache when a fetch fails.** The bytes are identical and the
|
||
loader still verifies the artifact's declared id and version, so a fallback can never mount
|
||
something the descriptor did not name.
|
||
- **Your display cannot render while the network is down.** Mounting requires the descriptor, the
|
||
descriptor is authorization-bearing, and it is never cached — so a device whose access was revoked
|
||
stays revoked, and cached code can never manufacture authorization it no longer has. The screen
|
||
shows Ekklesia's own quiet offline indicator and resolves again by itself when connectivity
|
||
returns.
|
||
- **Nothing of yours is cached but code.** Your module's API responses, your realtime stream, and
|
||
every signed object-delivery URL are deliberately never stored. If you want a screen to keep
|
||
showing content through an outage, persisting that content is *your module's* job — this is the
|
||
foundation it would build on, not a substitute for it.
|
||
|
||
### What a module UI will never own
|
||
|
||
A module UI consumes platform policy and never redefines it.
|
||
Authentication and session establishment, authorization enforcement, organization/platform context,
|
||
the containing application shell, top-level routing and mount boundaries, the platform navigation
|
||
container, the global theme and design-token contract, platform error boundaries, and
|
||
trusted-origin/CORS policy belong to Core and the host application. This is the same boundary module
|
||
HTTP routes already live inside: a module mounts routes, but the platform still owns the method
|
||
vocabulary, CORS preflight, body ceilings, and permission enforcement.
|
||
|
||
A module UI also grants no isolation. A module that ships UI is exactly as trusted as one that does
|
||
not — see [Security model and deployment trust boundaries](../development/security.md).
|
||
|
||
### Modules stay flat
|
||
|
||
Shipping UI creates no parent/child relationship between modules. `video`, `livestream`,
|
||
`announcements`, `calendar`, `roster`, and `signage` are peers. The only relationships a module may
|
||
express are a genuine module dependency and a genuine capability dependency — both version-ranged,
|
||
both meaning *requirement*, never *ownership*.
|
||
|
||
A Dashboard may later group modules into folders (`Media` holding Video and Livestream, or
|
||
`Sunday Production` holding Livestream, Signage, and Video). Those groups are Dashboard
|
||
configuration an administrator can reorganize freely. They are not dependencies, they are not
|
||
declared in your manifest, and your module must never require one to work correctly.
|
||
|
||
## Browser contributions
|
||
|
||
Sometimes another module owns a screen, and *you* own what belongs on part of it. A signage module
|
||
owns a timeline; an announcements module knows what an announcement should look like. Neither can
|
||
import the other — modules are independently installed, independently versioned peers — and neither
|
||
should re-implement the other's job.
|
||
|
||
A **browser contribution** is the mechanism for exactly that
|
||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)): a compiled renderer you
|
||
declare for a capability *another* module provides, which that module mounts inside a container it
|
||
owns.
|
||
|
||
```text
|
||
signage provides the capability signage.slide-renderer, and publishes its input contract
|
||
announcements contributes a React renderer for it
|
||
signage mounts that renderer in one timeline slot, updates it, and takes it down
|
||
```
|
||
|
||
### Contributing a renderer
|
||
|
||
Declare it in your manifest and put the source at the fixed path your contribution id implies:
|
||
|
||
```json
|
||
{
|
||
"contributions": [
|
||
{
|
||
"id": "announcements.signage-slide",
|
||
"version": "1.0.0",
|
||
"capability": "signage.slide-renderer",
|
||
"capabilityRange": "^1.0.0",
|
||
"artifact": "./dist/contributions/signage-slide/index.js"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
```text
|
||
src/contributions/signage-slide/index.tsx → the contribution's compiled artifact
|
||
```
|
||
|
||
```tsx
|
||
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
|
||
import { createRoot } from "react-dom/client";
|
||
import styles from "./styles.css";
|
||
import { Slide, type SlideInput } from "./Slide.js";
|
||
|
||
export default defineModuleContribution({
|
||
id: "announcements.signage-slide",
|
||
version: "1.0.0",
|
||
styles,
|
||
mount(container, context) {
|
||
const root = createRoot(container);
|
||
root.render(<Slide input={context.input as SlideInput} />);
|
||
return {
|
||
update(input) {
|
||
root.render(<Slide input={input as SlideInput} />);
|
||
},
|
||
unmount() {
|
||
root.unmount();
|
||
},
|
||
};
|
||
},
|
||
});
|
||
```
|
||
|
||
The id is `<your-module-id>.<local-name>`, which is what makes it unique platform-wide without a
|
||
registry. Your contribution carries **its own version**, independent of your module's, and the
|
||
platform verifies the artifact claims it before mounting — a stale bundle is caught rather than
|
||
rendered. `capabilityRange` says which provider versions you work with: if no installed module
|
||
provides that capability inside your range, your contribution is simply not published. It is never
|
||
mounted and broken.
|
||
|
||
`pnpm build` compiles it alongside your other artifacts. You configure no paths and no bundler.
|
||
|
||
### What a contributed renderer receives
|
||
|
||
Two things: its own id, and the consumer's `input`.
|
||
|
||
That is the whole context. **No API client, no realtime subscription, no principal, no permissions,
|
||
no configuration, no database, no storage, and no credential.** Mounting your renderer grants your
|
||
module nothing — server-side authorization is untouched, and everything the renderer needs arrives
|
||
through the consumer's input, because the consumer is the party that was authorized to fetch it.
|
||
|
||
The input is **opaque JSON to the platform**. Ekklesia validates that it is genuinely
|
||
JSON-serializable and nothing else: not its shape, not its meaning, not its version. The consuming
|
||
module publishes that contract alongside its capability and versions it through its capability
|
||
version. Validate what you receive and render a sensible default when it is not what you expected —
|
||
you may be running against an older or newer consumer than the one you developed against.
|
||
|
||
`update` is what lets a long-running screen change content without restarting animations or reloading
|
||
media on every tick. Implement it if you can render new input in place; omit it if you genuinely
|
||
cannot, and the consumer will unmount and mount you again.
|
||
|
||
Your renderer mounts in its own isolated root with its own stylesheet, exactly like a module UI
|
||
artifact (ADR 0040): your rules cannot reach the consuming module's screen around you, and its rules
|
||
cannot reach in.
|
||
|
||
### Consuming contributions
|
||
|
||
If *your* module provides a capability other modules can render for, ask the host what was
|
||
contributed:
|
||
|
||
```tsx
|
||
const renderers = context.contributions.list("signage.slide-renderer");
|
||
|
||
const slide = await context.contributions.mount({
|
||
container: slotElement, // an element your module owns
|
||
contributionId: renderers[0].id,
|
||
input: { headline, seconds }, // your own published contract
|
||
});
|
||
|
||
await slide.update({ headline: next, seconds });
|
||
await slide.unmount();
|
||
```
|
||
|
||
You see identity, version, and capability — never a URL. Asking the host to mount by id is what keeps
|
||
your module hostable somewhere other than Ekklesia, exactly as `context.api.request("records")` does.
|
||
|
||
You will only ever see contributions aimed at a capability **your** module provides, and only ones
|
||
the server judged compatible. Handle an empty list: a contributor may be absent, disabled, or outside
|
||
your compatible range, and that is ordinary rather than exceptional.
|
||
|
||
You own the input contract completely. Publish it beside your capability, version it with your
|
||
capability version, and remember that a renderer written against an older version of it may still be
|
||
installed.
|
||
|
||
## Runtime server definition
|
||
|
||
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
||
|
||
```ts
|
||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||
|
||
export default defineModuleRuntime({
|
||
id: "calendar",
|
||
version: "0.1.0",
|
||
|
||
register({ capabilities }) {
|
||
capabilities.provide("calendar.events", calendarEvents);
|
||
},
|
||
|
||
async start({ capabilities, services }) {
|
||
const people = capabilities.resolve("people.records");
|
||
await services.database.insert(events).values({ id: "seed", title: "First event" });
|
||
},
|
||
|
||
async stop() {
|
||
// Release runtime resources here.
|
||
}
|
||
});
|
||
```
|
||
|
||
ID and version are intentional build-artifact identity markers and must match the manifest. Core
|
||
rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its
|
||
own capabilities and the capability dependencies declared in its manifest. The `register` context
|
||
contains `moduleId`, capability access, the frozen configuration snapshot, and the HTTP and realtime
|
||
declaration registrars — never live `services` or storage. `start` and `stop` share one context shape:
|
||
capability access, configuration, `services`, storage, and a module-scoped realtime publisher. The
|
||
same scoped objects are also supplied to the module's HTTP handlers; see
|
||
[Database access](#database-access), [Object storage](#object-storage), and
|
||
[Realtime state](#realtime-state).
|
||
|
||
Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM
|
||
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
|
||
close them in `stop`.
|
||
|
||
### `stop()` may run after a partially completed `start()`
|
||
|
||
If your `start` hook throws, **your own `stop` hook runs first**, ahead of the previously started
|
||
modules being rolled back in reverse order. That is deliberate: a hook that opened a connection,
|
||
registered a listener, or recorded partial initialization before failing is the only code that knows
|
||
how to release it. Write `stop` so it is safe and idempotent about resources it may or may not have
|
||
acquired — guard on what you actually took, and never assume `start` completed.
|
||
|
||
Stop and rollback hooks have finite time: the platform waits at most **10 seconds** per module hook,
|
||
then records a timeout and continues with the next module in reverse order. Settle promptly. A hook
|
||
that exceeds its deadline is not killed — the platform simply stops waiting for it — but the shared
|
||
PostgreSQL pool closes after the bounded stop sequence, so **a timed-out hook cannot rely on
|
||
database availability afterwards**, and any late query will fail. The platform cannot undo side
|
||
effects your module performed outside its stop hook.
|
||
|
||
## HTTP routes
|
||
|
||
A module registers plain Fetch (`Request` in, `Response` out) route handlers during `register`,
|
||
through the `http` registrar `ModuleRegistrationContext` provides alongside `capabilities`:
|
||
|
||
```ts
|
||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||
|
||
export default defineModuleRuntime({
|
||
id: "calendar",
|
||
version: "0.1.0",
|
||
|
||
register({ capabilities, http }) {
|
||
capabilities.provide("calendar.events", calendarEvents);
|
||
|
||
http.route({
|
||
method: "GET",
|
||
path: "/events/:eventId",
|
||
permission: "calendar.events.read",
|
||
// This route accepts no request body. See the body-size contract below.
|
||
maxBodyBytes: 0,
|
||
async handler(request, context) {
|
||
return Response.json({
|
||
eventId: context.params.eventId,
|
||
principal: context.principal.kind,
|
||
});
|
||
},
|
||
});
|
||
},
|
||
});
|
||
```
|
||
|
||
A route definition has exactly five required fields — `method`, `path`, `permission`,
|
||
`maxBodyBytes`, `handler` — plus one optional `bodyMode`, and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
|
||
middleware, audit metadata, authentication behavior, or another module's namespace) fails
|
||
registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`,
|
||
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
|
||
wildcard/all-method registration.
|
||
|
||
### Two routes may not both match one request path
|
||
|
||
Within one module and one HTTP method, two route patterns must never be able to match the same
|
||
request path. `/events/:id` and `/events/new` can — `/events/new` matches both — and that pair is
|
||
rejected during registration with `MODULE_ROUTE_OVERLAP`. So are `/events/:id/edit` against
|
||
`/events/new/:action`, and `/a/:left/c` against `/a/b/:right`. Two identical shapes, including paths
|
||
differing only in parameter name (`/events/:id` and `/events/:eventId`), keep the existing
|
||
`MODULE_ROUTE_DUPLICATE` result.
|
||
|
||
This is rejected rather than resolved by a "static beats parameter" rule because the routes may
|
||
carry *different permissions*, and letting declaration order decide which one guards `/events/new`
|
||
would make a permission policy depend on the order lines appear in your `register` hook. A rejected
|
||
registration publishes **none** of that module's routes or capabilities and runs no start hook, so
|
||
the failure is loud at startup rather than subtle at request time.
|
||
|
||
Still perfectly legal: two different static literals (`/events/new` and `/events/archive`),
|
||
different segment counts (`/events/:id` and `/events/:id/edit`), the same path under different
|
||
methods, and the same local path in two different modules — each module has its own
|
||
platform-owned mount prefix. Declaration order among disjoint routes is preserved exactly.
|
||
|
||
### Route parameters are decoded, untrusted input — validate them yourself
|
||
|
||
`context.params` holds the router's **decoded** path values. Decoding is not validation, and the
|
||
fact that your *declared* path passed the platform's path grammar says nothing about what a caller
|
||
may *send*. A single encoded segment routinely carries characters no declared path could:
|
||
|
||
| Request segment | `context.params` value |
|
||
| --- | --- |
|
||
| `%2F..%2Fadmin` | `/../admin` |
|
||
| `a%5Cb` | `a\b` |
|
||
| `a%00b` | `a`, NUL, `b` |
|
||
| `a%0D%0Ab` | `a`, CR, LF, `b` |
|
||
| `1%3B%20drop` | `1; drop` |
|
||
| `%C3%A9v%C3%A9nement` | `événement` |
|
||
|
||
**Validate every parameter you use against your own exact domain grammar** — a UUID, slug, date,
|
||
numeric identifier, or whatever your module owns — before any database, filesystem, command,
|
||
identifier, or other side-effecting use, and reject a missing, repeated, malformed, or semantically
|
||
invalid value with your own sanitized response:
|
||
|
||
```ts
|
||
const eventId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
||
|
||
async handler(request, context) {
|
||
const id = context.params.eventId;
|
||
if (id === undefined || !eventId.test(id)) {
|
||
return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
|
||
}
|
||
// ...only now is `id` safe to use as a domain identifier.
|
||
}
|
||
```
|
||
|
||
Re-encoding the value, or a decode/re-encode round trip, is **not** a substitute for that check. The
|
||
platform deliberately supplies no generic parameter validator: your parameters are your domain
|
||
types, and one platform-wide pattern would either reject legitimate values or approve meaningless
|
||
ones. The platform continues to own authentication, authorization, `Origin`, CORS, body limits,
|
||
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
|
||
before your handler runs — an unauthorized caller can never reach your validation at all.
|
||
|
||
### Request bodies: `maxBodyBytes` and `bodyMode`
|
||
|
||
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
|
||
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
|
||
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
|
||
accepted. A negative, non-integer, non-finite, or over-ceiling value fails registration with
|
||
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`.
|
||
|
||
`bodyMode` chooses how that body reaches you, and therefore which ceiling applies
|
||
([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
|
||
|
||
| `bodyMode` | Ceiling | What the platform does |
|
||
| --- | --- | --- |
|
||
| `"buffered"` (default) | 1 MiB | Reads and counts the whole body **before** your handler runs |
|
||
| `"streamed"` | 32 MiB | Counts bytes **as you consume them**, and never materializes the body |
|
||
|
||
```ts
|
||
http.route({
|
||
method: "POST",
|
||
path: "/slides",
|
||
permission: "signage.slides.write",
|
||
maxBodyBytes: 33_554_432,
|
||
bodyMode: "streamed",
|
||
async handler(request, context) { /* read request.body as a stream */ },
|
||
});
|
||
```
|
||
|
||
Omitting `bodyMode` means `buffered`, which is what every route written before this existed meant.
|
||
|
||
**Memory.** In `buffered` mode the platform holds at most your declared allowance, which is why that
|
||
ceiling is small and why `await request.json()` is safe there. In `streamed` mode the platform holds
|
||
nothing: read `request.body` as a stream. Calling `request.arrayBuffer()` on a streamed route works
|
||
but throws away the entire point — you have just buffered 32 MiB yourself.
|
||
|
||
**Streaming.** A declared `Content-Length` over your limit is refused synchronously, before
|
||
authorization and before a byte is read. Otherwise the platform counts what actually arrives, so a
|
||
misleading or absent `Content-Length` bounds nothing.
|
||
|
||
**A `413` can arrive after your handler starts.** This is the one consequence of `streamed` mode you
|
||
must design for. When the running total passes your limit the stream errors, the source is cancelled,
|
||
and the platform replaces your response with the sanitized `413` — even if you caught the error and
|
||
answered anyway. If you persist as you read, write to a temporary key and commit only once the read
|
||
completes; the platform cannot do that for you, because only your module knows what a partial write
|
||
means to its own data.
|
||
|
||
**Timeouts.** Body reading is bounded by the server's own header and request timeouts, not by a
|
||
per-route setting. A slow upload is a slow request, and the platform has one answer for that.
|
||
|
||
**One file per request.** 32 MiB is the platform's absolute ceiling in every mode — there is no
|
||
unbounded option anywhere in the contract. A UI uploading twenty images issues twenty individually
|
||
bounded requests; nothing here can express a 500 MiB aggregate upload, deliberately, because one bad
|
||
byte in one would waste everything already sent.
|
||
|
||
Every rejection uses the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}` contract every
|
||
other body-consuming route in the platform uses — see
|
||
[API](../development/api.md#request-body-limits).
|
||
|
||
`path` is local to the module, never the path a caller actually sends. The platform alone owns the
|
||
production mount prefix, `/api/v1/modules/<module-id>`: `"/"` is the module's own root, `"/events"`
|
||
means `/api/v1/modules/calendar/events`, and `"/events/:eventId"` is a parameterized route using the
|
||
same `:name` syntax a caller's request is matched against. A canonical path begins with `/`, a
|
||
non-root path never ends with `/`, and every segment is either a lowercase kebab-case static segment
|
||
or one `:parameterName` segment — no empty or repeated slashes, no query string or fragment, no
|
||
backslash or percent-encoding, no dot segments, and no wildcard/catch-all segment. A malformed path
|
||
is rejected outright, never silently normalized.
|
||
|
||
Every route — including a route meant to be publicly reachable — names exactly one `permission` the
|
||
owning module's own manifest declares; there is no way to register an unprotected, no-permission
|
||
route. A module cannot supply a baseline directly: the permission's trusted `public`, `authenticated`,
|
||
or `restricted` baseline always comes from the composed platform registry, exactly like any other
|
||
enforcement decision. Public access is therefore expressed by referencing a permission whose manifest
|
||
declaration has `baseline: "public"`, never by omitting a permission. A module may only reference a
|
||
permission its own manifest declares — never a Core-owned permission, and never another module's —
|
||
so it can never gate a route behind authority it does not itself own. The same local path may be
|
||
registered under more than one method, but registering the same module twice under the identical
|
||
method and path (or two paths that are identical once every parameter is treated as an anonymous
|
||
placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route.
|
||
|
||
The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's
|
||
already-authorized `EkklesiaPrincipal`, immutable decoded path parameters, `services`, the module's
|
||
frozen configuration snapshot, scoped storage, and scoped realtime publisher. It never receives an
|
||
authorization store, grant/platform-owner state, jobs, a framework-specific request context, or a
|
||
raw Better Auth session. Secret configuration exists only when the module explicitly declared that
|
||
key and the deployment supplied it; Core never serializes it. The full query string remains
|
||
available through `Request.url`; there is no separate query or body abstraction.
|
||
|
||
Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
|
||
snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production
|
||
at startup, under the `/api/v1/modules/<module-id>` prefix described above: every route is enforced
|
||
through the identical `requirePermission` middleware Core's own routes use, a state-changing method
|
||
(`POST`/`PUT`/`PATCH`/`DELETE`) additionally requires a trusted `Origin` before authorization ever
|
||
runs, and a handler that throws, rejects, or returns something other than a `Response` becomes a
|
||
fixed, sanitized `500 {"code":"MODULE_HANDLER_FAILED"}` — never the caught value, a stack, the
|
||
route's path, its permission id, or the module id. The platform remains the sole owner of CORS and
|
||
caching for every module route: a handler-supplied CORS-control header is stripped, and every
|
||
module response is forced to `Cache-Control: no-store` with `Vary: Origin, Cookie` (in addition to
|
||
any of the handler's own legitimate `Vary` values) until a dedicated cache-policy contract exists —
|
||
deliberately conservative, since even a public-baseline handler still receives an authorized
|
||
principal and may personalize its response. See [API](../development/api.md#module-http-routes) for
|
||
the full production mounting, enforcement, and error-response contract.
|
||
|
||
## Realtime state
|
||
|
||
Realtime is a small state-delivery contract, not a broker. Declare each module-local channel during
|
||
`register`, using one permission owned by the same module:
|
||
|
||
```ts
|
||
export default defineModuleRuntime({
|
||
id: "announcements",
|
||
version: "0.1.0",
|
||
|
||
register({ realtime }) {
|
||
realtime.channel({
|
||
channel: "state",
|
||
permission: "announcements.slideshow.view",
|
||
});
|
||
},
|
||
|
||
start({ realtime }) {
|
||
realtime.publish("state", {
|
||
mode: "on",
|
||
currentSlide: 0,
|
||
previousSlide: -1,
|
||
slides: [],
|
||
});
|
||
},
|
||
});
|
||
```
|
||
|
||
Channel names are flat lowercase kebab case, at most 48 characters. There are no global topics,
|
||
wildcards, rooms, broker addresses, or module-id arguments: Core closes the registrar and publisher
|
||
over the module identity, so a cooperating module cannot accidentally publish into another module's
|
||
channel. A module may declare at most 16 channels. Each payload must be plain, finite JSON no larger
|
||
than 64 KiB after UTF-8 serialization; functions, class instances, sparse arrays, accessors,
|
||
non-finite numbers, cycles, and `toJSON` conversion are rejected with fixed errors.
|
||
|
||
Every declared channel must receive an initial publication before that module's `start` completes.
|
||
Core captures that latest state atomically when a subscriber is installed, then supplies later
|
||
publications with a monotonically increasing per-channel sequence. This avoids a separate
|
||
GET-then-subscribe race. State is ephemeral and best effort: Core keeps no history, restart resets
|
||
the sequence, and reconnect receives a fresh authoritative snapshot instead of replay. A slow client
|
||
never blocks `publish`; each subscription retains only the newest pending publication, so
|
||
intermediate updates may be coalesced. There are at most 100 subscribers per channel.
|
||
|
||
In browser UI, subscribe through the host context rather than opening a transport yourself:
|
||
|
||
```ts
|
||
const subscription = context.realtime.subscribe<SlideshowState>("state", {
|
||
onSnapshot(state, sequence) {
|
||
replaceSlideshow(state, sequence);
|
||
},
|
||
onUpdate(state, sequence) {
|
||
replaceSlideshow(state, sequence);
|
||
},
|
||
onStatus(status) {
|
||
showConnectionStatus(status);
|
||
},
|
||
onError(error) {
|
||
showSafeRealtimeError(error.code);
|
||
},
|
||
});
|
||
|
||
return () => subscription.close();
|
||
```
|
||
|
||
The public UI vocabulary contains no SSE, WebSocket, Socket.IO, URL, cookie, token, origin, or retry
|
||
setting. Ekklesia currently implements it with credentialed Server-Sent Events and native browser
|
||
reconnect; a standalone host can implement the same interface differently. The host-generated
|
||
endpoint is always scoped to this module, and the server performs the channel's normal permission
|
||
check before subscribing. Ekklesia closes each stream after five minutes so reconnect rechecks the
|
||
session and permission. Client permission hints remain presentation-only.
|
||
|
||
On shutdown, Core refuses new subscribers and closes existing subscriptions before HTTP drain, while
|
||
letting requests already in flight finish publishing. Reverse-order stop then disables each module's
|
||
publisher immediately before that module's own stop hook. Call `close()` during UI unmount as well;
|
||
it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are
|
||
forwarded unchanged.
|
||
Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Ekklesia's
|
||
authenticated display host consumes the same interface unchanged; public/device identity, durable
|
||
replay, multi-node fanout, and client-to-server commands remain future work. See
|
||
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md) and
|
||
[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md).
|
||
|
||
## Drizzle schema definition
|
||
|
||
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`:
|
||
|
||
```ts
|
||
import { integer, pgTableCreator, text } from "drizzle-orm/pg-core";
|
||
|
||
const moduleTable = pgTableCreator((localName) => `calendar_${localName}`);
|
||
|
||
export const events = moduleTable("events", {
|
||
id: integer("id").primaryKey(),
|
||
title: text("title").notNull()
|
||
});
|
||
```
|
||
|
||
The production schema entrypoint in `src/schema/index.ts` wraps those same objects with the Module
|
||
SDK ownership marker:
|
||
|
||
```ts
|
||
import { defineModuleSchema } from "@ekklesia/module-sdk";
|
||
import { events } from "./tables.js";
|
||
|
||
export { events } from "./tables.js";
|
||
|
||
export default defineModuleSchema({
|
||
moduleId: "calendar",
|
||
tables: { events }
|
||
});
|
||
```
|
||
|
||
The prefix callback converts the module ID to a snake-case namespace and prefixes every local table
|
||
name. Here the physical PostgreSQL name is `calendar_events`; `care-team` plus `members` becomes
|
||
`care_team_members`. Local names must be lowercase snake case, and the complete physical identifier
|
||
must not exceed 63 characters. The SDK's `createModuleTable` provides the same convenience when a
|
||
generator-facing pure declaration file is not needed; Core validates built metadata independently
|
||
of either helper.
|
||
|
||
Schema definitions use PostgreSQL tables in the platform-managed default/public schema. They must
|
||
be side-effect-free: do not connect to a database, read configuration, or perform I/O during import.
|
||
The `moduleId` is an ownership marker and must match the static manifest. Export physical base
|
||
tables, never query aliases.
|
||
|
||
Foreign keys may reference self or another exported table from the same module. They may not target
|
||
another module or an omitted private table. Every source column must belong to the declaring table,
|
||
and all columns in a compound target must belong to the same exported target table. Modules must
|
||
never import another module's tables, columns, relations, schema entrypoint, or migration files.
|
||
Store opaque identifiers and integrate through public capabilities, events, or APIs instead.
|
||
|
||
## Drizzle migrations
|
||
|
||
Module migrations use the stable Drizzle Kit `0.31.10` PostgreSQL format. Pin that exact version in
|
||
the module, set `migrations.prefix` to `index`, and generate from the module's own source schema:
|
||
|
||
```ts
|
||
import { defineConfig } from "drizzle-kit";
|
||
|
||
export default defineConfig({
|
||
dialect: "postgresql",
|
||
schema: "./src/schema/tables.ts",
|
||
out: "./drizzle",
|
||
migrations: { prefix: "index" },
|
||
breakpoints: true
|
||
});
|
||
```
|
||
|
||
Keep `tables.ts` limited to Drizzle declarations so the stable generator can load it without
|
||
executing the ESM module wrapper. For an ABI-v1 module, `src/schema/index.ts` remains the production
|
||
compiled schema entrypoint and converts those same table objects to plain metadata with
|
||
`moduleDrizzleSchemaMetadata`; tables are not declared twice. A legacy-v8 module continues to wrap
|
||
them with `defineModuleSchema` while that facade exists.
|
||
|
||
Run the module's `migration:generate` script with a lowercase snake-case name, review the generated
|
||
SQL and snapshot, and add the matching entry to `drizzle/meta/_ekklesia.json`. Every entry must
|
||
explicitly declare either `{"mode":"none"}` or
|
||
`{"mode":"approval-required","reason":"..."}`. Do not add an `approved` flag; approval will be
|
||
supplied by an external Core-owned provider and bound to module ID, identity, and checksum.
|
||
|
||
Migration identities are `<module-id>:<generated-tag>`. Core preserves Drizzle journal order inside
|
||
each module and dependency-first discovery order between modules. It calculates SHA-256 from the
|
||
exact SQL bytes, so changing whitespace or line endings changes the checksum. Never edit an
|
||
already-applied migration: Core compares the current exact checksum and metadata with its fixed
|
||
PostgreSQL history and refuses changed, missing, unknown, or out-of-prefix entries. Add a new forward
|
||
migration instead. Downgrade and uninstall migrations are not supported.
|
||
|
||
A module may migrate only its own objects. SQL is intentionally treated as opaque: offline
|
||
validation checks the artifact and metadata contract but cannot prove SQL ownership or classify
|
||
destructive behavior. Review those properties explicitly. Core is the only migration executor. It
|
||
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
|
||
falls back to non-transactional execution.
|
||
|
||
## Module Database ABI v1
|
||
|
||
The stable database boundary is Module Database ABI v1: SQL text, ordered bound values, an explicit
|
||
row mode, low-level PostgreSQL result values, and callback-scoped transactions. ORM objects never
|
||
cross it. A manifest version 8 module opts in with `"database": { "abi": 1 }`; omitting `database`
|
||
selects the temporary legacy facade. “Legacy” here describes that facade, never a version 7
|
||
manifest. Core selects the service from the validated manifest and gives each ABI module its own
|
||
executor, reused by that module's lifecycle hooks and route handlers.
|
||
|
||
A Drizzle module imports the optional adapter subpath and declares its own exact compatible
|
||
`drizzle-orm` dependency. The adapter's peer range (`>=0.45.2 <0.46.0`) describes adapter API
|
||
compatibility; it is not a Core or platform compatibility range:
|
||
|
||
```ts
|
||
import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
|
||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||
import * as schema from "./schema/tables.js";
|
||
|
||
export default defineModuleRuntime({
|
||
database: { abi: 1 },
|
||
id: "calendar",
|
||
version: "0.1.0",
|
||
async start({ services }) {
|
||
const db = moduleDrizzle(services.database, { schema });
|
||
const events = await db.select().from(schema.events);
|
||
},
|
||
});
|
||
```
|
||
|
||
The repeated runtime discriminator is what gives lifecycle and route-handler code the exact
|
||
`ModuleDatabaseExecutor` type instead of a legacy-or-ABI union. Core still chooses from the static
|
||
manifest and refuses startup when the runtime discriminator disagrees with it; neither contract can
|
||
silently receive the other service.
|
||
|
||
Drizzle compiles SQL and parameters before calling the ABI. The adapter always requests positional
|
||
(`rowMode: "array"`) results, gives those rows back to Drizzle in column order, and Drizzle applies
|
||
the module's own column decoders for bigint, numeric, dates/timestamps, JSON, byte arrays, nulls, and
|
||
supported arrays. This also makes joins with duplicate PostgreSQL column names unambiguous. Core
|
||
never imports this adapter and has no dependency on the module's Drizzle version. The adapter surface
|
||
is deliberately limited to `select`, `insert`, `update`, `delete`, and `transaction`; returning,
|
||
joins, and SQL expressions come from those normal Drizzle builders.
|
||
|
||
`db.transaction(callback)` maps to the ABI transaction callback. The callback receives another
|
||
Drizzle-facing database backed by the scoped ABI executor; a nested `.transaction()` maps to the
|
||
ABI's `savepoint` callback. Callback values and errors propagate, Core remains authoritative for
|
||
commit/rollback and escaped-scope behavior, and the adapter adds no retries or transaction-control
|
||
SQL.
|
||
|
||
Drizzle wraps execution failures in `DrizzleQueryError`. Recover the stable sanitized ABI error by
|
||
shape instead of using a PostgreSQL error instance, parsing a message, or reading `.cause.code`:
|
||
|
||
```ts
|
||
import { findEkklesiaDatabaseError } from "@ekklesia/module-database";
|
||
|
||
try {
|
||
await db.insert(schema.events).values(event);
|
||
} catch (error) {
|
||
const databaseError = findEkklesiaDatabaseError(error);
|
||
if (databaseError?.sqlState === "23505") {
|
||
// Handle a uniqueness conflict.
|
||
}
|
||
}
|
||
```
|
||
|
||
Modules that do not want Drizzle use the package's ORM-neutral tag. Every interpolation is one bound
|
||
value; v1 deliberately provides no dynamic-identifier or trusted-fragment escape hatch:
|
||
|
||
```ts
|
||
import { sql } from "@ekklesia/module-database";
|
||
|
||
const statement = sql`
|
||
SELECT id, title
|
||
FROM calendar_events
|
||
WHERE id = ${eventId}
|
||
`;
|
||
|
||
const result = await executor.query({ ...statement, rowMode: "object" });
|
||
```
|
||
|
||
The tag does not stringify values. Unsupported values pass through unchanged and the ABI executor
|
||
rejects them; strings containing quotes or SQL syntax always remain parameters.
|
||
|
||
ABI modules also export plain schema metadata rather than Drizzle objects. Drizzle users can derive
|
||
it on the module side; raw-SQL modules can write the same inert shape directly:
|
||
|
||
```ts
|
||
import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
|
||
import * as tables from "./tables.js";
|
||
|
||
export default moduleDrizzleSchemaMetadata("calendar", tables);
|
||
```
|
||
|
||
The result is `{ moduleId, tables: [{ name, foreignKeys: [{ table }] }] }`. Core validates table
|
||
prefixes, duplicates, and same-module foreign-key targets from those strings and never inspects an
|
||
ABI module's ORM objects.
|
||
|
||
The canonical module template declares ABI v1, uses the module-side Drizzle adapter, and exports
|
||
plain schema metadata derived with `moduleDrizzleSchemaMetadata`. Its Module Package v1 still
|
||
bundles Drizzle, but carries no `transitional.databaseRuntime`: that version belongs to the module,
|
||
not Core.
|
||
|
||
## Legacy database access
|
||
|
||
For a version 8 manifest that omits `database`, `context.services.database` — available in `start`, `stop`, and every authorized route handler,
|
||
never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`,
|
||
`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`,
|
||
preserving Drizzle's own table-aware argument and result inference. It is the platform's only
|
||
supported way for module code to reach PostgreSQL:
|
||
|
||
```ts
|
||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||
import { events } from "./schema/tables.js";
|
||
|
||
export default defineModuleRuntime({
|
||
id: "calendar",
|
||
version: "0.1.0",
|
||
|
||
register({ http }) {
|
||
http.route({
|
||
method: "GET",
|
||
path: "/events",
|
||
permission: "calendar.events.read",
|
||
maxBodyBytes: 0,
|
||
async handler(_request, context) {
|
||
const rows = await context.services.database
|
||
.select({ id: events.id, title: events.title })
|
||
.from(events);
|
||
return Response.json(rows);
|
||
},
|
||
});
|
||
},
|
||
});
|
||
```
|
||
|
||
Import your module's own table objects (`src/schema/tables.js` — the same declarations
|
||
[Drizzle schema definition](#drizzle-schema-definition) already covers) exactly as you would with
|
||
an independently constructed Drizzle instance; the only difference is that `services.database`,
|
||
not a client you built yourself, executes the query. Never:
|
||
|
||
- Construct a `Pool`, a `Client`, or a Drizzle instance in module code.
|
||
- Read `DATABASE_URL` or any other connection configuration.
|
||
- Access `$client`, a session, or a dialect object — `ModuleDatabase` exposes none of them, and not
|
||
merely at the type level: the constructed facade physically has no such property.
|
||
- Stash `services` (or `services.database`) in a module-global variable. The platform provides the
|
||
identical object directly to `start`, `stop`, and every authorized handler; there is nothing to
|
||
cache.
|
||
- Run or generate a migration from `register`, `start`, `stop`, or a route handler. Schema and
|
||
migration changes remain [explicit deployment work](#drizzle-migrations) — generate, review,
|
||
commit, and apply them the same way as any other module migration, *before* starting code that
|
||
depends on the new shape. Nothing in discovery, registration, or startup infers a schema or
|
||
creates a table automatically.
|
||
|
||
A handler's database error is sanitized through the same `500 {"code":"MODULE_HANDLER_FAILED"}`
|
||
boundary as any other handler failure (see [HTTP routes](#http-routes)) — there is no separate,
|
||
database-specific error contract. Catch a failure yourself if your route should return something
|
||
more specific; an uncaught one is sanitized automatically.
|
||
|
||
`ModuleDatabase` is an integration boundary for trusted, compiled backend module code, not a
|
||
sandbox: it exists so your module never has to construct or configure a PostgreSQL connection
|
||
itself, not to defend against malicious code, since a module already runs arbitrary code in this
|
||
process. It provides no tenant isolation and does not stop your own code from querying a table it
|
||
imports; `execute` in particular lets trusted code run arbitrary SQL, exactly as every other
|
||
operation already lets it construct arbitrary queries. Keep querying only your own module's tables
|
||
— the same convention [Drizzle schema definition](#drizzle-schema-definition) already establishes
|
||
for imports, just extended to runtime queries. See
|
||
[API](../development/api.md#module-database-access) for how `apps/api` composes and wires the
|
||
facade, and [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the complete
|
||
design.
|
||
|
||
## Discovery and lifecycle
|
||
|
||
From the repository root, run:
|
||
|
||
```bash
|
||
pnpm install
|
||
pnpm modules:validate
|
||
pnpm migrations:validate
|
||
pnpm doctor
|
||
pnpm migrations:plan
|
||
pnpm verify
|
||
```
|
||
|
||
`pnpm modules:validate` builds the workspace and then atomically validates every installed module,
|
||
from the checkout's `modules/` or from `EKKLESIA_MODULES_DIR` when it is set.
|
||
It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
|
||
platform or SDK ranges, missing required modules or capabilities, incompatible installed
|
||
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
|
||
Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable
|
||
identifier sorting.
|
||
|
||
After complete static discovery succeeds, validation loads optional schema entrypoints
|
||
dependency-first and rejects invalid definitions, unnamespaced or duplicate tables, custom
|
||
PostgreSQL schemas, and foreign keys outside the owning module. The aggregate is published only when
|
||
every schema is valid. This slice does not connect to PostgreSQL or execute migrations.
|
||
|
||
`pnpm migrations:validate` performs complete static discovery first, then reads every declared
|
||
migration directory without importing module code or connecting to PostgreSQL. It validates native
|
||
Drizzle journals and snapshot chains, Ekklesia policy metadata, ordering, inventory, paths, and
|
||
checksums atomically. Its concise report lists every installed module and migration in deterministic
|
||
order.
|
||
|
||
With `DATABASE_URL` set through the uncommitted root `.env`, `pnpm migrations:plan` performs a
|
||
read-only applied-history comparison. The plan can become stale. `pnpm migrations:apply` is a
|
||
separate, explicit database mutation: Core acquires its bounded session advisory lock, rereads and
|
||
validates history, preflights the whole destructive-approval set, and applies all pending modules in
|
||
dependency-first order in one transaction. If a later migration fails, every schema and history
|
||
change from that invocation rolls back; migrations committed by an earlier invocation remain.
|
||
|
||
An `approval-required` migration needs an approval from an external provider matching its module
|
||
ID, exact identity, and exact checksum. Module metadata cannot grant approval, and there is no
|
||
wildcard, force, or approve-all option. The default developer command supplies no approvals and
|
||
therefore refuses destructive pending migrations.
|
||
|
||
Runner behavior is unit-tested through a narrow database port. The separate
|
||
`pnpm test:migrations:integration` command now exercises the production adapter against disposable
|
||
PostgreSQL, including DDL rollback, advisory-lock contention, history behavior, approvals, and
|
||
sanitized failures. It remains outside the Docker-free `pnpm verify` gate; see the
|
||
[testing guide](../development/testing.md).
|
||
|
||
`pnpm modules:enable -- --module <id>` and `pnpm modules:disable -- --module <id>` record the
|
||
platform's enablement decision for one installed module. They read `DATABASE_URL`, run the same
|
||
manifest-only discovery every other command does (so a module this deployment cannot see is refused),
|
||
write one row, and start nothing. The change takes effect at the next `pnpm api:start`. These exist
|
||
so a module that breaks startup can be turned off without the API — the HTTP lifecycle API is served
|
||
by the process such a module takes down.
|
||
|
||
`pnpm doctor`'s `object-storage.integration` check reports whether a storage provider is configured
|
||
and reachable, and `object-storage.bindings` lists which modules require storage and where each
|
||
logical bucket is bound. Neither prints a credential. A module requiring storage on an installation
|
||
with none configured makes the bindings check fail with a remediation line; a provider that is merely
|
||
unreachable is a warning, because modules already running are unaffected. A provider whose settings
|
||
are structurally invalid is a blocking failure, reported as a sanitized code — the runtime refuses to
|
||
start on the same input.
|
||
|
||
`pnpm doctor`'s `modules.configuration` check reports whether each installed module's configuration
|
||
would let it run, naming the offending key when it would not. It prints **no configuration values** —
|
||
secret or otherwise — only counts, keys, and reasons.
|
||
|
||
Both checks evaluate the same host configuration the runtime receives: Doctor and the API executable
|
||
map one deployment environment through the same loader, so a bucket the platform would bind is never
|
||
reported unbound, and a module secret the deployment has set is never reported missing.
|
||
|
||
`pnpm doctor` builds first and presents the same production discovery results as a concise,
|
||
read-only dashboard. It lists discovered module IDs and versions in dependency order, reports
|
||
schema and migration counts, and shows safe migration identities with truncated checksums. Schema
|
||
discovery imports the compiled schema-definition entrypoint, so its no-I/O and no-configuration
|
||
contract still applies. Doctor does not import or start module server entrypoints, apply migrations,
|
||
or exercise runtime hooks; lifecycle behavior remains covered by automated tests. PostgreSQL
|
||
planning is skipped when `DATABASE_URL` is absent, and object storage is skipped until Core has a
|
||
production storage integration. Its `modules.lifecycle` check reports which installed modules are
|
||
enabled and which cannot run — also skipped without `DATABASE_URL`, since enablement is stored in
|
||
PostgreSQL. A deliberately disabled module is a pass with a detail line; an *enabled* module that
|
||
cannot run (blocked by a disabled required dependency) is a warning, because that is the anomaly. A skip or pending migration is visible as `READY WITH WARNINGS`,
|
||
not misreported as a successful check.
|
||
|
||
## Configuration
|
||
|
||
Your module declares what it needs configured in `ekklesia.module.json`, and receives it as a frozen,
|
||
already-validated snapshot ([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
|
||
|
||
```json
|
||
"configuration": [
|
||
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600,
|
||
"description": "How long each slide is shown." },
|
||
{ "key": "bucket", "type": "string", "required": true, "minLength": 1, "maxLength": 63 },
|
||
{ "key": "storageCredential", "type": "secret", "required": true }
|
||
]
|
||
```
|
||
|
||
```ts
|
||
export default defineModuleRuntime({
|
||
id: "announcements",
|
||
version: "1.0.0",
|
||
start({ config }) {
|
||
const slideSeconds = config.getInteger("slideSeconds");
|
||
const bucket = config.getString("bucket");
|
||
const credential = config.getSecret("storageCredential");
|
||
},
|
||
});
|
||
```
|
||
|
||
**Never read `process.env`, and never query a platform table for a setting.** `context.config` is the
|
||
whole contract. It has no module id but your own, no environment access, and no way to reach another
|
||
module's values — which is exactly what keeps your module runnable under a future standalone host
|
||
that stores configuration somewhere else entirely.
|
||
|
||
### The types you may declare
|
||
|
||
| type | constraints | notes |
|
||
| --- | --- | --- |
|
||
| `string` | `minLength`, `maxLength` | |
|
||
| `integer` | `minimum`, `maximum` | |
|
||
| `boolean` | — | |
|
||
| `enum` | `values` (1–32, unique) | |
|
||
| `url` | — | absolute `http:`/`https:` only |
|
||
| `secret` | — | deployment-supplied; no `default` |
|
||
|
||
No nested objects, arrays, or regular expressions. If you need structured data, own a table for it
|
||
(see [Database access](#database-access)).
|
||
|
||
### Defaults, required, and the difference
|
||
|
||
Give a `default` to anything with a safe one — a slide duration, a poll interval. Mark `required`
|
||
only what genuinely has no safe default, like a bucket name. A key cannot be both: a key with a
|
||
default can never be missing.
|
||
|
||
**A `secret` can never have a default.** A checked-in default credential is a leaked credential, and
|
||
the contract has no field to put one in.
|
||
|
||
If a `required` key has no value when the platform starts, your module is **not started** — it is
|
||
reported `blocked` with a structured reason naming the key, and its dependents are blocked too. Your
|
||
`start` hook never runs and never has to defend against missing configuration.
|
||
|
||
### Where values come from
|
||
|
||
Ordinary values are stored by the platform and set through
|
||
`PUT /api/v1/module-configuration/<module-id>` (there is no settings UI yet).
|
||
|
||
Secrets are **supplied by the deployment and never stored by Ekklesia**, because Ekklesia has no
|
||
encryption at rest. In an Ekklesia deployment an operator sets
|
||
`EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`. You never write that name anywhere — you call
|
||
`config.getSecret("storageCredential")`.
|
||
|
||
### Reading values
|
||
|
||
Every accessor returns `T | undefined` and fails closed: an undeclared key, a key of a different
|
||
declared type, and an absent optional value are all `undefined`. For a `required` key, or one with a
|
||
`default`, the platform guarantees a value exists by the time your code runs.
|
||
|
||
```ts
|
||
config.getString(key); config.getInteger(key); config.getBoolean(key);
|
||
config.getEnum(key); config.getUrl(key); config.getSecret(key);
|
||
config.get(key); config.has(key); config.keys();
|
||
```
|
||
|
||
The same frozen object reaches `register`, `start`, `stop`, and every authorized route handler, so
|
||
two parts of your module can never disagree about a value.
|
||
|
||
### Configuration is not runtime state
|
||
|
||
```text
|
||
slideDurationSeconds = 7 configuration — an operator sets it, it survives restarts
|
||
currentSlide = 4 domain state — your module owns it
|
||
mode = "off" domain state — your module owns it
|
||
```
|
||
|
||
Configuration is **immutable for the life of the process**: a change takes effect at the next restart,
|
||
and the API tells the operator so. If a value must change *without* a restart — a display toggle
|
||
flipped mid-service, say — that is telling you it is domain state, not configuration. Store it in your
|
||
own table and expose it through your own route.
|
||
|
||
### Never put infrastructure in your configuration
|
||
|
||
Declare the **bucket** you need, not an S3 endpoint, region, and access key. Endpoints and credentials
|
||
for shared infrastructure are the platform's to supply; if every module configured its own, the
|
||
eventual platform storage service would arrive to find a dozen competing endpoint settings in
|
||
production. (Core has no storage service yet — see
|
||
[ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md) for the boundary this
|
||
leaves room for.)
|
||
|
||
### Never ask whether you are enabled
|
||
|
||
Configuration tells you *how to behave*, never *whether to run*. Whether your module runs is its
|
||
host's decision, described next.
|
||
|
||
## Object storage
|
||
|
||
Declare the logical buckets you need in `ekklesia.module.json`, and read and write them through
|
||
`context.storage` ([ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md)).
|
||
|
||
```json
|
||
"storage": {
|
||
"buckets": [
|
||
{ "id": "announcement-assets", "description": "Slide images an operator uploads." }
|
||
]
|
||
}
|
||
```
|
||
|
||
```ts
|
||
export default defineModuleRuntime({
|
||
id: "announcements",
|
||
version: "1.0.0",
|
||
register({ http }) {
|
||
http.route({
|
||
method: "GET",
|
||
path: "/assets/:key",
|
||
permission: "announcements.assets.read",
|
||
maxBodyBytes: 0,
|
||
async handler(_request, context) {
|
||
const object = await context.storage.bucket("announcement-assets").get(context.params.key);
|
||
return new Response(object.body, {
|
||
headers: { "content-type": object.contentType ?? "application/octet-stream" },
|
||
});
|
||
},
|
||
});
|
||
},
|
||
});
|
||
```
|
||
|
||
**You name a logical bucket. You never name a service.** There is no endpoint, region, credential, or
|
||
physical bucket anywhere in this contract, and no accessor that could return one. That is what lets
|
||
the same module run under Ekklesia and under a future standalone host backed by its own SeaweedFS, a
|
||
shared one, MinIO, or AWS S3 — without your code changing.
|
||
|
||
Never add `storageEndpoint`, `storageAccessKey`, or similar to your module configuration. Those are
|
||
the host's, and a module that configures its own object store is a module welded to one deployment.
|
||
|
||
### Declaring a bucket is a real requirement
|
||
|
||
If you declare a bucket and the installation has no storage provider configured, your module is
|
||
**blocked** — reported as `storage_unavailable` naming that bucket, with its required dependents
|
||
blocked behind it. Your `start` hook never runs, so it never has to defend against missing storage.
|
||
|
||
Declare a bucket only if you need one. A module that declares none is completely unaffected by
|
||
whether the installation has object storage, which is why the template declares none.
|
||
|
||
### The operations
|
||
|
||
```ts
|
||
bucket.list({ prefix, limit, cursor }); // → { objects, cursor? }
|
||
bucket.head(key); // → metadata only
|
||
bucket.get(key); // → metadata + body: ReadableStream
|
||
bucket.put(key, body, { contentType, contentLength });
|
||
bucket.delete(key);
|
||
bucket.createReadUrl(key, { expiresIn, downloadFilename }); // → { url, expiresAt }
|
||
```
|
||
|
||
`get` gives you a Web stream, so hand it straight to a `Response` rather than buffering. `put` takes
|
||
a `Uint8Array`, string, `Blob`, or a `ReadableStream` — a stream **must** come with `contentLength`,
|
||
because every S3-compatible provider needs the length up front and inferring it would mean buffering
|
||
the object you were trying to stream.
|
||
|
||
A listing is paginated. When `cursor` is present, more objects remain; when it is absent, the listing
|
||
is complete. Ignoring it will silently truncate a large bucket.
|
||
|
||
There is deliberately **no** way to create or delete a bucket. Which buckets exist follows from your
|
||
manifest and an operator's installation, and nothing your module does can destroy one.
|
||
|
||
### Delivering objects to a browser
|
||
|
||
Streaming an object through your own route works, but it puts your module in the path of every byte.
|
||
For assets a browser is going to fetch anyway — slide images, video, a PDF — ask for a delivery URL
|
||
instead and let the browser fetch it directly
|
||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)):
|
||
|
||
```ts
|
||
const assets = context.storage.bucket("announcement-assets");
|
||
const slides = await assets.list({ prefix: "slides/" });
|
||
|
||
return Response.json(
|
||
await Promise.all(
|
||
slides.objects.map(async (object) => ({
|
||
key: object.key,
|
||
...(await assets.createReadUrl(object.key)),
|
||
})),
|
||
),
|
||
);
|
||
```
|
||
|
||
Signing is local, so building forty URLs costs no round trips and needs no batch call.
|
||
|
||
**Treat the URL as opaque.** Do not parse it, rewrite it, or read the storage host or bucket out of
|
||
it — all of that is the host's business and may change with the deployment. Everything you
|
||
legitimately need is `expiresAt`.
|
||
|
||
`expiresAt` is a `Date`, like `lastModified` on object metadata. Returning it through
|
||
`Response.json(...)` serializes it to an ISO 8601 string, which is what a browser should receive —
|
||
don't invent your own epoch or locale-formatted representation on the way out, and don't assume a
|
||
client can hand the string straight back as a `Date` without parsing it.
|
||
|
||
**Treat the URL as a bearer token.** Anyone holding it can read that one object until it expires.
|
||
Deciding *who may have one* is your route's authorization decision, made before you ask for it;
|
||
storage only guarantees the URL reaches no further than the single object it names. Never log a
|
||
complete delivery URL, and never return one to someone your route would not have served the bytes to.
|
||
|
||
**Persist the key, never the URL.** A key is stable and still resolves tomorrow. A URL is a
|
||
credential with an expiry date in it, and storing one in your tables will break.
|
||
|
||
`expiresIn` is in seconds and bounded — a minute at the shortest, six hours at the longest, fifteen
|
||
minutes if you say nothing. Out-of-range values are refused rather than quietly adjusted. A
|
||
long-running display should re-request state periodically rather than hold a URL forever; if an image
|
||
is already loaded when its URL expires, nothing breaks, and the next state refresh carries a fresh
|
||
one.
|
||
|
||
Pass `downloadFilename` only when you want the browser to *save* the file rather than display it —
|
||
a bulletin PDF, say. Images and video should stay inline, which is the default.
|
||
|
||
The URL is not checked against the object first. A URL for a key that does not exist, or one deleted
|
||
after signing, simply returns `404` when fetched.
|
||
|
||
There are no signed **write** URLs. Uploads come through your own route, where you can check size,
|
||
type, and who is asking.
|
||
|
||
### Object keys
|
||
|
||
Keys may nest (`slides/2026/welcome.png`). They are not filesystem paths — `..` means nothing to an
|
||
object store, and your bucket comes from the host's binding rather than from the key, so a key cannot
|
||
escape it. Empty keys, keys over 1024 UTF-8 bytes, control characters, backslashes, and a leading `/`
|
||
are rejected as `STORAGE_KEY_INVALID`.
|
||
|
||
### Storage failures are yours to handle, and never change your lifecycle
|
||
|
||
```ts
|
||
try {
|
||
const listing = await context.storage.bucket("announcement-assets").list();
|
||
} catch (error) {
|
||
// STORAGE_PROVIDER_UNAVAILABLE, STORAGE_OBJECT_NOT_FOUND, STORAGE_OPERATION_DENIED, ...
|
||
}
|
||
```
|
||
|
||
`ModuleStorageError` carries a code and nothing else — no endpoint, credential, bucket name, or
|
||
provider body — so it is safe to log or return.
|
||
|
||
**A storage outage is not a lifecycle event.** If the object store is briefly unreachable, your module
|
||
stays `running`; retry on your own schedule and report health in your own UI. The platform will never
|
||
disable or block a running module because a storage call timed out.
|
||
|
||
### Serving objects to a browser
|
||
|
||
Module UI gets no storage access and no credentials. Its authorized module route may either stream
|
||
through scoped storage or call `createReadUrl(key)` and return that short-lived bearer URL so the
|
||
browser fetches bytes directly from object storage. Primary and display UI consume that URL the same
|
||
way: keep it opaque, never persist or log it, and refresh it before expiry. A display host adds no
|
||
storage authority and realtime transport does not extend a URL's lifetime.
|
||
|
||
### Whether your module runs at all is your host's decision
|
||
|
||
Between discovery and startup, the platform consults its own **enablement configuration** and
|
||
resolves which discovered modules are eligible to run
|
||
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)):
|
||
|
||
```text
|
||
present on disk → discovered → enabled → eligible → running
|
||
```
|
||
|
||
Three things matter to you as a module author, and only three.
|
||
|
||
**You never see it.** Nothing in your manifest declares enablement, nothing in your `register`,
|
||
`start`, or `stop` context exposes it, and your module is never told it is enabled. Your host either
|
||
loads and starts you, or it does not. **Do not write feature code that asks whether it is enabled in
|
||
Ekklesia** — that is what keeps the same implementation runnable under a future standalone host,
|
||
whose loading decisions are its own.
|
||
|
||
**Disabled means nothing of yours runs.** A disabled module's compiled server entrypoint is never
|
||
imported, so no hook fires, no route is mounted, no capability is registered, and its UI artifact is
|
||
not published or listed. There is no per-request "is this enabled?" check to code against, because
|
||
nothing is mounted.
|
||
|
||
**A module with no stored decision is enabled.** Deploying your module is sufficient to run it; no
|
||
initialization step, database edit, or migration is needed to make it appear.
|
||
|
||
Your **required** dependencies are real: if a module (or the provider of a capability) you declare
|
||
under `dependencies.modules.required` / `dependencies.capabilities.required` is disabled, you are
|
||
blocked and will not start, with a structured reason naming it. Your **optional** dependencies are
|
||
not: if an optional provider is disabled, you start normally and
|
||
`context.capabilities.resolve(...)` returns `undefined` — exactly as it does when that provider was
|
||
never installed. Declare optional if you can degrade; declare required if you genuinely cannot run.
|
||
|
||
Your migrations are discovered and applied regardless of enablement — deployed schema ownership is a
|
||
deployment fact, not runtime state — so enabling your module later never needs a schema change in the
|
||
middle of a restart.
|
||
|
||
Finally, "running" means your `start` hook completed. It does **not** mean every external resource
|
||
you use is reachable. If your object storage or an upstream service is down after you started, that
|
||
is your module's own health to surface through your own API and UI; the platform has no health-probe
|
||
contract and will keep reporting you as running.
|
||
|
||
### Discovery and startup, step by step
|
||
|
||
Core completes discovery before importing any server entrypoint. It then loads and verifies every
|
||
eligible module's runtime definition before running hooks, registers all of them dependency-first
|
||
into an unpublished staged capability registry and an unpublished staged HTTP route list side by
|
||
side, and starts them in the same order. Neither the capability registry nor the route list is published — visible on
|
||
`StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is
|
||
reverse-order. If a later module fails to start, Core stops already-started modules in reverse order
|
||
and reports any cleanup failures without leaking caught exceptions or machine paths. A failing
|
||
`start` hook must clean up its own partial work before rejecting.
|
||
|
||
## Start from the template
|
||
|
||
Copy `templates/module` to `modules/<your-module-id>` and follow its README. No central registry edit
|
||
is needed: the workspace glob, build, discovery, and runtime loading all find the new package.
|