feat: add the module configuration and secrets contract
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:
co-authored by
Claude Opus 5
parent
fdaad58848
commit
baa689b2a7
@@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user