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]>
460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|