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
+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 }),
});
}