ADR 0017 capped a module route at 1 MiB and said large media belonged to a future storage design. That design arrived — ADR 0030 gave modules a bucket, ADR 0031 took Ekklesia out of the read path — and it did not solve the write path: signed write URLs were deliberately declined, so the only way bytes reach a bucket is a module's own route, and a photograph is not 1 MiB. Extend the route contract rather than adding a second HTTP system. A route declares `bodyMode`: `buffered` (the default, and what every existing route already meant) keeps the 1 MiB ceiling and materializes the body before the handler runs; `streamed` raises the ceiling to 32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather than inferred from the size, because inferring it would mean an author who raised a limit silently changed how their own handler receives its body and when a 413 can reach their own code. 32 MiB holds one 25 MiB image plus bounded overhead and is also the platform's absolute ceiling: no route in any mode may declare more, and no unbounded option exists in the contract. The unit is one file per request — twenty images are twenty individually bounded requests, and nothing here can express a 500 MiB aggregate whose failure would waste everything already sent. Replace the buffering dependency with the platform's own limiter. A declared Content-Length over the limit is refused synchronously, before authorization and before a byte is read. Buffered mode now counts what actually arrives rather than trusting the header, which closes a gap the previous implementation had. Streamed mode wraps the body in a counting transform; on overflow the stream errors, the source is cancelled, and the response is replaced with the same sanitized 413 even if the handler swallowed the error — a limit a handler can swallow is not a limit. An unread body is cancelled so an abandoned upload releases its socket, and an abort stays an ordinary failed request rather than becoming a 413. Ordering is unchanged and now strictly better: in streamed mode no byte is read until the handler reads it, and the handler runs only after authorization succeeds, so an unauthorized caller cannot push an upload through at all. The live suite uploads a real 25 MiB body over a real socket end to end. Co-Authored-By: Claude Opus 5 <[email protected]>
1826 lines
90 KiB
Markdown
1826 lines
90 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
|
||
|
||
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.
|
||
|
||
## Canonical manifest
|
||
|
||
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
|
||
without executing module code:
|
||
|
||
```json
|
||
{
|
||
"manifestVersion": 7,
|
||
"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: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||
version 6 manifest, and any other version, fails validation outright rather than being silently
|
||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||
Version 7 adds 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 in manifest version 6; 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, shared
|
||
CSS, Announcements migration, and Companion integration are deferred.
|
||
|
||
### 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 SDK wrapper. `src/schema/index.ts` remains the production compiled schema
|
||
entrypoint and wraps those same exported table objects with `defineModuleSchema`; tables are not
|
||
declared twice.
|
||
|
||
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.
|
||
|
||
## Database access
|
||
|
||
`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.
|
||
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.
|