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