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
+114
View File
@@ -969,6 +969,10 @@ write one row, and start nothing. The change takes effect at the next `pnpm api:
so a module that breaks startup can be turned off without the API — the HTTP lifecycle API is served
by the process such a module takes down.
`pnpm doctor`'s `modules.configuration` check reports whether each installed module's configuration
would let it run, naming the offending key when it would not. It prints **no configuration values** —
secret or otherwise — only counts, keys, and reasons.
`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
@@ -982,6 +986,116 @@ PostgreSQL. A deliberately disabled module is a pass with a detail line; an *ena
cannot run (blocked by a disabled required dependency) is a warning, because that is the anomaly. A skip or pending migration is visible as `READY WITH WARNINGS`,
not misreported as a successful check.
## Configuration
Your module declares what it needs configured in `ekklesia.module.json`, and receives it as a frozen,
already-validated snapshot ([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
```json
"configuration": [
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600,
"description": "How long each slide is shown." },
{ "key": "bucket", "type": "string", "required": true, "minLength": 1, "maxLength": 63 },
{ "key": "storageCredential", "type": "secret", "required": true }
]
```
```ts
export default defineModuleRuntime({
id: "announcements",
version: "1.0.0",
start({ config }) {
const slideSeconds = config.getInteger("slideSeconds");
const bucket = config.getString("bucket");
const credential = config.getSecret("storageCredential");
},
});
```
**Never read `process.env`, and never query a platform table for a setting.** `context.config` is the
whole contract. It has no module id but your own, no environment access, and no way to reach another
module's values — which is exactly what keeps your module runnable under a future standalone host
that stores configuration somewhere else entirely.
### The types you may declare
| type | constraints | notes |
| --- | --- | --- |
| `string` | `minLength`, `maxLength` | |
| `integer` | `minimum`, `maximum` | |
| `boolean` | — | |
| `enum` | `values` (1–32, unique) | |
| `url` | — | absolute `http:`/`https:` only |
| `secret` | — | deployment-supplied; no `default` |
No nested objects, arrays, or regular expressions. If you need structured data, own a table for it
(see [Database access](#database-access)).
### Defaults, required, and the difference
Give a `default` to anything with a safe one — a slide duration, a poll interval. Mark `required`
only what genuinely has no safe default, like a bucket name. A key cannot be both: a key with a
default can never be missing.
**A `secret` can never have a default.** A checked-in default credential is a leaked credential, and
the contract has no field to put one in.
If a `required` key has no value when the platform starts, your module is **not started** — it is
reported `blocked` with a structured reason naming the key, and its dependents are blocked too. Your
`start` hook never runs and never has to defend against missing configuration.
### Where values come from
Ordinary values are stored by the platform and set through
`PUT /api/v1/module-configuration/<module-id>` (there is no settings UI yet).
Secrets are **supplied by the deployment and never stored by Ekklesia**, because Ekklesia has no
encryption at rest. In an Ekklesia deployment an operator sets
`EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`. You never write that name anywhere — you call
`config.getSecret("storageCredential")`.
### Reading values
Every accessor returns `T | undefined` and fails closed: an undeclared key, a key of a different
declared type, and an absent optional value are all `undefined`. For a `required` key, or one with a
`default`, the platform guarantees a value exists by the time your code runs.
```ts
config.getString(key); config.getInteger(key); config.getBoolean(key);
config.getEnum(key); config.getUrl(key); config.getSecret(key);
config.get(key); config.has(key); config.keys();
```
The same frozen object reaches `register`, `start`, `stop`, and every authorized route handler, so
two parts of your module can never disagree about a value.
### Configuration is not runtime state
```text
slideDurationSeconds = 7 configuration — an operator sets it, it survives restarts
currentSlide = 4 domain state — your module owns it
mode = "off" domain state — your module owns it
```
Configuration is **immutable for the life of the process**: a change takes effect at the next restart,
and the API tells the operator so. If a value must change *without* a restart — a display toggle
flipped mid-service, say — that is telling you it is domain state, not configuration. Store it in your
own table and expose it through your own route.
### Never put infrastructure in your configuration
Declare the **bucket** you need, not an S3 endpoint, region, and access key. Endpoints and credentials
for shared infrastructure are the platform's to supply; if every module configured its own, the
eventual platform storage service would arrive to find a dozen competing endpoint settings in
production. (Core has no storage service yet — see
[ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md) for the boundary this
leaves room for.)
### Never ask whether you are enabled
Configuration tells you *how to behave*, never *whether to run*. Whether your module runs is its
host's decision, described next.
### Whether your module runs at all is your host's decision
Between discovery and startup, the platform consults its own **enablement configuration** and