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
@@ -169,6 +169,13 @@ otherwise be a deadlock — the HTTP lifecycle API is served by the process a ba
|
||||
so `pnpm modules:disable -- --module <id>` exists as an out-of-band operator command, in the same
|
||||
shape as `pnpm authorization:bootstrap-owner`: explicit, never HTTP-exposed, never inferred.
|
||||
|
||||
> **Cross-reference — see [ADR 0029](0029-module-configuration-and-secrets.md).**
|
||||
> The blocked-reason vocabulary this ADR introduced has since gained three configuration reasons
|
||||
> (`configuration_missing`, `configuration_invalid`, `secret_missing`), and `blocked` became a
|
||||
> discriminated union so a configuration reason names a **key** where a dependency reason names a
|
||||
> dependency. Everything else here is unchanged: startup atomicity, the absence of a `failed` state,
|
||||
> the default-enabled policy, and the dependency propagation those new reasons flow through.
|
||||
|
||||
There is consequently **no `failed` runtime state**, because no live process could ever report one.
|
||||
Naming a state nothing can produce is exactly the mistake `MODULE_LIFECYCLE_STAGES` made. It is the
|
||||
first thing a future isolated-startup pass adds.
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
# 0029: Module configuration is manifest-declared, host-resolved, and secret-free at rest
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-22
|
||||
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||
[0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||
[0016](0016-module-database-access.md),
|
||||
[0021](0021-modules-as-hostable-products.md),
|
||||
[0028](0028-module-operational-lifecycle.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0028 gave the platform an operational lifecycle: a module is enabled or disabled, eligible or
|
||||
blocked, running or not. It answered *whether* a module runs. It said nothing about *how it should
|
||||
behave once it does*, and the repository had no answer at all — no configuration API, no settings
|
||||
table, no schema, no concept.
|
||||
|
||||
That gap has a concrete shape. The Announcements product (`The_River/Announcements`, commit
|
||||
`9aab486`) is the first real module Ekklesia will absorb, and it currently contains **zero
|
||||
environment variables and zero configuration files**. Every deployment-specific value is a literal
|
||||
in source:
|
||||
|
||||
```js
|
||||
const PORT = 7532; // server/index.js:17
|
||||
const slideSeconds = 7; // server/index.js:20
|
||||
fetch("http://10.40.0.2:9000/announcements?list-type=2") // server/index.js:29
|
||||
mqtt.connect("mqtt://10.40.0.2") // server/index.js:73
|
||||
setInterval(() => loadSlides(), 30000) // server/index.js:127
|
||||
const socket = io("http://10.40.0.2:7532"); // client/src/App.jsx:5
|
||||
```
|
||||
|
||||
A module cannot become portable while a LAN IP address is compiled into it. But the fix is not "let
|
||||
modules read `process.env`" — that would weld every module to one host's variable names and destroy
|
||||
the standalone-hostability ADR 0021 exists to protect.
|
||||
|
||||
Two facts from the repository shaped what was possible.
|
||||
|
||||
**Nothing reads the environment except executables.** Every `process.env` access lives in
|
||||
`apps/api/src/server.ts` or `core/src/cli/*`; reusable Core constructors take typed inputs and
|
||||
`core/src/cli/database-transport-environment.ts` states the rule explicitly. Modules read the
|
||||
environment nowhere, and that was worth keeping.
|
||||
|
||||
**There is no encryption at rest, and no key management.** No cipher, no KMS, no keyring, nothing.
|
||||
The only secret-shaped values PostgreSQL holds are Better Auth's own hashed passwords and session
|
||||
tokens, which Better Auth owns. That single fact decides the secrets question below, and it decides
|
||||
it against the convenient answer.
|
||||
|
||||
## Decision
|
||||
|
||||
### Three categories, kept apart
|
||||
|
||||
```text
|
||||
enabled / disabled lifecycle configuration (ADR 0028)
|
||||
slideSeconds = 7 module configuration (this ADR)
|
||||
currentSlide = 4, mode=off runtime / domain state (the module's own, never the platform's)
|
||||
```
|
||||
|
||||
The lifecycle table gains no feature settings, and the configuration table holds nothing transient.
|
||||
The test for the third category is not "does it change?" but **"does the module own its meaning?"**
|
||||
Announcements' `mode` toggle blanks the screen, is flipped mid-service, and must take effect
|
||||
instantly; it is domain state the module stores and exposes through its own route. A value that must
|
||||
change without a restart is, by that fact, telling you it is domain state rather than configuration.
|
||||
|
||||
### Configuration is declared in the manifest, and that forces version 5
|
||||
|
||||
A module declares what it needs in `ekklesia.module.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 5,
|
||||
"configuration": [
|
||||
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600 },
|
||||
{ "key": "bucket", "type": "string", "required": true },
|
||||
{ "key": "storageCredential", "type": "secret", "required": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Static declaration was chosen over runtime registration for one decisive reason: **the platform must
|
||||
validate configuration before deciding whether a module may start.** An enabled module whose required
|
||||
configuration is missing is reported `blocked · configuration_missing` — it is never imported, and it
|
||||
never takes API startup down from inside its own `start` hook. Runtime registration cannot do that,
|
||||
because the module's code would have to run first, and under the preserved all-or-nothing startup
|
||||
model a throw at that point kills the platform. Doctor and `pnpm modules:validate` get the same
|
||||
benefit: they report configuration health while remaining manifest-only, importing nothing.
|
||||
|
||||
Version 4's manifest object is `.strict()`, so adding a field is an incompatible shape change and the
|
||||
repository's convention is a hard cutover per version (ADR 0006, 0014, 0021). Version 5 it is.
|
||||
Upgrading a version 4 manifest is a one-character edit — `configuration` is optional and defaults to
|
||||
empty, so a backend-only module needing no settings stays exactly as simple as it was. A separate
|
||||
`ekklesia.configuration.json` was rejected: it would create a second static-metadata mechanism and a
|
||||
second discovery path for something the manifest already is.
|
||||
|
||||
### Six types, and no seventh without a real module needing it
|
||||
|
||||
`string`, `integer`, `boolean`, `enum`, `url`, `secret`. No `object`, no `array`, no nesting, no
|
||||
recursion, no regular expressions.
|
||||
|
||||
Each earns its place against a real need — a bucket name, a poll interval, a toggle, a fixed choice,
|
||||
an endpoint, a credential. `number` was dropped for lack of one. Regular-expression constraints were
|
||||
rejected outright: a module-supplied pattern evaluated by the platform is a ReDoS vector for no
|
||||
benefit that `minLength`/`maxLength` does not already cover. `url` accepts only absolute
|
||||
`http:`/`https:`, because a configuration value is frequently handed straight to `fetch` and the
|
||||
difference between an endpoint and `file:///etc/passwd` must not be a parsing accident.
|
||||
|
||||
This is a validation vocabulary, not a form-builder DSL. A module needing structured data owns a
|
||||
table for it (ADR 0016).
|
||||
|
||||
### `secret` is a type, not a flag — and Ekklesia never stores one
|
||||
|
||||
Strategy A, without apology: **ordinary configuration is persisted in PostgreSQL; secret values are
|
||||
supplied by the deployment and never persisted at all.**
|
||||
|
||||
There is no encryption at rest in this repository. Storing credentials in plaintext because a column
|
||||
is easy to add would be the single worst thing this pass could do, and dressing it up as a "secret
|
||||
store" would be worse. The table has no column, flag, or convention that would make it look
|
||||
acceptable, and the write API returns `CONFIGURATION_SECRET_NOT_WRITABLE` rather than accepting
|
||||
material it cannot protect.
|
||||
|
||||
Making `secret` a distinct *type* rather than `string` + `secret: true` is what makes every
|
||||
consequence structural instead of remembered:
|
||||
|
||||
- it has no `default` field to populate, so a checked-in default credential is unrepresentable;
|
||||
- the resolver never reads one from storage, so a row planted directly in the database is ignored;
|
||||
- the HTTP view has no `value` field for it, so a secret is **absent from responses by construction
|
||||
rather than filtered by a branch** somebody could later delete.
|
||||
|
||||
The host maps a declared secret to a deployment value:
|
||||
|
||||
```text
|
||||
EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>
|
||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL
|
||||
```
|
||||
|
||||
**A module never sees that name.** It calls `config.getSecret("storageCredential")`. The mapping is
|
||||
the host's, which is exactly what lets a standalone host read the same declared secret from YAML, a
|
||||
file, or a secret manager without the module's feature code changing. Core's factory takes the
|
||||
environment *as an argument* and never touches `process.env` itself, preserving the rule that only
|
||||
executables read the environment.
|
||||
|
||||
There is deliberately **no** `context.secrets.getEnv("ANYTHING")`. The only reachable values are the
|
||||
module's own declared keys, looked up by a source that requires both a module id and a key — so a
|
||||
module cannot read `DATABASE_URL`, the identity signing secret, or another module's credential.
|
||||
|
||||
### A frozen snapshot, resolved once, before any module code runs
|
||||
|
||||
```text
|
||||
stored values + deployment secrets + manifest declarations
|
||||
↓ validate, apply defaults (one pass, one implementation)
|
||||
frozen per-module snapshot ─────────────→ eligible?
|
||||
↓
|
||||
register · start · stop · HTTP handlers
|
||||
```
|
||||
|
||||
`context.config` is a frozen object present on the registration context, the runtime context, and
|
||||
every authorized HTTP handler context. The *same object identity* across all of them, so two hooks
|
||||
can never disagree about a value, and no module ever queries a database for a setting.
|
||||
|
||||
It sits **beside** `services` rather than inside it, and that is load-bearing: `services` is
|
||||
deliberately one shared instance every module receives — a property the runtime has a test for —
|
||||
while configuration is per-module by definition. Putting it in `services` would have forced one of
|
||||
those two properties to give.
|
||||
|
||||
It is available during `register` even though `services` is not. Registration must stay declarative
|
||||
and I/O-free; an already-resolved in-memory snapshot reintroduces no I/O, whereas a live database
|
||||
facade would.
|
||||
|
||||
Every accessor fails closed. An undeclared key, a declared key of a different type, and an absent
|
||||
optional value are all `undefined` and deliberately indistinguishable, in the same style as
|
||||
`PermissionRegistry.get`. A `required` key, or one with a `default`, is guaranteed present by the
|
||||
time module code runs — the platform refuses to start the module otherwise.
|
||||
|
||||
### Validation rules, stated exactly
|
||||
|
||||
1. A stored value that validates against the current declaration wins.
|
||||
2. A stored value that does **not** validate is `configuration_invalid` — never replaced by the
|
||||
default. Silently falling back would run the module on a value nobody chose while the operator
|
||||
believes theirs took effect.
|
||||
3. Otherwise the declared `default` applies.
|
||||
4. Otherwise a `required` key is `configuration_missing` (or `secret_missing`); an optional key is
|
||||
simply absent.
|
||||
|
||||
Nothing is coerced. `"7"` is not `7`, `"yes"` is not `true`, `"07"` is not `7`. Exactly one spelling
|
||||
of any value is ever accepted, so a stored row can never validate differently from the request that
|
||||
created it, and two deployments can never read the same row differently.
|
||||
|
||||
**Schema evolution** needs no migration engine. A key a newer version adds takes its default. A key a
|
||||
newer version made required blocks until set. A stored key the current manifest no longer declares is
|
||||
**retained and ignored**, and reported as `unknownKeys` — deleting it would destroy an operator's
|
||||
value on a temporary rollback, the same reasoning ADR 0027 applied to a Dashboard placement for an
|
||||
uninstalled module.
|
||||
|
||||
### Configuration decides eligibility, through the lifecycle resolver that already exists
|
||||
|
||||
Three reasons join ADR 0028's two, each corresponding to a real check with a **different operational
|
||||
fix** — the only justification for a distinct code:
|
||||
|
||||
| reason | fix |
|
||||
| --- | --- |
|
||||
| `configuration_missing` | set a value through the configuration API |
|
||||
| `configuration_invalid` | correct the value |
|
||||
| `secret_missing` | set an environment variable on the deployment |
|
||||
|
||||
`ModuleEligibility.blocked` becomes a discriminated union: a dependency reason always names a
|
||||
dependency, a configuration reason always names a **key**. Neither can be accidentally omitted, and
|
||||
no consumer has to handle a `blocked` record that explains nothing. A key is safe to name — it is
|
||||
declared in a public manifest — while a value may be a credential or arbitrary operator text.
|
||||
|
||||
Validation runs *before* the dependency pass, so a module blocked by its own configuration blocks its
|
||||
required dependents through the existing mechanism rather than a parallel one:
|
||||
|
||||
```text
|
||||
storage blocked · configuration_missing · bucket
|
||||
↓
|
||||
media blocked · required_dependency_blocked · storage
|
||||
↓
|
||||
announcements blocked · required_dependency_blocked · media
|
||||
```
|
||||
|
||||
A **disabled** module's configuration is not evaluated at all. Reporting a configuration problem for
|
||||
something nobody asked to run would be noise, and claiming it is valid would assert something nothing
|
||||
checked.
|
||||
|
||||
Startup atomicity is unchanged: still all-or-nothing across the eligible set, still no `failed`
|
||||
runtime state.
|
||||
|
||||
### Changes take effect at the next restart, and the API says so
|
||||
|
||||
Modules hold a snapshot resolved at boot, so a `PUT` writes a decision and changes nothing about the
|
||||
running process. Both the configuration resource and the lifecycle resource report `restartRequired`
|
||||
by comparing stored state against what this process actually resolved — an observation, not a guess.
|
||||
No hot reload, no watchers. A value that genuinely needs to change live is domain state, not
|
||||
configuration (see the three categories above).
|
||||
|
||||
### One row per (module, key)
|
||||
|
||||
`ekklesia_core_module_configuration`: `module_id`, `key`, `value` (text), timestamps, primary key
|
||||
`(module_id, key)`.
|
||||
|
||||
A `jsonb` blob per module was rejected for the reason ADR 0027 rejected a generic settings framework:
|
||||
it would put every invariant in application code and make a single key unaddressable. `value` is
|
||||
`text` holding the unparsed literal, which is what makes "a persisted value is never silently
|
||||
trusted" structural — nothing can read it as a number until it has been validated against the
|
||||
module's current declaration.
|
||||
|
||||
`key` is **data in a parameterized column, never an SQL identifier.** There is no dynamic SQL in this
|
||||
domain, so an outlandish declared key can produce a strange row and nothing else. `module_id` carries
|
||||
no foreign key, like the lifecycle and Dashboard tables, so a value for an undeployed module is
|
||||
retained and redeploying restores it.
|
||||
|
||||
### `/api/v1/module-configuration/:moduleId`, a sibling prefix
|
||||
|
||||
The third application of ADR 0025's rule. `config` and `configuration` are ordinary lowercase static
|
||||
segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately register
|
||||
`PUT /config` and land on `/api/v1/modules/announcements/config`. Publishing the platform's own
|
||||
mutation there would put Core and an installed module in one URL namespace with registration order
|
||||
deciding which answers.
|
||||
|
||||
`GET` returns the declared schema, stored values, `unknownKeys`, and `restartRequired`. `PUT`
|
||||
replaces the whole document with closed validation — an undeclared key, a wrong JSON type, a
|
||||
constraint violation, or a secret key each fail with a distinct sanitized code and store nothing.
|
||||
Constraint checking runs through **the same resolver the platform boots with**, over the exact
|
||||
document about to be stored, so the API and startup can never disagree about validity.
|
||||
|
||||
An *incomplete* document is accepted (an operator may save partial work; the module reports
|
||||
`configuration_missing` until they finish). An *invalid* one never is.
|
||||
|
||||
Both verbs require a new restricted permission, `core.modules.configuration.manage`, deliberately
|
||||
**separate from** `core.modules.lifecycle.manage`: deciding whether a module runs and deciding how it
|
||||
behaves are different authorities, and folding them together would grant authority nobody asked for
|
||||
in whichever direction it was used. Mutations additionally require an exact trusted `Origin` and a
|
||||
bounded body.
|
||||
|
||||
### Browser editing is deferred, and no secret ever reaches `ModuleUiContext`
|
||||
|
||||
`ModuleUiHostContext` is unchanged. A settings UI is not in this pass, and module UI receives no
|
||||
configuration at all. When it does, it will receive non-secret values only — the server-only/browser
|
||||
split already exists in the shape of the API, which never serves a secret to anyone.
|
||||
|
||||
### The object-storage boundary, stated before it is crossed
|
||||
|
||||
Announcements reads `http://10.40.0.2:9000/announcements`. It would be easy, and wrong, to let it
|
||||
declare `storageEndpoint` and `storageAccessKey` as module configuration. That would make **every**
|
||||
module separately configure S3, and the eventual platform storage service would arrive to find a
|
||||
dozen competing endpoint settings already in production.
|
||||
|
||||
The boundary this ADR draws:
|
||||
|
||||
```text
|
||||
module declares which bucket it needs → host supplies the storage service
|
||||
```
|
||||
|
||||
not
|
||||
|
||||
```text
|
||||
every module configures its own endpoint, region, and credentials
|
||||
```
|
||||
|
||||
A storage *endpoint* and its credentials are **platform infrastructure**, and they belong to the
|
||||
storage service Core does not have yet — not to Announcements. A **bucket name** is arguably module
|
||||
configuration today, and is accepted as such transitionally, but note that `manifest.storage.buckets`
|
||||
already declares logical buckets: when the storage service exists, the bucket binding should move
|
||||
there and the configuration key should be retired. That overlap is recorded here deliberately so it
|
||||
is resolved by decision rather than by accident.
|
||||
|
||||
## Validated against the real product
|
||||
|
||||
Every configuration-shaped literal in Announcements at commit `9aab486`, classified:
|
||||
|
||||
| value | classification | under Ekklesia |
|
||||
| --- | --- | --- |
|
||||
| `slideSeconds = 7` | module configuration | `integer`, default 7 |
|
||||
| `30000` poll interval | module configuration | `pollIntervalSeconds`, default 30 |
|
||||
| bucket `announcements` | module configuration (transitional — see above) | `string`, required |
|
||||
| `10.40.0.2:9000` storage endpoint | **platform infrastructure** | future storage service, not module config |
|
||||
| storage credentials | **secret** (none exist today — the bucket is read anonymously) | deployment-supplied |
|
||||
| `PORT = 7532` | platform infrastructure | disappears; the host mounts routes |
|
||||
| Socket.IO `origin: "*"` | platform infrastructure | Ekklesia owns CORS |
|
||||
| `io("http://10.40.0.2:7532")` client URL | legacy | disappears; the host supplies the API base URL |
|
||||
| `mqtt://10.40.0.2` + topic | **legacy integration to remove** | removed; a future Companion module speaks the module API |
|
||||
| `mode = "on"` | **runtime/domain state** | the module's own state, not configuration |
|
||||
| `currentSlide`, `slides[]` | runtime/domain state | not configuration |
|
||||
| `1100` fade milliseconds | module-internal constant (coupled to CSS) | not platform configuration |
|
||||
|
||||
Two findings worth recording. **Announcements has no credentials at all today** — the bucket is read
|
||||
anonymously over plain HTTP — so `storageCredential` is a future need, and the secret mechanism is
|
||||
proven here with a fixture rather than by inventing a credential the product does not yet have. And
|
||||
**`slideSeconds` being restart-required is a real operational wrinkle**: a church may want to tweak it
|
||||
mid-service. The honest answer is the one above — if it must change live, it is domain state the
|
||||
module owns, exactly like `mode`. This ADR does not paper over that by adding hot reload.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Runtime/SDK schema registration.** Rejected: configuration must be validated before module code
|
||||
runs, or an unconfigured module takes startup down instead of being reported blocked.
|
||||
- **A separate `ekklesia.configuration.json`.** Rejected: a second static-metadata mechanism and a
|
||||
second discovery path for something the manifest already is.
|
||||
- **Extending manifest v4 additively.** Not available — the manifest object is `.strict()`, and the
|
||||
repository's convention is a hard cutover per version.
|
||||
- **Persisting secrets in PostgreSQL.** Rejected outright: there is no encryption at rest, and
|
||||
plaintext credentials in a database are not made acceptable by a column named `secret`.
|
||||
- **Secret *references* (`secretRef: "..."`) resolved from a vault.** Premature: there is no vault
|
||||
to reference. The declared-key-plus-host-mapping model already gives a standalone host the same
|
||||
freedom without inventing an indirection layer nothing implements.
|
||||
- **`jsonb` document per module.** Rejected: unaddressable keys and invariants living only in
|
||||
application code.
|
||||
- **A live, database-backed `config.get()`.** Rejected: it would make every read fallible and
|
||||
mid-request-mutable, and would reintroduce I/O into `register`.
|
||||
- **Configuration inside `ModuleRuntimeServices`.** Rejected: `services` is deliberately one shared
|
||||
object identity; configuration is per-module.
|
||||
- **Hot configuration reload.** Rejected for the same reason ADR 0028 rejected hot enable/disable:
|
||||
the platform must not claim a change took effect when the running process still holds the old one.
|
||||
- **Reusing `core.modules.lifecycle.manage`.** Rejected: running a module and configuring it are
|
||||
different authorities.
|
||||
- **Regular-expression constraints, nested objects, arrays.** Rejected: a validation vocabulary, not
|
||||
a UI DSL — and a module-supplied regex is a ReDoS vector.
|
||||
- **Letting modules read `process.env` directly.** Rejected: it welds a module to one host's variable
|
||||
names and destroys standalone hostability.
|
||||
|
||||
## Consequences
|
||||
|
||||
A module declares what it needs, an operator sets it, the platform validates it before anything runs,
|
||||
and the module receives a frozen, host-neutral snapshot. Announcements has a clear path away from
|
||||
every hardcoded value it currently contains, and none of that path requires it to know that Ekklesia
|
||||
uses PostgreSQL.
|
||||
|
||||
`createApiApp` takes a required `moduleConfiguration` option. Core gains a fifth permission and a
|
||||
sixth migration. The manifest moves to version 5, a hard cutover affecting the template and every
|
||||
fixture. `startApi` now reads two tables before importing any module, sequentially rather than
|
||||
concurrently so a broken deployment reports the same error code every time.
|
||||
|
||||
Costs worth naming. Configuration is platform-global and will need a scope column when an
|
||||
organization model exists, like every other Core table. Ordinary configuration changes require a
|
||||
restart, which is honest but not convenient, and modules with genuinely live settings must model them
|
||||
as domain state. And there is still **no settings UI** — configuration is an API, not a screen.
|
||||
|
||||
## Deferred
|
||||
|
||||
Not implemented or promised here: a settings Dashboard UI, form generation, or any
|
||||
administrator-facing affordance beyond the HTTP API; browser-visible configuration in
|
||||
`ModuleUiContext`; secret persistence, a vault, encryption at rest, or key management; secret
|
||||
rotation; a `secretRef` indirection; hot configuration reload or watchers; per-organization or
|
||||
per-environment configuration scoping; configuration history, audit, or diff; a migration engine for
|
||||
renamed keys; object storage, and the eventual move of bucket binding from configuration to
|
||||
`manifest.storage.buckets`; and the Announcements migration itself.
|
||||
@@ -37,3 +37,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted
|
||||
- [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted
|
||||
- [0028: Module operational lifecycle — enablement, eligibility, and what "running" means](0028-module-operational-lifecycle.md) — Accepted
|
||||
- [0029: Module configuration is manifest-declared, host-resolved, and secret-free at rest](0029-module-configuration-and-secrets.md) — Accepted
|
||||
|
||||
@@ -329,3 +329,30 @@ Migration discovery still spans the deployed inventory regardless of enablement,
|
||||
composition still spans the whole discovered set. `running` means a module's `start` hook completed
|
||||
and nothing more; there is no runtime health-probe contract. Modules remain trusted compiled code —
|
||||
disabling one is an operational control, never a sandbox. No installer exists.
|
||||
|
||||
Phase 7A pass 2 adds the module configuration and secrets contract
|
||||
([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). A module declares what it needs
|
||||
configured in its manifest — `manifestVersion: 5`, a hard cutover — using six types (`string`,
|
||||
`integer`, `boolean`, `enum`, `url`, `secret`) with bounded constraints and no nesting. Static
|
||||
declaration is what lets the platform validate configuration **before** deciding whether a module may
|
||||
start, so an unconfigured module is reported `blocked · configuration_missing` rather than taking
|
||||
startup down from inside its own `start` hook; discovery, `pnpm modules:validate`, and Doctor all read
|
||||
it while remaining manifest-only.
|
||||
|
||||
Ordinary values are persisted in one Core table, one row per `(module_id, key)`, stored as unvalidated
|
||||
text until checked against the declaring module's current manifest. **Secret values are never
|
||||
persisted**: Ekklesia has no encryption at rest, so a `secret` key is supplied by the deployment and
|
||||
mapped into the module's snapshot by the host, under a name the module never sees. A module receives a
|
||||
frozen `context.config` on its registration, runtime, and HTTP handler contexts — the same object
|
||||
identity across all of them — with no environment access, no database reference, and no way to reach
|
||||
another module's values, which is what keeps the same module implementation runnable under a future
|
||||
standalone host.
|
||||
|
||||
Configuration decides eligibility through ADR 0028's existing resolver rather than a parallel concept:
|
||||
three new blocked reasons (`configuration_missing`, `configuration_invalid`, `secret_missing`) name
|
||||
the offending key, and a configuration-blocked module blocks its required dependents exactly like a
|
||||
disabled one. Changes take effect at the next restart and the API reports `restartRequired`; nothing
|
||||
is hot-reloaded. Migration discovery remains independent of configuration validity, exactly as it is
|
||||
independent of enabled state. 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 does not have. There is no settings UI.
|
||||
|
||||
+120
-4
@@ -82,6 +82,8 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
||||
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
|
||||
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
|
||||
| `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). |
|
||||
| `PUT /api/v1/module-configuration/<module-id>` | Ekklesia | Replace one module's stored configuration. Takes effect at the next API start. Requires `core.modules.configuration.manage` and a trusted `Origin`. See [Module configuration](#module-configuration). |
|
||||
|
||||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||
@@ -387,9 +389,12 @@ account.
|
||||
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||||
pool (see [Module database access](#module-database-access)).
|
||||
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
5. Read the stored module enablement configuration and resolve, over that exact same discovered
|
||||
inventory, which modules are **eligible** to run (`resolveModuleEligibility`,
|
||||
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle)).
|
||||
5. Read the stored module enablement configuration and then the stored module configuration, and
|
||||
resolve, over that exact same discovered inventory, which modules are **eligible** to run and
|
||||
what each eligible module's frozen configuration snapshot is (`resolveModuleEligibility`,
|
||||
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle) and
|
||||
[Module configuration](#module-configuration)). The two reads are sequential rather than raced so
|
||||
a broken deployment reports the same error code every time.
|
||||
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
|
||||
never rediscovered for this step — passing it the services object every module's `start`/`stop`
|
||||
hook receives.
|
||||
@@ -417,7 +422,8 @@ disabled, or blocked by a disabled required dependency, is never imported at all
|
||||
|
||||
Step 5 makes startup depend on a reachable, migrated database: if the enablement configuration
|
||||
cannot be read, `startApi` **refuses to start** with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED`
|
||||
rather than starting every discovered module. The platform cannot know what an administrator
|
||||
(and `MODULE_CONFIGURATION_READ_FAILED` if the enablement read succeeded and the configuration read
|
||||
did not) rather than starting every discovered module. The platform cannot know what an administrator
|
||||
decided, and starting a module they disabled is worse than not starting. Nothing is created or
|
||||
repaired on that path.
|
||||
|
||||
@@ -548,6 +554,116 @@ pnpm modules:enable -- --module announcements
|
||||
It reads `DATABASE_URL`, runs the same manifest-only discovery the API does (so it refuses a module
|
||||
this deployment cannot see), writes the decision, and starts nothing. Restart the API to apply it.
|
||||
|
||||
## Module configuration
|
||||
|
||||
```text
|
||||
GET /api/v1/module-configuration/<module-id>
|
||||
PUT /api/v1/module-configuration/<module-id> { "values": { "slideSeconds": 12 } }
|
||||
```
|
||||
|
||||
What one installed module declares it needs configured, what an operator has stored, and whether the
|
||||
running process is still using it
|
||||
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
|
||||
|
||||
A **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` and
|
||||
`/api/v1/module-lifecycle` are: `config` and `configuration` are legal module route segments, so a
|
||||
module could otherwise collide with the platform at `/api/v1/modules/<id>/config`.
|
||||
|
||||
```json
|
||||
{
|
||||
"moduleId": "announcements",
|
||||
"version": "1.0.0",
|
||||
"keys": [
|
||||
{ "key": "slideSeconds", "type": "integer", "required": false, "default": 7,
|
||||
"description": "How long each slide is shown.", "configured": false },
|
||||
{ "key": "bucket", "type": "string", "required": true, "value": "slides", "configured": true },
|
||||
{ "key": "storageCredential", "type": "secret", "required": true, "configured": true }
|
||||
],
|
||||
"unknownKeys": [],
|
||||
"restartRequired": false
|
||||
}
|
||||
```
|
||||
|
||||
### A secret's value is absent, not redacted
|
||||
|
||||
Note what the `storageCredential` entry above does **not** have: a `value` field. Not `null`, not
|
||||
`"***"` — absent. Ekklesia never persists secret material, so the store this route reads from
|
||||
contains none, and there is no code path that could serve one. `configured` answers the only question
|
||||
an operator needs from here: does the deployment supply it?
|
||||
|
||||
### The declared types
|
||||
|
||||
| type | notes |
|
||||
| --- | --- |
|
||||
| `string` | optional `minLength` / `maxLength` |
|
||||
| `integer` | optional `minimum` / `maximum`; submitted as a JSON number, never a string |
|
||||
| `boolean` | submitted as a JSON boolean |
|
||||
| `enum` | closed `values` list |
|
||||
| `url` | absolute `http:`/`https:` only |
|
||||
| `secret` | deployment-supplied; never stored, never returned, never defaulted |
|
||||
|
||||
No nesting, no arrays, no regular expressions. This is a validation vocabulary, not a form-builder
|
||||
DSL; a module needing structured data owns a table for it.
|
||||
|
||||
### Writing is a whole-document replace, with closed validation
|
||||
|
||||
A key the module does not declare is `400 CONFIGURATION_KEY_UNDECLARED`. A wrong JSON type or a value
|
||||
failing the module's own declared constraints is `400 CONFIGURATION_VALUE_INVALID`. A `secret` key is
|
||||
`400 CONFIGURATION_SECRET_NOT_WRITABLE` — there is no supported way to submit secret material to this
|
||||
platform. Nothing is coerced: `{"slideSeconds": "7"}` is a `400`, not a silent `7`.
|
||||
|
||||
Constraint checking runs through **the same resolver startup uses**, over the exact document about to
|
||||
be stored, so the API and startup can never disagree about whether a value is valid.
|
||||
|
||||
An *incomplete* document is accepted deliberately — an operator may save partial work, and the module
|
||||
is reported `blocked · configuration_missing` until they finish. An *invalid* one never is.
|
||||
|
||||
A key omitted from the submission is deleted. Stored keys the module's current manifest no longer
|
||||
declares are **retained and ignored**, and listed as `unknownKeys`, so a downgrade or a rename does
|
||||
not destroy an operator's value.
|
||||
|
||||
### Changes take effect at the next start
|
||||
|
||||
Modules receive a configuration snapshot resolved once at startup, so a `PUT` changes nothing about
|
||||
the running process and the response reports `restartRequired` rather than pretending otherwise.
|
||||
Nothing is hot-reloaded. A setting that genuinely must change live is the module's own domain state,
|
||||
stored in the module's own tables and exposed through its own route — not platform configuration.
|
||||
|
||||
### Missing or invalid configuration blocks the module
|
||||
|
||||
An enabled module whose required configuration cannot be satisfied is never imported. It appears in
|
||||
`GET /api/v1/module-lifecycle` as `blocked` with a structured reason naming the offending **key**:
|
||||
|
||||
```json
|
||||
{ "runtime": { "state": "blocked", "reason": "configuration_missing", "configurationKey": "bucket" } }
|
||||
```
|
||||
|
||||
`configuration_missing` (set a value), `configuration_invalid` (correct the value), and
|
||||
`secret_missing` (set it in the deployment environment) are separate reasons because each has a
|
||||
different fix. A blocked module blocks its required dependents exactly like a disabled one does.
|
||||
|
||||
### Secrets come from the deployment
|
||||
|
||||
A module declares a `secret` key; the host maps it to a deployment-supplied value. In an Ekklesia
|
||||
deployment that mapping is an environment variable:
|
||||
|
||||
```text
|
||||
EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>
|
||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL
|
||||
```
|
||||
|
||||
The module never sees that name — it calls `config.getSecret("storageCredential")` — which is what
|
||||
lets a future standalone host supply the same declared secret from somewhere else entirely.
|
||||
|
||||
### Authority
|
||||
|
||||
Both verbs require `core.modules.configuration.manage` (baseline `restricted`), deliberately separate
|
||||
from `core.modules.lifecycle.manage`: deciding whether a module runs and deciding how it behaves are
|
||||
different authorities. Mutations additionally require an exact trusted `Origin` and a bounded body.
|
||||
|
||||
No response carries a filesystem path, package name, manifest path, caught error, stack, or secret
|
||||
value.
|
||||
|
||||
## Module HTTP routes
|
||||
|
||||
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||||
|
||||
@@ -111,6 +111,7 @@ list:
|
||||
| `core.modules.ui.read` | `authenticated` | List the installed module UIs a browser host may mount. |
|
||||
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
|
||||
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
|
||||
| `core.modules.configuration.manage` | `restricted` | Read and change an installed module's own configuration values. |
|
||||
|
||||
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
|
||||
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
|
||||
@@ -121,6 +122,14 @@ milder version of the write, because the lifecycle view is the only place a *dis
|
||||
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
|
||||
inside any module.
|
||||
|
||||
`core.modules.configuration.manage` is deliberately **separate from** the lifecycle permission
|
||||
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): deciding whether a
|
||||
module runs and deciding how it behaves are different authorities, and someone trusted to tune a
|
||||
module's settings need not be trusted to take modules offline. It gates reading as well as writing,
|
||||
because the read returns every stored value — endpoints, bucket names, and whatever else an operator
|
||||
configured. It never exposes secret material: a secret is deployment-supplied, never persisted, and
|
||||
never returned by any route, so holding this permission confers no ability to read a credential.
|
||||
|
||||
Enablement never changes how a permission is enforced. A **disabled** module's permissions stay
|
||||
registered and grantable — composition spans the whole discovered inventory, not the eligible subset
|
||||
— so a grant survives an enable/disable cycle and can be issued ahead of enabling a module. Nothing
|
||||
@@ -223,7 +232,7 @@ generic explicit-grant HTTP API, enforcement middleware, and now its own mounted
|
||||
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
||||
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
||||
it never re-discovers modules itself. A successful composition reports a pass with a safe
|
||||
declaration count (for example `4 permissions registered` with zero installed modules, since Core's
|
||||
declaration count (for example `5 permissions registered` with zero installed modules, since Core's
|
||||
own declarations are always present); zero installed modules still composes successfully. If module
|
||||
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
|
||||
pattern; if discovery succeeded but composition failed, schema and migration checks still run
|
||||
|
||||
@@ -195,6 +195,56 @@ enforced against that module's own declared permission, and a disabled module's
|
||||
registered and grantable precisely so that an enable/disable cycle changes nothing about who holds
|
||||
what.
|
||||
|
||||
### Ekklesia does not store module secrets, and does not pretend to
|
||||
|
||||
A module declares a `secret` configuration key; the **deployment** supplies its value, and Ekklesia
|
||||
never persists it ([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
|
||||
|
||||
This is a deliberate limit, not an oversight. **There is no encryption at rest in this repository and
|
||||
no key management** — no cipher, no KMS, no keyring. Storing credentials in a PostgreSQL column
|
||||
because one is easy to add would be worse than not offering the feature, and calling such a column a
|
||||
"secret store" would be worse still.
|
||||
|
||||
What that buys, structurally rather than by discipline:
|
||||
|
||||
- a `secret` declaration has no `default` field, so a checked-in default credential is
|
||||
unrepresentable;
|
||||
- the resolver never reads a secret from storage, so a row planted directly in the database by
|
||||
someone with database access is ignored rather than used;
|
||||
- the HTTP view has no `value` field for a secret, so a secret is **absent from responses by
|
||||
construction**, not filtered by a branch someone could later delete;
|
||||
- the write API refuses a `secret` key outright (`CONFIGURATION_SECRET_NOT_WRITABLE`);
|
||||
- Doctor prints keys and reasons, never values.
|
||||
|
||||
A module reaches its own secrets through `config.getSecret("key")` and nothing else. There is no
|
||||
`getEnv`, no arbitrary variable lookup, and no cross-module accessor: the secret source is asked for a
|
||||
module id **and** a key that module's own manifest declares, so a module cannot read `DATABASE_URL`,
|
||||
the identity signing secret, or another module's credential.
|
||||
|
||||
**What this does not claim.** A module is still trusted code in the API process (see above), so a
|
||||
malicious module could read `process.env` directly and obtain anything the process can. Scoped secret
|
||||
delivery is a correctness and portability boundary, exactly like every other module-facing mechanism
|
||||
here — not a sandbox. What it does guarantee is that Ekklesia itself never writes a credential to
|
||||
disk, never serves one over HTTP, and never prints one in diagnostics.
|
||||
|
||||
### Module configuration is operator input, and is treated as such
|
||||
|
||||
Ordinary configuration values *are* persisted, and are validated against the declaring module's own
|
||||
manifest before anything reads them. A stored value that does not satisfy its declaration blocks the
|
||||
module rather than being coerced, truncated, or silently replaced by a default.
|
||||
|
||||
Configuration keys are **data, never SQL identifiers** — there is no dynamic SQL in this domain — and
|
||||
are rejected unless they match a closed lower-camelCase grammar that also excludes reserved object
|
||||
property names (`__proto__`, `constructor`, `prototype`, ...). Core additionally never indexes a plain
|
||||
object with a module-supplied key; every lookup goes through a `Map`. Store errors never carry the
|
||||
offending value, because an operator-entered value may be a credential that ended up in the wrong
|
||||
field.
|
||||
|
||||
Reading or changing configuration requires `core.modules.configuration.manage` (baseline
|
||||
`restricted`), separately from `core.modules.lifecycle.manage`. Mutations require an exact trusted
|
||||
`Origin` and a bounded body, like every other Core mutation. Module UI receives no configuration at
|
||||
all in this phase.
|
||||
|
||||
### Client-side permission checks are not a security boundary
|
||||
|
||||
A host may tell a module UI what permissions it believes the viewer holds
|
||||
|
||||
@@ -174,6 +174,25 @@ demonstrably keeps serving, and is applied only by the next start; and a disable
|
||||
are still discovered, because deployed schema ownership is not runtime enabled state. See
|
||||
[ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md).
|
||||
|
||||
A sixth dedicated suite (`core/tests/module-configuration-store.integration.ts`, included in the same
|
||||
`pnpm test:migrations:integration` run) proves the module configuration store against real
|
||||
PostgreSQL: that migration `0005` really creates `ekklesia_core_module_configuration` with the
|
||||
composite `(module_id, key)` primary key, the declared CHECK constraints, and **no column that could
|
||||
hold secret material**; that a replace is transactional, whole-document, and scoped to one module so
|
||||
another module's rows are untouched; that stored values survive a completely new connection; that an
|
||||
oversized value is refused by the application *and* by the database; that a store error never carries
|
||||
the offending value; and — the security-relevant one — that a module id or key is only ever data in a
|
||||
parameterized column, so an injection attempt is a validation error and the table still exists
|
||||
afterwards.
|
||||
|
||||
The API suite additionally proves the configuration boundary end to end: a secret's value never
|
||||
appears in any response (including when a row for that key is planted directly in storage), a
|
||||
declared-but-undeclared key, a wrong JSON type, a constraint violation, and a `secret` key each fail
|
||||
with a distinct sanitized code and store nothing, a reserved property name such as `__proto__` is
|
||||
rejected without touching `Object.prototype`, and a module blocked by its own configuration is absent
|
||||
from the mountable UI inventory exactly like a disabled one. See
|
||||
[ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md).
|
||||
|
||||
### Which kind of timing proof lives where
|
||||
|
||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||
|
||||
@@ -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