Modules had no way to be told anything. Announcements — the first real
product Ekklesia will absorb — has zero environment variables and compiles
a LAN IP address into its source, so this is the contract standing between
it and the module system.
- A module declares what it needs in its manifest, using six bounded types
(string, integer, boolean, enum, url, secret) with no nesting and no
regular expressions. Static declaration is what lets the platform validate
configuration *before* deciding whether a module may start: an enabled
module missing a required value is reported `blocked ·
configuration_missing · <key>` rather than taking startup down from inside
its own `start` hook, and Doctor reports the same while still importing no
module code. That forces `manifestVersion: 5`, a hard cutover.
- Ordinary values persist in one Core table (migration 0005), one row per
(module_id, key), stored as text until validated against the declaring
module's current manifest. Nothing is coerced; an invalid stored value
blocks the module rather than falling back to the default. A key the
manifest no longer declares is retained and ignored.
- Secrets are never persisted. There is no encryption at rest in this
repository, so a `secret` key is deployment-supplied and mapped in by the
host under a name the module never sees. A secret declaration has no
`default` field, the write API refuses secret material, and the HTTP view
has no `value` field for one — absent by construction, not filtered.
- Modules receive a frozen `context.config` on the registration, runtime,
and HTTP handler contexts: the same object identity, per module, beside
`services` rather than inside it (services is deliberately one shared
instance). No environment access, no database reference, no way to reach
another module's values.
- Configuration decides eligibility through ADR 0028's existing resolver:
three new blocked reasons, each with a distinct operator fix, propagating
to required dependents through the same mechanism as a disabled module.
- Changes take effect at the next restart; both resources report
restartRequired. New restricted permission
`core.modules.configuration.manage`, separate from lifecycle, at the
sibling prefix `/api/v1/module-configuration`.
- Migration participation stays independent of configuration validity.
Endpoints and credentials for shared infrastructure stay platform
concerns; a module declares which bucket it needs, not an S3 endpoint.
See ADR 0029.
Co-Authored-By: Claude Opus 5 <[email protected]>
Ekklesia had no notion of a module being on or off: the accepted module set
was exactly "whatever discovery found on disk", and `ModuleLifecycleStage`
named nine states nothing produced or consumed. This replaces that vocabulary
with two axes that real code produces — persistent, administrator-owned
configuration (`enabled`/`disabled`) and per-boot runtime observation
(`running`/`disabled`/`blocked`) — and wires them through startup.
- One Core table (`ekklesia_core_module_state`, migration 0004) holding only
`module_id` and `enabled`. A module with no record is enabled by default, so
deploying a module is still sufficient to run it and discovery never needs an
initialization write. A record for an undeployed module is retained and
ignored, so redeploying restores the decision.
- `resolveModuleEligibility` is pure: a disabled module transitively blocks
every module that *requires* it (module and capability edges alike) with a
structured reason naming the provider; optional dependencies never block; and
a missing, incompatible, or cyclic dependency stays discovery's concern.
- `startApi` starts only the eligible set and builds the UI publication
registry from that same list, so routes, artifacts, browser inventory, and
Dashboard navigation all follow one authoritative set. A disabled module is
never imported, so there is no per-request enablement check anywhere.
- Startup stays all-or-nothing, deliberately; there is consequently no `failed`
runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band
recovery path for a module that breaks startup.
- Changes take effect at the next start, and the read model reports
configuration, runtime, and `restartRequired` separately rather than claiming
a module stopped when it has not.
- Migration participation and permission composition still span the deployed
and discovered inventories respectively, unchanged.
- New restricted platform permission `core.modules.lifecycle.manage` gates both
reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`)
that cannot collide with the module route namespace.
- `pnpm doctor` gains a `modules.lifecycle` check so "why is this module
unavailable?" is answerable without reading logs.
See ADR 0028.
Co-Authored-By: Claude Opus 5 <[email protected]>
Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI
build pipeline and the generic host loader: Core resolves entrypoints.ui to an
absolute server filesystem path, and a browser needs a URL. This adds that
publication layer.
Core owns the policy. createModuleUiPublicationRegistry builds, from the
discovered and started module inventory, the answer to "may this be published,
and which exact file is it?" — publishable means the module survived discovery
and declares entrypoints.ui, with no new lifecycle state and no installer.
apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both
segments are lookup keys, not path components. An exact match yields the
canonical artifact path discovery already validated; no request value is ever
composed into a filesystem path, and the route declares no wildcard segment, so
traversal, encoded separators, sibling chunks, package-root files, and source
maps are unreachable by construction. An installed module directory never
becomes a static file root.
The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module
route segment, so a module registering GET /ui/:version would otherwise compete
with the platform for one URL with routing order deciding the winner.
The route requires no authentication, deliberately. Fetching the bundle confers
no authority, and a cookie-gated route would break cross-origin import(), which
sends none. The cost is stated rather than hidden: a caller who knows a module
id and its exact version can download that bundle. The three failure modes
return one identical 404 so the route is not an enumeration oracle.
Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match
handling, not immutable: without an installer nothing stops a version being
rebuilt with different bytes. The server reads bytes and writes bytes — never
imports, evaluates, or transforms the artifact.
Nothing mounts a module UI. A host could now obtain a URL, which was previously
impossible; there is still no Dashboard shell, navigation, or metadata endpoint.
Co-Authored-By: Claude Opus 5 <[email protected]>
Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen
ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object
through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler
context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView)
over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and
wires it into startApi's fixed startup order ahead of Identity/Authorization construction.
Registration stays database-free; a module reaches its database only through an authorized start,
stop, or HTTP handler invocation.
Establishes the one canonical PermissionDeclaration contract
(packages/contracts/src/authorization.ts) — id/baseline/description,
strict, unknown-property-rejecting — consumed by both Core's
authorization registry and module manifests, replacing Core's
previously separate interface.
Bumps the canonical module manifest to manifestVersion 3: permissions
become complete declarations instead of bare identifier strings, with
manifest-owned namespace and duplicate-id enforcement. Version 2 and
bare string-array permissions are rejected outright; there is no
compatibility adapter.
Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts),
a pure, synchronous composer that builds one platform permission
registry from Core's own declarations plus every already-discovered
module's, in deterministic order, reusing createPermissionRegistry
and rejecting any module-contributed permission in the reserved
core.* namespace. core/src/authorization remains independent of
core/src/modules; only the module integration layer depends on
authorization.
Updates the module template, module-development docs, authorization
docs, and existing manifest fixtures for manifestVersion 3. Does not
wire the combined registry into apps/api — that is pass 2.