Files
Ekklesia/templates/module/README.md
T
ekklesiaandClaude Opus 5.5 4671622b9d Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:21:50 -05:00

103 lines
7.2 KiB
Markdown

# Ekklesia module template
This is the smallest supported Ekklesia module product. It proves the static manifest,
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
route (`GET /records`, declared in `register`) that reads that table through
`@ekklesia/module-database/drizzle` over the platform-provided ABI-v1 executor. Drizzle stays a
module-side implementation detail; Core receives SQL and bound values, never the module's ORM
objects, a raw `pg` client, or connection configuration. Migration execution and storage provisioning
are intentionally not included.
It also ships two independently optional React roles: a primary/management UI at `src/ui/index.tsx`
and a full-viewport display at `src/display/index.tsx`. One invocation of `pnpm build` compiles each
declared role to the exact artifact in `entrypoints.ui` and `entrypoints.display.artifact`
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). Both default-export
the same role-neutral `defineModuleUi({ id, version, mount })` ABI and receive the same
`ModuleUiHostContext`; there is deliberately no mode flag. The manifest entrypoint and the host route
choose the role.
Either role can be omitted independently. To remove only the display example, delete `src/display/`
and `entrypoints.display` (and remove `example.display.view` if nothing else uses it). To keep the
module backend-only, also delete `src/ui/`, `tsconfig.ui.json`, `tsconfig.ui-test.json`,
`tests/module-ui.test.ts`, the `ui` entrypoint, and the React/module-UI dependencies. The builder
succeeds with an empty artifact list when neither role is declared.
`tests/module-ui.test.ts` builds both artifacts from source, validates them with
`parseModuleUiExport`, mounts each into a real DOM, and unmounts it. Ekklesia mounts the primary UI
inside its Dashboard shell and serves the display without Dashboard chrome at the stable
`/display/example` page. See
[Declaring UI entrypoints](../../docs/module-development/README.md#declaring-ui-entrypoints).
To create a module inside this repository:
1. Copy this directory to `modules/<module-id>`.
2. Rename the package in `package.json` and remove `private` when it is ready for publication.
3. Update the ID, name, version, namespaces, compatibility ranges, and bucket requests in
`ekklesia.module.json`.
4. Keep the manifest capability versions and dependency ranges aligned with the contracts the
module actually implements and consumes.
5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version
must match the manifest; do not duplicate the rest of the manifest in TypeScript. Declare HTTP
routes only in `register`, against a permission the manifest already declares; `start`, `stop`,
and every route handler — never `register` — receive `context.services.database`, a typed
Module Database ABI-v1 executor. Adapt it with `moduleDrizzle`; do not pass ORM objects to Core.
6. Replace the pure Drizzle declarations in `src/schema/tables.ts` and keep its table prefix aligned
with the module ID. Keep `moduleDrizzleSchemaMetadata` in `src/schema/index.ts` aligned too; it
exports plain ownership and foreign-key metadata, never ORM objects. Never import another
module's schema.
7. Update `drizzle.config.ts`, generate forward migrations with `pnpm migration:generate
--name=lowercase_name` from the module directory, and review the SQL. Keep each generated tag in
`drizzle/meta/_ekklesia.json`, classify it as `none` or `approval-required`, and change that
file's `moduleId` when renaming the template.
8. Run `pnpm install`, `pnpm build`, `pnpm modules:validate`, `pnpm migrations:validate`, and
`pnpm verify` from the repository
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
That `modules/<module-id>` directory is where a module is **developed**. An installation never runs
it from there: Ekklesia's image contains no modules, and an installation installs a self-contained
copy of the module into its module storage
([An installed module is self-contained](../../docs/module-development/README.md#an-installed-module-is-self-contained),
ADR 0048). Copying `dist/` alone is not enough, because the compiled server code imports the Module
SDK and Drizzle by name. After building, package the module with the canonical packager, which
bundles the backend into one deterministic Ekklesia Module Package (ADR 0049):
```bash
pnpm modules:pack --module templates/module --target any --out <directory> \
--license-file LICENSE \
--source-revision "$(git rev-parse HEAD)"
```
The module owns its feature screens and display renderer. Its manifest (version 9) declares its
sidebar icon and two primary pages, Records and About. Its UI renders whichever page
`context.navigation.location()` names, links with `navigation.link()`, shows a record's detail at
`records/<id>` beneath the Records page, and opens its Settings page with `navigation.settings`
([ADR 0063](../../docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Do
not add a tab bar for those pages, the containing shell, global theme, authentication screens, or
top-level routing, and never touch `window.history`; those belong to surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page
or descriptor; `context.permissions` remains a presentation-only hint.
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
and release them in `stop`. Construct Drizzle only through the module-side ABI adapter; never read
`DATABASE_URL` or reach for a raw `pg` client. `context.services.database` is the only supported
executor for this module's own tables, and it is never available during `register`.
The migration directory uses the pinned stable Drizzle Kit `0.31.10` PostgreSQL journal and
snapshot format. Migrations are forward-only and may modify only this module's objects. Offline
validation treats SQL as opaque bytes, calculates SHA-256 from the exact contents, and never opens a
database or executes SQL. Destructive declarations are review metadata, not automatic SQL
classification or approval.
## License
The template's `package.json` declares `"license": "AGPL-3.0-or-later"`, the license of Ekklesia
and of official Ekklesia modules (see the repository [README](../../README.md#license) and
[LICENSE](../../LICENSE)). A module started from the template begins with that declaration; an
official Ekklesia module keeps it. The packaging command explicitly supplies the repository's
canonical `LICENSE`, avoiding a duplicate file in this nested template. Its exact bytes become the
package-root `LICENSE`, while `ekklesia.package.json.license` receives the source declaration.
Standalone modules should keep their own license file and pass its path explicitly. The packager
does not infer or download license material, and `THIRD_PARTY_NOTICES` remains only for bundled
third-party dependencies.