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, ): 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(); 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(); 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____`, 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>, ): ModuleSecretSource { return Object.freeze({ get: (moduleId: string, key: string): string | undefined => environment[moduleSecretEnvironmentVariableName(moduleId, key)], }); }