feat: add the module configuration and secrets contract
Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fdaad58848
commit
baa689b2a7
@@ -0,0 +1,796 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleConfigurationDeclarationInput,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type ModuleConfigurationRecord,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import {
|
||||
createFakeModuleSecretSource,
|
||||
createInMemoryModuleConfigurationStore,
|
||||
} from "./support/in-memory-module-configuration.js";
|
||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const viewerId = parseEkklesiaUserId("55555555-5555-4555-8555-555555555555");
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The configuration shape the real Announcements product would need as an Ekklesia module: a slide
|
||||
* duration and poll interval with defaults, a bucket name it must be told, and a deployment-supplied
|
||||
* storage credential.
|
||||
*
|
||||
* Modelled from the reference repository, never imported from it. Nothing Announcements-specific
|
||||
* exists in Core or in `apps/api`; this fixture exists to prove a real product's settings survive
|
||||
* the whole path — manifest, validation, storage, API, snapshot — without the module reaching any
|
||||
* platform infrastructure.
|
||||
*/
|
||||
const announcementsConfiguration: readonly ModuleConfigurationDeclarationInput[] = [
|
||||
{
|
||||
key: "slideSeconds",
|
||||
type: "integer",
|
||||
default: 7,
|
||||
minimum: 1,
|
||||
maximum: 600,
|
||||
description: "How long each slide is shown.",
|
||||
},
|
||||
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
|
||||
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
|
||||
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
|
||||
{ key: "storageCredential", type: "secret", required: true },
|
||||
];
|
||||
|
||||
interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, fixture.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-${fixture.id}`,
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
configuration: fixture.configuration ?? [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
interface InstallOptions {
|
||||
readonly fixtures?: readonly ModuleFixture[];
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly platformOwner?: boolean;
|
||||
readonly grants?: readonly string[];
|
||||
readonly records?: readonly ModuleConfigurationRecord[];
|
||||
readonly secrets?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
||||
readonly store?: ModuleConfigurationStore;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
readonly app: Hono;
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
readonly store: ModuleConfigurationStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
|
||||
* composition: read the stored settings, resolve eligibility and configuration in one pass, then
|
||||
* build the UI registry and every option from that same resolution. Doing it the same way here is
|
||||
* what makes these assertions evidence about production composition rather than a bespoke wiring.
|
||||
*/
|
||||
async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-config-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
for (const fixture of options.fixtures ?? [
|
||||
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||
]) {
|
||||
await createModule(modulesDirectory, fixture);
|
||||
}
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
const store = options.store ?? createInMemoryModuleConfigurationStore(options.records ?? []);
|
||||
const secrets = createFakeModuleSecretSource(options.secrets ?? {});
|
||||
const lifecycleStore = createInMemoryModuleLifecycleStore();
|
||||
const bootSettings = await store.readSettings();
|
||||
const eligibility = resolveModuleEligibility(modules, await lifecycleStore.readConfiguration(), {
|
||||
secrets,
|
||||
settings: bootSettings,
|
||||
});
|
||||
|
||||
const held = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
|
||||
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const authorizationStore = {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleConfiguration: {
|
||||
bootSettings,
|
||||
discoveredModules: modules,
|
||||
secrets,
|
||||
snapshots: eligibility.configurations,
|
||||
store,
|
||||
},
|
||||
moduleLifecycle: {
|
||||
snapshot: {
|
||||
discoveredModules: modules,
|
||||
eligibility: eligibility.entries,
|
||||
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
|
||||
},
|
||||
store: lifecycleStore,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
return { app, modules, store };
|
||||
}
|
||||
|
||||
function owner(): InstallOptions {
|
||||
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
|
||||
}
|
||||
|
||||
interface ConfigurationBody {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly keys: readonly {
|
||||
readonly key: string;
|
||||
readonly type: string;
|
||||
readonly required: boolean;
|
||||
readonly description?: string;
|
||||
readonly default?: string | number | boolean;
|
||||
readonly values?: readonly string[];
|
||||
readonly value?: string;
|
||||
readonly configured: boolean;
|
||||
}[];
|
||||
readonly unknownKeys: readonly string[];
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
|
||||
async function readConfiguration(
|
||||
app: Hono,
|
||||
moduleId = "announcements",
|
||||
): Promise<ConfigurationBody> {
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
return (await response.json()) as ConfigurationBody;
|
||||
}
|
||||
|
||||
function putConfiguration(
|
||||
app: Hono,
|
||||
values: unknown,
|
||||
moduleId = "announcements",
|
||||
origin = trustedOrigin,
|
||||
) {
|
||||
return app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||
body: JSON.stringify({ values }),
|
||||
headers: { "content-type": "application/json", origin },
|
||||
method: "PUT",
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/v1/module-configuration/:moduleId", () => {
|
||||
it("returns the module's declared schema with defaults and stored values", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||
});
|
||||
|
||||
const body = await readConfiguration(app);
|
||||
|
||||
expect(body.moduleId).toBe("announcements");
|
||||
expect(body.keys.map((entry) => entry.key)).toEqual([
|
||||
"slideSeconds",
|
||||
"pollIntervalSeconds",
|
||||
"bucket",
|
||||
"fit",
|
||||
"storageCredential",
|
||||
]);
|
||||
expect(body.keys[0]).toEqual({
|
||||
configured: false,
|
||||
default: 7,
|
||||
description: "How long each slide is shown.",
|
||||
key: "slideSeconds",
|
||||
required: false,
|
||||
type: "integer",
|
||||
});
|
||||
expect(body.keys.find((entry) => entry.key === "bucket")).toMatchObject({
|
||||
configured: true,
|
||||
required: true,
|
||||
value: "slides",
|
||||
});
|
||||
expect(body.keys.find((entry) => entry.key === "fit")?.values).toEqual(["contain", "cover"]);
|
||||
});
|
||||
|
||||
it("never returns a secret value, only whether the deployment supplies one", async () => {
|
||||
const configured = await install({
|
||||
...owner(),
|
||||
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||
});
|
||||
const body = await readConfiguration(configured.app);
|
||||
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||
|
||||
expect(secretEntry).toEqual({
|
||||
configured: true,
|
||||
key: "storageCredential",
|
||||
required: true,
|
||||
type: "secret",
|
||||
});
|
||||
// Not redacted, not null, not a placeholder — the field is absent entirely, and the secret
|
||||
// string appears nowhere in the payload.
|
||||
expect(secretEntry).not.toHaveProperty("value");
|
||||
expect(secretEntry).not.toHaveProperty("default");
|
||||
expect(JSON.stringify(body)).not.toContain("s3cret");
|
||||
});
|
||||
|
||||
it("reports an unsupplied secret as not configured, still without a value field", async () => {
|
||||
const { app } = await install(owner());
|
||||
const body = await readConfiguration(app);
|
||||
|
||||
expect(body.keys.find((entry) => entry.key === "storageCredential")).toEqual({
|
||||
configured: false,
|
||||
key: "storageCredential",
|
||||
required: true,
|
||||
type: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists stored keys the current manifest no longer declares, without failing", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "removedInThisVersion", moduleId: "announcements", value: "kept" },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await readConfiguration(app);
|
||||
|
||||
expect(body.unknownKeys).toEqual(["removedInThisVersion"]);
|
||||
expect(body.keys.map((entry) => entry.key)).not.toContain("removedInThisVersion");
|
||||
});
|
||||
|
||||
it("returns a module declaring no configuration as an empty, valid document", async () => {
|
||||
const { app } = await install({ ...owner(), fixtures: [{ id: "calendar" }] });
|
||||
const body = await readConfiguration(app, "calendar");
|
||||
|
||||
expect(body.keys).toEqual([]);
|
||||
expect(body.unknownKeys).toEqual([]);
|
||||
expect(body.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a module this process did not discover", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/not-installed`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
|
||||
});
|
||||
|
||||
it("is not cached and varies on origin and cookie", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(response.headers.get("Vary")).toContain("Cookie");
|
||||
});
|
||||
|
||||
it("reports a sanitized 503 when the store cannot be read, with no value or driver detail", async () => {
|
||||
const failing: ModuleConfigurationStore = {
|
||||
readSettings: async () => {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_READ_FAILED");
|
||||
},
|
||||
replace: async () => {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_WRITE_FAILED");
|
||||
},
|
||||
};
|
||||
let bootRead = false;
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
store: {
|
||||
readSettings: async () => {
|
||||
if (!bootRead) {
|
||||
bootRead = true;
|
||||
return { records: [] };
|
||||
}
|
||||
return failing.readSettings();
|
||||
},
|
||||
replace: (input) => failing.replace(input),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_CONFIGURATION_READ_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/v1/module-configuration/:moduleId", () => {
|
||||
it("stores values and reports that a restart is required", async () => {
|
||||
const { app, store } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const response = await putConfiguration(app, { bucket: "slides", slideSeconds: 12 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ConfigurationBody;
|
||||
expect(body.keys.find((entry) => entry.key === "slideSeconds")).toMatchObject({
|
||||
configured: true,
|
||||
value: "12",
|
||||
});
|
||||
// The running process still holds the boot snapshot; claiming otherwise would be a lie.
|
||||
expect(body.restartRequired).toBe(true);
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no pending restart when the submitted document matches what booted", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const response = await putConfiguration(app, { bucket: "slides" });
|
||||
|
||||
expect(((await response.json()) as ConfigurationBody).restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces the whole document, so an omitted key is cleared", async () => {
|
||||
const { app, store } = await install({
|
||||
...owner(),
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
],
|
||||
});
|
||||
|
||||
await putConfiguration(app, { bucket: "slides" });
|
||||
|
||||
expect((await store.readSettings()).records.map((record) => record.key)).toEqual(["bucket"]);
|
||||
});
|
||||
|
||||
it("never touches another module's stored values", async () => {
|
||||
const { app, store } = await install({
|
||||
...owner(),
|
||||
fixtures: [
|
||||
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||
{ configuration: [{ key: "bucket", type: "string" }], id: "calendar" },
|
||||
],
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||
],
|
||||
});
|
||||
|
||||
await putConfiguration(app, { bucket: "renamed" });
|
||||
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "renamed" },
|
||||
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses to accept secret material over HTTP", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(app, {
|
||||
bucket: "slides",
|
||||
storageCredential: "s3cret",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "CONFIGURATION_SECRET_NOT_WRITABLE" });
|
||||
// Nothing was written at all — not the secret, and not the valid key beside it.
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a key the module does not declare", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(app, { bucket: "slides", mqttEndpoint: "mqtt://x" });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "CONFIGURATION_KEY_UNDECLARED" });
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a string for an integer", { bucket: "slides", slideSeconds: "12" }],
|
||||
["a float for an integer", { bucket: "slides", slideSeconds: 12.5 }],
|
||||
["an integer below its declared minimum", { bucket: "slides", slideSeconds: 0 }],
|
||||
["an integer above its declared maximum", { bucket: "slides", slideSeconds: 100000 }],
|
||||
["a value outside a declared enum", { bucket: "slides", fit: "stretch" }],
|
||||
["a string shorter than its declared minLength", { bucket: "" }],
|
||||
["a nested object", { bucket: { name: "slides" } }],
|
||||
["an array", { bucket: ["slides"] }],
|
||||
["null", { bucket: null }],
|
||||
])("rejects %s without storing anything", async (_label, values) => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(app, values);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a body with an unexpected top-level key", { values: {}, force: true }],
|
||||
["a body with no values object", {}],
|
||||
["a bare array body", []],
|
||||
["a bare string body", "bucket=slides"],
|
||||
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
])("rejects the reserved property name %s as a submitted key", async (key) => {
|
||||
const { app, store } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: `{"values":{${JSON.stringify(key)}:"polluted"}}`,
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
// Nothing about the request reached `Object.prototype`.
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts an incomplete document, leaving the module blocked rather than refusing the save", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
// `bucket` is required and absent: an operator may save partial work.
|
||||
const response = await putConfiguration(app, { slideSeconds: 12 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an untrusted origin before authorization or the store runs", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(
|
||||
app,
|
||||
{ bucket: "slides" },
|
||||
"announcements",
|
||||
untrustedOrigin,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a missing origin", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: JSON.stringify({ values: { bucket: "slides" } }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
|
||||
const { app } = await install(owner());
|
||||
const oversized = JSON.stringify({
|
||||
values: { bucket: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) },
|
||||
});
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: oversized,
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("module configuration authority", () => {
|
||||
it("refuses an anonymous caller on both verbs", async () => {
|
||||
const { app } = await install();
|
||||
|
||||
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||
expect((await putConfiguration(app, { bucket: "slides" })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses an ordinary authenticated user who holds no configuration permission", async () => {
|
||||
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||
|
||||
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||
});
|
||||
|
||||
it("admits a non-owner holding exactly core.modules.configuration.manage", async () => {
|
||||
const { app } = await install({
|
||||
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("does not let the lifecycle permission configure a module, or the reverse", async () => {
|
||||
// The two authorities are deliberately separate: tuning how a module behaves and deciding
|
||||
// whether it runs at all are different decisions (ADR 0029).
|
||||
const lifecycleOnly = await install({
|
||||
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
expect(
|
||||
(await lifecycleOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||
).toBe(403);
|
||||
expect((await lifecycleOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
|
||||
|
||||
const configurationOnly = await install({
|
||||
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
expect((await configurationOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(403);
|
||||
expect(
|
||||
(await configurationOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||
).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration state is visible through the lifecycle resource", () => {
|
||||
it("reports a module blocked by missing required configuration, naming the key not the value", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||
});
|
||||
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
const body = (await response.json()) as {
|
||||
readonly modules: readonly {
|
||||
readonly id: string;
|
||||
readonly runtime: {
|
||||
readonly state: string;
|
||||
readonly reason?: string;
|
||||
readonly configurationKey?: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
expect(body.modules[0]?.runtime).toEqual({
|
||||
configurationKey: "bucket",
|
||||
reason: "configuration_missing",
|
||||
state: "blocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a module blocked by a missing secret, without naming the secret's value", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
const raw = await response.text();
|
||||
|
||||
expect(raw).toContain("secret_missing");
|
||||
expect(raw).toContain("storageCredential");
|
||||
});
|
||||
|
||||
it("keeps a configuration-blocked module out of the mountable UI inventory", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
fixtures: [
|
||||
{ configuration: [{ key: "bucket", type: "string", required: true }], id: "announcements" },
|
||||
],
|
||||
});
|
||||
|
||||
const inventory = (await (await app.request("/api/v1/module-ui")).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
|
||||
// Blocked by its own configuration behaves exactly like blocked by a disabled dependency: it is
|
||||
// not eligible, so it is not published.
|
||||
expect(inventory.modules).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no secret or configuration value leaks through any surface", () => {
|
||||
it("keeps secret material out of every response body this resource produces", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
secrets: { announcements: { storageCredential: "super-secret-value" } },
|
||||
});
|
||||
|
||||
const listed = await (
|
||||
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
})
|
||||
).text();
|
||||
const mutated = await (await putConfiguration(app, { bucket: "slides" })).text();
|
||||
const lifecycle = await (
|
||||
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
|
||||
).text();
|
||||
|
||||
for (const payload of [listed, mutated, lifecycle]) {
|
||||
expect(payload).not.toContain("super-secret-value");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not serve a secret value even if a row for that key somehow exists in storage", async () => {
|
||||
// Unreachable through the API, which refuses to write a secret key, and unreachable through the
|
||||
// resolver, which never reads one. This plants the row directly to prove the *serialization*
|
||||
// boundary holds independently: a secret's `value` is omitted by construction, not filtered by a
|
||||
// branch someone could remove.
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "storageCredential", moduleId: "announcements", value: "planted-in-the-database" },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await readConfiguration(app);
|
||||
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||
|
||||
expect(secretEntry).not.toHaveProperty("value");
|
||||
expect(secretEntry?.configured).toBe(false);
|
||||
expect(JSON.stringify(body)).not.toContain("planted-in-the-database");
|
||||
});
|
||||
|
||||
it("never leaks a filesystem path, package name, or manifest path", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const listed = await (
|
||||
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
})
|
||||
).text();
|
||||
|
||||
expect(listed).not.toContain("/tmp");
|
||||
expect(listed).not.toContain("@test/module-");
|
||||
expect(listed).not.toContain("ekklesia.module.json");
|
||||
expect(listed).not.toContain("dist/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the configuration prefix is a sibling of the module route namespace", () => {
|
||||
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
|
||||
// `config` and `configuration` are ordinary lowercase static segments under
|
||||
// `moduleRoutePathSchema`, so a module could legitimately register `PUT /config` and land on
|
||||
// `/api/v1/modules/announcements/config`. A sibling prefix cannot collide.
|
||||
expect(MODULE_CONFIGURATION_PATH.startsWith("/api/v1/modules/")).toBe(false);
|
||||
expect(MODULE_CONFIGURATION_PATH).toBe("/api/v1/module-configuration");
|
||||
|
||||
const { app } = await install(owner());
|
||||
const collision = await app.request("/api/v1/modules/announcements/config", {
|
||||
body: JSON.stringify({ values: {} }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(collision.status).toBe(404);
|
||||
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user