Files
Ekklesia/docs/module-development/README.md
T
ekklesiaandClaude Sonnet 5 e296e083ec feat: define module HTTP routes
Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime
registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical
path grammar, route definition/context/handler types, and validation)
and Core's module runtime staging/publication of a module's declared
routes alongside its capabilities, gated on the module's own manifest
permissions. Routes are collected into StartedModuleRuntime as an
immutable snapshot but are not yet mounted into apps/api; that is pass 2.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-24 18:36:23 -05:00

410 lines
20 KiB
Markdown

# Module development
An Ekklesia module is a backend capability package. It owns business behavior and data declarations;
it never owns components, styles, layouts, renderers, navigation, or other UI.
## 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": 3,
"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: 3` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 2 manifest, and any other version, fails validation outright rather than being silently
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
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.
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
migration entrypoint is a directory. 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`. Unknown
manifest properties are rejected, including presentation fields such as components or renderers.
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.
## 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 }) {
const people = capabilities.resolve("people.records");
// Acquire runtime resources here.
},
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 runtime context is
deliberately limited to the module ID and capability 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`.
## 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",
async handler(request, context) {
return Response.json({
eventId: context.params.eventId,
principal: context.principal.kind,
});
},
});
},
});
```
A route definition has exactly four fields — `method`, `path`, `permission`, `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.
`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`, and the immutable decoded path parameters — never a database
connection, 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.
This pass only defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
snapshot — Core never invokes a handler here. Mounting routes into the production `apps/api` Hono
application, enforcing each route's permission through `requirePermission`, applying
`requireTrustedOrigin`, mapping handler errors to sanitized responses, and wiring this into
`startApi`'s production lifecycle are later work.
## 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.
## 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 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. A skip or pending migration is visible as `READY WITH WARNINGS`,
not misreported as a successful check.
Core completes discovery before importing any server entrypoint. It then loads and verifies every
runtime definition before running hooks, registers all modules 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.