feat: add the module configuration and secrets contract
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s

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]>
This commit is contained in:
ekklesia
2026-08-22 21:44:43 -05:00
co-authored by Claude Opus 5
parent fdaad58848
commit baa689b2a7
76 changed files with 6650 additions and 131 deletions
+66 -5
View File
@@ -12,8 +12,8 @@ transport), and Phase 5C (module database access) have landed. **Phase 5D (secur
against an independent security audit) is complete: all five passes have landed and the final
independent whole-repository re-audit has closed, under the current trusted-contributor,
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) has
landed.** The operational
completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) and
pass 2 (the module configuration and secrets contract) have landed.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
pass 5 closure below and
@@ -449,9 +449,67 @@ logs. Reading and changing both require the new restricted platform permission
code is still on disk and still trusted. There is still **no installer**. See
[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md).
Phase 7A pass 2 adds the **module configuration and secrets contract** — the thing standing between
Announcements and the module system, since that product currently has zero environment variables and
compiles a LAN IP address into its source.
A module declares what it needs configured in its manifest, and receives it as a frozen, validated
snapshot:
```json
"configuration": [
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600 },
{ "key": "bucket", "type": "string", "required": true },
{ "key": "storageCredential", "type": "secret", "required": true }
]
```
```ts
start({ config }) {
const slideSeconds = config.getInteger("slideSeconds");
const credential = config.getSecret("storageCredential");
}
```
**Declaration is static, and that is the whole point.** The platform validates configuration
*before* deciding whether a module may start, so an enabled module missing a required value is
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That forces `manifestVersion: 5`, a hard cutover in this repository's usual style.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment
(`EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`) and never touches PostgreSQL. The module never sees
that variable name — it calls `config.getSecret("storageCredential")` — which is exactly what lets a
future standalone host supply the same declared secret from somewhere else. A `secret` declaration has
no `default` field, the write API refuses secret material outright, and the HTTP view has no `value`
field for one, so a secret is absent from responses *by construction* rather than filtered by a branch
somebody could delete.
Six types (`string`, `integer`, `boolean`, `enum`, `url`, `secret`), bounded constraints, no nesting,
no regular expressions — a validation vocabulary, not a form-builder DSL. Nothing is coerced:
`{"slideSeconds": "7"}` is a `400`, and a stored value that fails its own declared constraints blocks
the module rather than being quietly replaced by the default. Ordinary values live in one Core table
(migration `0005`), one row per `(module_id, key)`; a key the current manifest no longer declares is
retained and ignored so a downgrade never destroys an operator's value.
Changes take effect at the **next restart**, and `GET`/`PUT /api/v1/module-configuration/<id>` report
`restartRequired` rather than pretending otherwise — a sibling prefix, because `config` is a legal
module route segment. Both verbs need the new restricted `core.modules.configuration.manage`,
deliberately separate from the lifecycle permission: running a module and configuring it are different
authorities. A setting that must change *without* a restart is the module's own domain state — like
Announcements' `mode` toggle — not platform configuration.
**There is no settings UI**, and module UI receives no configuration at all in this phase. Endpoints
and credentials for shared infrastructure stay platform concerns: a module declares which *bucket* it
needs, not an S3 endpoint, leaving room for the object storage service Core still lacks. See
[ADR 0029](docs/architecture/decisions/0029-module-configuration-and-secrets.md).
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
and no module-management UI — module enablement is an API and a CLI, not a screen. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
and no module-management or settings UI — module enablement and configuration are an API and a CLI,
not a screen. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do
not exist.
@@ -509,20 +567,23 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
4 permissions registered
5 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
○ Module configuration skipped
DATABASE_URL is not configured.
Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
5 migrations · checksums valid
6 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
core:0003_dashboard_navigation_tables · 2cde27807aa4
core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b
PostgreSQL
✓ Database TLS verified