diff --git a/README.md b/README.md index 2615c68..dd115fb 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ transport), and Phase 5C (module database access) have landed. **Phase 5D (secur against an independent security audit) is complete: all five passes have landed and the final independent whole-repository re-audit has closed, under the current trusted-contributor, not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since -completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) has -landed.** The operational +completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) and +pass 2 (the module configuration and secrets contract) have landed.** The operational requirements that remain are prerequisites for two specific *future* activities — executing untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the pass 5 closure below and @@ -449,9 +449,67 @@ logs. Reading and changing both require the new restricted platform permission code is still on disk and still trusted. There is still **no installer**. See [ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md). +Phase 7A pass 2 adds the **module configuration and secrets contract** — the thing standing between +Announcements and the module system, since that product currently has zero environment variables and +compiles a LAN IP address into its source. + +A module declares what it needs configured in its manifest, and receives it as a frozen, validated +snapshot: + +```json +"configuration": [ + { "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600 }, + { "key": "bucket", "type": "string", "required": true }, + { "key": "storageCredential", "type": "secret", "required": true } +] +``` + +```ts +start({ config }) { + const slideSeconds = config.getInteger("slideSeconds"); + const credential = config.getSecret("storageCredential"); +} +``` + +**Declaration is static, and that is the whole point.** The platform validates configuration +*before* deciding whether a module may start, so an enabled module missing a required value is +reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down +from inside its own `start` hook — and its required dependents block behind it through ADR 0028's +existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no +module code. That forces `manifestVersion: 5`, a hard cutover in this repository's usual style. + +**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and +no key management, so a `secret` key's value is supplied by the deployment +(`EKKLESIA_MODULE_SECRET____`) and never touches PostgreSQL. The module never sees +that variable name — it calls `config.getSecret("storageCredential")` — which is exactly what lets a +future standalone host supply the same declared secret from somewhere else. A `secret` declaration has +no `default` field, the write API refuses secret material outright, and the HTTP view has no `value` +field for one, so a secret is absent from responses *by construction* rather than filtered by a branch +somebody could delete. + +Six types (`string`, `integer`, `boolean`, `enum`, `url`, `secret`), bounded constraints, no nesting, +no regular expressions — a validation vocabulary, not a form-builder DSL. Nothing is coerced: +`{"slideSeconds": "7"}` is a `400`, and a stored value that fails its own declared constraints blocks +the module rather than being quietly replaced by the default. Ordinary values live in one Core table +(migration `0005`), one row per `(module_id, key)`; a key the current manifest no longer declares is +retained and ignored so a downgrade never destroys an operator's value. + +Changes take effect at the **next restart**, and `GET`/`PUT /api/v1/module-configuration/` report +`restartRequired` rather than pretending otherwise — a sibling prefix, because `config` is a legal +module route segment. Both verbs need the new restricted `core.modules.configuration.manage`, +deliberately separate from the lifecycle permission: running a module and configuring it are different +authorities. A setting that must change *without* a restart is the module's own domain state — like +Announcements' `mode` toggle — not platform configuration. + +**There is no settings UI**, and module UI receives no configuration at all in this phase. 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 lacks. See +[ADR 0029](docs/architecture/decisions/0029-module-configuration-and-secrets.md). + **Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing, -and no module-management UI — module enablement is an API and a CLI, not a screen. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond +and no module-management or settings UI — module enablement and configuration are an API and a CLI, +not a screen. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do not exist. @@ -509,20 +567,23 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 4 permissions registered + 5 permissions registered ○ Module lifecycle skipped DATABASE_URL is not configured. + ○ Module configuration skipped + DATABASE_URL is not configured. Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 5 migrations · checksums valid + 6 migrations · checksums valid core:0000_identity_core_tables · f8f2086ef06f core:0001_authorization_core_tables · 830e1fbc26d0 core:0002_authorization_integrity · 634294731180 core:0003_dashboard_navigation_tables · 2cde27807aa4 core:0004_module_lifecycle_state · c8b5d8b3d48b + core:0005_module_configuration · c8d57f24f84b PostgreSQL ✓ Database TLS verified diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8a5e375..f37d504 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,6 @@ import { isAuthenticatedPrincipal, + type ModuleSecretSource, type ModuleRuntimeServices, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; @@ -11,7 +12,11 @@ import type { import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { + DiscoveredModule, + ModuleConfigurationSettings, + ModuleConfigurationStore, ModuleLifecycleSnapshot, + ModuleConfigurationLookup, ModuleLifecycleStore, ModuleUiPublicationRegistry, } from "@ekklesia/core/modules"; @@ -22,6 +27,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js import { createDashboardRoutes } from "./dashboard-routes.js"; import { createGrantsRoutes } from "./grants-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; +import { createModuleConfigurationRoutes } from "./module-configuration-routes.js"; import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js"; import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js"; import { createModuleUiRoutes } from "./module-ui-routes.js"; @@ -68,6 +74,25 @@ export interface CreateApiAppOptions { readonly snapshot: ModuleLifecycleSnapshot; readonly store: ModuleLifecycleStore; }; + /** + * The platform's module configuration resource (ADR 0029): the discovered inventory whose + * manifests declare what may be configured, the settings this process resolved at boot (used only + * to compute `restartRequired`), the deployment's secret source, and the store. + * + * Required rather than defaulted, like every other module option here. `secrets` is explicit for + * the same reason: a caller that supplies no module secrets says so, and a module requiring one is + * then reported `secret_missing` rather than silently starting without it. + */ + readonly moduleConfiguration: { + readonly discoveredModules: readonly DiscoveredModule[]; + readonly bootSettings: ModuleConfigurationSettings; + readonly secrets: ModuleSecretSource; + readonly store: ModuleConfigurationStore; + /** The per-module frozen snapshots the started runtime is actually using — the identical map + * `startDiscoveredModuleRuntime` received, so a module's HTTP handler and its lifecycle hooks + * read the same object rather than two independently resolved values. */ + readonly snapshots: ModuleConfigurationLookup; + }; /** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required * rather than defaulted, like every other module option here: a caller that exercises no * Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is @@ -244,13 +269,34 @@ export function createApiApp(options: CreateApiAppOptions): Hono { evaluator: options.authorization.evaluator, registry: options.authorization.registry, runtime, + configuration: { + secrets: options.moduleConfiguration.secrets, + store: options.moduleConfiguration.store, + }, snapshot: options.moduleLifecycle.snapshot, store: options.moduleLifecycle.store, trustedOrigins: allowedOrigins, }), ); + // Same sibling-prefix rule as the lifecycle resource above: `/api/v1/modules/` belongs entirely + // to installed modules. See `./module-configuration-routes.js`. + app.route( + "/", + createModuleConfigurationRoutes({ + bootSettings: options.moduleConfiguration.bootSettings, + discoveredModules: options.moduleConfiguration.discoveredModules, + evaluator: options.authorization.evaluator, + registry: options.authorization.registry, + runtime, + secrets: options.moduleConfiguration.secrets, + store: options.moduleConfiguration.store, + trustedOrigins: allowedOrigins, + }), + ); + mountModuleHttpRoutes(app, options.moduleRoutes, { + configurations: options.moduleConfiguration.snapshots, evaluator: options.authorization.evaluator, registry: options.authorization.registry, runtime, diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index 3eb41d6..f3c4c10 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -10,8 +10,10 @@ import { import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database"; import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity"; import { + createModuleConfigurationView, createModuleDatabaseView, createModuleLifecycleView, + type ModuleConfigurationDrizzleDatabase, type ModuleLifecycleDrizzleDatabase, } from "@ekklesia/core/modules"; @@ -20,6 +22,7 @@ export interface ApiDatabaseHandle { readonly dashboard: DashboardDrizzleDatabase; readonly identity: IdentityDrizzleDatabase; readonly module: ModuleDatabase; + readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase; readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase; /** Closes the one shared pool exactly once, regardless of how many typed views were created * over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */ @@ -31,7 +34,8 @@ export interface ApiDatabaseHandle { * authorization schemas plus the module database facade: one validated, timeout-bounded * `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent * typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`, - * `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleDatabaseView`). No domain + * `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleConfigurationView`, + * `createModuleDatabaseView`). No domain * owns or closes the pool in this composition — `close()` * here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/ * `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable @@ -49,6 +53,7 @@ export function createApiDatabase( dashboard: createDashboardDrizzleView(pool), identity: createIdentityDrizzleView(pool), module: createModuleDatabaseView(pool), + moduleConfiguration: createModuleConfigurationView(pool), moduleLifecycle: createModuleLifecycleView(pool), }); } diff --git a/apps/api/src/module-configuration-routes.ts b/apps/api/src/module-configuration-routes.ts new file mode 100644 index 0000000..c2922b3 --- /dev/null +++ b/apps/api/src/module-configuration-routes.ts @@ -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; +} + +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; +} diff --git a/apps/api/src/module-http-routes.ts b/apps/api/src/module-http-routes.ts index c210970..6d0ccfc 100644 --- a/apps/api/src/module-http-routes.ts +++ b/apps/api/src/module-http-routes.ts @@ -1,9 +1,11 @@ -import type { - ModuleId, - ModuleRouteHandlerContext, - ModuleRuntimeServices, - RegisteredModuleHttpRoute, +import { + EMPTY_MODULE_CONFIGURATION, + type ModuleId, + type ModuleRouteHandlerContext, + type ModuleRuntimeServices, + type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; +import type { ModuleConfigurationLookup } from "@ekklesia/core/modules"; import type { Context, Hono } from "hono"; import { type AuthorizationMiddlewareOptions, @@ -31,6 +33,16 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions * denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked * after authorization succeeds. */ readonly services: ModuleRuntimeServices; + /** + * Per-module frozen configuration snapshots — the exact map the module runtime started from + * (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object + * rather than two independently resolved values. A module with no entry receives + * `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present. + * + * Unlike `services`, which is one shared object, this is keyed by module id: a handler can only + * ever reach its own module's configuration, because that is the only entry looked up for it. + */ + readonly configurations: ModuleConfigurationLookup; } /** Never carries the offending permission id, route path, module id, or any other configuration @@ -212,6 +224,7 @@ export function mountModuleHttpRoutes( const path = moduleRoutePath(route.moduleId, route.path); const finalHandler = async (c: Context): Promise => { const context: ModuleRouteHandlerContext = Object.freeze({ + config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION, moduleId: route.moduleId, params: Object.freeze({ ...c.req.param() }), principal: getAuthorizedPrincipal(c), diff --git a/apps/api/src/module-lifecycle-routes.ts b/apps/api/src/module-lifecycle-routes.ts index 606376c..d363f81 100644 --- a/apps/api/src/module-lifecycle-routes.ts +++ b/apps/api/src/module-lifecycle-routes.ts @@ -2,9 +2,11 @@ import type { ModuleBlockedReason, ModuleBlockingDependency, ModuleRuntimeState, + ModuleSecretSource, } from "@ekklesia/contracts"; import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization"; import { + type ModuleConfigurationStore, type ModuleLifecycleSnapshot, type ModuleLifecycleStore, ModuleLifecycleStoreError, @@ -58,7 +60,11 @@ export interface ModuleLifecycleModuleView { readonly runtime: { readonly state: ModuleRuntimeState; readonly reason?: ModuleBlockedReason; + /** Present for a dependency reason. */ readonly dependency?: ModuleBlockingDependency; + /** Present for a configuration reason (ADR 0029): the offending key, never its value. A key is + * declared in a public manifest; a value may be a credential or arbitrary operator text. */ + readonly configurationKey?: string; }; readonly restartRequired: boolean; } @@ -74,6 +80,16 @@ export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlew * if it were restarted right now. */ readonly snapshot: ModuleLifecycleSnapshot; readonly store: ModuleLifecycleStore; + /** + * How to re-read the *other* axis that decides eligibility (ADR 0029). Since a module's own + * configuration can block it, `restartRequired` must be computed against live configuration as + * well as live enablement — otherwise correcting a missing required value would report no pending + * restart for a module the next boot really will start. + */ + readonly configuration: { + readonly store: ModuleConfigurationStore; + readonly secrets: ModuleSecretSource; + }; readonly trustedOrigins: ReadonlySet; } @@ -137,6 +153,9 @@ function present(view: ModuleLifecycleView): ModuleLifecycleModuleView { state: view.runtime.state, ...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }), ...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }), + ...(view.runtime.configurationKey === undefined + ? {} + : { configurationKey: view.runtime.configurationKey }), }, version: view.version, }; @@ -197,8 +216,14 @@ export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutes // every other authorized Core read does. c.header("Vary", "Origin, Cookie"); try { - const configuration = await options.store.readConfiguration(); - const modules = resolveModuleLifecycleView(options.snapshot, configuration).map(present); + const [configuration, settings] = await Promise.all([ + options.store.readConfiguration(), + options.configuration.store.readSettings(), + ]); + const modules = resolveModuleLifecycleView(options.snapshot, configuration, { + secrets: options.configuration.secrets, + settings, + }).map(present); return c.json({ modules } satisfies ModuleLifecycleResponse, 200); } catch (error) { return storeFailure(c, error, "read"); @@ -225,10 +250,14 @@ export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutes // Re-read rather than predict: the response describes the configuration that is actually // stored now, resolved against the same boot snapshot the collection read uses, so the two // endpoints can never disagree about `restartRequired`. - const configuration = await options.store.readConfiguration(); - const view = resolveModuleLifecycleView(options.snapshot, configuration).find( - (entry) => entry.moduleId === moduleId, - ); + const [configuration, settings] = await Promise.all([ + options.store.readConfiguration(), + options.configuration.store.readSettings(), + ]); + const view = resolveModuleLifecycleView(options.snapshot, configuration, { + secrets: options.configuration.secrets, + settings, + }).find((entry) => entry.moduleId === moduleId); if (!view) { return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED"); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 2b6b14e..4788036 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,7 +1,11 @@ import type { Socket } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import type { ModuleRuntimeServices } from "@ekklesia/contracts"; +import { + EMPTY_MODULE_SECRET_SOURCE, + type ModuleRuntimeServices, + type ModuleSecretSource, +} from "@ekklesia/contracts"; import { createAuthorizationEvaluator, createAuthorizationStore, @@ -9,9 +13,12 @@ import { import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { + createEnvironmentModuleSecretSource, + createModuleConfigurationStore, createModuleLifecycleStore, createModuleUiPublicationRegistry, type DiscoveredModule, + type ModuleConfigurationSettings, type ModuleEligibilityResolution, resolveModuleEligibility, type StartedModuleRuntime, @@ -142,6 +149,17 @@ export interface StartApiOptions { /** Test-only escape hatch: narrows the per-module stop/rollback hook deadline * (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */ readonly moduleHookTimeoutMsOverride?: number; + /** + * Where deployment-supplied module secrets come from (ADR 0029) — the one *production* dependency + * in this options object rather than a test-only escape hatch. + * + * It is a dependency rather than a field on `ApiRuntimeConfig` because it is a lookup, not a + * validated value, and because the set of secrets depends on which modules are discovered — which + * is not known when configuration is validated. The executable entry point supplies an + * environment-backed source; omitting it supplies nothing, so a module declaring a required secret + * is reported `secret_missing` rather than silently starting without it. + */ + readonly moduleSecrets?: ModuleSecretSource; } async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise { @@ -405,12 +423,28 @@ export async function startApi( // falling back to "start everything": the platform cannot know what it was told, and starting a // module an administrator disabled is worse than not starting at all. const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle); + const moduleConfigurationStore = createModuleConfigurationStore( + databaseHandle.moduleConfiguration, + ); + const moduleSecrets = options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE; let eligibility: ModuleEligibilityResolution; + let moduleConfigurationSettings: ModuleConfigurationSettings; try { - eligibility = resolveModuleEligibility( - inventory.modules, - await moduleLifecycleStore.readConfiguration(), - ); + // Both axes are read before a single module entrypoint is imported, and resolved together in + // one pass (ADR 0029): a module is eligible only if an administrator enabled it, its own + // required configuration is satisfiable, and every module it requires is eligible too. The pass + // also produces each eligible module's frozen configuration snapshot, so the configuration that + // made a module eligible is the identical object it later receives. + // Read sequentially rather than concurrently, deliberately: these are two tiny queries, and + // racing them would make the reported failure depend on which rejected first. An operator + // debugging a broken deployment must get the same error code every time. + const lifecycleConfiguration = await moduleLifecycleStore.readConfiguration(); + const settings = await moduleConfigurationStore.readSettings(); + moduleConfigurationSettings = settings; + eligibility = resolveModuleEligibility(inventory.modules, lifecycleConfiguration, { + secrets: moduleSecrets, + settings, + }); } catch (error) { await safeCloseDatabase(databaseHandle); throw error; @@ -427,6 +461,7 @@ export async function startApi( let moduleRuntime: StartedModuleRuntime; try { moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, { + configurations: eligibility.configurations, ...(options.moduleHookTimeoutMsOverride === undefined ? {} : { hookTimeoutMs: options.moduleHookTimeoutMsOverride }), @@ -463,6 +498,15 @@ export async function startApi( }), store: moduleLifecycleStore, }, + moduleConfiguration: { + // The settings this process actually resolved from, so `restartRequired` is a comparison + // against what is running rather than a guess. + bootSettings: moduleConfigurationSettings, + discoveredModules: inventory.modules, + secrets: moduleSecrets, + snapshots: eligibility.configurations, + store: moduleConfigurationStore, + }, // A typed view over the same single pool as identity, authorization, and the module facade. dashboard: createDashboardStore(databaseHandle.dashboard), runtime, @@ -615,7 +659,12 @@ function readConfigFromEnvironment(): { export async function main(): Promise { const { environment, input } = readConfigFromEnvironment(); const config = createApiRuntimeConfig(input, { environment }); - const running = await startApi(config); + // The one place this process turns its environment into module secrets. Core's factory takes the + // environment as an argument and never touches `process.env` itself, preserving the rule that + // only executables read the environment. + const running = await startApi(config, { + moduleSecrets: createEnvironmentModuleSecretSource(process.env), + }); const address = running.server.address(); const boundPort = address && typeof address === "object" ? address.port : config.port; diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 99a807e..d1a00f4 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -196,6 +196,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", + "core:0005_module_configuration", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", @@ -301,7 +302,7 @@ async function createGatedRouteModule( compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js" }, id, - manifestVersion: 4, + manifestVersion: 5, name: `${id} module`, permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], version: "0.1.0", @@ -342,7 +343,7 @@ async function createFixtureModule( ): Promise { const moduleDirectory = path.join(modulesDirectory, id); const manifest = { - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", @@ -434,7 +435,7 @@ async function createUiModule( compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id, - manifestVersion: 4, + manifestVersion: 5, name: `${id} module`, version, }), @@ -1102,12 +1103,14 @@ describe.sequential("live API — startup and migration guarantees", () => { { environment: "development" }, ); - // Startup reads the stored module enablement configuration before importing any module - // entrypoint (ADR 0028). Against an unmigrated database that read cannot succeed, and the - // platform fails closed rather than starting every discovered module: it cannot know what an - // administrator decided, and starting a module they disabled is worse than not starting. - // The failure is Core's own sanitized store error — no connection string, host, SQL, driver - // message, or stack. + // Startup reads the stored module enablement configuration and then the stored module + // configuration, both before importing any module entrypoint (ADR 0028, ADR 0029). Against an + // unmigrated database neither read can succeed, and the platform fails closed rather than + // starting every discovered module: it cannot know what an administrator decided, and starting + // a module they disabled — or one whose required configuration is absent — is worse than not + // starting. The two reads are sequential rather than raced precisely so this error code is + // deterministic; the failure is Core's own sanitized store error, with no connection string, + // host, SQL, driver message, or stack. let captured: unknown; try { await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); @@ -2189,10 +2192,10 @@ describe.sequential("live API — installed module permission end-to-end", () => expect(moduleCheck).toMatchObject({ status: "pass" }); expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); expect(permissionsCheck).toMatchObject({ - // Core's own four (`core.authorization.grants.manage`, `core.modules.ui.read`, - // `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`) plus the fixture - // module's one. - details: ["5 permissions registered"], + // Core's own five (`core.authorization.grants.manage`, `core.modules.ui.read`, + // `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`, + // `core.modules.configuration.manage`) plus the fixture module's one. + details: ["6 permissions registered"], status: "pass", }); // Doctor and permission composition read only the manifest — the fixture's register/start diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index ef12420..9fe7cb7 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -21,6 +21,7 @@ import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-stor import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; +import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; @@ -90,6 +91,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -110,6 +112,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -131,6 +134,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -154,6 +158,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -179,6 +184,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -204,6 +210,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -226,6 +233,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -250,6 +258,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -271,6 +280,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -294,6 +304,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -324,6 +335,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -355,6 +367,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -391,6 +404,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -409,6 +423,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -429,6 +444,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -448,6 +464,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -465,6 +482,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -489,6 +507,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -516,6 +535,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), @@ -546,6 +566,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ @@ -567,6 +588,7 @@ describe("Ekklesia API Hono application", () => { moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index 7bafaa1..5cf2680 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -30,6 +30,7 @@ import { createInMemoryModuleLifecycleStore, moduleLifecycleSnapshotFor, } from "./support/in-memory-module-lifecycle.js"; +import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; @@ -72,7 +73,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), }, id: fixture.id, - manifestVersion: 4, + manifestVersion: 5, name: fixture.name, version: "1.0.0", }), @@ -145,6 +146,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> { snapshot: moduleLifecycleSnapshotFor(modules), store: createInMemoryModuleLifecycleStore(), }, + moduleConfiguration: moduleConfigurationOptionFor(modules), runtime, trustedOrigins: [trustedOrigin], }); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index cafccec..5dcb7f6 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -82,7 +82,7 @@ async function createFixtureModule( ): Promise { const moduleDirectory = path.join(modulesDirectory, id); const manifest = { - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", @@ -215,7 +215,7 @@ function fakeDiscoveredModule( compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/does-not-exist/index.js" }, id, - manifestVersion: 4, + manifestVersion: 5, name: `${id} module`, permissions, version: "0.1.0", diff --git a/apps/api/tests/module-configuration-routes.test.ts b/apps/api/tests/module-configuration-routes.test.ts new file mode 100644 index 0000000..317396e --- /dev/null +++ b/apps/api/tests/module-configuration-routes.test.ts @@ -0,0 +1,796 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + type EkklesiaPrincipal, + type ModuleConfigurationDeclarationInput, + type ModuleDatabase, + type ModuleRuntimeServices, + parseEkklesiaUserId, +} from "@ekklesia/contracts"; +import { + type AuthorizationStore, + CORE_MODULES_CONFIGURATION_MANAGE, + CORE_MODULES_LIFECYCLE_MANAGE, + createAuthorizationEvaluator, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, + type DiscoveredModule, + discoverModules, + type ModuleConfigurationRecord, + type ModuleConfigurationStore, + ModuleConfigurationStoreError, + resolveModuleEligibility, +} from "@ekklesia/core/modules"; +import type { Hono } from "hono"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApiApp } from "../src/app.js"; +import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; +import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js"; +import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { + createFakeModuleSecretSource, + createInMemoryModuleConfigurationStore, +} from "./support/in-memory-module-configuration.js"; +import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js"; + +const trustedOrigin = "https://dashboard.example.test"; +const untrustedOrigin = "https://evil.example.test"; +const viewerId = parseEkklesiaUserId("55555555-5555-4555-8555-555555555555"); + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +/** + * The configuration shape the real Announcements product would need as an Ekklesia module: a slide + * duration and poll interval with defaults, a bucket name it must be told, and a deployment-supplied + * storage credential. + * + * Modelled from the reference repository, never imported from it. Nothing Announcements-specific + * exists in Core or in `apps/api`; this fixture exists to prove a real product's settings survive + * the whole path — manifest, validation, storage, API, snapshot — without the module reaching any + * platform infrastructure. + */ +const announcementsConfiguration: readonly ModuleConfigurationDeclarationInput[] = [ + { + key: "slideSeconds", + type: "integer", + default: 7, + minimum: 1, + maximum: 600, + description: "How long each slide is shown.", + }, + { key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 }, + { key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 }, + { key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" }, + { key: "storageCredential", type: "secret", required: true }, +]; + +interface ModuleFixture { + readonly id: string; + readonly configuration?: readonly ModuleConfigurationDeclarationInput[]; +} + +async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { + const moduleDirectory = path.join(modulesDirectory, fixture.id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${fixture.id}`, + type: "module", + version: "1.0.0", + }), + ), + writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + configuration: fixture.configuration ?? [], + entrypoints: { server: "./dist/server/index.js" }, + id: fixture.id, + manifestVersion: 5, + name: `${fixture.id} module`, + version: "1.0.0", + }), + ), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`, + ), + ]); +} + +interface InstallOptions { + readonly fixtures?: readonly ModuleFixture[]; + readonly principal?: EkklesiaPrincipal; + readonly platformOwner?: boolean; + readonly grants?: readonly string[]; + readonly records?: readonly ModuleConfigurationRecord[]; + readonly secrets?: Readonly>>>; + readonly store?: ModuleConfigurationStore; +} + +interface Installation { + readonly app: Hono; + readonly modules: readonly DiscoveredModule[]; + readonly store: ModuleConfigurationStore; +} + +/** + * Composes the real application over a real discovered inventory, reproducing `startApi`'s own + * composition: read the stored settings, resolve eligibility and configuration in one pass, then + * build the UI registry and every option from that same resolution. Doing it the same way here is + * what makes these assertions evidence about production composition rather than a bespoke wiring. + */ +async function install(options: InstallOptions = {}): Promise { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-config-")); + temporaryDirectories.push(modulesDirectory); + for (const fixture of options.fixtures ?? [ + { configuration: announcementsConfiguration, id: "announcements" }, + ]) { + await createModule(modulesDirectory, fixture); + } + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + + const store = options.store ?? createInMemoryModuleConfigurationStore(options.records ?? []); + const secrets = createFakeModuleSecretSource(options.secrets ?? {}); + const lifecycleStore = createInMemoryModuleLifecycleStore(); + const bootSettings = await store.readSettings(); + const eligibility = resolveModuleEligibility(modules, await lifecycleStore.readConfiguration(), { + secrets, + settings: bootSettings, + }); + + const held = new Set(options.grants ?? []); + const evaluator = createAuthorizationEvaluator({ + grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) }, + platformOwners: { isPlatformOwner: async () => options.platformOwner === true }, + }); + const runtime: EkklesiaIdentityRuntime = { + handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), + resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL), + }; + const authorizationStore = { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn().mockResolvedValue(false), + isPlatformOwner: vi.fn().mockResolvedValue(false), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + } as unknown as AuthorizationStore; + + const app = createApiApp({ + authorization: { + evaluator, + registry: composePlatformPermissionRegistry(modules), + store: authorizationStore, + }, + dashboard: createInMemoryDashboardStore(), + moduleConfiguration: { + bootSettings, + discoveredModules: modules, + secrets, + snapshots: eligibility.configurations, + store, + }, + moduleLifecycle: { + snapshot: { + discoveredModules: modules, + eligibility: eligibility.entries, + startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id), + }, + store: lifecycleStore, + }, + moduleRoutes: Object.freeze([]), + moduleServices: Object.freeze({ + database: Object.freeze({}) as unknown as ModuleDatabase, + }) as ModuleRuntimeServices, + moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules), + runtime, + trustedOrigins: [trustedOrigin], + }); + + return { app, modules, store }; +} + +function owner(): InstallOptions { + return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) }; +} + +interface ConfigurationBody { + readonly moduleId: string; + readonly version: string; + readonly keys: readonly { + readonly key: string; + readonly type: string; + readonly required: boolean; + readonly description?: string; + readonly default?: string | number | boolean; + readonly values?: readonly string[]; + readonly value?: string; + readonly configured: boolean; + }[]; + readonly unknownKeys: readonly string[]; + readonly restartRequired: boolean; +} + +async function readConfiguration( + app: Hono, + moduleId = "announcements", +): Promise { + const response = await app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, { + headers: { origin: trustedOrigin }, + }); + expect(response.status).toBe(200); + return (await response.json()) as ConfigurationBody; +} + +function putConfiguration( + app: Hono, + values: unknown, + moduleId = "announcements", + origin = trustedOrigin, +) { + return app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, { + body: JSON.stringify({ values }), + headers: { "content-type": "application/json", origin }, + method: "PUT", + }); +} + +describe("GET /api/v1/module-configuration/:moduleId", () => { + it("returns the module's declared schema with defaults and stored values", async () => { + const { app } = await install({ + ...owner(), + records: [{ key: "bucket", moduleId: "announcements", value: "slides" }], + secrets: { announcements: { storageCredential: "s3cret" } }, + }); + + const body = await readConfiguration(app); + + expect(body.moduleId).toBe("announcements"); + expect(body.keys.map((entry) => entry.key)).toEqual([ + "slideSeconds", + "pollIntervalSeconds", + "bucket", + "fit", + "storageCredential", + ]); + expect(body.keys[0]).toEqual({ + configured: false, + default: 7, + description: "How long each slide is shown.", + key: "slideSeconds", + required: false, + type: "integer", + }); + expect(body.keys.find((entry) => entry.key === "bucket")).toMatchObject({ + configured: true, + required: true, + value: "slides", + }); + expect(body.keys.find((entry) => entry.key === "fit")?.values).toEqual(["contain", "cover"]); + }); + + it("never returns a secret value, only whether the deployment supplies one", async () => { + const configured = await install({ + ...owner(), + secrets: { announcements: { storageCredential: "s3cret" } }, + }); + const body = await readConfiguration(configured.app); + const secretEntry = body.keys.find((entry) => entry.key === "storageCredential"); + + expect(secretEntry).toEqual({ + configured: true, + key: "storageCredential", + required: true, + type: "secret", + }); + // Not redacted, not null, not a placeholder — the field is absent entirely, and the secret + // string appears nowhere in the payload. + expect(secretEntry).not.toHaveProperty("value"); + expect(secretEntry).not.toHaveProperty("default"); + expect(JSON.stringify(body)).not.toContain("s3cret"); + }); + + it("reports an unsupplied secret as not configured, still without a value field", async () => { + const { app } = await install(owner()); + const body = await readConfiguration(app); + + expect(body.keys.find((entry) => entry.key === "storageCredential")).toEqual({ + configured: false, + key: "storageCredential", + required: true, + type: "secret", + }); + }); + + it("lists stored keys the current manifest no longer declares, without failing", async () => { + const { app } = await install({ + ...owner(), + records: [ + { key: "bucket", moduleId: "announcements", value: "slides" }, + { key: "removedInThisVersion", moduleId: "announcements", value: "kept" }, + ], + }); + + const body = await readConfiguration(app); + + expect(body.unknownKeys).toEqual(["removedInThisVersion"]); + expect(body.keys.map((entry) => entry.key)).not.toContain("removedInThisVersion"); + }); + + it("returns a module declaring no configuration as an empty, valid document", async () => { + const { app } = await install({ ...owner(), fixtures: [{ id: "calendar" }] }); + const body = await readConfiguration(app, "calendar"); + + expect(body.keys).toEqual([]); + expect(body.unknownKeys).toEqual([]); + expect(body.restartRequired).toBe(false); + }); + + it("refuses a module this process did not discover", async () => { + const { app } = await install(owner()); + const response = await app.request(`${MODULE_CONFIGURATION_PATH}/not-installed`, { + headers: { origin: trustedOrigin }, + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" }); + }); + + it("is not cached and varies on origin and cookie", async () => { + const { app } = await install(owner()); + const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, { + headers: { origin: trustedOrigin }, + }); + + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.get("Vary")).toContain("Cookie"); + }); + + it("reports a sanitized 503 when the store cannot be read, with no value or driver detail", async () => { + const failing: ModuleConfigurationStore = { + readSettings: async () => { + throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_READ_FAILED"); + }, + replace: async () => { + throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_WRITE_FAILED"); + }, + }; + let bootRead = false; + const { app } = await install({ + ...owner(), + store: { + readSettings: async () => { + if (!bootRead) { + bootRead = true; + return { records: [] }; + } + return failing.readSettings(); + }, + replace: (input) => failing.replace(input), + }, + }); + + const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, { + headers: { origin: trustedOrigin }, + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "MODULE_CONFIGURATION_READ_FAILED" }); + }); +}); + +describe("PUT /api/v1/module-configuration/:moduleId", () => { + it("stores values and reports that a restart is required", async () => { + const { app, store } = await install({ + ...owner(), + records: [{ key: "bucket", moduleId: "announcements", value: "slides" }], + }); + + const response = await putConfiguration(app, { bucket: "slides", slideSeconds: 12 }); + + expect(response.status).toBe(200); + const body = (await response.json()) as ConfigurationBody; + expect(body.keys.find((entry) => entry.key === "slideSeconds")).toMatchObject({ + configured: true, + value: "12", + }); + // The running process still holds the boot snapshot; claiming otherwise would be a lie. + expect(body.restartRequired).toBe(true); + expect((await store.readSettings()).records).toEqual([ + { key: "bucket", moduleId: "announcements", value: "slides" }, + { key: "slideSeconds", moduleId: "announcements", value: "12" }, + ]); + }); + + it("reports no pending restart when the submitted document matches what booted", async () => { + const { app } = await install({ + ...owner(), + records: [{ key: "bucket", moduleId: "announcements", value: "slides" }], + }); + + const response = await putConfiguration(app, { bucket: "slides" }); + + expect(((await response.json()) as ConfigurationBody).restartRequired).toBe(false); + }); + + it("replaces the whole document, so an omitted key is cleared", async () => { + const { app, store } = await install({ + ...owner(), + records: [ + { key: "bucket", moduleId: "announcements", value: "slides" }, + { key: "slideSeconds", moduleId: "announcements", value: "12" }, + ], + }); + + await putConfiguration(app, { bucket: "slides" }); + + expect((await store.readSettings()).records.map((record) => record.key)).toEqual(["bucket"]); + }); + + it("never touches another module's stored values", async () => { + const { app, store } = await install({ + ...owner(), + fixtures: [ + { configuration: announcementsConfiguration, id: "announcements" }, + { configuration: [{ key: "bucket", type: "string" }], id: "calendar" }, + ], + records: [ + { key: "bucket", moduleId: "announcements", value: "slides" }, + { key: "bucket", moduleId: "calendar", value: "events" }, + ], + }); + + await putConfiguration(app, { bucket: "renamed" }); + + expect((await store.readSettings()).records).toEqual([ + { key: "bucket", moduleId: "announcements", value: "renamed" }, + { key: "bucket", moduleId: "calendar", value: "events" }, + ]); + }); + + it("refuses to accept secret material over HTTP", async () => { + const { app, store } = await install(owner()); + + const response = await putConfiguration(app, { + bucket: "slides", + storageCredential: "s3cret", + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: "CONFIGURATION_SECRET_NOT_WRITABLE" }); + // Nothing was written at all — not the secret, and not the valid key beside it. + expect((await store.readSettings()).records).toEqual([]); + }); + + it("rejects a key the module does not declare", async () => { + const { app, store } = await install(owner()); + + const response = await putConfiguration(app, { bucket: "slides", mqttEndpoint: "mqtt://x" }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: "CONFIGURATION_KEY_UNDECLARED" }); + expect((await store.readSettings()).records).toEqual([]); + }); + + it.each([ + ["a string for an integer", { bucket: "slides", slideSeconds: "12" }], + ["a float for an integer", { bucket: "slides", slideSeconds: 12.5 }], + ["an integer below its declared minimum", { bucket: "slides", slideSeconds: 0 }], + ["an integer above its declared maximum", { bucket: "slides", slideSeconds: 100000 }], + ["a value outside a declared enum", { bucket: "slides", fit: "stretch" }], + ["a string shorter than its declared minLength", { bucket: "" }], + ["a nested object", { bucket: { name: "slides" } }], + ["an array", { bucket: ["slides"] }], + ["null", { bucket: null }], + ])("rejects %s without storing anything", async (_label, values) => { + const { app, store } = await install(owner()); + + const response = await putConfiguration(app, values); + + expect(response.status).toBe(400); + expect((await store.readSettings()).records).toEqual([]); + }); + + it.each([ + ["a body with an unexpected top-level key", { values: {}, force: true }], + ["a body with no values object", {}], + ["a bare array body", []], + ["a bare string body", "bucket=slides"], + ])("rejects %s with a closed, structured validation failure", async (_label, body) => { + const { app } = await install(owner()); + const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "PUT", + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: "INVALID_REQUEST" }); + }); + + it.each([ + "__proto__", + "constructor", + "prototype", + ])("rejects the reserved property name %s as a submitted key", async (key) => { + const { app, store } = await install(owner()); + const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, { + body: `{"values":{${JSON.stringify(key)}:"polluted"}}`, + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "PUT", + }); + + expect(response.status).toBe(400); + expect((await store.readSettings()).records).toEqual([]); + // Nothing about the request reached `Object.prototype`. + expect(({} as Record).polluted).toBeUndefined(); + }); + + it("accepts an incomplete document, leaving the module blocked rather than refusing the save", async () => { + const { app, store } = await install(owner()); + + // `bucket` is required and absent: an operator may save partial work. + const response = await putConfiguration(app, { slideSeconds: 12 }); + + expect(response.status).toBe(200); + expect((await store.readSettings()).records).toEqual([ + { key: "slideSeconds", moduleId: "announcements", value: "12" }, + ]); + }); + + it("rejects an untrusted origin before authorization or the store runs", async () => { + const { app, store } = await install(owner()); + + const response = await putConfiguration( + app, + { bucket: "slides" }, + "announcements", + untrustedOrigin, + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + expect((await store.readSettings()).records).toEqual([]); + }); + + it("rejects a missing origin", async () => { + const { app } = await install(owner()); + const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, { + body: JSON.stringify({ values: { bucket: "slides" } }), + headers: { "content-type": "application/json" }, + method: "PUT", + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + }); + + it("enforces the platform body limit with the one sanitized 413 contract", async () => { + const { app } = await install(owner()); + const oversized = JSON.stringify({ + values: { bucket: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) }, + }); + const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, { + body: oversized, + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "PUT", + }); + + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); + }); +}); + +describe("module configuration authority", () => { + it("refuses an anonymous caller on both verbs", async () => { + const { app } = await install(); + + expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403); + expect((await putConfiguration(app, { bucket: "slides" })).status).toBe(403); + }); + + it("refuses an ordinary authenticated user who holds no configuration permission", async () => { + const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) }); + + expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403); + }); + + it("admits a non-owner holding exactly core.modules.configuration.manage", async () => { + const { app } = await install({ + grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)], + principal: createAuthenticatedPrincipal(viewerId), + }); + + expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(200); + }); + + it("does not let the lifecycle permission configure a module, or the reverse", async () => { + // The two authorities are deliberately separate: tuning how a module behaves and deciding + // whether it runs at all are different decisions (ADR 0029). + const lifecycleOnly = await install({ + grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)], + principal: createAuthenticatedPrincipal(viewerId), + }); + expect( + (await lifecycleOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status, + ).toBe(403); + expect((await lifecycleOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200); + + const configurationOnly = await install({ + grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)], + principal: createAuthenticatedPrincipal(viewerId), + }); + expect((await configurationOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(403); + expect( + (await configurationOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status, + ).toBe(200); + }); +}); + +describe("configuration state is visible through the lifecycle resource", () => { + it("reports a module blocked by missing required configuration, naming the key not the value", async () => { + const { app } = await install({ + ...owner(), + secrets: { announcements: { storageCredential: "s3cret" } }, + }); + + const response = await app.request(MODULE_LIFECYCLE_PATH, { + headers: { origin: trustedOrigin }, + }); + const body = (await response.json()) as { + readonly modules: readonly { + readonly id: string; + readonly runtime: { + readonly state: string; + readonly reason?: string; + readonly configurationKey?: string; + }; + }[]; + }; + + expect(body.modules[0]?.runtime).toEqual({ + configurationKey: "bucket", + reason: "configuration_missing", + state: "blocked", + }); + }); + + it("reports a module blocked by a missing secret, without naming the secret's value", async () => { + const { app } = await install({ + ...owner(), + records: [{ key: "bucket", moduleId: "announcements", value: "slides" }], + }); + + const response = await app.request(MODULE_LIFECYCLE_PATH, { + headers: { origin: trustedOrigin }, + }); + const raw = await response.text(); + + expect(raw).toContain("secret_missing"); + expect(raw).toContain("storageCredential"); + }); + + it("keeps a configuration-blocked module out of the mountable UI inventory", async () => { + const { app } = await install({ + ...owner(), + fixtures: [ + { configuration: [{ key: "bucket", type: "string", required: true }], id: "announcements" }, + ], + }); + + const inventory = (await (await app.request("/api/v1/module-ui")).json()) as { + readonly modules: readonly { readonly id: string }[]; + }; + + // Blocked by its own configuration behaves exactly like blocked by a disabled dependency: it is + // not eligible, so it is not published. + expect(inventory.modules).toEqual([]); + }); +}); + +describe("no secret or configuration value leaks through any surface", () => { + it("keeps secret material out of every response body this resource produces", async () => { + const { app } = await install({ + ...owner(), + records: [{ key: "bucket", moduleId: "announcements", value: "slides" }], + secrets: { announcements: { storageCredential: "super-secret-value" } }, + }); + + const listed = await ( + await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, { + headers: { origin: trustedOrigin }, + }) + ).text(); + const mutated = await (await putConfiguration(app, { bucket: "slides" })).text(); + const lifecycle = await ( + await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } }) + ).text(); + + for (const payload of [listed, mutated, lifecycle]) { + expect(payload).not.toContain("super-secret-value"); + } + }); + + it("does not serve a secret value even if a row for that key somehow exists in storage", async () => { + // Unreachable through the API, which refuses to write a secret key, and unreachable through the + // resolver, which never reads one. This plants the row directly to prove the *serialization* + // boundary holds independently: a secret's `value` is omitted by construction, not filtered by a + // branch someone could remove. + const { app } = await install({ + ...owner(), + records: [ + { key: "bucket", moduleId: "announcements", value: "slides" }, + { key: "storageCredential", moduleId: "announcements", value: "planted-in-the-database" }, + ], + }); + + const body = await readConfiguration(app); + const secretEntry = body.keys.find((entry) => entry.key === "storageCredential"); + + expect(secretEntry).not.toHaveProperty("value"); + expect(secretEntry?.configured).toBe(false); + expect(JSON.stringify(body)).not.toContain("planted-in-the-database"); + }); + + it("never leaks a filesystem path, package name, or manifest path", async () => { + const { app } = await install({ + ...owner(), + records: [{ key: "bucket", moduleId: "announcements", value: "slides" }], + }); + + const listed = await ( + await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, { + headers: { origin: trustedOrigin }, + }) + ).text(); + + expect(listed).not.toContain("/tmp"); + expect(listed).not.toContain("@test/module-"); + expect(listed).not.toContain("ekklesia.module.json"); + expect(listed).not.toContain("dist/"); + }); +}); + +describe("the configuration prefix is a sibling of the module route namespace", () => { + it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => { + // `config` and `configuration` are ordinary lowercase static segments under + // `moduleRoutePathSchema`, so a module could legitimately register `PUT /config` and land on + // `/api/v1/modules/announcements/config`. A sibling prefix cannot collide. + expect(MODULE_CONFIGURATION_PATH.startsWith("/api/v1/modules/")).toBe(false); + expect(MODULE_CONFIGURATION_PATH).toBe("/api/v1/module-configuration"); + + const { app } = await install(owner()); + const collision = await app.request("/api/v1/modules/announcements/config", { + body: JSON.stringify({ values: {} }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "PUT", + }); + + expect(collision.status).toBe(404); + expect(await collision.json()).toEqual({ code: "NOT_FOUND" }); + }); +}); diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index 14271d0..44b9ee7 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -222,6 +222,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", + "core:0005_module_configuration", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -249,6 +250,7 @@ async function applyMigrationsWithModule(url: string): Promise { "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", + "core:0005_module_configuration", "db-facade:0000_initial", ]); expect(aggregate.migrations[0]?.checksum).toBe( @@ -517,7 +519,13 @@ describe.sequential("live API — module database access through ModuleRuntimeSe const readHandlerEvent = [...events] .reverse() .find((event) => event.type === "handler" && event.route === "GET /records/:id"); - expect(readHandlerEvent?.contextKeys).toEqual(["moduleId", "params", "principal", "services"]); + expect(readHandlerEvent?.contextKeys).toEqual([ + "config", + "moduleId", + "params", + "principal", + "services", + ]); expect(readHandlerEvent?.services).toBeDefined(); expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database); }); diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index 1ed81ec..11d4b94 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -27,6 +27,7 @@ import { ModuleRouteConfigurationError, } from "../src/module-http-routes.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; +import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; @@ -111,6 +112,7 @@ function buildModuleApp(options: { moduleServices: services, moduleUi: createModuleUiPublicationRegistry([]), moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), runtime, trustedOrigins: options.trustedOrigins ?? [trustedOrigin], @@ -337,9 +339,13 @@ describe("module route handler adaptation", () => { expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true"); expect(capturedRequest).toBeInstanceOf(Request); expect(capturedRequest?.headers.get("x-custom")).toBe("abc"); - // Exactly moduleId, params, principal, services — never a Hono Context, authorization store, - // or configuration reaches a module handler. + // Exactly config, moduleId, params, principal, services — and nothing else. `config` is this + // module's own frozen configuration snapshot, added deliberately by ADR 0029; a Hono Context, + // the authorization store or evaluator, raw PostgreSQL, platform configuration, and another + // module's configuration all remain unreachable, which is what this exact-shape assertion + // exists to keep true. expect(Object.keys(capturedContext ?? {}).sort()).toEqual([ + "config", "moduleId", "params", "principal", @@ -356,6 +362,10 @@ describe("module route handler adaptation", () => { expect(Object.isFrozen(capturedContext)).toBe(true); expect(Object.isFrozen(capturedContext?.params)).toBe(true); expect(Object.isFrozen(capturedContext?.services)).toBe(true); + expect(Object.isFrozen(capturedContext?.config)).toBe(true); + // This module declared no configuration, so its snapshot is empty rather than absent — module + // code never has to branch on whether the platform supplied one. + expect(capturedContext?.config.keys()).toEqual([]); }); it("gives static and root routes a frozen, empty params object", async () => { diff --git a/apps/api/tests/module-lifecycle-routes.test.ts b/apps/api/tests/module-lifecycle-routes.test.ts index 7d138bc..7899e85 100644 --- a/apps/api/tests/module-lifecycle-routes.test.ts +++ b/apps/api/tests/module-lifecycle-routes.test.ts @@ -33,6 +33,7 @@ import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js"; +import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; @@ -74,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id: fixture.id, - manifestVersion: 4, + manifestVersion: 5, name: `${fixture.id} module`, version: "1.0.0", }), @@ -168,6 +169,7 @@ async function install(options: InstallOptions = {}): Promise { store: authorizationStore, }, dashboard: createInMemoryDashboardStore(), + moduleConfiguration: moduleConfigurationOptionFor(modules), moduleLifecycle: { snapshot: { discoveredModules: modules, diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index caec7f5..c95668e 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -34,6 +34,7 @@ import { createInMemoryModuleLifecycleStore, moduleLifecycleSnapshotFor, } from "./support/in-memory-module-lifecycle.js"; +import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; /** * The whole chain, with nothing between the halves faked: @@ -102,7 +103,7 @@ async function createFixtureModule( compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id, - manifestVersion: 4, + manifestVersion: 5, name, permissions: [ { @@ -221,6 +222,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { snapshot: moduleLifecycleSnapshotFor(modules), store: createInMemoryModuleLifecycleStore(), }, + moduleConfiguration: moduleConfigurationOptionFor(modules), dashboard: createInMemoryDashboardStore(), runtime, trustedOrigins: ["https://dashboard.example.test"], diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index 92ca157..34660d9 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -32,6 +32,7 @@ import { createInMemoryModuleLifecycleStore, moduleLifecycleSnapshotFor, } from "./support/in-memory-module-lifecycle.js"; +import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; const trustedOrigin = "https://dashboard.example.test"; const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111"); @@ -79,7 +80,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}), }, id: fixture.id, - manifestVersion: 4, + manifestVersion: 5, name: `${fixture.id} module`, permissions: (fixture.permissions ?? []).map((permission) => ({ ...permission, @@ -177,6 +178,7 @@ async function install(options: InstallOptions): Promise { snapshot: moduleLifecycleSnapshotFor(modules), store: createInMemoryModuleLifecycleStore(), }, + moduleConfiguration: moduleConfigurationOptionFor(modules), dashboard: createInMemoryDashboardStore(), runtime, trustedOrigins: [trustedOrigin], diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index de486a3..242ebda 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -32,6 +32,7 @@ import { createInMemoryModuleLifecycleStore, moduleLifecycleSnapshotFor, } from "./support/in-memory-module-lifecycle.js"; +import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; const trustedOrigin = "https://dashboard.example.test"; @@ -103,7 +104,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }), }, id: fixture.id, - manifestVersion: 4, + manifestVersion: 5, name: `${fixture.id} module`, version, }), @@ -177,6 +178,7 @@ async function install(fixtures: readonly ModuleFixture[]): Promise 0), + CONSTRAINT "ekklesia_core_module_configuration_key_check" CHECK (length("ekklesia_core_module_configuration"."key") > 0), + CONSTRAINT "ekklesia_core_module_configuration_value_check" CHECK (length("ekklesia_core_module_configuration"."value") <= 2048) +); +--> statement-breakpoint +CREATE INDEX "ekklesia_core_module_configuration_module_idx" ON "ekklesia_core_module_configuration" USING btree ("module_id"); \ No newline at end of file diff --git a/core/drizzle/meta/0005_snapshot.json b/core/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..1a7cfc4 --- /dev/null +++ b/core/drizzle/meta/0005_snapshot.json @@ -0,0 +1,993 @@ +{ + "id": "d41e490c-dbe1-4d73-9e11-6e79baa91908", + "prevId": "b153e7a4-3100-4610-b296-a1a0b3cbfff6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ekklesia_core_account": { + "name": "ekklesia_core_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_account_user_id_idx": { + "name": "ekklesia_core_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_account_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_account", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_session": { + "name": "ekklesia_core_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_session_user_id_idx": { + "name": "ekklesia_core_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_session_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_session", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_session_token_unique": { + "name": "ekklesia_core_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_user": { + "name": "ekklesia_core_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_user_email_unique": { + "name": "ekklesia_core_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_verification": { + "name": "ekklesia_core_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_verification_identifier_idx": { + "name": "ekklesia_core_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_authorization_audit": { + "name": "ekklesia_core_authorization_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_authorization_audit_subject_idx": { + "name": "ekklesia_core_authorization_audit_subject_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_authorization_audit_action_check": { + "name": "ekklesia_core_authorization_audit_action_check", + "value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')" + }, + "ekklesia_core_authorization_audit_actor_type_check": { + "name": "ekklesia_core_authorization_audit_actor_type_check", + "value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')" + }, + "ekklesia_core_authorization_audit_scope_check": { + "name": "ekklesia_core_authorization_audit_scope_check", + "value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_grant": { + "name": "ekklesia_core_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_grant_user_permission_idx": { + "name": "ekklesia_core_grant_user_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_grant", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_grant_source_kind_check": { + "name": "ekklesia_core_grant_source_kind_check", + "value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')" + }, + "ekklesia_core_grant_created_actor_type_check": { + "name": "ekklesia_core_grant_created_actor_type_check", + "value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_grant_revoked_actor_type_check": { + "name": "ekklesia_core_grant_revoked_actor_type_check", + "value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner": { + "name": "ekklesia_core_platform_owner", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_actor_type": { + "name": "granted_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_actor_id": { + "name": "granted_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_platform_owner", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_granted_actor_type_check": { + "name": "ekklesia_core_platform_owner_granted_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_platform_owner_revoked_actor_type_check": { + "name": "ekklesia_core_platform_owner_revoked_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner_bootstrap": { + "name": "ekklesia_core_platform_owner_bootstrap", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_for_user_id": { + "name": "consumed_for_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_bootstrap_singleton_check": { + "name": "ekklesia_core_platform_owner_bootstrap_singleton_check", + "value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_group": { + "name": "ekklesia_core_dashboard_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_group_order_idx": { + "name": "ekklesia_core_dashboard_group_order_idx", + "columns": [ + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_group_id_check": { + "name": "ekklesia_core_dashboard_group_id_check", + "value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0" + }, + "ekklesia_core_dashboard_group_label_check": { + "name": "ekklesia_core_dashboard_group_label_check", + "value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_placement": { + "name": "ekklesia_core_dashboard_placement", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_placement_group_idx": { + "name": "ekklesia_core_dashboard_placement_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": { + "name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk", + "tableFrom": "ekklesia_core_dashboard_placement", + "tableTo": "ekklesia_core_dashboard_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_placement_module_check": { + "name": "ekklesia_core_dashboard_placement_module_check", + "value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_state": { + "name": "ekklesia_core_module_state", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_state_module_check": { + "name": "ekklesia_core_module_state_module_check", + "value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_configuration": { + "name": "ekklesia_core_module_configuration", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_module_configuration_module_idx": { + "name": "ekklesia_core_module_configuration_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ekklesia_core_module_configuration_module_id_key_pk": { + "name": "ekklesia_core_module_configuration_module_id_key_pk", + "columns": ["module_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_configuration_module_check": { + "name": "ekklesia_core_module_configuration_module_check", + "value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0" + }, + "ekklesia_core_module_configuration_key_check": { + "name": "ekklesia_core_module_configuration_key_check", + "value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0" + }, + "ekklesia_core_module_configuration_value_check": { + "name": "ekklesia_core_module_configuration_value_check", + "value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index f5612b5..8eb093e 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -31,6 +31,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0005_module_configuration", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index 20a2465..5a273da 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1787423121678, "tag": "0004_module_lifecycle_state", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1787451496425, + "tag": "0005_module_configuration", + "breakpoints": true } ] } diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts index 1fe0fe2..3cb081b 100644 --- a/core/src/authorization/permissions.ts +++ b/core/src/authorization/permissions.ts @@ -129,6 +129,29 @@ export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId( "core.modules.lifecycle.manage", ); +/** + * Restricted, and deliberately **separate from `core.modules.lifecycle.manage`**. The two authorize + * genuinely different actions: lifecycle decides *whether a module runs at all*, while this decides + * *how a running module behaves*. Someone trusted to tune a slideshow's timing need not be trusted + * to take modules offline, and the reverse is equally true, so folding them into one permission + * would grant authority nobody asked for in whichever direction it was used (ADR 0029). + * + * It gates reading as well as writing, because the read returns the module's declared schema + * together with every stored value — endpoints, bucket names, and whatever else an operator has + * configured. That is deployment detail, not something every signed-in user needs. + * + * **It never exposes secret material.** A `secret` key's value is deployment-supplied, is never + * persisted, and is never returned by any route; the API reports only whether one is configured. + * Holding this permission therefore confers no ability to read a credential — there is no code path + * that would serve one. + * + * Like every other Core permission it is a **platform** permission a module can neither declare, + * gate, nor observe, and holding it confers no authority inside any module. + */ +export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId( + "core.modules.configuration.manage", +); + /** * Core's own declarations, exported in deeply immutable form so a trusted composer (the module * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from @@ -157,6 +180,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob description: "Read and change which installed modules are enabled.", id: CORE_MODULES_LIFECYCLE_MANAGE, }), + Object.freeze({ + baseline: "restricted" as const, + description: "Read and change an installed module's own configuration values.", + id: CORE_MODULES_CONFIGURATION_MANAGE, + }), ]); /** diff --git a/core/src/cli/set-module-enabled.ts b/core/src/cli/set-module-enabled.ts index b03861d..6bfeeab 100644 --- a/core/src/cli/set-module-enabled.ts +++ b/core/src/cli/set-module-enabled.ts @@ -127,11 +127,15 @@ async function main(): Promise { console.log( `Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`, ); - const blocked = resolution.entries.filter((entry) => entry.blocked !== undefined); - for (const entry of blocked) { - console.log( - `- "${entry.moduleId}" will not start: ${entry.blocked?.reason} (${entry.blocked?.dependency.moduleId}).`, - ); + for (const entry of resolution.entries) { + const blocked = entry.blocked; + if (!blocked) { + continue; + } + // Names the dependency or the configuration key, never a configuration value. + const cause = + "dependency" in blocked ? blocked.dependency.moduleId : blocked.configurationKey; + console.log(`- "${entry.moduleId}" will not start: ${blocked.reason} (${cause}).`); } } finally { await close().catch(() => undefined); diff --git a/core/src/diagnostics/doctor-report.ts b/core/src/diagnostics/doctor-report.ts index e634769..199df52 100644 --- a/core/src/diagnostics/doctor-report.ts +++ b/core/src/diagnostics/doctor-report.ts @@ -16,6 +16,7 @@ const sections: readonly DoctorSection[] = Object.freeze([ "modules.dependencies", "modules.permissions", "modules.lifecycle", + "modules.configuration", ]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]), section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]), diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index eef2766..47b13ac 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -1,8 +1,11 @@ import path from "node:path"; import { capabilityIdSchema, + EMPTY_MODULE_SECRET_SOURCE, isVersionCompatible, + moduleConfigurationKeySchema, moduleIdSchema, + type ModuleSecretSource, permissionIdSchema, semanticVersionSchema, } from "@ekklesia/contracts"; @@ -41,6 +44,8 @@ import { } from "../migrations/index.js"; import { composePlatformPermissionRegistry, + createModuleConfigurationDatabase, + createModuleConfigurationStore, createModuleLifecycleDatabase, createModuleLifecycleStore, type DiscoveredModule, @@ -49,6 +54,7 @@ import { type ModuleEligibility, ModulePermissionCompositionError, type ModuleDiscoveryOptions, + type ModuleConfigurationSettings, type ModuleLifecycleConfiguration, type ModuleMigrationAggregate, type ModuleSchemaAggregate, @@ -62,6 +68,7 @@ export type DoctorCheckId = | "modules.dependencies" | "modules.permissions" | "modules.lifecycle" + | "modules.configuration" | "schemas.discovery" | "migrations.discovery" | "postgresql.connection" @@ -102,6 +109,10 @@ export interface DoctorOptions { readonly databaseUrl?: string | undefined; readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined; readonly moduleDiscovery: ModuleDiscoveryOptions; + /** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret + * matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required + * secret as missing, which is the honest answer for a deployment that has not set it. */ + readonly moduleSecrets?: ModuleSecretSource; readonly runtimeRange: string; readonly runtimeVersion: string; } @@ -128,6 +139,12 @@ export interface DoctorDependencies { connectionString: string, options: PostgresPoolOptions, ) => Promise; + /** Reads the stored module configuration read-only, behind the same dependency boundary as every + * other I/O Doctor performs. */ + readonly readModuleConfiguration: ( + connectionString: string, + options: PostgresPoolOptions, + ) => Promise; readonly planMigrations: (options: { readonly aggregate: ModuleMigrationAggregate; readonly database: MigrationDatabase; @@ -163,6 +180,20 @@ async function readModuleLifecycleForRuntime( } } +/** Opens its own short-lived connection, reads, and always closes it — the same discipline + * `readModuleLifecycleForRuntime` follows. */ +async function readModuleConfigurationForRuntime( + connectionString: string, + options: PostgresPoolOptions, +): Promise { + const { close, db } = createModuleConfigurationDatabase(connectionString, options); + try { + return await createModuleConfigurationStore(db).readSettings(); + } finally { + await close().catch(() => undefined); + } +} + const productionDependencies: DoctorDependencies = Object.freeze({ composePermissions: composePlatformPermissionRegistry, createMigrationDatabase: createPostgresMigrationDatabase, @@ -170,6 +201,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({ discoverModules, discoverSchemas: discoverModuleSchemas, planMigrations: planModuleMigrations, + readModuleConfiguration: readModuleConfigurationForRuntime, readModuleLifecycle: readModuleLifecycleForRuntime, runtimeSatisfies: isVersionCompatible, }); @@ -301,6 +333,16 @@ function safeLifecycleDetail(entry: ModuleEligibility): string { if (!entry.blocked) { return `${identity} · enabled`; } + if (!("dependency" in entry.blocked)) { + // A configuration block names the offending **key** and never its value: a key is declared in a + // public manifest, while a value may be a credential or arbitrary operator-entered text. The key + // is re-validated against the same grammar the manifest accepted it under, for the same + // defense-in-depth reason every other interpolation here is. + if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) { + throw new TypeError("Unsafe module lifecycle diagnostic value."); + } + return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`; + } const dependency = entry.blocked.dependency; if (!moduleIdSchema.safeParse(dependency.moduleId).success) { throw new TypeError("Unsafe module lifecycle diagnostic value."); @@ -343,6 +385,79 @@ function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorChec return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details); } +/** + * Reports whether each installed module's configuration would actually let it run — and + * deliberately reports **nothing about the values themselves**. + * + * Not one configuration value is printed, secret or otherwise. A value is operator-entered text that + * may be an endpoint, a bucket name, or a credential that ended up in the wrong field; a *key* is + * declared in a public manifest and is safe. So a healthy module renders as a count, and an unhealthy + * one names the reason and the offending key. That is everything an operator needs to act, and + * nothing that turns `pnpm doctor` output — which people paste into chat — into a disclosure channel. + */ +function safeConfigurationDetail(entry: ModuleEligibility, declaredCount: number): string { + if ( + !moduleIdSchema.safeParse(entry.moduleId).success || + !semanticVersionSchema.safeParse(entry.version).success + ) { + throw new TypeError("Unsafe module configuration diagnostic value."); + } + const identity = `${entry.moduleId} ${entry.version}`; + if (declaredCount === 0) { + return `${identity} · no configuration declared`; + } + const declared = `${declaredCount} ${plural(declaredCount, "key")} declared`; + if (entry.blocked && !("dependency" in entry.blocked)) { + if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) { + throw new TypeError("Unsafe module configuration diagnostic value."); + } + return `${identity} · ${declared} · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`; + } + if (!entry.configured.enabled) { + // Not evaluated: a disabled module's configuration is not resolved at all, and claiming it is + // valid would be asserting something nothing checked. + return `${identity} · ${declared} · not evaluated (disabled)`; + } + return `${identity} · ${declared} · valid`; +} + +/** + * The configuration counterpart to `modules.lifecycle`: which installed modules could not run + * because of their own configuration, and which key is at fault. + * + * A module whose configuration is fine, or which declares none, is a `pass` line. A module blocked + * by configuration is a `warning`, for the same reason a dependency-blocked module is: an enabled + * module that cannot run is an anomaly an operator should see without reading logs. + */ +function moduleConfigurationCheck( + entries: readonly ModuleEligibility[], + declaredCounts: ReadonlyMap, +): DoctorCheck { + const blockedByConfiguration = entries.filter( + (entry) => entry.blocked !== undefined && !("dependency" in entry.blocked), + ).length; + const declaringModules = entries.filter( + (entry) => (declaredCounts.get(entry.moduleId) ?? 0) > 0, + ).length; + const details = [ + `${declaringModules} ${plural(declaringModules, "module")} declaring configuration · ${blockedByConfiguration} blocked`, + ...entries.map((entry) => + safeConfigurationDetail(entry, declaredCounts.get(entry.moduleId) ?? 0), + ), + ]; + if (blockedByConfiguration > 0) { + return freezeCheck( + "modules.configuration", + "warning", + blockedByConfiguration === 1 + ? "1 module cannot run with its current configuration" + : `${blockedByConfiguration} modules cannot run with their current configuration`, + details, + ); + } + return freezeCheck("modules.configuration", "pass", "Module configuration valid", details); +} + function moduleDiscoveryFailure(error: unknown): DoctorCheck { const details = error instanceof Error && error.name === "ModuleDiscoveryError" @@ -672,31 +787,61 @@ export async function diagnoseEkklesia( // operator asking "is this module running?" must still get an answer when an unrelated check // failed. It needs only a successful discovery and a configured database. let lifecycleCheck: DoctorCheck; + let configurationCheck: DoctorCheck; + const skippedConfiguration = (detail: string): DoctorCheck => + freezeCheck("modules.configuration", "skipped", "Module configuration skipped", [detail]); if (!discoveredModules) { lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [ "Complete module discovery is required.", ]); + configurationCheck = skippedConfiguration("Complete module discovery is required."); } else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") { lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [ "DATABASE_URL is not configured.", ]); + configurationCheck = skippedConfiguration("DATABASE_URL is not configured."); } else { try { const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, { environment: options.databaseEnvironment ?? "production", }); - const configuration = await dependencies.readModuleLifecycle(options.databaseUrl, { + const poolOptions = { connectionTimeoutMs: doctorPostgresTimeoutMs, operationTimeoutMs: doctorPostgresTimeoutMs, transport: lifecycleTransport, + }; + // Both axes read and resolved in the one pass the platform itself uses, so Doctor can never + // report a module as runnable that startup would block, or the reverse. + // Sequential for the same reason `startApi` reads them sequentially: a deterministic failure + // beats two tiny queries racing. + const configuration = await dependencies.readModuleLifecycle( + options.databaseUrl, + poolOptions, + ); + const settings = await dependencies.readModuleConfiguration(options.databaseUrl, poolOptions); + const resolution = resolveModuleEligibility(discoveredModules, configuration, { + secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, + settings, }); - lifecycleCheck = moduleLifecycleCheck( - resolveModuleEligibility(discoveredModules, configuration).entries, + lifecycleCheck = moduleLifecycleCheck(resolution.entries); + configurationCheck = moduleConfigurationCheck( + resolution.entries, + new Map( + discoveredModules.map( + (module) => [module.manifest.id, module.manifest.configuration.length] as const, + ), + ), ); } catch { lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [ "The stored module enablement configuration could not be read safely.", ]); + configurationCheck = freezeCheck( + "modules.configuration", + "failure", + "Module configuration unavailable", + ["The stored module configuration could not be read safely."], + ); } } @@ -795,6 +940,7 @@ export async function diagnoseEkklesia( dependencyCheck, permissionsCheck, lifecycleCheck, + configurationCheck, schemaCheck, migrationCheck, transportCheck, diff --git a/core/src/modules/configuration-schema.ts b/core/src/modules/configuration-schema.ts new file mode 100644 index 0000000..ba7062a --- /dev/null +++ b/core/src/modules/configuration-schema.ts @@ -0,0 +1,67 @@ +import { sql } from "drizzle-orm"; +import { check, index, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"; + +/** + * Core-owned storage for the ordinary, non-secret configuration an operator has set for an + * installed module. + * + * **Secret material is never stored here, and there is nowhere to put it.** Ekklesia has no + * encryption at rest and no key management, so a `secret` key's value is supplied by the deployment + * environment and resolved into a module's snapshot without ever touching PostgreSQL (ADR 0029). + * The store refuses to write a key a module declared as `secret`; this table has no column, flag, or + * convention that would make a plaintext credential look acceptable. + * + * **One row per (module, key), not one JSON document per module.** A `jsonb` blob would put every + * invariant in application code, make a single key impossible to address, and invite exactly the + * arbitrary-settings-bag this pass exists to avoid — the same reasoning ADR 0027 used to reject a + * generic settings framework in favour of purpose-built tables. + * + * Platform-global, like the Dashboard and lifecycle tables, because Ekklesia has no organization or + * tenant model to scope it to. When one exists, this table gains a scope column. + */ + +/** + * One stored configuration value. + * + * `key` is **data, never an SQL identifier**. It is a value in a parameterized column, so a module + * declaring an outlandish key can produce a strange row and nothing else — no column, no table, and + * no dynamic SQL anywhere in this domain. + * + * `value` is `text` holding the unparsed literal. Every declared type has one canonical text form, + * and keeping storage untyped is what makes "a persisted value is never silently trusted" a + * structural property: nothing can read this as a number until `resolveModuleConfigurationFor` has + * validated it against the module's own current declaration. + * + * `module_id` carries **no foreign key**, for the same reason the lifecycle and Dashboard tables do + * not: modules are packages discovered on disk, not rows. A value for a module that is not currently + * deployed is therefore normal and is retained, so redeploying restores an operator's settings + * rather than resetting them. + */ +export const ekklesiaCoreModuleConfiguration = pgTable( + "ekklesia_core_module_configuration", + { + moduleId: text("module_id").notNull(), + key: text("key").notNull(), + value: text("value").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + primaryKey({ columns: [table.moduleId, table.key] }), + index("ekklesia_core_module_configuration_module_idx").on(table.moduleId), + // Defense in depth beneath the application-layer identifier contracts, which never produce + // either: an empty module id or key would be an unaddressable, permanently inert row. + check("ekklesia_core_module_configuration_module_check", sql`length(${table.moduleId}) > 0`), + check("ekklesia_core_module_configuration_key_check", sql`length(${table.key}) > 0`), + // The same 2048-character ceiling the contract and the store enforce, restated where the data + // actually lives, so no caller — including a future one — can store a value the resolver would + // then reject as invalid on every boot. + check("ekklesia_core_module_configuration_value_check", sql`length(${table.value}) <= 2048`), + ], +); + +/** Explicit map passed to Drizzle, mirroring `authorizationSchema`, `dashboardSchema`, and + * `moduleLifecycleSchema`. */ +export const moduleConfigurationSchema = { + moduleConfiguration: ekklesiaCoreModuleConfiguration, +} as const; diff --git a/core/src/modules/configuration-store.ts b/core/src/modules/configuration-store.ts new file mode 100644 index 0000000..3b6475a --- /dev/null +++ b/core/src/modules/configuration-store.ts @@ -0,0 +1,221 @@ +import { + MODULE_CONFIGURATION_VALUE_MAX_LENGTH, + moduleConfigurationKeySchema, + moduleIdSchema, +} from "@ekklesia/contracts"; +import { asc, eq } from "drizzle-orm"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { createValidatedPostgresPool, type PostgresPoolOptions } from "../database/index.js"; +import type { ModuleConfigurationRecord, ModuleConfigurationSettings } from "./configuration.js"; +import { + ekklesiaCoreModuleConfiguration, + moduleConfigurationSchema, +} from "./configuration-schema.js"; + +export type ModuleConfigurationDrizzleDatabase = NodePgDatabase; + +/** A typed Drizzle view over an externally owned pool, exactly like `createModuleLifecycleView` and + * `createDashboardDrizzleView`: it never constructs, registers an error listener on, or closes a + * pool. */ +export function createModuleConfigurationView(pool: Pool): ModuleConfigurationDrizzleDatabase { + return drizzle(pool, { schema: moduleConfigurationSchema }); +} + +export interface ModuleConfigurationDatabaseHandle { + readonly close: () => Promise; + readonly db: ModuleConfigurationDrizzleDatabase; +} + +/** A self-contained handle for a caller that needs only this domain for the length of one command — + * Doctor's read-only configuration check. Composes the shared validated pool factory rather than + * repeating connection-string and timeout validation. `apps/api` shares one pool and uses + * `createModuleConfigurationView` instead. */ +export function createModuleConfigurationDatabase( + connectionString: string, + options: PostgresPoolOptions = {}, +): ModuleConfigurationDatabaseHandle { + const { close, pool } = createValidatedPostgresPool(connectionString, options); + return Object.freeze({ close, db: createModuleConfigurationView(pool) }); +} + +export type ModuleConfigurationStoreErrorCode = + | "MODULE_CONFIGURATION_KEY_INVALID" + | "MODULE_CONFIGURATION_MODULE_ID_INVALID" + | "MODULE_CONFIGURATION_READ_FAILED" + | "MODULE_CONFIGURATION_VALUE_INVALID" + | "MODULE_CONFIGURATION_WRITE_FAILED"; + +const storeErrorMessages: Readonly> = + Object.freeze({ + MODULE_CONFIGURATION_KEY_INVALID: "A module configuration key is invalid.", + MODULE_CONFIGURATION_MODULE_ID_INVALID: "A module identifier is invalid.", + MODULE_CONFIGURATION_READ_FAILED: "The module configuration could not be read.", + MODULE_CONFIGURATION_VALUE_INVALID: "A module configuration value is invalid.", + MODULE_CONFIGURATION_WRITE_FAILED: "The module configuration could not be saved.", + }); + +/** + * Never carries the underlying database error, SQL text, host, connection string, stack — **or the + * offending value**. + * + * That last exclusion is specific to this domain and deliberate: a configuration value can be + * operator-entered text of any kind, and an error message is one of the easiest ways for a value to + * end up in a log. The key is safe to name (it is declared in a public manifest); the value never + * is. + */ +export class ModuleConfigurationStoreError extends Error { + readonly code: ModuleConfigurationStoreErrorCode; + + constructor(code: ModuleConfigurationStoreErrorCode) { + super(storeErrorMessages[code]); + this.name = "ModuleConfigurationStoreError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +export interface ReplaceModuleConfigurationInput { + readonly moduleId: string; + /** The complete set of ordinary values for this module. Replaces, rather than merges: a key + * absent here is deleted, so an operator clearing a value does not have to discover a separate + * delete verb, and the stored document always matches what was last submitted. */ + readonly values: readonly { readonly key: string; readonly value: string }[]; +} + +export interface ModuleConfigurationStore { + /** Every stored value, in deterministic (module, key) order. Small by nature — a handful of rows + * per configured module — so it is read whole rather than paginated. */ + readSettings(): Promise; + /** + * Replaces one module's stored values atomically. + * + * Scoped to a single module id by signature: there is no call shape that writes across modules, + * and no verb that takes a raw table, column, or predicate. Writing configuration never starts, + * stops, or reconfigures anything in the running process — the change is applied by the next + * restart. + */ + replace(input: ReplaceModuleConfigurationInput): Promise; +} + +function assertValidModuleId(value: string): void { + if (!moduleIdSchema.safeParse(value).success) { + throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_MODULE_ID_INVALID"); + } +} + +function assertValidKey(value: string): void { + if (!moduleConfigurationKeySchema.safeParse(value).success) { + throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID"); + } +} + +function assertValidValue(value: string): void { + if (typeof value !== "string" || value.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) { + throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_VALUE_INVALID"); + } +} + +function rethrowAsStoreError(error: unknown, code: ModuleConfigurationStoreErrorCode): never { + if (error instanceof ModuleConfigurationStoreError) { + throw error; + } + throw new ModuleConfigurationStoreError(code); +} + +/** + * Core's persistence for ordinary module configuration. + * + * Validation lives here rather than only at the HTTP boundary, so the invariants hold for every + * caller: identifiers are checked against the platform's one module-id grammar and one + * configuration-key grammar rather than second copies invented here, and values are bounded exactly + * as the contract and the database bound them. + * + * Nothing in this file reads a manifest, resolves a declaration, applies a default, or decides + * whether a value is *meaningful* — it stores text under a validated key. + * `resolveModuleConfigurationFor` is the one place a value is interpreted, which is what keeps the + * store from acquiring a second, drifting copy of the type rules. It equally never sees a secret: + * the HTTP boundary rejects a `secret` key before a write reaches here, and secret material has no + * storage path at all. + */ +export function createModuleConfigurationStore( + db: ModuleConfigurationDrizzleDatabase, +): ModuleConfigurationStore { + return Object.freeze({ + async readSettings(): Promise { + try { + const rows = await db + .select({ + key: ekklesiaCoreModuleConfiguration.key, + moduleId: ekklesiaCoreModuleConfiguration.moduleId, + value: ekklesiaCoreModuleConfiguration.value, + }) + .from(ekklesiaCoreModuleConfiguration) + .orderBy( + asc(ekklesiaCoreModuleConfiguration.moduleId), + asc(ekklesiaCoreModuleConfiguration.key), + ); + return Object.freeze({ + records: Object.freeze(rows.map((row) => Object.freeze({ ...row }))), + }); + } catch (error) { + rethrowAsStoreError(error, "MODULE_CONFIGURATION_READ_FAILED"); + } + }, + + async replace( + input: ReplaceModuleConfigurationInput, + ): Promise { + assertValidModuleId(input.moduleId); + const seen = new Set(); + for (const entry of input.values) { + assertValidKey(entry.key); + assertValidValue(entry.value); + if (seen.has(entry.key)) { + throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID"); + } + seen.add(entry.key); + } + try { + // One transaction, so a replace is never observed half-applied: a concurrent boot either + // sees the previous complete document or the new one, never a mixture that would validate + // differently from both. + return await db.transaction(async (tx) => { + // Delete this module's rows, then insert the submitted ones. Expressed as "delete all, + // insert submitted" rather than a key-wise diff: the whole document is being replaced, so + // the outcome is identical and there is one predicate to audit instead of two. The + // predicate is scoped to `input.moduleId`, so another module's rows are unreachable from + // here by construction, not by care. + await tx + .delete(ekklesiaCoreModuleConfiguration) + .where(eq(ekklesiaCoreModuleConfiguration.moduleId, input.moduleId)); + if (input.values.length === 0) { + return Object.freeze([]); + } + const inserted = await tx + .insert(ekklesiaCoreModuleConfiguration) + .values( + input.values.map((entry) => ({ + key: entry.key, + moduleId: input.moduleId, + value: entry.value, + })), + ) + .returning({ + key: ekklesiaCoreModuleConfiguration.key, + moduleId: ekklesiaCoreModuleConfiguration.moduleId, + value: ekklesiaCoreModuleConfiguration.value, + }); + return Object.freeze( + [...inserted] + .sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) + .map((row) => Object.freeze({ ...row })), + ); + }); + } catch (error) { + rethrowAsStoreError(error, "MODULE_CONFIGURATION_WRITE_FAILED"); + } + }, + }); +} diff --git a/core/src/modules/configuration.ts b/core/src/modules/configuration.ts new file mode 100644 index 0000000..8c5ef11 --- /dev/null +++ b/core/src/modules/configuration.ts @@ -0,0 +1,316 @@ +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)], + }); +} diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index d643553..8b233c0 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -1,8 +1,11 @@ +export * from "./configuration-schema.js"; +export * from "./configuration-store.js"; +export * from "./configuration.js"; export * from "./database.js"; export * from "./discovery.js"; -export * from "./lifecycle.js"; export * from "./lifecycle-schema.js"; export * from "./lifecycle-store.js"; +export * from "./lifecycle.js"; export * from "./migration-discovery.js"; export * from "./permissions.js"; export * from "./runtime.js"; diff --git a/core/src/modules/lifecycle.ts b/core/src/modules/lifecycle.ts index 739dbe7..3b532b0 100644 --- a/core/src/modules/lifecycle.ts +++ b/core/src/modules/lifecycle.ts @@ -1,9 +1,12 @@ import type { ModuleBlockedReason, ModuleBlockingDependency, + ModuleConfiguration, ModuleEnablementRecord, ModuleRuntimeState, } from "@ekklesia/contracts"; +import type { ModuleConfigurationInput } from "./configuration.js"; +import { resolveModuleConfigurationFor } from "./configuration.js"; import type { DiscoveredModule } from "./discovery.js"; /** @@ -35,14 +38,32 @@ export interface ModuleEligibility { readonly version: string; readonly configured: ModuleConfiguredState; readonly eligible: boolean; - /** Present exactly when the module is configured enabled but not eligible. A disabled module is - * not "blocked" — it is doing what it was told. */ - readonly blocked?: { - readonly reason: ModuleBlockedReason; - readonly dependency: ModuleBlockingDependency; - }; + /** + * Present exactly when the module is configured enabled but not eligible. A disabled module is + * not "blocked" — it is doing what it was told. + * + * A discriminated pair rather than one shape with optional halves: a dependency reason always + * names a dependency and a configuration reason always names a key, so neither can be + * accidentally omitted and no consumer has to handle a `blocked` record that explains nothing. + */ + readonly blocked?: ModuleBlockedDetail; } +/** A block caused by the dependency graph: some module this one *requires* cannot run. */ +export interface ModuleDependencyBlockedDetail { + readonly reason: "required_dependency_disabled" | "required_dependency_blocked"; + readonly dependency: ModuleBlockingDependency; +} + +/** A block caused by this module's own configuration (ADR 0029), naming the offending key — never + * the value, which for a secret is a credential and for anything else is operator-entered text. */ +export interface ModuleConfigurationBlockedDetail { + readonly reason: "configuration_missing" | "configuration_invalid" | "secret_missing"; + readonly configurationKey: string; +} + +export type ModuleBlockedDetail = ModuleDependencyBlockedDetail | ModuleConfigurationBlockedDetail; + export interface ModuleEligibilityResolution { /** * The modules the platform may start, **in the exact discovery order they were given**. Filtered, @@ -54,6 +75,15 @@ export interface ModuleEligibilityResolution { /** Every discovered module, in discovery order, eligible or not. This is the explanation surface; * `eligibleModules` is the executable one. */ readonly entries: readonly ModuleEligibility[]; + /** + * The frozen configuration snapshot for each **eligible** module, keyed by module id (ADR 0029). + * + * Produced by the same pass that decided eligibility, so a module's configuration is validated + * exactly once and the value that made it eligible is the identical value it later receives — + * never revalidated, and never re-read from the database, between those two moments. An + * ineligible module has no entry: there is nothing to hand a module that is not going to start. + */ + readonly configurations: ReadonlyMap; } interface MutableEligibility { @@ -61,10 +91,7 @@ interface MutableEligibility { readonly version: string; readonly configured: ModuleConfiguredState; eligible: boolean; - blocked?: { - readonly reason: ModuleBlockedReason; - readonly dependency: ModuleBlockingDependency; - }; + blocked?: ModuleBlockedDetail; } function frozenEligibility(entry: MutableEligibility): ModuleEligibility { @@ -75,10 +102,16 @@ function frozenEligibility(entry: MutableEligibility): ModuleEligibility { version: entry.version, ...(entry.blocked ? { - blocked: Object.freeze({ - dependency: Object.freeze({ ...entry.blocked.dependency }), - reason: entry.blocked.reason, - }), + blocked: + "dependency" in entry.blocked + ? Object.freeze({ + dependency: Object.freeze({ ...entry.blocked.dependency }), + reason: entry.blocked.reason, + }) + : Object.freeze({ + configurationKey: entry.blocked.configurationKey, + reason: entry.blocked.reason, + }), } : {}), }); @@ -128,6 +161,7 @@ function frozenEligibility(entry: MutableEligibility): ModuleEligibility { export function resolveModuleEligibility( discoveredModules: readonly DiscoveredModule[], configuration: ModuleLifecycleConfiguration, + moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } }, ): ModuleEligibilityResolution { const configuredByModuleId = new Map( configuration.modules.map((record) => [record.moduleId, record.enabled] as const), @@ -146,6 +180,31 @@ export function resolveModuleEligibility( }); } + // Validate each enabled module's own configuration before the dependency pass, so a module + // blocked by its own configuration propagates to its required dependents through exactly the + // existing mechanism rather than a second one. Runs only for modules that are still eligible: a + // disabled module's configuration is not evaluated at all, because it is not going to start and + // reporting a configuration problem for something nobody asked to run would be noise. + const configurations = new Map(); + for (const discovered of discoveredModules) { + const entry = entries.get(discovered.manifest.id); + if (!entry?.eligible) { + continue; + } + const resolved = resolveModuleConfigurationFor(discovered.manifest, moduleConfiguration); + if (resolved.issue) { + entry.eligible = false; + entry.blocked = { + configurationKey: resolved.issue.key, + reason: resolved.issue.reason, + }; + continue; + } + if (resolved.configuration) { + configurations.set(discovered.manifest.id, resolved.configuration); + } + } + // The provider of each capability, taken from the same manifests discovery already validated. // A capability has exactly one provider in a valid inventory (discovery rejects a duplicate // module id, and a manifest may only declare capabilities inside its own namespace). @@ -214,7 +273,17 @@ export function resolveModuleEligibility( resolvedEntries.filter((entry) => entry.eligible).map((entry) => entry.moduleId), ); + // A module whose own configuration resolved may still have lost eligibility in the dependency + // pass above. Dropping its snapshot here keeps one invariant true: this map contains exactly the + // modules that are going to start. + for (const moduleId of [...configurations.keys()]) { + if (!eligibleIds.has(moduleId)) { + configurations.delete(moduleId); + } + } + return Object.freeze({ + configurations: configurations as ReadonlyMap, eligibleModules: Object.freeze( discoveredModules.filter((discovered) => eligibleIds.has(discovered.manifest.id)), ), @@ -233,7 +302,10 @@ export interface ModuleLifecycleView { readonly runtime: { readonly state: ModuleRuntimeState; readonly reason?: ModuleBlockedReason; + /** Present for a dependency reason. */ readonly dependency?: ModuleBlockingDependency; + /** Present for a configuration reason (ADR 0029) — the offending key, never its value. */ + readonly configurationKey?: string; }; /** * `true` when applying the *current* configuration would produce a different running set than @@ -274,12 +346,22 @@ export interface ModuleLifecycleSnapshot { export function resolveModuleLifecycleView( snapshot: ModuleLifecycleSnapshot, configuration: ModuleLifecycleConfiguration, + moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } }, ): readonly ModuleLifecycleView[] { const started = new Set(snapshot.startedModuleIds); const bootEligibility = new Map( snapshot.eligibility.map((entry) => [entry.moduleId, entry] as const), ); - const pending = resolveModuleEligibility(snapshot.discoveredModules, configuration); + // Re-resolved against *both* live axes: enablement and module configuration. Since ADR 0029 a + // module's own configuration can decide eligibility, so correcting a missing required value must + // show up as a pending restart exactly like re-enabling a module does. Passing only the + // enablement half here would have quietly reported `restartRequired: false` for a module the next + // boot really will start. + const pending = resolveModuleEligibility( + snapshot.discoveredModules, + configuration, + moduleConfiguration, + ); const wouldRun = new Set(pending.eligibleModules.map((module) => module.manifest.id)); return Object.freeze( @@ -291,11 +373,17 @@ export function resolveModuleLifecycleView( : boot === undefined || !boot.configured.enabled ? { state: "disabled" as const } : boot.blocked - ? { - dependency: boot.blocked.dependency, - reason: boot.blocked.reason, - state: "blocked" as const, - } + ? "dependency" in boot.blocked + ? { + dependency: boot.blocked.dependency, + reason: boot.blocked.reason, + state: "blocked" as const, + } + : { + configurationKey: boot.blocked.configurationKey, + reason: boot.blocked.reason, + state: "blocked" as const, + } : // Enabled and unblocked at boot, yet not started: unreachable while startup stays // all-or-nothing, and reported as blocked-by-nothing rather than as `running`, so a // future change to startup atomicity can never make this silently claim success. diff --git a/core/src/modules/runtime.ts b/core/src/modules/runtime.ts index 168a192..6307a19 100644 --- a/core/src/modules/runtime.ts +++ b/core/src/modules/runtime.ts @@ -1,6 +1,8 @@ import { pathToFileURL } from "node:url"; import { type CapabilityId, + EMPTY_MODULE_CONFIGURATION, + type ModuleConfiguration, type CapabilityRegistrar, type CapabilityResolver, capabilityIdSchema, @@ -76,6 +78,24 @@ export interface StartedModuleRuntime { stop(): Promise; } +/** + * Per-module frozen configuration snapshots, keyed by module id (ADR 0029). + * + * A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one + * shared object identity every module receives** — a property this runtime has a test for — while + * configuration is per-module by definition. A module with no entry receives + * `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and + * module code never branches on whether the platform supplied one. + */ +export type ModuleConfigurationLookup = ReadonlyMap; + +function configurationFor( + configurations: ModuleConfigurationLookup, + moduleId: string, +): ModuleConfiguration { + return configurations.get(moduleId) ?? EMPTY_MODULE_CONFIGURATION; +} + export interface ModuleRuntimeTimeoutOptions { /** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS`, applied to both normal stop and startup * rollback, so a deadline can be proven in milliseconds instead of ten seconds. Production @@ -83,9 +103,23 @@ export interface ModuleRuntimeTimeoutOptions { readonly hookTimeoutMs?: number; } +export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOptions { + /** + * The per-module configuration snapshots this runtime hands to each module's `register`, `start`, + * `stop`, and HTTP handler contexts (ADR 0029). Production composition supplies exactly the map + * `resolveModuleEligibility` produced while deciding which modules may run, so the configuration + * that made a module eligible is the identical object it receives — never revalidated, and never + * re-read, between those two moments. + * + * Omitted by a caller exercising no configuration; every module then receives + * `EMPTY_MODULE_CONFIGURATION`. + */ + readonly configurations?: ModuleConfigurationLookup; +} + export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions, - ModuleRuntimeTimeoutOptions { + ModuleRuntimeCompositionOptions { /** The same frozen services object every started module's `start`/`stop` hooks and (once * `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production * dependency, never an optional test-only global. See `ModuleRuntimeServices`. */ @@ -222,6 +256,7 @@ class StagedCapabilityRegistry { module: DiscoveredModule, phase: "rollback" | "start" | "stop", services: ModuleRuntimeServices, + config: ModuleConfiguration, ): ModuleRuntimeContext { const capabilities: CapabilityResolver = Object.freeze({ resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => { @@ -229,7 +264,7 @@ class StagedCapabilityRegistry { return this.#registered.get(capabilityId); }, }); - return Object.freeze({ capabilities, moduleId: module.manifest.id, services }); + return Object.freeze({ capabilities, config, moduleId: module.manifest.id, services }); } publish(): CapabilityRegistry { @@ -464,12 +499,14 @@ async function registerModules( modules: readonly LoadedModule[], capabilityRegistry: StagedCapabilityRegistry, httpRoutes: StagedModuleHttpRoutes, + configurations: ModuleConfigurationLookup, ): Promise { for (const module of modules) { const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered); const routeStage = httpRoutes.createRegistrationStage(module.discovered); const context: ModuleRegistrationContext = Object.freeze({ capabilities: capabilityStage.capabilities, + config: configurationFor(configurations, module.discovered.manifest.id), http: routeStage.http, moduleId: module.discovered.manifest.id, }); @@ -561,6 +598,7 @@ async function stopModules( registry: StagedCapabilityRegistry, phase: "rollback" | "stop", services: ModuleRuntimeServices, + configurations: ModuleConfigurationLookup, options: StopModulesOptions = {}, ): Promise { const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS; @@ -574,7 +612,16 @@ async function stopModules( } try { const outcome = await awaitHookWithinDeadline( - Promise.resolve(stop(registry.createRuntimeContext(module.discovered, phase, services))), + Promise.resolve( + stop( + registry.createRuntimeContext( + module.discovered, + phase, + services, + configurationFor(configurations, moduleId), + ), + ), + ), timeoutMs, ); if (outcome === "timed-out") { @@ -609,6 +656,7 @@ async function startModules( modules: readonly LoadedModule[], registry: StagedCapabilityRegistry, services: ModuleRuntimeServices, + configurations: ModuleConfigurationLookup, options: StopModulesOptions = {}, ): Promise { // A module joins the rollback set the moment its `start` hook is *invoked*, not when it @@ -622,7 +670,12 @@ async function startModules( rollbackCandidates.push(module); try { await module.definition.start?.( - registry.createRuntimeContext(module.discovered, "start", services), + registry.createRuntimeContext( + module.discovered, + "start", + services, + configurationFor(configurations, module.discovered.manifest.id), + ), ); } catch (error) { const startIssue = @@ -639,6 +692,7 @@ async function startModules( registry, "rollback", services, + configurations, options, ); throw new ModuleRuntimeError([startIssue, ...rollbackIssues]); @@ -657,13 +711,14 @@ async function startModules( export async function startDiscoveredModuleRuntime( discoveredModules: readonly DiscoveredModule[], services: ModuleRuntimeServices, - timeouts: ModuleRuntimeTimeoutOptions = {}, + timeouts: ModuleRuntimeCompositionOptions = {}, ): Promise { const modules = await loadRuntimeDefinitions(discoveredModules); const capabilityRegistry = new StagedCapabilityRegistry(); const httpRoutes = new StagedModuleHttpRoutes(); - await registerModules(modules, capabilityRegistry, httpRoutes); - await startModules(modules, capabilityRegistry, services, timeouts); + const configurations = timeouts.configurations ?? new Map(); + await registerModules(modules, capabilityRegistry, httpRoutes, configurations); + await startModules(modules, capabilityRegistry, services, configurations, timeouts); const capabilities = capabilityRegistry.publish(); const routes = httpRoutes.publish(); @@ -683,7 +738,14 @@ export async function startDiscoveredModuleRuntime( routes, stop: (): Promise => { stopPromise ??= (async () => { - const issues = await stopModules(modules, capabilityRegistry, "stop", services, timeouts); + const issues = await stopModules( + modules, + capabilityRegistry, + "stop", + services, + configurations, + timeouts, + ); if (issues.length > 0) { throw new ModuleRuntimeError(issues); } @@ -698,6 +760,7 @@ export async function startModuleRuntime( ): Promise { const discovered = await discoverModules(options); return startDiscoveredModuleRuntime(discovered, options.services, { + ...(options.configurations === undefined ? {} : { configurations: options.configurations }), ...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }), }); } diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts index 82aa1d7..369923c 100644 --- a/core/tests/authorization-permissions.test.ts +++ b/core/tests/authorization-permissions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_UI_READ, CORE_PERMISSION_DECLARATIONS, @@ -121,12 +122,23 @@ describe("CORE_PERMISSION_REGISTRY", () => { expect(declaration?.id).toBe("core.modules.lifecycle.manage"); }); - it("contains exactly these four permissions and no others", () => { + it("registers core.modules.configuration.manage as restricted, separately from lifecycle", () => { + const declaration = CORE_PERMISSION_REGISTRY.get(CORE_MODULES_CONFIGURATION_MANAGE); + expect(declaration).toBeDefined(); + expect(declaration?.baseline).toBe("restricted"); + expect(declaration?.id).toBe("core.modules.configuration.manage"); + // Deliberately a different permission from lifecycle management: tuning how a module behaves + // and deciding whether it runs at all are different authorities (ADR 0029). + expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE); + }); + + it("contains exactly these five permissions and no others", () => { expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([ CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_MODULES_UI_READ, CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, + CORE_MODULES_CONFIGURATION_MANAGE, ]); }); }); @@ -154,6 +166,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => { description: "Read and change which installed modules are enabled.", id: CORE_MODULES_LIFECYCLE_MANAGE, }, + { + baseline: "restricted", + description: "Read and change an installed module's own configuration values.", + id: CORE_MODULES_CONFIGURATION_MANAGE, + }, ]); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true); for (const declaration of CORE_PERMISSION_DECLARATIONS) { diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts index 938eba7..2f1ca35 100644 --- a/core/tests/authorization-store.integration.ts +++ b/core/tests/authorization-store.integration.ts @@ -175,6 +175,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", + "core:0005_module_configuration", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -229,6 +230,7 @@ describe.sequential("live Core authorization store", () => { "ekklesia_core_dashboard_placement", "ekklesia_core_grant", "ekklesia_core_migrations", + "ekklesia_core_module_configuration", "ekklesia_core_module_state", "ekklesia_core_platform_owner", "ekklesia_core_platform_owner_bootstrap", diff --git a/core/tests/core-migration-discovery.test.ts b/core/tests/core-migration-discovery.test.ts index e7a7460..115b00c 100644 --- a/core/tests/core-migration-discovery.test.ts +++ b/core/tests/core-migration-discovery.test.ts @@ -91,7 +91,7 @@ async function createModule( writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/dashboard-flat-modules.test.ts b/core/tests/dashboard-flat-modules.test.ts index 7b52bd9..5c37889 100644 --- a/core/tests/dashboard-flat-modules.test.ts +++ b/core/tests/dashboard-flat-modules.test.ts @@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P : {}), entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id: fixture.id, - manifestVersion: 4, + manifestVersion: 5, name: fixture.name, version: "1.0.0", }), diff --git a/core/tests/dashboard-store.integration.ts b/core/tests/dashboard-store.integration.ts index 1a633ec..0406684 100644 --- a/core/tests/dashboard-store.integration.ts +++ b/core/tests/dashboard-store.integration.ts @@ -174,6 +174,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", + "core:0005_module_configuration", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index 0716851..15c5b1e 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies { planMigrations: async () => { throw new Error("database must not be planned"); }, + readModuleConfiguration: async () => { + throw new Error("module configuration must not be read"); + }, readModuleLifecycle: async () => { throw new Error("module lifecycle configuration must not be read"); }, @@ -81,9 +84,11 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 4 permissions registered + 5 permissions registered ○ Module lifecycle skipped DATABASE_URL is not configured. + ○ Module configuration skipped + DATABASE_URL is not configured. Data definitions ✓ Schemas validated diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index b27d443..4c6cf6f 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -35,13 +35,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { manifest: { capabilities: [], compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + configuration: [], dependencies: { capabilities: { optional: {}, required: {} }, modules: { optional: {}, required: {} }, }, entrypoints: { server: "./dist/server.js" }, id, - manifestVersion: 4, + manifestVersion: 5, name: `${id} module`, permissions: [], storage: { buckets: [] }, @@ -137,6 +138,7 @@ function dependencies(overrides: Partial = {}): DoctorDepend discoverModules: vi.fn(async () => Object.freeze([])), discoverSchemas: vi.fn(async () => schemaAggregate()), planMigrations: vi.fn(async () => plan()), + readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })), readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })), runtimeSatisfies: vi.fn(() => true), ...overrides, @@ -206,6 +208,7 @@ describe("Ekklesia Doctor diagnostics", () => { "modules.dependencies", "modules.permissions", "modules.lifecycle", + "modules.configuration", "schemas.discovery", "migrations.discovery", "database.transport", @@ -1016,3 +1019,192 @@ describe("Ekklesia Doctor — module lifecycle", () => { expect(check(report, "modules.lifecycle").status).toBe("pass"); }); }); + +describe("Ekklesia Doctor — module configuration", () => { + function configuringModule( + id: string, + configuration: readonly Record[], + ): DiscoveredModule { + const base = discoveredModule(id); + return { + ...base, + manifest: { ...base.manifest, configuration } as unknown as ModuleManifest, + }; + } + + it("skips the check, rather than guessing, when DATABASE_URL is not configured", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => Object.freeze([discoveredModule("announcements")])), + }); + + const report = await diagnoseEkklesia(options(), services); + + expect(check(report, "modules.configuration").status).toBe("skipped"); + expect(services.readModuleConfiguration).not.toHaveBeenCalled(); + }); + + it("passes and says so plainly when a module declares no configuration", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + + expect(check(report, "modules.configuration").status).toBe("pass"); + expect(check(report, "modules.configuration").details).toEqual([ + "0 modules declaring configuration · 0 blocked", + "calendar 0.1.0 · no configuration declared", + ]); + }); + + it("passes with a key count when configuration is satisfied, and prints no value", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => + Object.freeze([ + configuringModule("announcements", [ + { key: "slideSeconds", type: "integer", default: 7, required: false }, + { key: "bucket", type: "string", required: true }, + ]), + ]), + ), + readModuleConfiguration: vi.fn(async () => + Object.freeze({ + records: Object.freeze([ + { key: "bucket", moduleId: "announcements", value: "very-specific-bucket-name" }, + ]), + }), + ), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + const configuration = check(report, "modules.configuration"); + + expect(configuration.status).toBe("pass"); + expect(configuration.details).toEqual([ + "1 module declaring configuration · 0 blocked", + "announcements 0.1.0 · 2 keys declared · valid", + ]); + // Counts and keys, never values: `pnpm doctor` output gets pasted into chat. + expect(JSON.stringify(configuration)).not.toContain("very-specific-bucket-name"); + }); + + it("warns and names the offending key when required configuration is missing", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => + Object.freeze([ + configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]), + ]), + ), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + const configuration = check(report, "modules.configuration"); + + expect(configuration.status).toBe("warning"); + expect(configuration.summary).toBe("1 module cannot run with its current configuration"); + expect(configuration.details).toEqual([ + "1 module declaring configuration · 1 blocked", + "announcements 0.1.0 · 1 key declared · configuration_missing · bucket", + ]); + }); + + it("names a missing secret's key and never its value", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => + Object.freeze([ + configuringModule("announcements", [ + { key: "storageCredential", type: "secret", required: true }, + ]), + ]), + ), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + + expect(check(report, "modules.configuration").details).toContain( + "announcements 0.1.0 · 1 key declared · secret_missing · storageCredential", + ); + }); + + it("reports a supplied secret as satisfied, without printing it", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => + Object.freeze([ + configuringModule("announcements", [ + { key: "storageCredential", type: "secret", required: true }, + ]), + ]), + ), + }); + + const report = await diagnoseEkklesia( + { + ...options(databaseUrl), + moduleSecrets: { get: () => "super-secret-value" }, + }, + services, + ); + const configuration = check(report, "modules.configuration"); + + expect(configuration.status).toBe("pass"); + expect(JSON.stringify(configuration)).not.toContain("super-secret-value"); + }); + + it("does not evaluate a disabled module's configuration, and says so", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => + Object.freeze([ + configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]), + ]), + ), + readModuleLifecycle: vi.fn(async () => + Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "announcements" }]) }), + ), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + const configuration = check(report, "modules.configuration"); + + // Claiming it is valid would assert something nothing checked; claiming it is broken would be + // noise about a module nobody asked to run. + expect(configuration.status).toBe("pass"); + expect(configuration.details).toContain( + "announcements 0.1.0 · 1 key declared · not evaluated (disabled)", + ); + }); + + it("fails safely, without a driver detail, when the configuration cannot be read", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => Object.freeze([discoveredModule("announcements")])), + readModuleConfiguration: vi.fn(async () => { + throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2"); + }), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + const configuration = check(report, "modules.configuration"); + + expect(configuration.status).toBe("failure"); + expect(JSON.stringify(configuration)).not.toContain("hunter2"); + expect(JSON.stringify(configuration)).not.toContain("10.40.0.2"); + }); + + it("agrees with the lifecycle check, because both come from one resolution", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => + Object.freeze([ + configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]), + ]), + ), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + + // Doctor can never report a module as runnable that startup would block, or the reverse. + expect(check(report, "modules.lifecycle").status).toBe("warning"); + expect(check(report, "modules.configuration").status).toBe("warning"); + expect(check(report, "modules.lifecycle").details).toContain( + "announcements 0.1.0 · blocked · configuration_missing · bucket", + ); + }); +}); diff --git a/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json index 138e33e..f4c4173 100644 --- a/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json +++ b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 4, + "manifestVersion": 5, "id": "db-facade", "name": "Database Facade Integration Test Module", "description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.", diff --git a/core/tests/module-configuration-lifecycle.test.ts b/core/tests/module-configuration-lifecycle.test.ts new file mode 100644 index 0000000..a4e9019 --- /dev/null +++ b/core/tests/module-configuration-lifecycle.test.ts @@ -0,0 +1,354 @@ +import { + type ModuleConfigurationDeclarationInput, + type ModuleSecretSource, + parseModuleManifest, +} from "@ekklesia/contracts"; +import { describe, expect, it } from "vitest"; +import type { DiscoveredModule } from "../src/modules/discovery.js"; +import { resolveModuleEligibility, resolveModuleLifecycleView } from "../src/modules/lifecycle.js"; + +/** A minimal in-memory `DiscoveredModule`, built through `parseModuleManifest` so a fixture real + * discovery would reject can never stand in for one it would accept. */ +function fakeModule( + id: string, + options: { + readonly configuration?: readonly ModuleConfigurationDeclarationInput[]; + readonly dependencies?: Record; + } = {}, +): DiscoveredModule { + const manifest = parseModuleManifest({ + manifestVersion: 5, + id, + name: `${id} module`, + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration: options.configuration ?? [], + ...(options.dependencies ? { dependencies: options.dependencies } : {}), + entrypoints: { server: "./dist/does-not-exist/index.js" }, + }); + return { + directory: `/modules/${id}`, + entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, + manifest, + manifestPath: `/modules/${id}/ekklesia.module.json`, + packageName: `@test/${id}`, + }; +} + +function enablement(...records: readonly (readonly [string, boolean])[]) { + return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) }; +} + +function settings(...records: readonly (readonly [string, string, string])[]) { + return { + records: records.map(([moduleId, key, value]) => ({ key, moduleId, value })), + }; +} + +function secretsFor( + map: Readonly>>>, +): ModuleSecretSource { + return { get: (moduleId, key) => map[moduleId]?.[key] }; +} + +function ids(modules: readonly DiscoveredModule[]): readonly string[] { + return modules.map((module) => module.manifest.id); +} + +function entry( + resolution: ReturnType, + moduleId: string, +): ReturnType["entries"][number] { + const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId); + if (!found) { + throw new Error(`No eligibility entry for ${moduleId}.`); + } + return found; +} + +describe("configuration decides eligibility, through the existing lifecycle resolver", () => { + it("keeps a module with satisfied configuration eligible, and hands it a snapshot", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("announcements", { + configuration: [{ key: "bucket", type: "string", required: true }], + }), + ], + enablement(), + { settings: settings(["announcements", "bucket", "announcements"]) }, + ); + + expect(ids(resolution.eligibleModules)).toEqual(["announcements"]); + expect(entry(resolution, "announcements").blocked).toBeUndefined(); + // The same pass that decided eligibility produced the snapshot, so the configuration that made + // the module eligible is the identical object it will receive. + expect(resolution.configurations.get("announcements")?.getString("bucket")).toBe( + "announcements", + ); + }); + + it("blocks an enabled module whose required configuration is missing, naming the key", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("announcements", { + configuration: [{ key: "bucket", type: "string", required: true }], + }), + ], + enablement(), + { settings: settings() }, + ); + + expect(resolution.eligibleModules).toEqual([]); + expect(entry(resolution, "announcements").blocked).toEqual({ + configurationKey: "bucket", + reason: "configuration_missing", + }); + // No snapshot for a module that is not going to start. + expect(resolution.configurations.has("announcements")).toBe(false); + }); + + it("blocks on an invalid stored value rather than starting on a default nobody chose", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("announcements", { + configuration: [{ key: "slideSeconds", type: "integer", default: 7, maximum: 600 }], + }), + ], + enablement(), + { settings: settings(["announcements", "slideSeconds", "100000"]) }, + ); + + expect(entry(resolution, "announcements").blocked).toEqual({ + configurationKey: "slideSeconds", + reason: "configuration_invalid", + }); + }); + + it("blocks on a missing required secret, distinctly from a missing ordinary value", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("announcements", { + configuration: [{ key: "storageCredential", type: "secret", required: true }], + }), + ], + enablement(), + { settings: settings() }, + ); + + // A different reason because it has a different fix: set an environment variable on the + // deployment, not a value in the database. + expect(entry(resolution, "announcements").blocked).toEqual({ + configurationKey: "storageCredential", + reason: "secret_missing", + }); + }); + + it("becomes eligible once the deployment supplies the secret, with no stored value involved", () => { + const module = fakeModule("announcements", { + configuration: [{ key: "storageCredential", type: "secret", required: true }], + }); + + expect( + resolveModuleEligibility([module], enablement(), { settings: settings() }).eligibleModules, + ).toEqual([]); + expect( + ids( + resolveModuleEligibility([module], enablement(), { + secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }), + settings: settings(), + }).eligibleModules, + ), + ).toEqual(["announcements"]); + }); + + it("does not evaluate a disabled module's configuration at all", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("announcements", { + configuration: [{ key: "bucket", type: "string", required: true }], + }), + ], + enablement(["announcements", false]), + { settings: settings() }, + ); + + // Disabled is not blocked: it is doing what it was told, and reporting a configuration problem + // for something nobody asked to run would be noise. + expect(entry(resolution, "announcements").blocked).toBeUndefined(); + expect(entry(resolution, "announcements").configured.enabled).toBe(false); + }); + + it("leaves a module declaring no configuration exactly as it was", () => { + const resolution = resolveModuleEligibility([fakeModule("calendar")], enablement(), { + settings: settings(), + }); + + expect(ids(resolution.eligibleModules)).toEqual(["calendar"]); + expect(resolution.configurations.get("calendar")?.keys()).toEqual([]); + }); + + it("resolves identically when no configuration input is supplied at all", () => { + // The third argument is optional, so every pre-existing caller keeps its exact behaviour. + const withoutInput = resolveModuleEligibility([fakeModule("calendar")], enablement()); + const withEmptyInput = resolveModuleEligibility([fakeModule("calendar")], enablement(), { + settings: settings(), + }); + + expect(withoutInput.entries).toEqual(withEmptyInput.entries); + expect(ids(withoutInput.eligibleModules)).toEqual(["calendar"]); + }); +}); + +describe("configuration blocking propagates through the dependency graph", () => { + const graph = [ + fakeModule("storage", { + configuration: [{ key: "bucket", type: "string", required: true }], + }), + fakeModule("media", { dependencies: { modules: { required: { storage: "^1.0.0" } } } }), + fakeModule("announcements", { dependencies: { modules: { required: { media: "^1.0.0" } } } }), + ]; + + it("blocks required dependents of a configuration-blocked module, transitively", () => { + const resolution = resolveModuleEligibility(graph, enablement(), { settings: settings() }); + + expect(resolution.eligibleModules).toEqual([]); + // The chain terminates at the module whose own configuration is at fault, exactly as it + // terminates at a disabled module — one mechanism, not two. + expect(entry(resolution, "storage").blocked).toEqual({ + configurationKey: "bucket", + reason: "configuration_missing", + }); + expect(entry(resolution, "media").blocked).toEqual({ + dependency: { kind: "module", moduleId: "storage" }, + reason: "required_dependency_blocked", + }); + expect(entry(resolution, "announcements").blocked).toEqual({ + dependency: { kind: "module", moduleId: "media" }, + reason: "required_dependency_blocked", + }); + }); + + it("unblocks the whole chain once the configuration is supplied", () => { + const resolution = resolveModuleEligibility(graph, enablement(), { + settings: settings(["storage", "bucket", "media-objects"]), + }); + + expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]); + }); + + it("never blocks an unrelated module because of another module's configuration", () => { + const resolution = resolveModuleEligibility([...graph, fakeModule("calendar")], enablement(), { + settings: settings(), + }); + + expect(ids(resolution.eligibleModules)).toEqual(["calendar"]); + expect(entry(resolution, "calendar").blocked).toBeUndefined(); + }); + + it("drops the snapshot of a module that resolved its own configuration but lost eligibility", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("storage"), + fakeModule("announcements", { + configuration: [{ key: "bucket", type: "string", default: "b" }], + dependencies: { modules: { required: { storage: "^1.0.0" } } }, + }), + ], + enablement(["storage", false]), + { settings: settings() }, + ); + + // Its own configuration was fine; it is blocked by its dependency. The map must still contain + // exactly the modules that are going to start. + expect(entry(resolution, "announcements").blocked?.reason).toBe("required_dependency_disabled"); + expect(resolution.configurations.has("announcements")).toBe(false); + expect([...resolution.configurations.keys()]).toEqual([]); + }); +}); + +describe("configuration changes are restart-required, and reported honestly", () => { + const module = fakeModule("announcements", { + configuration: [{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 }], + }); + const discoveredModules = [module]; + + function bootSnapshot(bootSettings: ReturnType) { + const resolution = resolveModuleEligibility(discoveredModules, enablement(), { + settings: bootSettings, + }); + return { + discoveredModules, + eligibility: resolution.entries, + startedModuleIds: resolution.eligibleModules.map((entry) => entry.manifest.id), + }; + } + + it("does not claim a running module changed when only stored configuration did", () => { + const view = resolveModuleLifecycleView(bootSnapshot(settings()), enablement(), { + settings: settings(["announcements", "slideSeconds", "12"]), + }); + + // The module really is still running with 7; a configuration change is applied by the next + // restart, and this reports exactly that rather than pretending the value took effect. + expect(view[0]?.runtime).toEqual({ state: "running" }); + expect(view[0]?.restartRequired).toBe(false); + }); + + it("reports a pending restart when new configuration would change whether the module runs", () => { + // Boot with a value that blocked the module, then correct it. + const blocked = resolveModuleEligibility(discoveredModules, enablement(), { + settings: settings(["announcements", "slideSeconds", "0"]), + }); + expect(blocked.eligibleModules).toEqual([]); + + const view = resolveModuleLifecycleView( + { + discoveredModules, + eligibility: blocked.entries, + startedModuleIds: [], + }, + enablement(), + { settings: settings(["announcements", "slideSeconds", "12"]) }, + ); + + // Correcting the value must show as restart-pending: the next boot really will start it. + expect(view[0]?.runtime).toEqual({ + configurationKey: "slideSeconds", + reason: "configuration_invalid", + state: "blocked", + }); + expect(view[0]?.restartRequired).toBe(true); + }); + + it("reports the boot-time configuration reason, not a recomputed one", () => { + const blocked = resolveModuleEligibility(discoveredModules, enablement(), { + settings: settings(["announcements", "slideSeconds", "0"]), + }); + const view = resolveModuleLifecycleView( + { discoveredModules, eligibility: blocked.entries, startedModuleIds: [] }, + enablement(), + { settings: settings(["announcements", "slideSeconds", "0"]) }, + ); + + expect(view[0]?.runtime).toEqual({ + configurationKey: "slideSeconds", + reason: "configuration_invalid", + state: "blocked", + }); + expect(view[0]?.restartRequired).toBe(false); + }); + + it("never puts a configuration value into the lifecycle view, only the key", () => { + const blocked = resolveModuleEligibility(discoveredModules, enablement(), { + settings: settings(["announcements", "slideSeconds", "0"]), + }); + const view = resolveModuleLifecycleView( + { discoveredModules, eligibility: blocked.entries, startedModuleIds: [] }, + enablement(), + { settings: settings(["announcements", "slideSeconds", "0"]) }, + ); + + expect(JSON.stringify(view)).toContain("slideSeconds"); + expect(JSON.stringify(view)).not.toContain('"0"'); + }); +}); diff --git a/core/tests/module-configuration-store.integration.ts b/core/tests/module-configuration-store.integration.ts new file mode 100644 index 0000000..86f46b2 --- /dev/null +++ b/core/tests/module-configuration-store.integration.ts @@ -0,0 +1,459 @@ +import { randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client, type QueryResultRow } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { + createModuleConfigurationStore, + createModuleConfigurationView, + type ModuleConfigurationStore, + ModuleConfigurationStoreError, + resolveModuleConfigurationFor, +} from "../src/modules/index.js"; +import { parseModuleManifest } from "@ekklesia/contracts"; +import { createValidatedPostgresPool } from "../src/database/index.js"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "../src/migrations/index.js"; + +/** + * The module configuration store against real PostgreSQL. + * + * `core/tests/module-configuration.test.ts` proves the validation and defaulting rules with no + * database at all; this proves the things only a database can: that migration `0005` really creates + * the table with the composite primary key and value ceiling the schema declares, that a replace is + * transactional and scoped to one module, that stored values survive a completely new connection, + * and — the security-relevant one — that a module id or key is only ever *data* in a parameterized + * column, never an SQL identifier. The in-memory double the API route tests use reproduces none of + * that, which is why it lives here. + */ +const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; +const coreMigrationsDirectory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "drizzle", +); +const trackedDatabases = new Set(); +let databaseCounter = 0; + +class IntegrationHarnessError extends Error { + constructor(message: string) { + super(message); + this.name = "IntegrationHarnessError"; + this.stack = `${this.name}: ${this.message}`; + } +} + +function harnessError(message: string): IntegrationHarnessError { + return new IntegrationHarnessError(message); +} + +function baseDatabaseUrl(): string { + const value = process.env[integrationDatabaseUrlEnvironment]; + if (typeof value !== "string" || value.trim() === "") { + throw harnessError("The module configuration integration database is not configured."); + } + try { + const parsed = new URL(value); + if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { + throw new TypeError("Unsupported protocol."); + } + } catch { + throw harnessError("The module configuration integration database configuration is invalid."); + } + return value; +} + +function quoteDatabaseName(databaseName: string): string { + if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) { + throw harnessError("The generated integration database name is invalid."); + } + return `"${databaseName}"`; +} + +function makeDatabaseName(label: string): string { + const safeLabel = label + .toLowerCase() + .replaceAll(/[^a-z0-9]+/gu, "_") + .replaceAll(/^_+|_+$/gu, "") + .slice(0, 10); + databaseCounter += 1; + return `ekk_modcfg_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`; +} + +function databaseUrl(databaseName: string): string { + const parsed = new URL(baseDatabaseUrl()); + parsed.pathname = `/${databaseName}`; + parsed.searchParams.delete("application_name"); + return parsed.toString(); +} + +async function openAdministrativeClient(connectionString: string): Promise { + const client = new Client({ connectionString }); + client.on("error", () => undefined); + try { + await client.connect(); + return client; + } catch { + try { + await client.end(); + } catch { + // The public harness failure stays static when connection cleanup also fails. + } + throw harnessError("The module configuration integration database connection failed."); + } +} + +async function safeQuery( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +async function dropDatabase(databaseName: string): Promise { + const admin = await openAdministrativeClient(baseDatabaseUrl()); + try { + await safeQuery( + admin, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()", + [databaseName], + ); + await safeQuery( + admin, + `DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`, + ); + trackedDatabases.delete(databaseName); + } finally { + await safeEnd(admin); + } +} + +async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> { + const name = makeDatabaseName(label); + const admin = await openAdministrativeClient(baseDatabaseUrl()); + try { + await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`); + } finally { + await safeEnd(admin); + } + trackedDatabases.add(name); + return { name, url: databaseUrl(name) }; +} + +async function applyCoreMigrations(url: string): Promise { + const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-modcfg-no-modules-")); + try { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: emptyModulesDirectory, + platformVersion: "0.1.0", + }, + }); + expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ + "core:0000_identity_core_tables", + "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", + "core:0003_dashboard_navigation_tables", + "core:0004_module_lifecycle_state", + "core:0005_module_configuration", + ]); + await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); + } finally { + await rm(emptyModulesDirectory, { force: true, recursive: true }); + } +} + +afterAll(async () => { + for (const databaseName of [...trackedDatabases]) { + await dropDatabase(databaseName).catch(() => undefined); + } +}); + +function manifestWith(configuration: readonly Record[], id = "announcements") { + return parseModuleManifest({ + manifestVersion: 5, + id, + name: `${id} module`, + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration, + entrypoints: { server: "./dist/does-not-exist/index.js" }, + }); +} + +describe.sequential("live Core module configuration store", () => { + let database: { name: string; url: string }; + let admin: Client; + let store: ModuleConfigurationStore; + let close: () => Promise; + + it("creates exactly the one configuration table, with its composite key and declared constraints", async () => { + database = await createDisposableDatabase("fresh"); + await applyCoreMigrations(database.url); + admin = await openAdministrativeClient(database.url); + + const columns = await safeQuery<{ column_name: string; is_nullable: string }>( + admin, + `select column_name, is_nullable + from information_schema.columns + where table_name = 'ekklesia_core_module_configuration' + order by column_name`, + ); + const primaryKey = await safeQuery<{ column_name: string }>( + admin, + `select kcu.column_name + from information_schema.table_constraints tc + join information_schema.key_column_usage kcu + on kcu.constraint_name = tc.constraint_name + where tc.table_name = 'ekklesia_core_module_configuration' + and tc.constraint_type = 'PRIMARY KEY' + order by kcu.ordinal_position`, + ); + // Only the explicitly declared CHECKs: PostgreSQL 17 also materializes a system `..._not_null` + // CHECK per NOT NULL column, which is not something this schema declares. + const checks = await safeQuery<{ constraint_name: string }>( + admin, + `select tc.constraint_name + from information_schema.table_constraints tc + where tc.table_name = 'ekklesia_core_module_configuration' + and tc.constraint_type = 'CHECK' + and tc.constraint_name not like '%_not_null' + order by tc.constraint_name`, + ); + + expect(columns.map((column) => column.column_name)).toEqual([ + "created_at", + "key", + "module_id", + "updated_at", + "value", + ]); + // One row per (module, key): a module can never have two values for one key, enforced by the + // database rather than by application care. + expect(primaryKey.map((column) => column.column_name)).toEqual(["module_id", "key"]); + expect(checks.map((check) => check.constraint_name)).toEqual([ + "ekklesia_core_module_configuration_key_check", + "ekklesia_core_module_configuration_module_check", + "ekklesia_core_module_configuration_value_check", + ]); + // Deliberately absent: there is no column that could hold secret material, and none that + // records where a value came from. Secrets are deployment-supplied and never persisted. + expect(columns.map((column) => column.column_name)).not.toContain("secret"); + expect(columns.map((column) => column.column_name)).not.toContain("encrypted"); + }); + + it("reads an empty configuration from a freshly migrated database", async () => { + const view = createValidatedPostgresPool(database.url, { + connectionTimeoutMs: 10_000, + operationTimeoutMs: 10_000, + }); + close = view.close; + store = createModuleConfigurationStore(createModuleConfigurationView(view.pool)); + + expect(await store.readSettings()).toEqual({ records: [] }); + }); + + it("persists values and reads them back over a completely new connection", async () => { + await store.replace({ + moduleId: "announcements", + values: [ + { key: "bucket", value: "slides" }, + { key: "slideSeconds", value: "12" }, + ], + }); + + const separate = createValidatedPostgresPool(database.url, { + connectionTimeoutMs: 10_000, + operationTimeoutMs: 10_000, + }); + try { + const reread = await createModuleConfigurationStore( + createModuleConfigurationView(separate.pool), + ).readSettings(); + expect(reread.records).toEqual([ + { key: "bucket", moduleId: "announcements", value: "slides" }, + { key: "slideSeconds", moduleId: "announcements", value: "12" }, + ]); + } finally { + await separate.close(); + } + }); + + it("replaces the whole document for one module, clearing keys the submission omits", async () => { + await store.replace({ + moduleId: "announcements", + values: [{ key: "bucket", value: "slides" }], + }); + + expect((await store.readSettings()).records).toEqual([ + { key: "bucket", moduleId: "announcements", value: "slides" }, + ]); + }); + + it("never touches another module's rows during a replace", async () => { + await store.replace({ moduleId: "calendar", values: [{ key: "bucket", value: "events" }] }); + await store.replace({ + moduleId: "announcements", + values: [{ key: "bucket", value: "renamed" }], + }); + + expect((await store.readSettings()).records).toEqual([ + { key: "bucket", moduleId: "announcements", value: "renamed" }, + { key: "bucket", moduleId: "calendar", value: "events" }, + ]); + }); + + it("clears a module's document entirely when given no values", async () => { + await store.replace({ moduleId: "calendar", values: [] }); + + expect( + (await store.readSettings()).records.filter((record) => record.moduleId === "calendar"), + ).toEqual([]); + }); + + it("treats a module id and a key as data, never as an SQL identifier", async () => { + // The store validates identifiers before they reach PostgreSQL, so this is rejected by the + // application. The row count afterwards proves nothing was executed either way. + await expect( + store.replace({ + moduleId: 'announcements"; drop table ekklesia_core_module_configuration; --', + values: [{ key: "bucket", value: "x" }], + }), + ).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_MODULE_ID_INVALID" }); + await expect( + store.replace({ + moduleId: "announcements", + values: [{ key: 'bucket"; drop table ekklesia_core_module_configuration; --', value: "x" }], + }), + ).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_KEY_INVALID" }); + + const tables = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from information_schema.tables + where table_name = 'ekklesia_core_module_configuration'`, + ); + expect(tables[0]?.count).toBe("1"); + }); + + it("rejects a duplicate key in one submission before reaching the database", async () => { + await expect( + store.replace({ + moduleId: "announcements", + values: [ + { key: "bucket", value: "a" }, + { key: "bucket", value: "b" }, + ], + }), + ).rejects.toBeInstanceOf(ModuleConfigurationStoreError); + }); + + it("rejects an oversized value at the application boundary and at the database", async () => { + await expect( + store.replace({ + moduleId: "announcements", + values: [{ key: "bucket", value: "x".repeat(2049) }], + }), + ).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_VALUE_INVALID" }); + + // Defense in depth: the same ceiling restated where the data lives, so no future caller can + // store a value the resolver would then reject as invalid on every boot. + await expect( + safeQuery( + admin, + `insert into ekklesia_core_module_configuration (module_id, key, value) + values ('announcements', 'oversized', repeat('x', 2049))`, + ), + ).rejects.toThrow(); + }); + + it("never carries a configuration value in a store error", async () => { + let captured: unknown; + try { + await store.replace({ + moduleId: "announcements", + values: [{ key: "bucket", value: "a-value-nobody-should-see".repeat(200) }], + }); + } catch (error) { + captured = error; + } + + const rendered = `${String(captured)} ${JSON.stringify(captured)}`; + expect(rendered).not.toContain("a-value-nobody-should-see"); + }); + + it("retains a value for a module that is no longer deployed, and resolution ignores it", async () => { + await store.replace({ + moduleId: "announcements", + values: [{ key: "bucket", value: "slides" }], + }); + await store.replace({ moduleId: "retired", values: [{ key: "bucket", value: "old" }] }); + const settings = await store.readSettings(); + + const resolved = resolveModuleConfigurationFor( + manifestWith([{ key: "bucket", type: "string", required: true }]), + { settings }, + ); + + expect(resolved.configuration?.getString("bucket")).toBe("slides"); + // Retained rather than deleted: redeploying `retired` restores its value with no new write. + expect(settings.records.some((record) => record.moduleId === "retired")).toBe(true); + }); + + it("drives real validation and defaulting from real stored rows", async () => { + await store.replace({ + moduleId: "announcements", + values: [ + { key: "bucket", value: "slides" }, + { key: "slideSeconds", value: "900" }, + ], + }); + + const manifest = manifestWith([ + { key: "bucket", type: "string", required: true }, + { key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 }, + { key: "pollIntervalSeconds", type: "integer", default: 30 }, + ]); + const resolved = resolveModuleConfigurationFor(manifest, { + settings: await store.readSettings(), + }); + + // 900 exceeds the module's own declared maximum: reported, never clamped and never silently + // replaced by the default. + expect(resolved.configuration).toBeUndefined(); + expect(resolved.issue).toEqual({ key: "slideSeconds", reason: "configuration_invalid" }); + + await store.replace({ + moduleId: "announcements", + values: [ + { key: "bucket", value: "slides" }, + { key: "slideSeconds", value: "12" }, + ], + }); + const fixed = resolveModuleConfigurationFor(manifest, { settings: await store.readSettings() }); + expect(fixed.configuration?.getInteger("slideSeconds")).toBe(12); + expect(fixed.configuration?.getInteger("pollIntervalSeconds")).toBe(30); + + await close(); + }); +}); diff --git a/core/tests/module-configuration.test.ts b/core/tests/module-configuration.test.ts new file mode 100644 index 0000000..482c072 --- /dev/null +++ b/core/tests/module-configuration.test.ts @@ -0,0 +1,667 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + EMPTY_MODULE_CONFIGURATION, + type ModuleConfigurationDeclarationInput, + type ModuleSecretSource, + parseModuleManifest, + safeParseModuleManifest, +} from "@ekklesia/contracts"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createEnvironmentModuleSecretSource, + moduleSecretEnvironmentVariableName, + resolveModuleConfigurationFor, +} from "../src/modules/configuration.js"; +import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js"; + +function manifestWith( + configuration: readonly ModuleConfigurationDeclarationInput[], + id = "announcements", +) { + return parseModuleManifest({ + manifestVersion: 5, + id, + name: `${id} module`, + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration, + entrypoints: { server: "./dist/server/index.js" }, + }); +} + +/** The stored document, in the shape the store produces. */ +function settings(...records: readonly (readonly [string, string, string])[]) { + return { + records: records.map(([moduleId, key, value]) => ({ key, moduleId, value })), + }; +} + +function secretsFor( + map: Readonly>>>, +): ModuleSecretSource { + return { get: (moduleId, key) => map[moduleId]?.[key] }; +} + +/** + * A declaration set modelled on what the real Announcements product would need as an Ekklesia + * module — a slide duration with a default, a poll interval with a default, a bucket name, and a + * deployment-supplied storage credential. + * + * Modelled, never imported: the Announcements repository is a reference consumer for this design and + * is not a dependency of Ekklesia's tests. Nothing Announcements-specific exists in Core; this + * fixture only proves the *shape* of settings a real product needs can be declared, validated, + * defaulted, and delivered without the module reaching any platform infrastructure. + */ +const announcementsShapedDeclarations: readonly ModuleConfigurationDeclarationInput[] = [ + { key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 }, + { key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 }, + { key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 }, + { key: "storageCredential", type: "secret", required: true }, +]; + +describe("module configuration declarations", () => { + it("accepts a module that declares no configuration at all", () => { + const manifest = manifestWith([]); + + expect(manifest.configuration).toEqual([]); + // A backend-only module stays exactly as valid as it was before configuration existed. + expect(resolveModuleConfigurationFor(manifest, { settings: settings() }).issue).toBeUndefined(); + }); + + it("accepts every declared type with its own constraints", () => { + const manifest = manifestWith([ + { key: "bucket", type: "string", minLength: 1, maxLength: 63 }, + { key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 }, + { key: "shuffle", type: "boolean", default: false }, + { key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" }, + { key: "webhook", type: "url" }, + { key: "apiToken", type: "secret" }, + ]); + + expect(manifest.configuration.map((entry) => entry.type)).toEqual([ + "string", + "integer", + "boolean", + "enum", + "url", + "secret", + ]); + }); + + it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => { + const result = safeParseModuleManifest({ + manifestVersion: 5, + id: "announcements", + name: "announcements module", + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration: [ + { key: "apiToken", type: "string", default: "not-a-secret" }, + { key: "apiToken", type: "secret", required: true }, + ], + entrypoints: { server: "./dist/server/index.js" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((issue) => issue.path.join("."))).toContain( + "configuration.1.key", + ); + } + }); + + it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => { + const result = safeParseModuleManifest({ + manifestVersion: 5, + id: "announcements", + name: "announcements module", + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration: [{ key: "apiToken", type: "secret", default: "hunter2" }], + entrypoints: { server: "./dist/server/index.js" }, + }); + + // `.strict()` on the secret shape means there is no `default` field to populate at all — the + // rejection is structural rather than a rule someone has to remember to apply. + expect(result.success).toBe(false); + }); + + it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => { + const result = safeParseModuleManifest({ + manifestVersion: 5, + id: "announcements", + name: "announcements module", + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration: [{ key: "slideSeconds", type: "integer", required: true, default: 7 }], + entrypoints: { server: "./dist/server/index.js" }, + }); + + expect(result.success).toBe(false); + }); + + it.each([ + [ + "an integer default below its own minimum", + { key: "n", type: "integer", default: 0, minimum: 1 }, + ], + [ + "an integer default above its own maximum", + { key: "n", type: "integer", default: 9, maximum: 5 }, + ], + [ + "a string default shorter than its own minLength", + { key: "s", type: "string", default: "", minLength: 1 }, + ], + [ + "an enum default outside its own values", + { key: "e", type: "enum", values: ["a"], default: "b" }, + ], + [ + "a url default that is not an absolute http(s) URL", + { key: "u", type: "url", default: "/relative" }, + ], + [ + "a url default with a dangerous protocol", + { key: "u", type: "url", default: "file:///etc/passwd" }, + ], + ])("rejects %s", (_label, declaration) => { + const result = safeParseModuleManifest({ + manifestVersion: 5, + id: "announcements", + name: "announcements module", + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration: [declaration], + entrypoints: { server: "./dist/server/index.js" }, + }); + + expect(result.success).toBe(false); + }); + + it.each([ + "__proto__", + "constructor", + "prototype", + "toString", + "valueOf", + ])("rejects the reserved object property name %s as a configuration key", (key) => { + const result = safeParseModuleManifest({ + manifestVersion: 5, + id: "announcements", + name: "announcements module", + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration: [{ key, type: "string" }], + entrypoints: { server: "./dist/server/index.js" }, + }); + + expect(result.success).toBe(false); + }); + + it.each([ + "Slide", + "slide_seconds", + "slide.seconds", + "slide-seconds", + "9lives", + "", + ])("rejects the malformed configuration key %s", (key) => { + const result = safeParseModuleManifest({ + manifestVersion: 5, + id: "announcements", + name: "announcements module", + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration: [{ key, type: "string" }], + entrypoints: { server: "./dist/server/index.js" }, + }); + + expect(result.success).toBe(false); + }); +}); + +describe("resolveModuleConfigurationFor — defaults and precedence", () => { + it("applies declared defaults when nothing is stored", () => { + const manifest = manifestWith(announcementsShapedDeclarations); + const resolved = resolveModuleConfigurationFor(manifest, { + secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }), + settings: settings(["announcements", "bucket", "announcements"]), + }); + + expect(resolved.issue).toBeUndefined(); + expect(resolved.configuration?.getInteger("slideSeconds")).toBe(7); + expect(resolved.configuration?.getInteger("pollIntervalSeconds")).toBe(30); + }); + + it("lets a stored value override a default", () => { + const manifest = manifestWith(announcementsShapedDeclarations); + const resolved = resolveModuleConfigurationFor(manifest, { + secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }), + settings: settings( + ["announcements", "bucket", "announcements"], + ["announcements", "slideSeconds", "12"], + ), + }); + + expect(resolved.configuration?.getInteger("slideSeconds")).toBe(12); + }); + + it("never falls back to the default when a stored value is invalid", () => { + const manifest = manifestWith(announcementsShapedDeclarations); + const resolved = resolveModuleConfigurationFor(manifest, { + secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }), + settings: settings( + ["announcements", "bucket", "announcements"], + ["announcements", "slideSeconds", "99999"], + ), + }); + + // Silently substituting 7 here would run the slideshow at a duration nobody chose while the + // operator believes theirs took effect. + expect(resolved.configuration).toBeUndefined(); + expect(resolved.issue).toEqual({ key: "slideSeconds", reason: "configuration_invalid" }); + }); + + it("reports a required key with no value and no default as missing", () => { + const manifest = manifestWith(announcementsShapedDeclarations); + const resolved = resolveModuleConfigurationFor(manifest, { + secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }), + settings: settings(), + }); + + expect(resolved.issue).toEqual({ key: "bucket", reason: "configuration_missing" }); + }); + + it("leaves an optional key with no value and no default simply absent", () => { + const manifest = manifestWith([{ key: "webhook", type: "url" }]); + const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() }); + + expect(resolved.issue).toBeUndefined(); + expect(resolved.configuration?.has("webhook")).toBe(false); + expect(resolved.configuration?.getUrl("webhook")).toBeUndefined(); + expect(resolved.configuration?.keys()).toEqual([]); + }); + + it.each([ + ["a non-canonical integer", "integer", "07"], + ["an integer with whitespace", "integer", " 7 "], + ["a non-numeric integer", "integer", "seven"], + ["a float for an integer", "integer", "7.5"], + ["a non-canonical boolean", "boolean", "yes"], + ["a numeric boolean", "boolean", "1"], + ["a capitalized boolean", "boolean", "True"], + ])("rejects %s rather than coercing it", (_label, type, stored) => { + const manifest = manifestWith([{ key: "value", type } as ModuleConfigurationDeclarationInput]); + const resolved = resolveModuleConfigurationFor(manifest, { + settings: settings(["announcements", "value", stored]), + }); + + expect(resolved.issue).toEqual({ key: "value", reason: "configuration_invalid" }); + }); + + it("validates a stored value against every declared constraint", () => { + const manifest = manifestWith([ + { key: "fit", type: "enum", values: ["contain", "cover"] }, + { key: "webhook", type: "url" }, + { key: "bucket", type: "string", maxLength: 4 }, + ]); + + for (const [key, bad] of [ + ["fit", "stretch"], + ["webhook", "javascript:alert(1)"], + ["bucket", "far-too-long"], + ] as const) { + const resolved = resolveModuleConfigurationFor(manifest, { + settings: settings(["announcements", key, bad]), + }); + expect(resolved.issue).toEqual({ key, reason: "configuration_invalid" }); + } + }); + + it("accepts a valid stored value for every declared type", () => { + const manifest = manifestWith([ + { key: "bucket", type: "string" }, + { key: "slideSeconds", type: "integer" }, + { key: "shuffle", type: "boolean" }, + { key: "fit", type: "enum", values: ["contain", "cover"] }, + { key: "webhook", type: "url" }, + ]); + const resolved = resolveModuleConfigurationFor(manifest, { + settings: settings( + ["announcements", "bucket", "announcements"], + ["announcements", "slideSeconds", "12"], + ["announcements", "shuffle", "true"], + ["announcements", "fit", "cover"], + ["announcements", "webhook", "https://hooks.example.test/notify"], + ), + }); + + expect(resolved.issue).toBeUndefined(); + expect(resolved.configuration?.getString("bucket")).toBe("announcements"); + expect(resolved.configuration?.getInteger("slideSeconds")).toBe(12); + expect(resolved.configuration?.getBoolean("shuffle")).toBe(true); + expect(resolved.configuration?.getEnum("fit")).toBe("cover"); + expect(resolved.configuration?.getUrl("webhook")).toBe("https://hooks.example.test/notify"); + }); +}); + +describe("resolveModuleConfigurationFor — keys the manifest no longer declares", () => { + it("ignores an undeclared stored key rather than failing, and reports it", () => { + const manifest = manifestWith([{ key: "slideSeconds", type: "integer", default: 7 }]); + const resolved = resolveModuleConfigurationFor(manifest, { + settings: settings( + ["announcements", "slideSeconds", "9"], + ["announcements", "transitionMilliseconds", "1100"], + ), + }); + + expect(resolved.issue).toBeUndefined(); + expect(resolved.configuration?.keys()).toEqual(["slideSeconds"]); + // Retained by the store and surfaced here, so a downgrade or a rename does not destroy an + // operator's value and is visible rather than merely tolerated. + expect(resolved.unknownKeys).toEqual(["transitionMilliseconds"]); + expect(resolved.configuration?.get("transitionMilliseconds")).toBeUndefined(); + }); + + it("uses the default for a key a newer version added, without any migration step", () => { + // v1 stored only `slideSeconds`; v2 adds `transitionMilliseconds` with a default. + const v2 = manifestWith([ + { key: "slideSeconds", type: "integer", default: 7 }, + { key: "transitionMilliseconds", type: "integer", default: 1100 }, + ]); + const resolved = resolveModuleConfigurationFor(v2, { + settings: settings(["announcements", "slideSeconds", "9"]), + }); + + expect(resolved.issue).toBeUndefined(); + expect(resolved.configuration?.getInteger("slideSeconds")).toBe(9); + expect(resolved.configuration?.getInteger("transitionMilliseconds")).toBe(1100); + }); + + it("blocks rather than guesses when a newer version adds a required key", () => { + const v2 = manifestWith([ + { key: "slideSeconds", type: "integer", default: 7 }, + { key: "bucket", type: "string", required: true }, + ]); + const resolved = resolveModuleConfigurationFor(v2, { + settings: settings(["announcements", "slideSeconds", "9"]), + }); + + expect(resolved.issue).toEqual({ key: "bucket", reason: "configuration_missing" }); + }); +}); + +describe("resolveModuleConfigurationFor — isolation", () => { + it("reads only its own module's records, never another module's", () => { + const manifest = manifestWith([{ key: "bucket", type: "string" }]); + const resolved = resolveModuleConfigurationFor(manifest, { + settings: settings(["announcements", "bucket", "mine"], ["calendar", "bucket", "not-mine"]), + }); + + expect(resolved.configuration?.getString("bucket")).toBe("mine"); + expect(resolved.configuration?.keys()).toEqual(["bucket"]); + }); + + it("asks the secret source only for its own module id", () => { + const asked: string[] = []; + const manifest = manifestWith([{ key: "apiToken", type: "secret", required: true }]); + const resolved = resolveModuleConfigurationFor(manifest, { + secrets: { + get: (moduleId, key) => { + asked.push(`${moduleId}:${key}`); + return moduleId === "announcements" ? "s3cret" : "another-module-secret"; + }, + }, + settings: settings(), + }); + + expect(asked).toEqual(["announcements:apiToken"]); + expect(resolved.configuration?.getSecret("apiToken")).toBe("s3cret"); + }); + + it("gives a module no way to reach a key it never declared", () => { + const manifest = manifestWith([{ key: "bucket", type: "string", default: "b" }]); + const resolved = resolveModuleConfigurationFor(manifest, { + settings: settings(["announcements", "somebodyElsesKey", "value"]), + }); + + for (const probe of ["somebodyElsesKey", "__proto__", "constructor", "toString"]) { + expect(resolved.configuration?.get(probe)).toBeUndefined(); + expect(resolved.configuration?.has(probe)).toBe(false); + } + }); +}); + +describe("module configuration accessors", () => { + it("fails closed when the accessor does not match the declared type", () => { + const manifest = manifestWith([{ key: "slideSeconds", type: "integer", default: 7 }]); + const config = resolveModuleConfigurationFor(manifest, { settings: settings() }).configuration; + + expect(config?.getInteger("slideSeconds")).toBe(7); + // An undeclared key and a declared key of another type are deliberately indistinguishable, in + // the same style as `PermissionRegistry.get`. + expect(config?.getString("slideSeconds")).toBeUndefined(); + expect(config?.getBoolean("slideSeconds")).toBeUndefined(); + expect(config?.getSecret("slideSeconds")).toBeUndefined(); + expect(config?.getString("neverDeclared")).toBeUndefined(); + }); + + it("does not expose a secret through any non-secret accessor", () => { + const manifest = manifestWith([{ key: "apiToken", type: "secret", required: true }]); + const config = resolveModuleConfigurationFor(manifest, { + secrets: secretsFor({ announcements: { apiToken: "s3cret" } }), + settings: settings(), + }).configuration; + + expect(config?.getSecret("apiToken")).toBe("s3cret"); + expect(config?.getString("apiToken")).toBeUndefined(); + expect(config?.getUrl("apiToken")).toBeUndefined(); + expect(config?.getEnum("apiToken")).toBeUndefined(); + }); + + it("is frozen, sorted, and has no other surface", () => { + const manifest = manifestWith([ + { key: "zulu", type: "string", default: "z" }, + { key: "alpha", type: "string", default: "a" }, + ]); + const config = resolveModuleConfigurationFor(manifest, { settings: settings() }).configuration; + + expect(Object.isFrozen(config)).toBe(true); + expect(config?.keys()).toEqual(["alpha", "zulu"]); + expect(Object.keys(config ?? {}).sort()).toEqual([ + "get", + "getBoolean", + "getEnum", + "getInteger", + "getSecret", + "getString", + "getUrl", + "has", + "keys", + ]); + }); + + it("supplies an empty configuration that behaves like any other, for a module declaring none", () => { + expect(EMPTY_MODULE_CONFIGURATION.keys()).toEqual([]); + expect(EMPTY_MODULE_CONFIGURATION.has("anything")).toBe(false); + expect(EMPTY_MODULE_CONFIGURATION.get("anything")).toBeUndefined(); + expect(EMPTY_MODULE_CONFIGURATION.getSecret("anything")).toBeUndefined(); + expect(Object.isFrozen(EMPTY_MODULE_CONFIGURATION)).toBe(true); + }); +}); + +describe("deployment-supplied secrets", () => { + it("reports a required secret the deployment does not supply", () => { + const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]); + const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() }); + + expect(resolved.configuration).toBeUndefined(); + expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" }); + }); + + it("treats an empty-string secret as not supplied", () => { + const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]); + const resolved = resolveModuleConfigurationFor(manifest, { + secrets: secretsFor({ announcements: { storageCredential: "" } }), + settings: settings(), + }); + + expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" }); + }); + + it("leaves an optional secret absent rather than blocking", () => { + const manifest = manifestWith([{ key: "storageCredential", type: "secret" }]); + const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() }); + + expect(resolved.issue).toBeUndefined(); + expect(resolved.configuration?.has("storageCredential")).toBe(false); + }); + + it("never reads a secret from stored configuration, even if a row exists for that key", () => { + const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]); + const resolved = resolveModuleConfigurationFor(manifest, { + // A row that should be unreachable — the HTTP boundary refuses to write one, and this proves + // the resolver would ignore it even if one arrived by another route. + settings: settings(["announcements", "storageCredential", "planted-in-the-database"]), + }); + + expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" }); + expect(resolved.configuration).toBeUndefined(); + }); + + it("derives a deterministic, collision-free environment variable name", () => { + expect(moduleSecretEnvironmentVariableName("announcements", "storageCredential")).toBe( + "EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL", + ); + expect(moduleSecretEnvironmentVariableName("media-storage", "accessKeyId")).toBe( + "EKKLESIA_MODULE_SECRET__MEDIA_STORAGE__ACCESS_KEY_ID", + ); + // The double underscore is what keeps two different (module, key) pairs from folding onto one + // name once kebab-case and camelCase are both flattened to underscores. + expect(moduleSecretEnvironmentVariableName("media", "storageAccessKey")).not.toBe( + moduleSecretEnvironmentVariableName("media-storage", "accessKey"), + ); + }); + + it("reads only the derived name from the supplied environment, never an arbitrary variable", () => { + const environment = { + DATABASE_URL: "postgresql://secret@host/db", + EKKLESIA_IDENTITY_SECRET: "platform-secret", + EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "s3cret", + }; + const source = createEnvironmentModuleSecretSource(environment); + + expect(source.get("announcements", "storageCredential")).toBe("s3cret"); + // There is no accessor that takes a raw variable name, so a module can never reach these. + expect(source.get("announcements", "databaseUrl")).toBeUndefined(); + expect(source.get("calendar", "storageCredential")).toBeUndefined(); + expect(Object.keys(source)).toEqual(["get"]); + }); +}); + +describe("configuration over a real discovered inventory", () => { + const temporaryDirectories: string[] = []; + + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); + }); + + async function writeModule( + modulesDirectory: string, + id: string, + configuration: readonly ModuleConfigurationDeclarationInput[], + ): Promise { + const moduleDirectory = path.join(modulesDirectory, id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${id}`, + type: "module", + version: "1.0.0", + }), + ), + writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + manifestVersion: 5, + id, + name: `${id} module`, + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + configuration, + entrypoints: { server: "./dist/server/index.js" }, + }), + ), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + 'throw new Error("configuration discovery must never import a module server entrypoint");', + ), + ]); + } + + it("discovers declarations without importing a single line of module code", async () => { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-config-")); + temporaryDirectories.push(modulesDirectory); + await writeModule(modulesDirectory, "announcements", announcementsShapedDeclarations); + + const discovered: readonly DiscoveredModule[] = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + + // Every fixture entrypoint throws on import; reaching these assertions is direct evidence that + // discovery stayed inert, exactly as it was before configuration existed. + expect(discovered).toHaveLength(1); + expect(discovered[0]?.manifest.configuration.map((entry) => entry.key)).toEqual([ + "slideSeconds", + "pollIntervalSeconds", + "bucket", + "storageCredential", + ]); + + const manifest = discovered[0]?.manifest; + if (!manifest) { + throw new Error("expected a discovered module"); + } + const resolved = resolveModuleConfigurationFor(manifest, { + secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }), + settings: settings(["announcements", "bucket", "announcements"]), + }); + + // The whole point, end to end: a real product's settings arrive validated and defaulted, and the + // module reached no database, no environment, and no platform internal to get them. + expect(resolved.issue).toBeUndefined(); + expect(resolved.configuration?.getInteger("slideSeconds")).toBe(7); + expect(resolved.configuration?.getInteger("pollIntervalSeconds")).toBe(30); + expect(resolved.configuration?.getString("bucket")).toBe("announcements"); + expect(resolved.configuration?.getSecret("storageCredential")).toBe("s3cret"); + }); + + it("rejects a manifest whose configuration declaration is malformed, at discovery", async () => { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-config-")); + temporaryDirectories.push(modulesDirectory); + await writeModule(modulesDirectory, "announcements", [ + { key: "apiToken", type: "secret", required: true }, + // A duplicate key: the manifest is invalid, so the whole inventory is rejected before any + // configuration is ever resolved. + { key: "apiToken", type: "string" }, + ]); + + await expect( + discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }), + ).rejects.toMatchObject({ name: "ModuleDiscoveryError" }); + }); +}); diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index 7f4d164..a150baf 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise { function manifestFor(id: string): Record { return { - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-lifecycle-store.integration.ts b/core/tests/module-lifecycle-store.integration.ts index e6feead..2788b29 100644 --- a/core/tests/module-lifecycle-store.integration.ts +++ b/core/tests/module-lifecycle-store.integration.ts @@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", + "core:0005_module_configuration", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -197,7 +198,7 @@ afterAll(async () => { * by eligibility resolution. */ function fakeModule(id: string, dependencies?: Record): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "1.0.0", @@ -228,7 +229,7 @@ describe.sequential("live Core module lifecycle store", () => { const tables = await safeQuery<{ table_name: string }>( admin, `select table_name from information_schema.tables - where table_schema = 'public' and table_name like 'ekklesia_core_module%' + where table_schema = 'public' and table_name = 'ekklesia_core_module_state' order by table_name`, ); const columns = await safeQuery<{ diff --git a/core/tests/module-lifecycle.test.ts b/core/tests/module-lifecycle.test.ts index da40e43..0ffdd32 100644 --- a/core/tests/module-lifecycle.test.ts +++ b/core/tests/module-lifecycle.test.ts @@ -22,7 +22,7 @@ function fakeModule( } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", @@ -343,7 +343,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () => writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-migration-discovery.test.ts b/core/tests/module-migration-discovery.test.ts index 6f8e0bb..7daa0f5 100644 --- a/core/tests/module-migration-discovery.test.ts +++ b/core/tests/module-migration-discovery.test.ts @@ -65,7 +65,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts index 760236e..ef7d94f 100644 --- a/core/tests/module-permissions.test.ts +++ b/core/tests/module-permissions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_UI_READ, CORE_PERMISSION_DECLARATIONS, @@ -26,7 +27,7 @@ interface PermissionInput { * is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule { const manifest = parseModuleManifest({ - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", @@ -71,8 +72,9 @@ describe("composePlatformPermissionRegistry", () => { expect(registry.has("people.directory.read")).toBe(true); expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true); expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true); - // Core's own four declarations plus one from each module. - expect(registry.declarations).toHaveLength(6); + expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true); + // Core's own five declarations plus one from each module. + expect(registry.declarations).toHaveLength(7); }); it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => { @@ -91,6 +93,7 @@ describe("composePlatformPermissionRegistry", () => { CORE_MODULES_UI_READ, CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, + CORE_MODULES_CONFIGURATION_MANAGE, "calendar.events.read", "calendar.events.write", "people.directory.read", diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts index d702d4c..1abe282 100644 --- a/core/tests/module-runtime.test.ts +++ b/core/tests/module-runtime.test.ts @@ -1,9 +1,14 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts"; +import type { + ModuleConfiguration, + ModuleDatabase, + ModuleRuntimeServices, +} from "@ekklesia/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { discoverModules } from "../src/modules/discovery.js"; +import { resolveModuleEligibility } from "../src/modules/lifecycle.js"; import { ModuleDiscoveryError, ModuleRuntimeError, @@ -52,7 +57,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", @@ -1351,3 +1356,148 @@ describe("module startup rollback and bounded stop hooks", () => { } }); }); + +describe("module configuration delivery (ADR 0029)", () => { + it("hands each module its own frozen configuration in register, start, and stop", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createReferenceRecorder(); + await createRuntimeModule(modulesDirectory, "announcements", { + manifest: { + ...manifestFor("announcements"), + configuration: [ + { key: "slideSeconds", type: "integer", default: 7 }, + { key: "bucket", type: "string", required: true }, + ], + }, + serverSource: `export default { + id: "announcements", version: "0.1.0", + register(context) { ${recorder.expression}.push(context.config); }, + start(context) { ${recorder.expression}.push(context.config); }, + stop(context) { ${recorder.expression}.push(context.config); } + };`, + }); + + const discovered = await discoverModules(runtimeOptions(modulesDirectory)); + const resolution = resolveModuleEligibility( + discovered, + { modules: [] }, + { settings: { records: [{ key: "bucket", moduleId: "announcements", value: "slides" }] } }, + ); + const runtime = await startDiscoveredModuleRuntime( + resolution.eligibleModules, + fakeModuleRuntimeServices(), + { configurations: resolution.configurations }, + ); + await runtime.stop(); + + expect(recorder.values).toHaveLength(3); + const [registerConfig, startConfig, stopConfig] = recorder.values as ModuleConfiguration[]; + // Configuration is available during `register` too — it is an already-resolved in-memory + // snapshot, not a database-backed getter, so it reintroduces no I/O into a hook that must stay + // declarative. + expect(registerConfig?.getInteger("slideSeconds")).toBe(7); + expect(registerConfig?.getString("bucket")).toBe("slides"); + // The exact same object across every hook, so two hooks can never disagree about a value. + expect(startConfig).toBe(registerConfig); + expect(stopConfig).toBe(registerConfig); + expect(Object.isFrozen(registerConfig)).toBe(true); + }); + + it("gives each module only its own configuration, never another module's", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createReferenceRecorder(); + for (const id of ["alpha", "bravo"]) { + await createRuntimeModule(modulesDirectory, id, { + manifest: { + ...manifestFor(id), + configuration: [{ key: "label", type: "string", required: true }], + }, + serverSource: `export default { + id: ${JSON.stringify(id)}, version: "0.1.0", + start(context) { ${recorder.expression}.push([context.moduleId, context.config.getString("label"), context.config.keys()]); } + };`, + }); + } + + const discovered = await discoverModules(runtimeOptions(modulesDirectory)); + const resolution = resolveModuleEligibility( + discovered, + { modules: [] }, + { + settings: { + records: [ + { key: "label", moduleId: "alpha", value: "alpha-value" }, + { key: "label", moduleId: "bravo", value: "bravo-value" }, + ], + }, + }, + ); + const runtime = await startDiscoveredModuleRuntime( + resolution.eligibleModules, + fakeModuleRuntimeServices(), + { configurations: resolution.configurations }, + ); + await runtime.stop(); + + expect(recorder.values).toEqual([ + ["alpha", "alpha-value", ["label"]], + ["bravo", "bravo-value", ["label"]], + ]); + }); + + it("supplies an empty configuration, never undefined, to a module that declares none", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createReferenceRecorder(); + await createRuntimeModule(modulesDirectory, "plain", { + serverSource: `export default { + id: "plain", version: "0.1.0", + start(context) { ${recorder.expression}.push([typeof context.config, context.config.keys()]); } + };`, + }); + + const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory)); + await runtime.stop(); + + // Module code never has to branch on whether the platform supplied a configuration. + expect(recorder.values).toEqual([["object", []]]); + }); + + it("keeps the services object one shared identity even though configuration is per-module", async () => { + const modulesDirectory = await createModulesDirectory(); + const services = fakeModuleRuntimeServices(); + const recorder = createReferenceRecorder(); + for (const id of ["alpha", "bravo"]) { + await createRuntimeModule(modulesDirectory, id, { + manifest: { + ...manifestFor(id), + configuration: [{ key: "label", type: "string", default: id }], + }, + serverSource: `export default { + id: ${JSON.stringify(id)}, version: "0.1.0", + start(context) { ${recorder.expression}.push(context.services); ${recorder.expression}.push(context.config); } + };`, + }); + } + + const discovered = await discoverModules(runtimeOptions(modulesDirectory, services)); + const resolution = resolveModuleEligibility( + discovered, + { modules: [] }, + { settings: { records: [] } }, + ); + const runtime = await startDiscoveredModuleRuntime(resolution.eligibleModules, services, { + configurations: resolution.configurations, + }); + await runtime.stop(); + + const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values; + // This is the reason configuration is a sibling of `services` rather than a field inside it: + // `services` is deliberately one shared object for the whole runtime, while configuration must + // differ per module. Both properties hold simultaneously. + expect(alphaServices).toBe(services); + expect(bravoServices).toBe(services); + expect(alphaConfig).not.toBe(bravoConfig); + expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha"); + expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo"); + }); +}); diff --git a/core/tests/module-schema-discovery.test.ts b/core/tests/module-schema-discovery.test.ts index 7c9dc2a..4d33eba 100644 --- a/core/tests/module-schema-discovery.test.ts +++ b/core/tests/module-schema-discovery.test.ts @@ -41,7 +41,7 @@ function manifestFor( options: { dependencies?: Record; schema?: string | false } = {}, ): Record { return { - manifestVersion: 4, + manifestVersion: 5, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-ui-publication.test.ts b/core/tests/module-ui-publication.test.ts index d85e65c..8fc0e7a 100644 --- a/core/tests/module-ui-publication.test.ts +++ b/core/tests/module-ui-publication.test.ts @@ -51,7 +51,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }), }, id: fixture.id, - manifestVersion: 4, + manifestVersion: 5, name: `${fixture.id} module`, version, }), diff --git a/docs/architecture/decisions/0028-module-operational-lifecycle.md b/docs/architecture/decisions/0028-module-operational-lifecycle.md index 0143942..24df2f4 100644 --- a/docs/architecture/decisions/0028-module-operational-lifecycle.md +++ b/docs/architecture/decisions/0028-module-operational-lifecycle.md @@ -169,6 +169,13 @@ otherwise be a deadlock — the HTTP lifecycle API is served by the process a ba so `pnpm modules:disable -- --module ` 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. diff --git a/docs/architecture/decisions/0029-module-configuration-and-secrets.md b/docs/architecture/decisions/0029-module-configuration-and-secrets.md new file mode 100644 index 0000000..3b4b2f1 --- /dev/null +++ b/docs/architecture/decisions/0029-module-configuration-and-secrets.md @@ -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____ +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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index b381394..dcaa709 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 20c7b44..28e19a3 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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. diff --git a/docs/development/api.md b/docs/development/api.md index 20b9e9f..b548a74 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -82,6 +82,8 @@ No real values are shown here — configure per environment and never commit the | `GET /api/v1/module-ui//` | 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//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/` | 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/` | 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/ +PUT /api/v1/module-configuration/ { "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//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____ +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 diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 601143b..1ee021f 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -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 diff --git a/docs/development/security.md b/docs/development/security.md index 53ce364..7fad83e 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -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 diff --git a/docs/development/testing.md b/docs/development/testing.md index bfa7907..1d4e616 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 2306bd7..d136402 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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/` (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____`. 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 diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index a134206..a808ad3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./authorization.js"; export * from "./identifiers.js"; export * from "./identity.js"; +export * from "./module-configuration.js"; export * from "./module-database.js"; export * from "./module-http.js"; export * from "./module-lifecycle.js"; diff --git a/packages/contracts/src/module-configuration.ts b/packages/contracts/src/module-configuration.ts new file mode 100644 index 0000000..59d7f0d --- /dev/null +++ b/packages/contracts/src/module-configuration.ts @@ -0,0 +1,344 @@ +import { z } from "zod"; + +/** + * The complete set of value shapes a module may declare as configuration. + * + * Deliberately six, each earning its place against a real module need rather than anticipating one: + * a bucket name (`string`), a poll interval or slide duration (`integer`), a feature toggle + * (`boolean`), a fixed choice (`enum`), an endpoint (`url`), and a deployment-supplied credential + * (`secret`). There is no `object`, no `array`, no nesting, and no recursion — this is a validation + * vocabulary, not a form-builder DSL, and a module that genuinely needs structured data owns a table + * for it (ADR 0029). + * + * `secret` is a *type* rather than a flag on `string` because it is not a string with an extra + * property: it is never persisted by Ekklesia, never defaulted, never returned by any API, and + * never printed by diagnostics. Making that a separate declaration is what lets every one of those + * rules be enforced structurally instead of remembered. + */ +export const MODULE_CONFIGURATION_TYPES = [ + "string", + "integer", + "boolean", + "enum", + "url", + "secret", +] as const; + +export const moduleConfigurationTypeSchema = z.enum(MODULE_CONFIGURATION_TYPES); + +export type ModuleConfigurationType = z.infer; + +/** + * Names JavaScript can carry safely and JSON can round-trip without surprise: lower camelCase, + * letters and digits only, bounded. + * + * No dots, so a configuration key can never be mistaken for a permission or capability identifier, + * which are dot-namespaced. No underscores or leading capitals, so there is exactly one spelling of + * any key. And an explicit reserved-name rejection below, because `constructor`, `prototype`, and + * friends satisfy this pattern while being exactly the property names that turn an ordinary object + * write into prototype pollution. Core additionally never indexes a plain object with a + * module-supplied key — every lookup goes through a `Map` — so this check is defense in depth over a + * structural guarantee rather than the only thing standing between a manifest and `Object.prototype`. + */ +const RESERVED_CONFIGURATION_KEYS: ReadonlySet = new Set([ + "__proto__", + "constructor", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "prototype", + "toLocaleString", + "toString", + "valueOf", +]); + +export const moduleConfigurationKeySchema = z + .string() + .min(1) + .max(64) + .regex(/^[a-z][a-zA-Z0-9]*$/u, "Must be a lower camelCase configuration key.") + .refine( + (key) => !RESERVED_CONFIGURATION_KEYS.has(key), + "Must not be a reserved JavaScript object property name.", + ); + +export type ModuleConfigurationKey = z.infer; + +/** The one bound on any stored configuration literal. Generous for a bucket name, an endpoint, or a + * short list, and far below anything that would make the per-module document expensive to read at + * startup. Enforced by the contract, by the store, and by the database. */ +export const MODULE_CONFIGURATION_VALUE_MAX_LENGTH = 2048; + +/** Operator-facing text only. Never parsed, never interpreted, and never used to decide anything — + * changing it can never change what a value means, exactly like a permission's `description`. */ +const descriptionSchema = z.string().trim().min(1).max(240).optional(); + +const commonFields = { + key: moduleConfigurationKeySchema, + description: descriptionSchema, + /** When true and no value resolves, the module is not eligible to start and the platform reports + * a structured reason naming this key. Mutually exclusive with `default`: a key that always has a + * value can never be missing, so declaring both states an intent the platform cannot honour. */ + required: z.boolean().default(false), +}; + +const stringDeclarationSchema = z + .object({ + ...commonFields, + type: z.literal("string"), + default: z.string().max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(), + minLength: z.number().int().min(0).max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(), + maxLength: z.number().int().min(1).max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(), + }) + .strict(); + +const integerDeclarationSchema = z + .object({ + ...commonFields, + type: z.literal("integer"), + default: z.number().int().safe().optional(), + minimum: z.number().int().safe().optional(), + maximum: z.number().int().safe().optional(), + }) + .strict(); + +const booleanDeclarationSchema = z + .object({ + ...commonFields, + type: z.literal("boolean"), + default: z.boolean().optional(), + }) + .strict(); + +const enumDeclarationSchema = z + .object({ + ...commonFields, + type: z.literal("enum"), + /** Bounded and non-empty: an enum with no members can never be satisfied, and one with hundreds + * is a lookup table that belongs in the module's own storage. */ + values: z.array(z.string().min(1).max(120)).min(1).max(32), + default: z.string().optional(), + }) + .strict(); + +const urlDeclarationSchema = z + .object({ + ...commonFields, + type: z.literal("url"), + default: z.string().max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(), + }) + .strict(); + +/** + * A deployment-supplied credential. Note what this shape does **not** have: no `default`, because a + * checked-in default credential is a leaked credential, and no constraints, because Ekklesia never + * inspects the value. + */ +const secretDeclarationSchema = z + .object({ + ...commonFields, + type: z.literal("secret"), + }) + .strict(); + +export const moduleConfigurationDeclarationSchema = z + .discriminatedUnion("type", [ + stringDeclarationSchema, + integerDeclarationSchema, + booleanDeclarationSchema, + enumDeclarationSchema, + urlDeclarationSchema, + secretDeclarationSchema, + ]) + .superRefine((declaration, context) => { + if ( + declaration.type !== "secret" && + declaration.required && + declaration.default !== undefined + ) { + context.addIssue({ + code: "custom", + message: "A key with a default can never be missing, so it must not also be required.", + path: ["required"], + }); + } + if (declaration.type === "string") { + if ( + declaration.minLength !== undefined && + declaration.maxLength !== undefined && + declaration.minLength > declaration.maxLength + ) { + context.addIssue({ + code: "custom", + message: "minLength must not exceed maxLength.", + path: ["minLength"], + }); + } + if (declaration.default !== undefined) { + if ( + (declaration.minLength !== undefined && + declaration.default.length < declaration.minLength) || + (declaration.maxLength !== undefined && + declaration.default.length > declaration.maxLength) + ) { + context.addIssue({ + code: "custom", + message: "A default value must satisfy its own declared constraints.", + path: ["default"], + }); + } + } + } + if (declaration.type === "integer") { + if ( + declaration.minimum !== undefined && + declaration.maximum !== undefined && + declaration.minimum > declaration.maximum + ) { + context.addIssue({ + code: "custom", + message: "minimum must not exceed maximum.", + path: ["minimum"], + }); + } + if (declaration.default !== undefined) { + if ( + (declaration.minimum !== undefined && declaration.default < declaration.minimum) || + (declaration.maximum !== undefined && declaration.default > declaration.maximum) + ) { + context.addIssue({ + code: "custom", + message: "A default value must satisfy its own declared constraints.", + path: ["default"], + }); + } + } + } + if (declaration.type === "enum") { + if (new Set(declaration.values).size !== declaration.values.length) { + context.addIssue({ + code: "custom", + message: "Enum values must be unique.", + path: ["values"], + }); + } + if (declaration.default !== undefined && !declaration.values.includes(declaration.default)) { + context.addIssue({ + code: "custom", + message: "A default value must be one of the declared enum values.", + path: ["default"], + }); + } + } + if (declaration.type === "url" && declaration.default !== undefined) { + if (!isSupportedConfigurationUrl(declaration.default)) { + context.addIssue({ + code: "custom", + message: "A default URL must be an absolute http: or https: URL.", + path: ["default"], + }); + } + } + }); + +export type ModuleConfigurationDeclarationInput = z.input< + typeof moduleConfigurationDeclarationSchema +>; +export type ModuleConfigurationDeclaration = z.output; + +/** + * The one URL rule for `type: "url"`, shared by declaration validation and value validation so the + * two can never disagree. + * + * Only absolute `http:`/`https:` URLs. Everything else — `file:`, `javascript:`, `data:`, a + * protocol-relative `//host`, a bare hostname — is rejected outright rather than normalized, + * because a configuration value is frequently handed straight to `fetch`, and the difference + * between "an endpoint an operator meant" and "a local file the server can read" must not be a + * parsing accident. + */ +export function isSupportedConfigurationUrl(value: string): boolean { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + return parsed.protocol === "http:" || parsed.protocol === "https:"; +} + +/** Every value a resolved configuration can hold. `secret` resolves to a `string` like the others — + * the difference is entirely in where it comes from and where it may go, never in its shape. */ +export type ModuleConfigurationValue = string | number | boolean; + +/** + * A module's own validated configuration, frozen at startup. + * + * **This is the whole module-facing contract.** It has no reference to PostgreSQL, no environment + * access, no module id but its own, and no way to reach another module's values: the object simply + * does not contain them, and no accessor takes a namespace. A future standalone host implements + * this exact interface from a YAML file, its own database, or anything else, and the module's + * feature code cannot tell the difference — which is the point (ADR 0029). + * + * Every accessor fails closed: an undeclared key, a key whose declared type does not match the + * accessor, and an absent optional value are all `undefined`, deliberately indistinguishable, in the + * same style as `PermissionRegistry.get`. A key the manifest declares `required`, or gives a + * `default`, is guaranteed present by the time any module code runs — the platform refuses to start + * a module whose required configuration is missing — so those accessors return `undefined` only for + * a key the module never declared. + */ +export interface ModuleConfiguration { + /** Every key this module declared *and* that resolved to a value, sorted. Never another module's, + * and never a key that only exists in storage. */ + keys(): readonly string[]; + has(key: string): boolean; + /** The validated value under its declared type, or `undefined`. Secrets are reachable here too — + * a module reading its own secret is the entire point — which is exactly why this object must + * never be serialized into a response. Core never does; see `module-configuration-routes.ts`. */ + get(key: string): ModuleConfigurationValue | undefined; + getString(key: string): string | undefined; + getInteger(key: string): number | undefined; + getBoolean(key: string): boolean | undefined; + /** The resolved member of a `type: "enum"` key. Separate from `getString` so a module cannot + * silently start reading a free-text key as though it were a closed choice. */ + getEnum(key: string): string | undefined; + /** A validated absolute `http:`/`https:` URL string. Returned as a string rather than a `URL` so + * the contract stays JSON-shaped and identical across hosts. */ + getUrl(key: string): string | undefined; + /** A deployment-supplied credential. Never persisted by Ekklesia, never returned by any HTTP + * route, and never printed by diagnostics. */ + getSecret(key: string): string | undefined; +} + +/** The empty configuration every module without declarations receives. `context.config` is therefore + * always present and never `undefined`, so module code never branches on whether the platform + * supplied one. */ +export const EMPTY_MODULE_CONFIGURATION: ModuleConfiguration = Object.freeze({ + get: (): undefined => undefined, + getBoolean: (): undefined => undefined, + getEnum: (): undefined => undefined, + getInteger: (): undefined => undefined, + getSecret: (): undefined => undefined, + getString: (): undefined => undefined, + getUrl: (): undefined => undefined, + has: (): boolean => false, + keys: (): readonly string[] => Object.freeze([]), +}); + +/** + * How a host supplies deployment-owned secret material for one module's declared `secret` keys. + * + * Scoped by construction: a lookup takes a module id **and** a key the platform already validated + * against that module's own manifest, so there is no call shape that could read another module's + * secret and no way to ask for an arbitrary environment variable. A module never sees this + * interface at all — the host resolves secrets into the module's frozen snapshot before any module + * code runs. + */ +export interface ModuleSecretSource { + get(moduleId: string, key: string): string | undefined; +} + +/** A secret source that supplies nothing. The explicit "this deployment provides no module secrets" + * value, so a caller states that rather than passing `undefined` and hoping. */ +export const EMPTY_MODULE_SECRET_SOURCE: ModuleSecretSource = Object.freeze({ + get: (): undefined => undefined, +}); diff --git a/packages/contracts/src/module-http.ts b/packages/contracts/src/module-http.ts index 8208314..c8981a6 100644 --- a/packages/contracts/src/module-http.ts +++ b/packages/contracts/src/module-http.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { EkklesiaPrincipal } from "./identity.js"; import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js"; +import type { ModuleConfiguration } from "./module-configuration.js"; import type { ModuleRuntimeServices } from "./module-database.js"; // Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing @@ -220,6 +221,12 @@ export interface ModuleRouteHandlerContext { readonly principal: EkklesiaPrincipal; readonly params: Readonly>; readonly services: ModuleRuntimeServices; + /** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the + * same object, not a re-read — so a handler and a lifecycle hook can never disagree about a value + * (ADR 0029). Never serialized into a response by Core; a module that chooses to expose part of + * its own configuration does so through its own route, deliberately, and must never expose a + * secret. */ + readonly config: ModuleConfiguration; } /** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full diff --git a/packages/contracts/src/module-lifecycle.ts b/packages/contracts/src/module-lifecycle.ts index 7fb80a8..0d65328 100644 --- a/packages/contracts/src/module-lifecycle.ts +++ b/packages/contracts/src/module-lifecycle.ts @@ -43,24 +43,62 @@ export const moduleRuntimeStateSchema = z.enum(MODULE_RUNTIME_STATES); export type ModuleRuntimeState = z.infer; /** - * Why an enabled module is not running. A structured reason plus the exact dependency it names, - * rather than one state name per case — four states would encode the same two facts twice. + * Why an enabled module is not running. A structured reason plus the exact thing it names — a + * dependency, or a configuration key — rather than one state name per case, which would encode the + * same two facts twice. * * `required_dependency_disabled` means the dependency an administrator turned off is the direct * cause. `required_dependency_blocked` means the named dependency is itself blocked, so the cause - * is transitive; following the chain always terminates at a `..._disabled` entry, because a - * dependency that is merely absent or incompatible is rejected by discovery long before - * eligibility is resolved. + * is transitive; following the chain always terminates at a reason that is not + * `required_dependency_blocked`, because a dependency that is merely absent or incompatible is + * rejected by discovery long before eligibility is resolved. Since ADR 0029 that terminal reason + * may also be one of the three configuration reasons below — a module blocked by its own + * configuration blocks its required dependents exactly like a disabled one does. */ export const MODULE_BLOCKED_REASONS = [ "required_dependency_disabled", "required_dependency_blocked", + "configuration_missing", + "configuration_invalid", + "secret_missing", ] as const; export const moduleBlockedReasonSchema = z.enum(MODULE_BLOCKED_REASONS); export type ModuleBlockedReason = z.infer; +/** + * The three configuration reasons an enabled module may fail to become eligible (ADR 0029). They + * are separate because each has a **different operational fix**, which is the only justification + * for a distinct reason code: + * + * - `configuration_missing` — a required key has no stored value and no default. Fix: set a value + * through the configuration API. + * - `configuration_invalid` — a stored value exists but does not satisfy the module's own declared + * type or constraints. Fix: correct the value. A persisted value is never silently coerced, + * truncated, or replaced by a default. + * - `secret_missing` — a required `secret` key has no deployment-supplied value. Fix: set it in the + * deployment environment, not in the database — Ekklesia never stores secret material + * (ADR 0029). + * + * Each names the offending **key**, never the value. A key is public — it is declared in an + * installed module's manifest — while a value may be a credential. + */ +export const MODULE_CONFIGURATION_BLOCKED_REASONS = [ + "configuration_missing", + "configuration_invalid", + "secret_missing", +] as const; + +export type ModuleConfigurationBlockedReason = + (typeof MODULE_CONFIGURATION_BLOCKED_REASONS)[number]; + +export function isModuleConfigurationBlockedReason( + reason: ModuleBlockedReason, +): reason is ModuleConfigurationBlockedReason { + return (MODULE_CONFIGURATION_BLOCKED_REASONS as readonly string[]).includes(reason); +} + /** Both kinds of edge a module's manifest can declare as required: another module, or a capability * some other module provides. Both block identically — a required capability is exactly as * required as a required module — and both name the *providing module* so an operator has diff --git a/packages/contracts/src/module-manifest.ts b/packages/contracts/src/module-manifest.ts index 1df7987..2ad4775 100644 --- a/packages/contracts/src/module-manifest.ts +++ b/packages/contracts/src/module-manifest.ts @@ -1,5 +1,9 @@ import { z } from "zod"; import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js"; +import { + type ModuleConfigurationDeclaration, + moduleConfigurationDeclarationSchema, +} from "./module-configuration.js"; import { capabilityIdSchema, logicalBucketIdSchema, @@ -102,17 +106,25 @@ function reportDuplicates( } /** - * The one manifest shape Core accepts. Adding `entrypoints.ui` is an incompatible shape change — - * version 3's `entrypoints` object is `.strict()` and rejects `ui` outright — so the canonical - * manifest moves to `manifestVersion: 4` rather than reinterpreting version 3 (ADR 0021). + * The one manifest shape Core accepts. Adding a top-level `configuration` array is an incompatible + * shape change — version 4's manifest object is `.strict()` and rejects `configuration` outright — + * so the canonical manifest moves to `manifestVersion: 5` rather than reinterpreting version 4 + * (ADR 0029). * - * Version 3, and every other version, fails validation: there is no compatibility adapter, no + * Configuration is declared **statically, in this inert document**, rather than registered by + * executable module code, for one decisive reason: the platform validates a module's configuration + * *before* deciding whether it may start, so an enabled module whose required configuration is + * missing is reported as `blocked` with a structured reason instead of taking API startup down from + * inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without + * importing a single line of module code, exactly as they always have. + * + * Version 4, and every other version, fails validation: there is no compatibility adapter, no * automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2 - * (ADR 0006) and version 2 by version 3 (ADR 0014). One manifest document can therefore never be - * read as two different versions. Upgrading a backend-only version 3 manifest is a one-character - * edit: no field added by version 4 is required. + * (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One + * manifest document can therefore never be read as two different versions. Upgrading a version 4 + * manifest is a one-character edit: no field added by version 5 is required. */ -export const MODULE_MANIFEST_VERSION = 4; +export const MODULE_MANIFEST_VERSION = 5; export const moduleManifestSchema = z .object({ @@ -130,6 +142,10 @@ export const moduleManifestSchema = z dependencies: dependenciesSchema, capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []), permissions: z.array(permissionDeclarationSchema).default(() => []), + /** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert + * data: declaring configuration never causes Core to read a database, contact a host, or run + * module code during discovery. A module that needs nothing configured omits the field. */ + configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []), storage: moduleStorageSchema, entrypoints: entrypointsSchema, }) @@ -153,6 +169,15 @@ export const moduleManifestSchema = z context, "id", ); + // One namespace for every configuration key a module owns, so an ordinary key and a secret key + // can never collide: a module cannot declare `apiToken` as both a defaultable string and a + // deployment-supplied secret and leave the platform to guess which one a stored row means. + reportDuplicates( + manifest.configuration.map((declaration) => declaration.key), + ["configuration"], + context, + "key", + ); manifest.capabilities.forEach((capability, index) => { if (!capability.id.startsWith(`${manifest.id}.`)) { @@ -228,6 +253,10 @@ export function safeParseModuleManifest(input: unknown) { export type ModuleManifestInput = z.input; export type ModuleManifest = z.output; export type ModuleBucketDeclaration = z.output; +/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape + * Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased + * here for discoverability alongside the manifest's other per-field declaration types. */ +export type ModuleManifestConfigurationDeclaration = ModuleConfigurationDeclaration; export type ModuleCapabilityDeclaration = z.output; /** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's * authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for diff --git a/packages/contracts/src/module-runtime.ts b/packages/contracts/src/module-runtime.ts index 521a3a9..3150c3f 100644 --- a/packages/contracts/src/module-runtime.ts +++ b/packages/contracts/src/module-runtime.ts @@ -1,3 +1,4 @@ +import type { ModuleConfiguration } from "./module-configuration.js"; import type { CapabilityId, ModuleId } from "./identifiers.js"; import { moduleIdSchema } from "./identifiers.js"; import type { ModuleRuntimeServices } from "./module-database.js"; @@ -28,6 +29,15 @@ export interface ModuleRegistrationContext { * afterward from `start`, `stop`, or any other post-registration code path. */ readonly http: ModuleHttpRouteRegistrar; readonly moduleId: ModuleId; + /** + * This module's own validated configuration, available here as well as at runtime (ADR 0029). + * + * Present during `register` deliberately, and without reopening the rule that registration stays + * declarative and I/O-free: this is an already-resolved frozen snapshot held in memory, not a + * database-backed getter, so reading it performs no query and cannot fail. That is exactly why + * `services` — which *is* a live database facade — remains absent from this context. + */ + readonly config: ModuleConfiguration; } /** The same context shape for both `start` and `stop`: whichever hook runs, it receives the exact @@ -38,6 +48,19 @@ export interface ModuleRuntimeContext { readonly capabilities: CapabilityResolver; readonly moduleId: ModuleId; readonly services: ModuleRuntimeServices; + /** + * This module's own validated configuration, frozen for the lifetime of the process. + * + * Deliberately **not** a field on `ModuleRuntimeServices`: that object is one shared instance + * every module receives, and configuration is per-module by definition. It sits beside `moduleId` + * — the context's other per-module fact — so the scoping is visible in the shape rather than + * asserted in a comment. + * + * Immutable for the process lifetime: a configuration change is applied by the next restart, so a + * value read during `start` and the same value read in a request handler an hour later are the + * same value (ADR 0029). + */ + readonly config: ModuleConfiguration; } export interface ModuleRuntimeDefinition { diff --git a/packages/contracts/tests/module-manifest.test.ts b/packages/contracts/tests/module-manifest.test.ts index a468fb1..8a0c75f 100644 --- a/packages/contracts/tests/module-manifest.test.ts +++ b/packages/contracts/tests/module-manifest.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { isVersionCompatible, + isModuleConfigurationBlockedReason, MODULE_BLOCKED_REASONS, + MODULE_CONFIGURATION_BLOCKED_REASONS, MODULE_DEPENDENCY_KINDS, MODULE_MANIFEST_VERSION, MODULE_RUNTIME_STATES, @@ -13,7 +15,7 @@ import { function validManifest(): Record { return { - manifestVersion: 4, + manifestVersion: 5, id: "calendar", name: "Calendar", version: "0.1.0", @@ -209,17 +211,23 @@ describe("module manifest contract", () => { expect(safeParseModuleManifest(version2Manifest).success).toBe(false); }); - it("rejects a version 3 manifest rather than silently interpreting it as version 4", () => { - // A backend-only version 3 manifest is otherwise byte-identical to a valid version 4 one: - // only the version literal separates them, and it is not reinterpreted. + it("rejects a version 3 manifest rather than silently interpreting it as a later version", () => { const version3Manifest = { ...validManifest(), manifestVersion: 3 }; expect(safeParseModuleManifest(version3Manifest).success).toBe(false); }); + it("rejects a version 4 manifest rather than silently interpreting it as version 5", () => { + // A manifest declaring no configuration is otherwise byte-identical between version 4 and + // version 5: only the version literal separates them, and it is not reinterpreted (ADR 0029). + const version4Manifest = { ...validManifest(), manifestVersion: 4 }; + + expect(safeParseModuleManifest(version4Manifest).success).toBe(false); + }); + it("accepts exactly one manifest version, so no document can be read as two versions", () => { - expect(MODULE_MANIFEST_VERSION).toBe(4); - for (const manifestVersion of [1, 2, 3, 5, "4", 4.5, null]) { + expect(MODULE_MANIFEST_VERSION).toBe(5); + for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) { expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false); } expect( @@ -353,7 +361,21 @@ describe("module manifest contract", () => { expect(MODULE_BLOCKED_REASONS).toEqual([ "required_dependency_disabled", "required_dependency_blocked", + "configuration_missing", + "configuration_invalid", + "secret_missing", ]); + // The three configuration reasons are a subset, and each corresponds to a real implemented + // check with a distinct operator fix (ADR 0029) — the only justification for a separate code. + expect(MODULE_CONFIGURATION_BLOCKED_REASONS).toEqual([ + "configuration_missing", + "configuration_invalid", + "secret_missing", + ]); + for (const reason of MODULE_CONFIGURATION_BLOCKED_REASONS) { + expect(isModuleConfigurationBlockedReason(reason)).toBe(true); + } + expect(isModuleConfigurationBlockedReason("required_dependency_disabled")).toBe(false); expect(MODULE_DEPENDENCY_KINDS).toEqual(["module", "capability"]); }); diff --git a/packages/module-sdk/tests/define-module-runtime.test.ts b/packages/module-sdk/tests/define-module-runtime.test.ts index fd004ee..9deaf7c 100644 --- a/packages/module-sdk/tests/define-module-runtime.test.ts +++ b/packages/module-sdk/tests/define-module-runtime.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + EMPTY_MODULE_CONFIGURATION, defineModuleRuntime, isModuleHttpMethod, MODULE_HTTP_METHODS, @@ -59,6 +60,9 @@ describe("defineModuleRuntime", () => { provide: () => undefined, resolve: () => undefined, }, + // A module declaring no configuration still receives an empty snapshot rather than + // `undefined`, so module code never branches on whether the platform supplied one. + config: EMPTY_MODULE_CONFIGURATION, http, moduleId: "example", }; diff --git a/packages/module-ui-build/tests/build-module-ui.test.ts b/packages/module-ui-build/tests/build-module-ui.test.ts index fde3f75..15682df 100644 --- a/packages/module-ui-build/tests/build-module-ui.test.ts +++ b/packages/module-ui-build/tests/build-module-ui.test.ts @@ -28,7 +28,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise const manifest = options.manifest ?? JSON.stringify({ - manifestVersion: 4, + manifestVersion: 5, id: "example", name: "Example", version: "0.1.0", diff --git a/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json b/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json index 30a704c..1a7788d 100644 --- a/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json +++ b/packages/module-ui-host/tests/fixtures/artifact-module/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 4, + "manifestVersion": 5, "id": "example", "name": "Example", "version": "0.1.0", diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index bf4fdbf..cf12b89 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 4, + "manifestVersion": 5, "id": "example", "name": "Example Module", "description": "A minimal Ekklesia module template with backend behavior and optional UI.", @@ -21,6 +21,15 @@ "description": "Read example records." } ], + "configuration": [ + { + "key": "greeting", + "type": "string", + "default": "Hello", + "maxLength": 120, + "description": "Prefix this module puts in front of a record value." + } + ], "storage": { "buckets": [ { diff --git a/templates/module/src/server/index.ts b/templates/module/src/server/index.ts index a9f84e9..00a38b1 100644 --- a/templates/module/src/server/index.ts +++ b/templates/module/src/server/index.ts @@ -31,7 +31,17 @@ export default defineModuleRuntime({ const rows = await context.services.database .select({ id: records.id, value: records.value }) .from(records); - return Response.json(rows); + // The accepted module configuration boundary: `context.config` is a frozen, already + // validated snapshot of exactly this module's own declared keys (ADR 0029). It reaches + // `register`, `start`, `stop`, and every handler as the same object. This module never + // reads `process.env`, never queries a platform table for a setting, and has no way to see + // another module's configuration — which is what keeps it runnable under a future + // standalone host that stores configuration somewhere else entirely. + // + // `greeting` is declared with a default in `ekklesia.module.json`, so it is always present; + // the `??` is only here because accessors are typed `T | undefined` for undeclared keys. + const greeting = context.config.getString("greeting") ?? "Hello"; + return Response.json(rows.map((row) => ({ ...row, value: `${greeting}, ${row.value}` }))); }, }); }, diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 7564609..757a3d8 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -3,6 +3,8 @@ import { readFile } from "node:fs/promises"; import { ANONYMOUS_PRINCIPAL, type CapabilityId, + EMPTY_MODULE_CONFIGURATION, + type ModuleConfiguration, type ModuleDatabase, type ModuleRouteDefinitionInput, type ModuleRouteHandlerContext, @@ -31,6 +33,9 @@ async function registerAndCaptureRoutes(): Promise<{ const provided = new Map(); const routes: ModuleRouteDefinitionInput[] = []; await moduleRuntime.register?.({ + // The template declares no configuration, so it receives an empty snapshot — proof that a + // backend module needing no settings stays exactly as simple as it was (ADR 0029). + config: EMPTY_MODULE_CONFIGURATION, capabilities: { provide(capabilityId, implementation) { provided.set(capabilityId, implementation); @@ -124,6 +129,7 @@ describe("module template", () => { database: fakeModuleDatabase(fakeRows), }); const context: ModuleRouteHandlerContext = Object.freeze({ + config: EMPTY_MODULE_CONFIGURATION, moduleId: "example", params: Object.freeze({}), principal: ANONYMOUS_PRINCIPAL, @@ -133,7 +139,69 @@ describe("module template", () => { const response = await route.handler(new Request("http://localhost/records"), context); expect(response).toBeInstanceOf(Response); - expect(await response.json()).toEqual(fakeRows); + // With no configuration supplied, the handler falls back to the same default the manifest + // declares. In a real Ekklesia deployment that fallback is unreachable — the platform resolves + // the declared default before any module code runs — but the accessor is typed `T | undefined` + // for undeclared keys, so module code still writes it. + expect(await response.json()).toEqual([{ ...fakeRows[0], value: "Hello, hello" }]); + }); + + it("reads its own declared configuration through context.config, never the environment", async () => { + const { routes } = await registerAndCaptureRoutes(); + const route = routes[0]; + if (!route) { + throw new Error("Expected the example route to be registered."); + } + + const fakeRows = [{ id: "9d3f9f7e-3b1a-4c9a-8b8e-2b6b1e6f9a11", value: "hello" }]; + // A hand-rolled `ModuleConfiguration`: it is a `@ekklesia/contracts` interface, and a module may + // never import Core, so this is what a module-layer test can legitimately construct. It also + // demonstrates the portability claim directly — the host that produces this object could be + // Ekklesia, or a future standalone host reading YAML, and this module cannot tell. + const config: ModuleConfiguration = Object.freeze({ + get: (key: string) => (key === "greeting" ? "Welcome" : undefined), + getBoolean: () => undefined, + getEnum: () => undefined, + getInteger: () => undefined, + getSecret: () => undefined, + getString: (key: string) => (key === "greeting" ? "Welcome" : undefined), + getUrl: () => undefined, + has: (key: string) => key === "greeting", + keys: () => Object.freeze(["greeting"]), + }); + const context: ModuleRouteHandlerContext = Object.freeze({ + config, + moduleId: "example", + params: Object.freeze({}), + principal: ANONYMOUS_PRINCIPAL, + services: Object.freeze({ database: fakeModuleDatabase(fakeRows) }), + }); + + const response = await route.handler(new Request("http://localhost/records"), context); + + expect(await response.json()).toEqual([{ ...fakeRows[0], value: "Welcome, hello" }]); + }); + + it("declares exactly one configuration key, with a default, and no secret", async () => { + const manifestUrl = new URL("../ekklesia.module.json", import.meta.url); + const manifest = parseModuleManifest( + JSON.parse(await readFile(manifestUrl, "utf8")) as unknown, + ); + + // The template stays the smallest thing that demonstrates the contract: one ordinary key with a + // safe default. A backend module needing no settings omits `configuration` entirely and remains + // valid, which the manifest schema's own default proves. + expect(manifest.configuration).toEqual([ + { + default: "Hello", + description: "Prefix this module puts in front of a record value.", + key: "greeting", + maxLength: 120, + required: false, + type: "string", + }, + ]); + expect(manifest.configuration.some((entry) => entry.type === "secret")).toBe(false); }); it("exports a namespaced module-owned Drizzle schema", () => { diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index cc438b1..a6d7ac5 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "core/tests/authorization-bootstrap.integration.ts", "core/tests/dashboard-store.integration.ts", "core/tests/module-lifecycle-store.integration.ts", + "core/tests/module-configuration-store.integration.ts", "apps/api/tests/api-http.integration.ts", "apps/api/tests/module-database.integration.ts", ],