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
+375
View File
@@ -0,0 +1,375 @@
import {
type ModuleConfigurationDeclaration,
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
moduleConfigurationKeySchema,
moduleIdSchema,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import { CORE_MODULES_CONFIGURATION_MANAGE } from "@ekklesia/core/authorization";
import {
type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
resolveModuleConfigurationFor,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
/**
* A **sibling** of `/api/v1/modules/`, never a child — the third application of the rule ADR 0025
* established and ADR 0028 reapplied. `config` and `configuration` are both ordinary lowercase
* static segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately
* register `PUT /config` and land on exactly `/api/v1/modules/announcements/config`. Publishing the
* platform's own configuration mutation there would put Core and an installed module in one URL
* namespace with registration order deciding which one answers — for a route that changes how
* modules behave.
*/
export const MODULE_CONFIGURATION_PATH = "/api/v1/module-configuration";
const MODULE_CONFIGURATION_MODULE_PATH = `${MODULE_CONFIGURATION_PATH}/:moduleId`;
/**
* One declared key as an operator sees it: the module's own declaration, plus what is currently
* stored, plus what the running process actually used.
*
* `value` is **absent for every `secret` key, always**. Not null, not redacted, not a placeholder —
* absent, because there is no code path that could put secret material here: Ekklesia never persists
* it, and the resolver's snapshot is never serialized. `configured` reports whether the deployment
* supplied one, which is the only thing an operator needs to know from here.
*/
export interface ModuleConfigurationKeyView {
readonly key: string;
readonly type: ModuleConfigurationDeclaration["type"];
readonly required: boolean;
readonly description?: string;
/** The declared default, for non-secret keys that have one. Part of the module's public manifest. */
readonly default?: string | number | boolean;
/** Present only for `type: "enum"`. */
readonly values?: readonly string[];
/** The stored value, when one is stored and the key is not a secret. Absent means "no value
* stored" — the default applies, or the key is unset. */
readonly value?: string;
/** For a secret: whether the deployment supplies it. For anything else: whether a value is stored.
* Never implies the value is valid — see the module's `runtime` entry for that. */
readonly configured: boolean;
}
export interface ModuleConfigurationView {
readonly moduleId: string;
readonly version: string;
readonly keys: readonly ModuleConfigurationKeyView[];
/** Stored keys this module's current manifest no longer declares. Retained rather than deleted, so
* a downgrade or a rename does not destroy an operator's value, and surfaced here so it is visible
* rather than merely tolerated. */
readonly unknownKeys: readonly string[];
/** `true` when the stored configuration no longer matches what this process resolved at boot. A
* configuration change is applied by the next restart; nothing is hot-reloaded (ADR 0029). */
readonly restartRequired: boolean;
}
export interface CreateModuleConfigurationRoutesOptions extends AuthorizationMiddlewareOptions {
/** The discovered inventory this process booted from — the source of every declaration this route
* validates against. Never re-discovered: a manifest that changed on disk since boot is not what
* the running platform is using. */
readonly discoveredModules: readonly DiscoveredModule[];
/** The stored settings this process resolved at boot, used only to compute `restartRequired` by
* comparison with what is stored now. */
readonly bootSettings: ModuleConfigurationSettings;
readonly secrets: ModuleSecretSource;
readonly store: ModuleConfigurationStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. The store's
* errors never carry a value, so nothing here can leak one either. */
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
if (error instanceof ModuleConfigurationStoreError) {
if (
error.code === "MODULE_CONFIGURATION_READ_FAILED" ||
error.code === "MODULE_CONFIGURATION_WRITE_FAILED"
) {
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(
c,
503,
fallback === "read" ? "MODULE_CONFIGURATION_READ_FAILED" : "MODULE_CONFIGURATION_WRITE_FAILED",
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const BODY_KEYS = new Set(["values"]);
/**
* The canonical text form of a submitted value, or `undefined` when it is not one this key accepts.
*
* Accepts JSON's own types — a number for `integer`, a boolean for `boolean`, a string for the rest
* — and converts to the single canonical text form the store persists and the resolver parses. It
* never accepts a string for a number (`"7"`), so exactly one spelling reaches storage and a stored
* row can never validate differently from the request that created it.
*/
function canonicalStoredValue(
declaration: ModuleConfigurationDeclaration,
submitted: unknown,
): string | undefined {
switch (declaration.type) {
case "integer": {
return typeof submitted === "number" && Number.isSafeInteger(submitted)
? String(submitted)
: undefined;
}
case "boolean": {
return typeof submitted === "boolean" ? String(submitted) : undefined;
}
case "string":
case "enum":
case "url": {
return typeof submitted === "string" &&
submitted.length <= MODULE_CONFIGURATION_VALUE_MAX_LENGTH
? submitted
: undefined;
}
case "secret": {
// Unreachable: a secret key is rejected before this is called. The branch exists so adding a
// type to the union is a compile error rather than a silent acceptance of secret material.
return undefined;
}
}
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
function declarationView(
declaration: ModuleConfigurationDeclaration,
storedValue: string | undefined,
secretConfigured: boolean,
): ModuleConfigurationKeyView {
const isSecret = declaration.type === "secret";
return {
// A secret is `configured` when the deployment supplies it; anything else when a value is
// stored. The two are different questions with one honest answer each.
configured: isSecret ? secretConfigured : storedValue !== undefined,
key: declaration.key,
required: declaration.required,
type: declaration.type,
...(declaration.description === undefined ? {} : { description: declaration.description }),
...(isSecret || declaration.default === undefined ? {} : { default: declaration.default }),
...(declaration.type === "enum" ? { values: declaration.values } : {}),
// The one place a stored value reaches a response — and structurally never for a secret.
...(isSecret || storedValue === undefined ? {} : { value: storedValue }),
};
}
/**
* The platform's module configuration resource: what each installed module declares it needs, what
* an operator has stored, and whether the running process is still using it.
*
* ## Secrets are absent, not filtered
*
* There is no branch here that redacts a secret, because there is nothing to redact: Ekklesia never
* persists secret material, so the store this route reads from contains none. A `secret` key's
* `value` field is omitted by construction and `configured` is computed by asking the deployment's
* secret source a yes/no question. A future code change that tried to serve a secret value would
* have to first invent somewhere to read one from.
*
* ## Changing configuration does not change the running process
*
* `PUT` replaces the stored document. It does not restart, reload, or reconfigure anything: modules
* receive a frozen snapshot resolved once at startup (ADR 0029), so the response reports
* `restartRequired` rather than pretending the change took effect.
*
* ## Closed validation against the module's own declaration
*
* A submitted key the module does not declare is rejected, as is a value of the wrong JSON type or a
* value failing the module's declared constraints. Nothing is coerced, truncated, or dropped:
* `{"slideSeconds": "7"}` is a `400`, not a silent `7`. A secret key is rejected outright — there is
* no supported way to submit secret material to this platform.
*/
export function createModuleConfigurationRoutes(
options: CreateModuleConfigurationRoutesOptions,
): Hono {
const routes = new Hono();
const requireManage = requirePermission(
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
CORE_MODULES_CONFIGURATION_MANAGE,
);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
const modulesById = new Map(
options.discoveredModules.map((module) => [module.manifest.id, module] as const),
);
const storedFor = (
settings: ModuleConfigurationSettings,
moduleId: string,
): ReadonlyMap<string, string> =>
new Map(
settings.records
.filter((record) => record.moduleId === moduleId)
.map((record) => [record.key, record.value] as const),
);
/** Two documents differ when the exact set of (key, value) pairs differs. Compared against the
* settings this process actually resolved at boot, so `restartRequired` is an observation rather
* than a guess. */
const differsFromBoot = (moduleId: string, current: ReadonlyMap<string, string>): boolean => {
const boot = storedFor(options.bootSettings, moduleId);
if (boot.size !== current.size) {
return true;
}
for (const [key, value] of current) {
if (boot.get(key) !== value) {
return true;
}
}
return false;
};
const buildView = (
module: DiscoveredModule,
settings: ModuleConfigurationSettings,
): ModuleConfigurationView => {
const stored = storedFor(settings, module.manifest.id);
const resolved = resolveModuleConfigurationFor(module.manifest, {
secrets: options.secrets,
settings,
});
return {
keys: module.manifest.configuration.map((declaration) =>
declarationView(
declaration as ModuleConfigurationDeclaration,
stored.get(declaration.key),
// Asked, never read into the response: the answer is a boolean, and the value it was
// derived from never leaves the resolver.
declaration.type === "secret"
? options.secrets.get(module.manifest.id, declaration.key) !== undefined
: false,
),
),
moduleId: module.manifest.id,
restartRequired: differsFromBoot(module.manifest.id, stored),
unknownKeys: resolved.unknownKeys,
version: module.manifest.version,
};
};
routes.get(MODULE_CONFIGURATION_MODULE_PATH, requireManage, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const moduleId = c.req.param("moduleId");
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
try {
return c.json(buildView(module, await options.store.readSettings()), 200);
} catch (error) {
return storeFailure(c, error, "read");
}
});
routes.put(
MODULE_CONFIGURATION_MODULE_PATH,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const moduleId = c.req.param("moduleId");
const raw = await readJsonBody(c);
if (!isRecord(raw) || Object.keys(raw).some((key) => !BODY_KEYS.has(key))) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const submitted = raw.values;
if (!isRecord(submitted)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
const declarations = new Map(
module.manifest.configuration.map(
(declaration) =>
[declaration.key, declaration as ModuleConfigurationDeclaration] as const,
),
);
const values: { key: string; value: string }[] = [];
// `Object.keys` on a parsed JSON object never yields inherited properties, and every key is
// re-validated against the platform's one configuration-key grammar before it is used, so a
// body carrying `__proto__` or `constructor` is a plain `400` rather than anything stranger.
for (const key of Object.keys(submitted)) {
if (!moduleConfigurationKeySchema.safeParse(key).success) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const declaration = declarations.get(key);
if (!declaration) {
return sanitizedFailure(c, 400, "CONFIGURATION_KEY_UNDECLARED");
}
if (declaration.type === "secret") {
// Deliberate and permanent for this pass: Ekklesia has no encryption at rest, so there is
// no honest way to accept secret material over HTTP and store it. Secrets are supplied by
// the deployment environment (ADR 0029).
return sanitizedFailure(c, 400, "CONFIGURATION_SECRET_NOT_WRITABLE");
}
const canonical = canonicalStoredValue(declaration, submitted[key]);
if (canonical === undefined) {
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
}
values.push({ key, value: canonical });
}
// Constraint validation runs through the **same resolver the platform boots with**, over the
// exact document about to be stored, rather than a second copy of the type rules here. That
// is what guarantees the API and startup can never disagree about whether a value is valid:
// there is one implementation, and this is it.
//
// An *incomplete* document is accepted deliberately — an operator may save partial work, and
// the module is then reported `blocked · configuration_missing` until they finish. An
// *invalid* one never is: storing a value that could not survive the next boot would hand
// back a success for a change that silently breaks the module.
const candidate = resolveModuleConfigurationFor(module.manifest, {
secrets: options.secrets,
settings: {
records: values.map((entry) => ({ ...entry, moduleId: module.manifest.id })),
},
});
if (candidate.issue?.reason === "configuration_invalid") {
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
}
try {
await options.store.replace({ moduleId: module.manifest.id, values });
// Re-read rather than predict, so this response and a later GET can never disagree.
return c.json(buildView(module, await options.store.readSettings()), 200);
} catch (error) {
return storeFailure(c, error, "write");
}
},
);
return routes;
}