Files
Ekklesia/docs/module-development/README.md
T
ekklesiaandClaude Opus 5 b9ca0d560d
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Failing after 2m20s
feat: add host-provided module object storage
`manifest.storage.buckets` has existed since manifest version 1 and meant
nothing — discovery validated it and no code read it. This turns it into a
real capability, which is what stands between Announcements and the module
system: that product currently fetches a hardcoded LAN S3 endpoint, parses
ListObjectsV2 XML by hand, and hands the browser direct object URLs.

- A module declares a logical bucket and receives a scoped facade at
  `context.storage` — list, head, get, put, delete. The contract names no
  provider: no endpoint, region, credential, or physical bucket is reachable
  from it, which is what lets the same module run under a future standalone
  host over its own or a shared S3-compatible service.
- Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the
  deployment target (ADR 0004), never an API name. Probing the pinned image
  found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's
  `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would
  have failed every second boot. A live test pins it.
- Logical → physical mapping is deterministic and collision-safe: an
  installation prefix, the module and bucket ids joined by `--` (which cannot
  occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide),
  and a digest over the full untruncated identity. Two modules declaring
  `assets` always get separate namespaces; neither can see the physical name.
- Declaring a bucket is now a requirement: with no provider configured the
  module is `blocked · storage_unavailable` through ADR 0028's existing
  resolver, propagating to required dependents. An installation with no object
  storage stays a complete deployment.
- Binding availability is not provider health. Eligibility asks whether
  storage is configured, never whether it answered; bucket creation is lazy on
  first use so a storage outage cannot stop the platform booting; a running
  module whose store blinks stays running and retries. Nothing deletes a
  bucket.
- Credentials are deployment-supplied, never persisted, never routed through
  module configuration, and never visible to modules, module UI, diagnostics,
  or API responses. Provider errors translate to a closed code set carrying no
  endpoint, bucket, request id, or XML.
- Retires ADR 0029's transitional `bucket` configuration key. No manifest
  version change. The template declares no bucket, so it still runs without S3.

See ADR 0030.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 08:45:53 -05:00

1264 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 a module UI in React, build it into the compiled artifact your
> manifest declares, have Core validate it, have Ekklesia publish it at a browser URL, and have
> Ekklesia's browser shell discover and mount it — the loop closes end to end. **What does not exist
> is the Dashboard**: no navigation, grouping, icons, ordering, theme, or design system, and no
> standalone host. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint),
> [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
├── 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
```
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": 4,
"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."
}
],
"storage": {
"buckets": [
{
"id": "assets",
"description": "Private event assets owned by Calendar."
}
]
},
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
"migrations": "./drizzle"
}
}
```
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: 4` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 3 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 4 adds one optional field, `entrypoints.ui`, so upgrading a backend-only version 3 manifest
is a one-character edit and changes nothing else
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.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 entrypoint except `migrations` must be a compiled ESM `.js` or `.mjs` file — `server`,
`schema`, `jobs`, and `ui` alike. `migrations` is the one directory entrypoint. All entrypoints begin
with `./`, remain inside the package, and exist. Production never imports source TypeScript directly.
Modules with no tables omit `entrypoints.schema`; modules with no migration history omit
`entrypoints.migrations`; modules with no UI omit `entrypoints.ui`.
Unknown manifest properties are still rejected. `ui` is the only entrypoint version 4 adds — 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 a UI entrypoint
A module may declare one optional presentation entrypoint alongside its server, schema, and
migration entrypoints:
```json
{
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
"migrations": "./drizzle",
"ui": "./dist/ui/index.js"
}
}
```
`ui` obeys exactly the rules `server` and `schema` already obey. It must be package-relative, stay
inside the module package (checked before *and* after symlink resolution), resolve to a real file,
and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts` path is rejected at
discovery — the platform never imports source TypeScript, and a UI artifact is a build output like
any other.
### What the artifact must export
The 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.
### What the host gives you
`mount` receives exactly four 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 |
Navigation, host theme and design tokens, module configuration, organization context, and
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a
speculative field frozen into a public contract is harder to remove than to add.
### 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 your UI source at `src/ui/index.tsx`, declare the artifact in your manifest, and build:
```text
src/ui/index.tsx # the compiled entrypoint's source
src/ui/ExampleApp.tsx # your components
↓ pnpm build
dist/ui/index.js # the artifact your manifest declares
```
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"
}
}
```
The output path is **read from your manifest**, not from a build convention, so the artifact you
declare and the artifact you produce cannot drift. Declare it exactly as you would any other
entrypoint:
```json
{ "entrypoints": { "ui": "./dist/ui/index.js" } }
```
A module with no `entrypoints.ui` 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 `src/ui/index.tsx` is missing.
#### 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"]
}
```
`"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.
### Styling: inline style objects only, for now
Style with React's `style` prop:
```tsx
<section style={{ display: "grid", gap: "0.5rem" }}>…</section>
```
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
an oversight: esbuild would emit a `dist/ui/index.css` beside your artifact, but no manifest field
declares it and no host loads it — so it would work in your tests and silently do nothing in a real
host. A stylesheet contract needs a manifest field, a host loading mechanism, and a style-isolation
decision, none of which exist yet. Inline styles are the whole supported story for this phase.
### 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 | `.jsx` (strict TypeScript only), `.css`, `.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/ui` and other shared UI-role packages |
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 artifact is served, and Ekklesia's shell mounts it.** The platform publishes your compiled UI
> at `GET /api/v1/module-ui/<module-id>/<version>`
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)) and its browser
> shell discovers and mounts it
> ([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 the `ui` path, validates it, and records it on
the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or renders a UI
entrypoint** — a UI 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 artifact (dist/ui/index.js)
↓ asset publication — host-specific; Ekklesia serves it at
↓ GET /api/v1/module-ui/<module-id>/<version>, and its shell finds it
↓ through GET /api/v1/module-ui
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 `entrypoints.ui` to a path on the *server's filesystem*, which a browser cannot import.
Ekklesia turns that into a URL
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)):
```text
GET /api/v1/module-ui/<module-id>/<version>
```
Four things about it matter to you as a module author:
- **Only the artifact your manifest declares is served.** The module id and version in that 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; there is no sidecar contract yet, which is also why a
stylesheet import still fails the build.
- **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 is public.** The route requires no authentication — fetching it 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.
### 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.
## 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
is deliberately limited to `moduleId`, capability access, and the HTTP route registrar (below) —
never `services`. `start` and `stop` share one context shape and both additionally receive
`services` — the same frozen `ModuleRuntimeServices` object for the lifetime of one running API,
currently exposing only `database` — see [Database access](#database-access).
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 fields — `method`, `path`, `permission`, `maxBodyBytes`,
`handler` — 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.
`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. It must be a non-negative integer no greater than the platform ceiling,
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (`@ekklesia/contracts`, currently 1 MiB) — a negative,
non-integer, non-finite, or over-ceiling value fails registration with
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`. The platform enforces this limit itself, ahead of permission
evaluation and the handler, using 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`, the immutable decoded path parameters, and `services` — the
same frozen `ModuleRuntimeServices` object the module's own `start`/`stop` hooks receive, currently
exposing only `database` (see [Database access](#database-access)) — never an authorization store,
grant/platform-owner state, storage, jobs, configuration, secrets, a framework-specific request
context, or a raw Better Auth session. 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.
## 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.
`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.
`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);
```
`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.
### 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. Serve bytes through your own authorized route,
as in the example above: `browser → your module API → your scoped storage`. Signed URLs and public
object delivery do not exist yet.
### 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.