feat: add the module configuration and secrets contract
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:
co-authored by
Claude Opus 5
parent
fdaad58848
commit
baa689b2a7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user