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]>
668 lines
26 KiB
TypeScript
668 lines
26 KiB
TypeScript
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" });
|
|
});
|
|
});
|