Phase 6C pass 2. Every link in the module UI chain existed except the one that closes it: a module could be authored, built, validated, published at a browser URL, and loaded by a generic loader — and nothing in Ekklesia did any of it. There was no browser application at all. Two pieces were missing. A browser could not discover what to mount, and it must not work that out for itself. GET /api/v1/module-ui is the inventory: per publishable module, its id and version, the server-produced artifact URL, the server-produced root of the module's own API, and the subset of that module's declared permissions the caller holds. Five fields, each because building a ModuleUiHostContext needs it — no filesystem paths, and no name/icon/folder/order, because navigation metadata is a design that does not exist yet. Its module set is the ADR 0025 publication registry rather than a rescan, so the inventory and the artifact route cannot disagree. It requires a new Core permission, core.modules.ui.read (baseline authenticated), because it is precisely the enumeration the anonymous artifact route deliberately withholds. Both URLs are server-produced. moduleUiArtifactUrl already existed; moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL shape has exactly one author. Without it the shell would hardcode /api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling, reappearing in the host instead. surfaces/shell (@ekklesia/shell) is the repository's first surface: a small React application built to static files by its own esbuild script. It resolves the principal, fetches the inventory, builds the host context entirely from server-derived data, and hands the artifact URL to @ekklesia/module-ui-host. One frame per selection — keyed by module identity — defeats the stale-load race; the host owns the container, the loading state, and every failure, so a broken module costs its own panel rather than the shell. The shell statically imports no module, and both levels are checked: no source import may name a module, and the emitted bundle must contain exactly one dynamic import (the loader's) with no module id, no /api/v1/modules, no eval, and no blob URL. Permission hints come from Core's own evaluator and stay presentation-only — the end-to-end test has a UI holding no grant call its API anyway and still receive 403. Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts is deliberately excluded, since its barrel pulls Drizzle's types and Node's globals into the browser — the hole ADR 0024 closed one layer down. Not the Dashboard: no navigation, grouping, icons, ordering, design system, or routing. The selector is a list of module ids. Co-Authored-By: Claude Opus 5 <[email protected]>
966 lines
49 KiB
Markdown
966 lines
49 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 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.
|
|
|
|
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 Dashboard.** The shell is a module list and a mount target. There is no
|
|
> navigation, grouping, icons, ordering, theme, or design system yet, and the selector shows your
|
|
> module's id — the inventory deliberately carries no display metadata until that design exists.
|
|
|
|
### 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 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.
|