Files
Ekklesia/core/src/modules/configuration.ts
T
ekklesiaandClaude Opus 5 baa689b2a7
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s
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]>
2026-08-22 21:44:43 -05:00

317 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
EMPTY_MODULE_SECRET_SOURCE,
isSupportedConfigurationUrl,
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
type ModuleConfiguration,
type ModuleConfigurationBlockedReason,
type ModuleConfigurationValue,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import type { ReadonlyModuleManifest } from "./discovery.js";
/**
* One declaration exactly as discovery hands it over: deeply frozen, so an `enum`'s `values` arrive
* as a `readonly string[]`. Derived from the manifest type rather than restated, so a change to the
* declaration contract becomes a compile error here instead of a silently divergent second copy.
*/
type DiscoveredConfigurationDeclaration = ReadonlyModuleManifest["configuration"][number];
/**
* One persisted configuration value, exactly as it is stored: an owning module, a key, and an
* **unparsed text literal**.
*
* The value is text rather than a typed column or a JSON document on purpose. Every declared type
* has one canonical text form, values are small and bounded, and — most importantly — keeping the
* stored form untyped makes "a persisted value is never silently trusted" structural rather than
* aspirational: nothing can read this as a number until it has been validated against the module's
* own current declaration.
*/
export interface ModuleConfigurationRecord {
readonly moduleId: string;
readonly key: string;
readonly value: string;
}
/** The complete stored configuration for every module anyone has ever configured. */
export interface ModuleConfigurationSettings {
readonly records: readonly ModuleConfigurationRecord[];
}
/** Everything resolution needs that Core cannot derive from a manifest: what an operator stored,
* and what the deployment supplies for secrets. */
export interface ModuleConfigurationInput {
readonly settings: ModuleConfigurationSettings;
/** Defaults to supplying nothing, so a caller that provides no secrets says so by omission and a
* module requiring one is reported `secret_missing` rather than silently starting without it. */
readonly secrets?: ModuleSecretSource;
}
/** Why one module's configuration could not be resolved, naming the offending key. Never the value:
* a key is public (it is declared in an installed manifest), a value may be a credential. */
export interface ModuleConfigurationIssue {
readonly reason: ModuleConfigurationBlockedReason;
readonly key: string;
}
export interface ResolvedModuleConfiguration {
/** The frozen snapshot the module receives, present only when every declared requirement was
* satisfied. */
readonly configuration?: ModuleConfiguration;
/** The first unsatisfied requirement, in declaration order, or `undefined` when resolution
* succeeded. Reporting one rather than all of them matches how a blocked dependency is reported:
* an operator fixes the named problem and re-runs, and a list of cascading complaints from one
* missing value would be noise. */
readonly issue?: ModuleConfigurationIssue;
/** Stored keys this module's *current* manifest no longer declares. Retained by the store and
* ignored here — never an error, never passed to the module. Surfaced so an operator can see that
* a downgrade or a renamed key left something behind. */
readonly unknownKeys: readonly string[];
}
/**
* The module-facing snapshot, built over a `Map`.
*
* A `Map` rather than a plain object is deliberate and load-bearing: every lookup key here
* originates in a module's manifest, and indexing a plain object with such a key is the exact shape
* of a prototype-pollution bug. With a `Map` there is no prototype to reach, so `constructor` or
* `__proto__` would be ordinary absent keys even if the contract's own reserved-name check were
* ever removed.
*/
function createModuleConfiguration(
values: ReadonlyMap<string, { readonly type: string; readonly value: ModuleConfigurationValue }>,
): ModuleConfiguration {
const sortedKeys = Object.freeze([...values.keys()].sort());
const typed = (key: string, type: string): ModuleConfigurationValue | undefined => {
const entry = values.get(key);
// Fails closed identically for an undeclared key and a declared key of another type, in the
// same style as `PermissionRegistry.get`: a caller can never distinguish "you did not declare
// this" from "you declared it as something else", and neither one silently returns a value.
return entry?.type === type ? entry.value : undefined;
};
return Object.freeze({
get: (key: string): ModuleConfigurationValue | undefined => values.get(key)?.value,
getBoolean: (key: string): boolean | undefined => {
const value = typed(key, "boolean");
return typeof value === "boolean" ? value : undefined;
},
getEnum: (key: string): string | undefined => {
const value = typed(key, "enum");
return typeof value === "string" ? value : undefined;
},
getInteger: (key: string): number | undefined => {
const value = typed(key, "integer");
return typeof value === "number" ? value : undefined;
},
getSecret: (key: string): string | undefined => {
const value = typed(key, "secret");
return typeof value === "string" ? value : undefined;
},
getString: (key: string): string | undefined => {
const value = typed(key, "string");
return typeof value === "string" ? value : undefined;
},
getUrl: (key: string): string | undefined => {
const value = typed(key, "url");
return typeof value === "string" ? value : undefined;
},
has: (key: string): boolean => values.has(key),
keys: (): readonly string[] => sortedKeys,
});
}
/**
* Parses one stored text literal against its declaration, or reports that it does not satisfy it.
*
* Never coerces, truncates, rounds, or falls back to the default: a stored value that does not
* validate is an operator-visible error, not something to quietly repair. `"7 "`, `"07"`, `"yes"`,
* and `"1"` are all rejected for the types where they are not the canonical form, so exactly one
* spelling of any value is ever accepted and two deployments can never resolve the same stored row
* differently.
*/
function parseStoredValue(
declaration: DiscoveredConfigurationDeclaration,
raw: string,
): ModuleConfigurationValue | undefined {
if (raw.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) {
return undefined;
}
switch (declaration.type) {
case "string": {
if (declaration.minLength !== undefined && raw.length < declaration.minLength) {
return undefined;
}
if (declaration.maxLength !== undefined && raw.length > declaration.maxLength) {
return undefined;
}
return raw;
}
case "integer": {
if (!/^-?(?:0|[1-9]\d*)$/u.test(raw)) {
return undefined;
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
return undefined;
}
if (declaration.minimum !== undefined && parsed < declaration.minimum) {
return undefined;
}
if (declaration.maximum !== undefined && parsed > declaration.maximum) {
return undefined;
}
return parsed;
}
case "boolean": {
return raw === "true" ? true : raw === "false" ? false : undefined;
}
case "enum": {
return declaration.values.includes(raw) ? raw : undefined;
}
case "url": {
return isSupportedConfigurationUrl(raw) ? raw : undefined;
}
case "secret": {
// Unreachable: a secret is never read from storage, and the store refuses to write one. The
// branch exists so adding a type to the union is a compile error here rather than a silent
// acceptance of persisted secret material.
return undefined;
}
}
}
/**
* Resolves one module's complete configuration from its own manifest declarations, the operator's
* stored values, and the deployment's secrets.
*
* Pure and synchronous: no database, no filesystem, no `process.env`, no clock, and no module code.
* It is handed everything it needs and returns either a frozen snapshot or the first unsatisfied
* requirement, so every rule below is testable without PostgreSQL.
*
* ## Precedence, 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, because 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, if there is one.
* 4. Otherwise a `required` key is `configuration_missing` (or `secret_missing`), and an optional
* key is simply absent.
*
* Secrets never participate in steps 1–3: they are never stored and never defaulted, so a secret is
* whatever the deployment supplies, or missing.
*
* ## Keys the manifest no longer declares
*
* Ignored, and retained by the store. A stored key the current manifest does not declare is exactly
* the situation a module downgrade or a renamed key produces, and deleting the row would destroy an
* operator's value on a temporary rollback — the same reasoning ADR 0027 applied to a Dashboard
* placement for an uninstalled module.
*/
export function resolveModuleConfigurationFor(
manifest: ReadonlyModuleManifest,
input: ModuleConfigurationInput,
): ResolvedModuleConfiguration {
const secrets = input.secrets ?? EMPTY_MODULE_SECRET_SOURCE;
const declarations = manifest.configuration;
const declaredKeys = new Set(declarations.map((declaration) => declaration.key));
const stored = new Map<string, string>();
for (const record of input.settings.records) {
if (record.moduleId === manifest.id) {
stored.set(record.key, record.value);
}
}
const unknownKeys = Object.freeze(
[...stored.keys()].filter((key) => !declaredKeys.has(key)).sort(),
);
const values = new Map<string, { type: string; value: ModuleConfigurationValue }>();
for (const declaration of declarations) {
if (declaration.type === "secret") {
// Scoped by construction: the source is asked for this module's id and a key that module's
// own manifest declares. There is no call shape that reaches another module's secret, and no
// way to ask the host for an arbitrary environment variable.
const supplied = secrets.get(manifest.id, declaration.key);
if (typeof supplied === "string" && supplied.length > 0) {
values.set(declaration.key, { type: "secret", value: supplied });
continue;
}
if (declaration.required) {
return Object.freeze({
issue: Object.freeze({ key: declaration.key, reason: "secret_missing" as const }),
unknownKeys,
});
}
continue;
}
const raw = stored.get(declaration.key);
if (raw !== undefined) {
const parsed = parseStoredValue(declaration, raw);
if (parsed === undefined) {
return Object.freeze({
issue: Object.freeze({ key: declaration.key, reason: "configuration_invalid" as const }),
unknownKeys,
});
}
values.set(declaration.key, { type: declaration.type, value: parsed });
continue;
}
if (declaration.default !== undefined) {
values.set(declaration.key, { type: declaration.type, value: declaration.default });
continue;
}
if (declaration.required) {
return Object.freeze({
issue: Object.freeze({ key: declaration.key, reason: "configuration_missing" as const }),
unknownKeys,
});
}
}
return Object.freeze({ configuration: createModuleConfiguration(values), unknownKeys });
}
/**
* The environment variable one module's declared secret is read from, in an Ekklesia deployment.
*
* `EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`, with the module id's kebab-case and the key's
* camelCase each folded to `UPPER_SNAKE`. A double underscore separates the three parts so a single
* underscore inside a converted name can never make two different (module, key) pairs collide.
*
* **A module never sees this name.** It calls `config.getSecret("storageCredential")`; the mapping
* is entirely the host's, which is exactly what lets a standalone host read the same declared secret
* from a YAML file or a secret manager without the module's feature code changing (ADR 0029).
*/
export function moduleSecretEnvironmentVariableName(moduleId: string, key: string): string {
const upperSnake = (value: string): string =>
value
.replace(/-/gu, "_")
.replace(/([a-z0-9])([A-Z])/gu, "$1_$2")
.toUpperCase();
return `EKKLESIA_MODULE_SECRET__${upperSnake(moduleId)}__${upperSnake(key)}`;
}
/**
* A secret source backed by a deployment's environment.
*
* Takes the environment **as an argument** rather than reading `process.env` itself, preserving the
* repository rule that reusable Core code never touches the process environment — only executables
* do (`core/src/cli/database-transport-environment.ts` states the same rule for database
* transport). It is also what makes this trivially testable without mutating global state.
*
* There is deliberately no method that takes an arbitrary variable name. The only reachable values
* are those a module declared as `secret` in its own manifest, looked up under a name this function
* derives — a module cannot read `DATABASE_URL`, another module's secret, or anything else about
* the host.
*/
export function createEnvironmentModuleSecretSource(
environment: Readonly<Record<string, string | undefined>>,
): ModuleSecretSource {
return Object.freeze({
get: (moduleId: string, key: string): string | undefined =>
environment[moduleSecretEnvironmentVariableName(moduleId, key)],
});
}