feat: add the module configuration and secrets contract
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s

Modules had no way to be told anything. Announcements — the first real
product Ekklesia will absorb — has zero environment variables and compiles
a LAN IP address into its source, so this is the contract standing between
it and the module system.

- A module declares what it needs in its manifest, using six bounded types
  (string, integer, boolean, enum, url, secret) with no nesting and no
  regular expressions. Static declaration is what lets the platform validate
  configuration *before* deciding whether a module may start: an enabled
  module missing a required value is reported `blocked ·
  configuration_missing · <key>` rather than taking startup down from inside
  its own `start` hook, and Doctor reports the same while still importing no
  module code. That forces `manifestVersion: 5`, a hard cutover.
- Ordinary values persist in one Core table (migration 0005), one row per
  (module_id, key), stored as text until validated against the declaring
  module's current manifest. Nothing is coerced; an invalid stored value
  blocks the module rather than falling back to the default. A key the
  manifest no longer declares is retained and ignored.
- Secrets are never persisted. There is no encryption at rest in this
  repository, so a `secret` key is deployment-supplied and mapped in by the
  host under a name the module never sees. A secret declaration has no
  `default` field, the write API refuses secret material, and the HTTP view
  has no `value` field for one — absent by construction, not filtered.
- Modules receive a frozen `context.config` on the registration, runtime,
  and HTTP handler contexts: the same object identity, per module, beside
  `services` rather than inside it (services is deliberately one shared
  instance). No environment access, no database reference, no way to reach
  another module's values.
- Configuration decides eligibility through ADR 0028's existing resolver:
  three new blocked reasons, each with a distinct operator fix, propagating
  to required dependents through the same mechanism as a disabled module.
- Changes take effect at the next restart; both resources report
  restartRequired. New restricted permission
  `core.modules.configuration.manage`, separate from lifecycle, at the
  sibling prefix `/api/v1/module-configuration`.
- Migration participation stays independent of configuration validity.
  Endpoints and credentials for shared infrastructure stay platform
  concerns; a module declares which bucket it needs, not an S3 endpoint.

See ADR 0029.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 21:44:43 -05:00
co-authored by Claude Opus 5
parent fdaad58848
commit baa689b2a7
76 changed files with 6650 additions and 131 deletions
+66 -5
View File
@@ -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__<MODULE_ID>__<KEY>`) 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/<id>` 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
+46
View File
@@ -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,
+6 -1
View File
@@ -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),
});
}
+375
View File
@@ -0,0 +1,375 @@
import {
type ModuleConfigurationDeclaration,
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
moduleConfigurationKeySchema,
moduleIdSchema,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import { CORE_MODULES_CONFIGURATION_MANAGE } from "@ekklesia/core/authorization";
import {
type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
resolveModuleConfigurationFor,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
/**
* A **sibling** of `/api/v1/modules/`, never a child — the third application of the rule ADR 0025
* established and ADR 0028 reapplied. `config` and `configuration` are both ordinary lowercase
* static segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately
* register `PUT /config` and land on exactly `/api/v1/modules/announcements/config`. Publishing the
* platform's own configuration mutation there would put Core and an installed module in one URL
* namespace with registration order deciding which one answers — for a route that changes how
* modules behave.
*/
export const MODULE_CONFIGURATION_PATH = "/api/v1/module-configuration";
const MODULE_CONFIGURATION_MODULE_PATH = `${MODULE_CONFIGURATION_PATH}/:moduleId`;
/**
* One declared key as an operator sees it: the module's own declaration, plus what is currently
* stored, plus what the running process actually used.
*
* `value` is **absent for every `secret` key, always**. Not null, not redacted, not a placeholder —
* absent, because there is no code path that could put secret material here: Ekklesia never persists
* it, and the resolver's snapshot is never serialized. `configured` reports whether the deployment
* supplied one, which is the only thing an operator needs to know from here.
*/
export interface ModuleConfigurationKeyView {
readonly key: string;
readonly type: ModuleConfigurationDeclaration["type"];
readonly required: boolean;
readonly description?: string;
/** The declared default, for non-secret keys that have one. Part of the module's public manifest. */
readonly default?: string | number | boolean;
/** Present only for `type: "enum"`. */
readonly values?: readonly string[];
/** The stored value, when one is stored and the key is not a secret. Absent means "no value
* stored" — the default applies, or the key is unset. */
readonly value?: string;
/** For a secret: whether the deployment supplies it. For anything else: whether a value is stored.
* Never implies the value is valid — see the module's `runtime` entry for that. */
readonly configured: boolean;
}
export interface ModuleConfigurationView {
readonly moduleId: string;
readonly version: string;
readonly keys: readonly ModuleConfigurationKeyView[];
/** Stored keys this module's current manifest no longer declares. Retained rather than deleted, so
* a downgrade or a rename does not destroy an operator's value, and surfaced here so it is visible
* rather than merely tolerated. */
readonly unknownKeys: readonly string[];
/** `true` when the stored configuration no longer matches what this process resolved at boot. A
* configuration change is applied by the next restart; nothing is hot-reloaded (ADR 0029). */
readonly restartRequired: boolean;
}
export interface CreateModuleConfigurationRoutesOptions extends AuthorizationMiddlewareOptions {
/** The discovered inventory this process booted from — the source of every declaration this route
* validates against. Never re-discovered: a manifest that changed on disk since boot is not what
* the running platform is using. */
readonly discoveredModules: readonly DiscoveredModule[];
/** The stored settings this process resolved at boot, used only to compute `restartRequired` by
* comparison with what is stored now. */
readonly bootSettings: ModuleConfigurationSettings;
readonly secrets: ModuleSecretSource;
readonly store: ModuleConfigurationStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. The store's
* errors never carry a value, so nothing here can leak one either. */
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
if (error instanceof ModuleConfigurationStoreError) {
if (
error.code === "MODULE_CONFIGURATION_READ_FAILED" ||
error.code === "MODULE_CONFIGURATION_WRITE_FAILED"
) {
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(
c,
503,
fallback === "read" ? "MODULE_CONFIGURATION_READ_FAILED" : "MODULE_CONFIGURATION_WRITE_FAILED",
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const BODY_KEYS = new Set(["values"]);
/**
* The canonical text form of a submitted value, or `undefined` when it is not one this key accepts.
*
* Accepts JSON's own types — a number for `integer`, a boolean for `boolean`, a string for the rest
* — and converts to the single canonical text form the store persists and the resolver parses. It
* never accepts a string for a number (`"7"`), so exactly one spelling reaches storage and a stored
* row can never validate differently from the request that created it.
*/
function canonicalStoredValue(
declaration: ModuleConfigurationDeclaration,
submitted: unknown,
): string | undefined {
switch (declaration.type) {
case "integer": {
return typeof submitted === "number" && Number.isSafeInteger(submitted)
? String(submitted)
: undefined;
}
case "boolean": {
return typeof submitted === "boolean" ? String(submitted) : undefined;
}
case "string":
case "enum":
case "url": {
return typeof submitted === "string" &&
submitted.length <= MODULE_CONFIGURATION_VALUE_MAX_LENGTH
? submitted
: undefined;
}
case "secret": {
// Unreachable: a secret key is rejected before this is called. The branch exists so adding a
// type to the union is a compile error rather than a silent acceptance of secret material.
return undefined;
}
}
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
function declarationView(
declaration: ModuleConfigurationDeclaration,
storedValue: string | undefined,
secretConfigured: boolean,
): ModuleConfigurationKeyView {
const isSecret = declaration.type === "secret";
return {
// A secret is `configured` when the deployment supplies it; anything else when a value is
// stored. The two are different questions with one honest answer each.
configured: isSecret ? secretConfigured : storedValue !== undefined,
key: declaration.key,
required: declaration.required,
type: declaration.type,
...(declaration.description === undefined ? {} : { description: declaration.description }),
...(isSecret || declaration.default === undefined ? {} : { default: declaration.default }),
...(declaration.type === "enum" ? { values: declaration.values } : {}),
// The one place a stored value reaches a response — and structurally never for a secret.
...(isSecret || storedValue === undefined ? {} : { value: storedValue }),
};
}
/**
* The platform's module configuration resource: what each installed module declares it needs, what
* an operator has stored, and whether the running process is still using it.
*
* ## Secrets are absent, not filtered
*
* There is no branch here that redacts a secret, because there is nothing to redact: Ekklesia never
* persists secret material, so the store this route reads from contains none. A `secret` key's
* `value` field is omitted by construction and `configured` is computed by asking the deployment's
* secret source a yes/no question. A future code change that tried to serve a secret value would
* have to first invent somewhere to read one from.
*
* ## Changing configuration does not change the running process
*
* `PUT` replaces the stored document. It does not restart, reload, or reconfigure anything: modules
* receive a frozen snapshot resolved once at startup (ADR 0029), so the response reports
* `restartRequired` rather than pretending the change took effect.
*
* ## Closed validation against the module's own declaration
*
* A submitted key the module does not declare is rejected, as is a value of the wrong JSON type or a
* value failing the module's declared constraints. Nothing is coerced, truncated, or dropped:
* `{"slideSeconds": "7"}` is a `400`, not a silent `7`. A secret key is rejected outright — there is
* no supported way to submit secret material to this platform.
*/
export function createModuleConfigurationRoutes(
options: CreateModuleConfigurationRoutesOptions,
): Hono {
const routes = new Hono();
const requireManage = requirePermission(
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
CORE_MODULES_CONFIGURATION_MANAGE,
);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
const modulesById = new Map(
options.discoveredModules.map((module) => [module.manifest.id, module] as const),
);
const storedFor = (
settings: ModuleConfigurationSettings,
moduleId: string,
): ReadonlyMap<string, string> =>
new Map(
settings.records
.filter((record) => record.moduleId === moduleId)
.map((record) => [record.key, record.value] as const),
);
/** Two documents differ when the exact set of (key, value) pairs differs. Compared against the
* settings this process actually resolved at boot, so `restartRequired` is an observation rather
* than a guess. */
const differsFromBoot = (moduleId: string, current: ReadonlyMap<string, string>): boolean => {
const boot = storedFor(options.bootSettings, moduleId);
if (boot.size !== current.size) {
return true;
}
for (const [key, value] of current) {
if (boot.get(key) !== value) {
return true;
}
}
return false;
};
const buildView = (
module: DiscoveredModule,
settings: ModuleConfigurationSettings,
): ModuleConfigurationView => {
const stored = storedFor(settings, module.manifest.id);
const resolved = resolveModuleConfigurationFor(module.manifest, {
secrets: options.secrets,
settings,
});
return {
keys: module.manifest.configuration.map((declaration) =>
declarationView(
declaration as ModuleConfigurationDeclaration,
stored.get(declaration.key),
// Asked, never read into the response: the answer is a boolean, and the value it was
// derived from never leaves the resolver.
declaration.type === "secret"
? options.secrets.get(module.manifest.id, declaration.key) !== undefined
: false,
),
),
moduleId: module.manifest.id,
restartRequired: differsFromBoot(module.manifest.id, stored),
unknownKeys: resolved.unknownKeys,
version: module.manifest.version,
};
};
routes.get(MODULE_CONFIGURATION_MODULE_PATH, requireManage, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const moduleId = c.req.param("moduleId");
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
try {
return c.json(buildView(module, await options.store.readSettings()), 200);
} catch (error) {
return storeFailure(c, error, "read");
}
});
routes.put(
MODULE_CONFIGURATION_MODULE_PATH,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const moduleId = c.req.param("moduleId");
const raw = await readJsonBody(c);
if (!isRecord(raw) || Object.keys(raw).some((key) => !BODY_KEYS.has(key))) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const submitted = raw.values;
if (!isRecord(submitted)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
const declarations = new Map(
module.manifest.configuration.map(
(declaration) =>
[declaration.key, declaration as ModuleConfigurationDeclaration] as const,
),
);
const values: { key: string; value: string }[] = [];
// `Object.keys` on a parsed JSON object never yields inherited properties, and every key is
// re-validated against the platform's one configuration-key grammar before it is used, so a
// body carrying `__proto__` or `constructor` is a plain `400` rather than anything stranger.
for (const key of Object.keys(submitted)) {
if (!moduleConfigurationKeySchema.safeParse(key).success) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const declaration = declarations.get(key);
if (!declaration) {
return sanitizedFailure(c, 400, "CONFIGURATION_KEY_UNDECLARED");
}
if (declaration.type === "secret") {
// Deliberate and permanent for this pass: Ekklesia has no encryption at rest, so there is
// no honest way to accept secret material over HTTP and store it. Secrets are supplied by
// the deployment environment (ADR 0029).
return sanitizedFailure(c, 400, "CONFIGURATION_SECRET_NOT_WRITABLE");
}
const canonical = canonicalStoredValue(declaration, submitted[key]);
if (canonical === undefined) {
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
}
values.push({ key, value: canonical });
}
// Constraint validation runs through the **same resolver the platform boots with**, over the
// exact document about to be stored, rather than a second copy of the type rules here. That
// is what guarantees the API and startup can never disagree about whether a value is valid:
// there is one implementation, and this is it.
//
// An *incomplete* document is accepted deliberately — an operator may save partial work, and
// the module is then reported `blocked · configuration_missing` until they finish. An
// *invalid* one never is: storing a value that could not survive the next boot would hand
// back a success for a change that silently breaks the module.
const candidate = resolveModuleConfigurationFor(module.manifest, {
secrets: options.secrets,
settings: {
records: values.map((entry) => ({ ...entry, moduleId: module.manifest.id })),
},
});
if (candidate.issue?.reason === "configuration_invalid") {
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
}
try {
await options.store.replace({ moduleId: module.manifest.id, values });
// Re-read rather than predict, so this response and a later GET can never disagree.
return c.json(buildView(module, await options.store.readSettings()), 200);
} catch (error) {
return storeFailure(c, error, "write");
}
},
);
return routes;
}
+18 -5
View File
@@ -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<Response> => {
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),
+35 -6
View File
@@ -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<string>;
}
@@ -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");
}
+55 -6
View File
@@ -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<void> {
@@ -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<void> {
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;
+16 -13
View File
@@ -196,6 +196,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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<void> {
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
+22
View File
@@ -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({}),
+3 -1
View File
@@ -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],
});
+2 -2
View File
@@ -82,7 +82,7 @@ async function createFixtureModule(
): Promise<void> {
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",
@@ -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<void> {
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<Record<string, Readonly<Record<string, string>>>>;
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<Installation> {
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<ConfigurationBody> {
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<string, unknown>).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" });
});
});
@@ -222,6 +222,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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<void> {
"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);
});
+12 -2
View File
@@ -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 () => {
@@ -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<Installation> {
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleLifecycle: {
snapshot: {
discoveredModules: modules,
+3 -1
View File
@@ -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<Harness> {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: ["https://dashboard.example.test"],
+3 -1
View File
@@ -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<Installation> {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: [trustedOrigin],
+3 -1
View File
@@ -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<Installation
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: [trustedOrigin],
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
"./src/authorization/schema.ts",
"./src/dashboard/schema.ts",
"./src/modules/lifecycle-schema.ts",
"./src/modules/configuration-schema.ts",
],
out: "./drizzle",
migrations: {
@@ -0,0 +1,13 @@
CREATE TABLE "ekklesia_core_module_configuration" (
"module_id" text NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_module_configuration_module_id_key_pk" PRIMARY KEY("module_id","key"),
CONSTRAINT "ekklesia_core_module_configuration_module_check" CHECK (length("ekklesia_core_module_configuration"."module_id") > 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");
+993
View File
@@ -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": {}
}
}
+6
View File
@@ -31,6 +31,12 @@
"destructive": {
"mode": "none"
}
},
{
"tag": "0005_module_configuration",
"destructive": {
"mode": "none"
}
}
]
}
+7
View File
@@ -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
}
]
}
+28
View File
@@ -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,
}),
]);
/**
+9 -5
View File
@@ -127,11 +127,15 @@ async function main(): Promise<void> {
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);
+1
View File
@@ -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"]),
+149 -3
View File
@@ -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<ModuleLifecycleConfiguration>;
/** 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<ModuleConfigurationSettings>;
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<ModuleConfigurationSettings> {
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<string, number>,
): 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,
+67
View File
@@ -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;
+221
View File
@@ -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<typeof moduleConfigurationSchema>;
/** 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<void>;
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<Record<ModuleConfigurationStoreErrorCode, string>> =
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<ModuleConfigurationSettings>;
/**
* 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<readonly ModuleConfigurationRecord[]>;
}
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<ModuleConfigurationSettings> {
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<readonly ModuleConfigurationRecord[]> {
assertValidModuleId(input.moduleId);
const seen = new Set<string>();
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");
}
},
});
}
+316
View File
@@ -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<string, { readonly type: string; readonly value: ModuleConfigurationValue }>,
): ModuleConfiguration {
const sortedKeys = Object.freeze([...values.keys()].sort());
const typed = (key: string, type: string): ModuleConfigurationValue | undefined => {
const entry = values.get(key);
// Fails closed identically for an undeclared key and a declared key of another type, in the
// same style as `PermissionRegistry.get`: a caller can never distinguish "you did not declare
// this" from "you declared it as something else", and neither one silently returns a value.
return entry?.type === type ? entry.value : undefined;
};
return Object.freeze({
get: (key: string): ModuleConfigurationValue | undefined => values.get(key)?.value,
getBoolean: (key: string): boolean | undefined => {
const value = typed(key, "boolean");
return typeof value === "boolean" ? value : undefined;
},
getEnum: (key: string): string | undefined => {
const value = typed(key, "enum");
return typeof value === "string" ? value : undefined;
},
getInteger: (key: string): number | undefined => {
const value = typed(key, "integer");
return typeof value === "number" ? value : undefined;
},
getSecret: (key: string): string | undefined => {
const value = typed(key, "secret");
return typeof value === "string" ? value : undefined;
},
getString: (key: string): string | undefined => {
const value = typed(key, "string");
return typeof value === "string" ? value : undefined;
},
getUrl: (key: string): string | undefined => {
const value = typed(key, "url");
return typeof value === "string" ? value : undefined;
},
has: (key: string): boolean => values.has(key),
keys: (): readonly string[] => sortedKeys,
});
}
/**
* Parses one stored text literal against its declaration, or reports that it does not satisfy it.
*
* Never coerces, truncates, rounds, or falls back to the default: a stored value that does not
* validate is an operator-visible error, not something to quietly repair. `"7 "`, `"07"`, `"yes"`,
* and `"1"` are all rejected for the types where they are not the canonical form, so exactly one
* spelling of any value is ever accepted and two deployments can never resolve the same stored row
* differently.
*/
function parseStoredValue(
declaration: DiscoveredConfigurationDeclaration,
raw: string,
): ModuleConfigurationValue | undefined {
if (raw.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) {
return undefined;
}
switch (declaration.type) {
case "string": {
if (declaration.minLength !== undefined && raw.length < declaration.minLength) {
return undefined;
}
if (declaration.maxLength !== undefined && raw.length > declaration.maxLength) {
return undefined;
}
return raw;
}
case "integer": {
if (!/^-?(?:0|[1-9]\d*)$/u.test(raw)) {
return undefined;
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
return undefined;
}
if (declaration.minimum !== undefined && parsed < declaration.minimum) {
return undefined;
}
if (declaration.maximum !== undefined && parsed > declaration.maximum) {
return undefined;
}
return parsed;
}
case "boolean": {
return raw === "true" ? true : raw === "false" ? false : undefined;
}
case "enum": {
return declaration.values.includes(raw) ? raw : undefined;
}
case "url": {
return isSupportedConfigurationUrl(raw) ? raw : undefined;
}
case "secret": {
// Unreachable: a secret is never read from storage, and the store refuses to write one. The
// branch exists so adding a type to the union is a compile error here rather than a silent
// acceptance of persisted secret material.
return undefined;
}
}
}
/**
* Resolves one module's complete configuration from its own manifest declarations, the operator's
* stored values, and the deployment's secrets.
*
* Pure and synchronous: no database, no filesystem, no `process.env`, no clock, and no module code.
* It is handed everything it needs and returns either a frozen snapshot or the first unsatisfied
* requirement, so every rule below is testable without PostgreSQL.
*
* ## Precedence, exactly
*
* 1. A stored value that validates against the current declaration wins.
* 2. A stored value that does **not** validate is `configuration_invalid` — never replaced by the
* default, because silently falling back would run the module on a value nobody chose while the
* operator believes theirs took effect.
* 3. Otherwise the declared `default` applies, if there is one.
* 4. Otherwise a `required` key is `configuration_missing` (or `secret_missing`), and an optional
* key is simply absent.
*
* Secrets never participate in steps 1–3: they are never stored and never defaulted, so a secret is
* whatever the deployment supplies, or missing.
*
* ## Keys the manifest no longer declares
*
* Ignored, and retained by the store. A stored key the current manifest does not declare is exactly
* the situation a module downgrade or a renamed key produces, and deleting the row would destroy an
* operator's value on a temporary rollback — the same reasoning ADR 0027 applied to a Dashboard
* placement for an uninstalled module.
*/
export function resolveModuleConfigurationFor(
manifest: ReadonlyModuleManifest,
input: ModuleConfigurationInput,
): ResolvedModuleConfiguration {
const secrets = input.secrets ?? EMPTY_MODULE_SECRET_SOURCE;
const declarations = manifest.configuration;
const declaredKeys = new Set(declarations.map((declaration) => declaration.key));
const stored = new Map<string, string>();
for (const record of input.settings.records) {
if (record.moduleId === manifest.id) {
stored.set(record.key, record.value);
}
}
const unknownKeys = Object.freeze(
[...stored.keys()].filter((key) => !declaredKeys.has(key)).sort(),
);
const values = new Map<string, { type: string; value: ModuleConfigurationValue }>();
for (const declaration of declarations) {
if (declaration.type === "secret") {
// Scoped by construction: the source is asked for this module's id and a key that module's
// own manifest declares. There is no call shape that reaches another module's secret, and no
// way to ask the host for an arbitrary environment variable.
const supplied = secrets.get(manifest.id, declaration.key);
if (typeof supplied === "string" && supplied.length > 0) {
values.set(declaration.key, { type: "secret", value: supplied });
continue;
}
if (declaration.required) {
return Object.freeze({
issue: Object.freeze({ key: declaration.key, reason: "secret_missing" as const }),
unknownKeys,
});
}
continue;
}
const raw = stored.get(declaration.key);
if (raw !== undefined) {
const parsed = parseStoredValue(declaration, raw);
if (parsed === undefined) {
return Object.freeze({
issue: Object.freeze({ key: declaration.key, reason: "configuration_invalid" as const }),
unknownKeys,
});
}
values.set(declaration.key, { type: declaration.type, value: parsed });
continue;
}
if (declaration.default !== undefined) {
values.set(declaration.key, { type: declaration.type, value: declaration.default });
continue;
}
if (declaration.required) {
return Object.freeze({
issue: Object.freeze({ key: declaration.key, reason: "configuration_missing" as const }),
unknownKeys,
});
}
}
return Object.freeze({ configuration: createModuleConfiguration(values), unknownKeys });
}
/**
* The environment variable one module's declared secret is read from, in an Ekklesia deployment.
*
* `EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`, with the module id's kebab-case and the key's
* camelCase each folded to `UPPER_SNAKE`. A double underscore separates the three parts so a single
* underscore inside a converted name can never make two different (module, key) pairs collide.
*
* **A module never sees this name.** It calls `config.getSecret("storageCredential")`; the mapping
* is entirely the host's, which is exactly what lets a standalone host read the same declared secret
* from a YAML file or a secret manager without the module's feature code changing (ADR 0029).
*/
export function moduleSecretEnvironmentVariableName(moduleId: string, key: string): string {
const upperSnake = (value: string): string =>
value
.replace(/-/gu, "_")
.replace(/([a-z0-9])([A-Z])/gu, "$1_$2")
.toUpperCase();
return `EKKLESIA_MODULE_SECRET__${upperSnake(moduleId)}__${upperSnake(key)}`;
}
/**
* A secret source backed by a deployment's environment.
*
* Takes the environment **as an argument** rather than reading `process.env` itself, preserving the
* repository rule that reusable Core code never touches the process environment — only executables
* do (`core/src/cli/database-transport-environment.ts` states the same rule for database
* transport). It is also what makes this trivially testable without mutating global state.
*
* There is deliberately no method that takes an arbitrary variable name. The only reachable values
* are those a module declared as `secret` in its own manifest, looked up under a name this function
* derives — a module cannot read `DATABASE_URL`, another module's secret, or anything else about
* the host.
*/
export function createEnvironmentModuleSecretSource(
environment: Readonly<Record<string, string | undefined>>,
): ModuleSecretSource {
return Object.freeze({
get: (moduleId: string, key: string): string | undefined =>
environment[moduleSecretEnvironmentVariableName(moduleId, key)],
});
}
+4 -1
View File
@@ -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";
+108 -20
View File
@@ -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<string, ModuleConfiguration>;
}
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<string, ModuleConfiguration>();
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<string, ModuleConfiguration>,
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.
+71 -8
View File
@@ -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<void>;
}
/**
* 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<string, ModuleConfiguration>;
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<void> {
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<readonly ModuleRuntimeIssue[]> {
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<void> {
// 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<StartedModuleRuntime> {
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<string, ModuleConfiguration>();
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<void> => {
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<StartedModuleRuntime> {
const discovered = await discoverModules(options);
return startDiscoveredModuleRuntime(discovered, options.services, {
...(options.configurations === undefined ? {} : { configurations: options.configurations }),
...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }),
});
}
+18 -1
View File
@@ -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) {
@@ -175,6 +175,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
}),
@@ -174,6 +174,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
+6 -1
View File
@@ -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
+193 -1
View File
@@ -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<DoctorDependencies> = {}): 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<string, unknown>[],
): 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",
);
});
});
@@ -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.",
@@ -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<string, unknown>;
} = {},
): 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<Record<string, Readonly<Record<string, string>>>>,
): 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<typeof resolveModuleEligibility>,
moduleId: string,
): ReturnType<typeof resolveModuleEligibility>["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<typeof settings>) {
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"');
});
});
@@ -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<string>();
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<Client> {
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<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function dropDatabase(databaseName: string): Promise<void> {
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<void> {
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<string, unknown>[], 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<void>;
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();
});
});
+667
View File
@@ -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<Record<string, Readonly<Record<string, string>>>>,
): 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<void> {
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" });
});
});
+1 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> {
return {
manifestVersion: 4,
manifestVersion: 5,
id,
name: `${id} module`,
version: "0.1.0",
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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<string, unknown>): 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<{
+2 -2
View File
@@ -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",
@@ -65,7 +65,7 @@ function manifestFor(
} = {},
): Record<string, unknown> {
return {
manifestVersion: 4,
manifestVersion: 5,
id,
name: `${id} module`,
version: "0.1.0",
+6 -3
View File
@@ -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",
+152 -2
View File
@@ -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<string, unknown> {
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");
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ function manifestFor(
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
): Record<string, unknown> {
return {
manifestVersion: 4,
manifestVersion: 5,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -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,
}),
@@ -169,6 +169,13 @@ otherwise be a deadlock — the HTTP lifecycle API is served by the process a ba
so `pnpm modules:disable -- --module <id>` 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.
@@ -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__<MODULE_ID>__<KEY>
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.
+1
View File
@@ -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
@@ -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.
+120 -4
View File
@@ -82,6 +82,8 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/module-ui/<module-id>/<version>` | 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/<module-id>/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/<module-id>` | 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/<module-id>` | 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/<module-id>
PUT /api/v1/module-configuration/<module-id> { "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/<id>/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__<MODULE_ID>__<KEY>
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
+10 -1
View File
@@ -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
+50
View File
@@ -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
+19
View File
@@ -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:
+114
View File
@@ -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/<module-id>` (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__<MODULE_ID>__<KEY>`. 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
+1
View File
@@ -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";
@@ -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<typeof moduleConfigurationTypeSchema>;
/**
* 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<string> = 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<typeof moduleConfigurationKeySchema>;
/** 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<typeof moduleConfigurationDeclarationSchema>;
/**
* 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,
});
+7
View File
@@ -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<Record<string, string>>;
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
+43 -5
View File
@@ -43,24 +43,62 @@ export const moduleRuntimeStateSchema = z.enum(MODULE_RUNTIME_STATES);
export type ModuleRuntimeState = z.infer<typeof moduleRuntimeStateSchema>;
/**
* 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<typeof moduleBlockedReasonSchema>;
/**
* 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
+37 -8
View File
@@ -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<typeof moduleManifestSchema>;
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
/** 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<typeof moduleCapabilityDeclarationSchema>;
/** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's
* authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for
+23
View File
@@ -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 {
@@ -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<string, unknown> {
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"]);
});
@@ -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",
};
@@ -28,7 +28,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
const manifest =
options.manifest ??
JSON.stringify({
manifestVersion: 4,
manifestVersion: 5,
id: "example",
name: "Example",
version: "0.1.0",
@@ -1,5 +1,5 @@
{
"manifestVersion": 4,
"manifestVersion": 5,
"id": "example",
"name": "Example",
"version": "0.1.0",
+10 -1
View File
@@ -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": [
{
+11 -1
View File
@@ -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}` })));
},
});
},
+69 -1
View File
@@ -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<CapabilityId, unknown>();
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", () => {
+1
View File
@@ -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",
],