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: 8, 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>>>, ): 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: 8, 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: 8, 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: 8, 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: 8, 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: 8, 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: 8, 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 { 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: 8, 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" }); }); });