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; } 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 { 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 { 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 => 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): 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; }