Files
Ekklesia/packages/contracts/src/module-configuration.ts
ekklesiaandClaude Opus 5 baa689b2a7
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s
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]>
2026-08-22 21:44:43 -05:00

345 lines
14 KiB
TypeScript

import { z } from "zod";
/**
* The complete set of value shapes a module may declare as configuration.
*
* Deliberately six, each earning its place against a real module need rather than anticipating one:
* a bucket name (`string`), a poll interval or slide duration (`integer`), a feature toggle
* (`boolean`), a fixed choice (`enum`), an endpoint (`url`), and a deployment-supplied credential
* (`secret`). There is no `object`, no `array`, no nesting, and no recursion — this is a validation
* vocabulary, not a form-builder DSL, and a module that genuinely needs structured data owns a table
* for it (ADR 0029).
*
* `secret` is a *type* rather than a flag on `string` because it is not a string with an extra
* property: it is never persisted by Ekklesia, never defaulted, never returned by any API, and
* never printed by diagnostics. Making that a separate declaration is what lets every one of those
* rules be enforced structurally instead of remembered.
*/
export const MODULE_CONFIGURATION_TYPES = [
"string",
"integer",
"boolean",
"enum",
"url",
"secret",
] as const;
export const moduleConfigurationTypeSchema = z.enum(MODULE_CONFIGURATION_TYPES);
export type ModuleConfigurationType = z.infer<typeof moduleConfigurationTypeSchema>;
/**
* Names JavaScript can carry safely and JSON can round-trip without surprise: lower camelCase,
* letters and digits only, bounded.
*
* No dots, so a configuration key can never be mistaken for a permission or capability identifier,
* which are dot-namespaced. No underscores or leading capitals, so there is exactly one spelling of
* any key. And an explicit reserved-name rejection below, because `constructor`, `prototype`, and
* friends satisfy this pattern while being exactly the property names that turn an ordinary object
* write into prototype pollution. Core additionally never indexes a plain object with a
* module-supplied key — every lookup goes through a `Map` — so this check is defense in depth over a
* structural guarantee rather than the only thing standing between a manifest and `Object.prototype`.
*/
const RESERVED_CONFIGURATION_KEYS: ReadonlySet<string> = new Set([
"__proto__",
"constructor",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"prototype",
"toLocaleString",
"toString",
"valueOf",
]);
export const moduleConfigurationKeySchema = z
.string()
.min(1)
.max(64)
.regex(/^[a-z][a-zA-Z0-9]*$/u, "Must be a lower camelCase configuration key.")
.refine(
(key) => !RESERVED_CONFIGURATION_KEYS.has(key),
"Must not be a reserved JavaScript object property name.",
);
export type ModuleConfigurationKey = z.infer<typeof moduleConfigurationKeySchema>;
/** The one bound on any stored configuration literal. Generous for a bucket name, an endpoint, or a
* short list, and far below anything that would make the per-module document expensive to read at
* startup. Enforced by the contract, by the store, and by the database. */
export const MODULE_CONFIGURATION_VALUE_MAX_LENGTH = 2048;
/** Operator-facing text only. Never parsed, never interpreted, and never used to decide anything —
* changing it can never change what a value means, exactly like a permission's `description`. */
const descriptionSchema = z.string().trim().min(1).max(240).optional();
const commonFields = {
key: moduleConfigurationKeySchema,
description: descriptionSchema,
/** When true and no value resolves, the module is not eligible to start and the platform reports
* a structured reason naming this key. Mutually exclusive with `default`: a key that always has a
* value can never be missing, so declaring both states an intent the platform cannot honour. */
required: z.boolean().default(false),
};
const stringDeclarationSchema = z
.object({
...commonFields,
type: z.literal("string"),
default: z.string().max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
minLength: z.number().int().min(0).max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
maxLength: z.number().int().min(1).max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
})
.strict();
const integerDeclarationSchema = z
.object({
...commonFields,
type: z.literal("integer"),
default: z.number().int().safe().optional(),
minimum: z.number().int().safe().optional(),
maximum: z.number().int().safe().optional(),
})
.strict();
const booleanDeclarationSchema = z
.object({
...commonFields,
type: z.literal("boolean"),
default: z.boolean().optional(),
})
.strict();
const enumDeclarationSchema = z
.object({
...commonFields,
type: z.literal("enum"),
/** Bounded and non-empty: an enum with no members can never be satisfied, and one with hundreds
* is a lookup table that belongs in the module's own storage. */
values: z.array(z.string().min(1).max(120)).min(1).max(32),
default: z.string().optional(),
})
.strict();
const urlDeclarationSchema = z
.object({
...commonFields,
type: z.literal("url"),
default: z.string().max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
})
.strict();
/**
* A deployment-supplied credential. Note what this shape does **not** have: no `default`, because a
* checked-in default credential is a leaked credential, and no constraints, because Ekklesia never
* inspects the value.
*/
const secretDeclarationSchema = z
.object({
...commonFields,
type: z.literal("secret"),
})
.strict();
export const moduleConfigurationDeclarationSchema = z
.discriminatedUnion("type", [
stringDeclarationSchema,
integerDeclarationSchema,
booleanDeclarationSchema,
enumDeclarationSchema,
urlDeclarationSchema,
secretDeclarationSchema,
])
.superRefine((declaration, context) => {
if (
declaration.type !== "secret" &&
declaration.required &&
declaration.default !== undefined
) {
context.addIssue({
code: "custom",
message: "A key with a default can never be missing, so it must not also be required.",
path: ["required"],
});
}
if (declaration.type === "string") {
if (
declaration.minLength !== undefined &&
declaration.maxLength !== undefined &&
declaration.minLength > declaration.maxLength
) {
context.addIssue({
code: "custom",
message: "minLength must not exceed maxLength.",
path: ["minLength"],
});
}
if (declaration.default !== undefined) {
if (
(declaration.minLength !== undefined &&
declaration.default.length < declaration.minLength) ||
(declaration.maxLength !== undefined &&
declaration.default.length > declaration.maxLength)
) {
context.addIssue({
code: "custom",
message: "A default value must satisfy its own declared constraints.",
path: ["default"],
});
}
}
}
if (declaration.type === "integer") {
if (
declaration.minimum !== undefined &&
declaration.maximum !== undefined &&
declaration.minimum > declaration.maximum
) {
context.addIssue({
code: "custom",
message: "minimum must not exceed maximum.",
path: ["minimum"],
});
}
if (declaration.default !== undefined) {
if (
(declaration.minimum !== undefined && declaration.default < declaration.minimum) ||
(declaration.maximum !== undefined && declaration.default > declaration.maximum)
) {
context.addIssue({
code: "custom",
message: "A default value must satisfy its own declared constraints.",
path: ["default"],
});
}
}
}
if (declaration.type === "enum") {
if (new Set(declaration.values).size !== declaration.values.length) {
context.addIssue({
code: "custom",
message: "Enum values must be unique.",
path: ["values"],
});
}
if (declaration.default !== undefined && !declaration.values.includes(declaration.default)) {
context.addIssue({
code: "custom",
message: "A default value must be one of the declared enum values.",
path: ["default"],
});
}
}
if (declaration.type === "url" && declaration.default !== undefined) {
if (!isSupportedConfigurationUrl(declaration.default)) {
context.addIssue({
code: "custom",
message: "A default URL must be an absolute http: or https: URL.",
path: ["default"],
});
}
}
});
export type ModuleConfigurationDeclarationInput = z.input<
typeof moduleConfigurationDeclarationSchema
>;
export type ModuleConfigurationDeclaration = z.output<typeof moduleConfigurationDeclarationSchema>;
/**
* The one URL rule for `type: "url"`, shared by declaration validation and value validation so the
* two can never disagree.
*
* Only absolute `http:`/`https:` URLs. Everything else — `file:`, `javascript:`, `data:`, a
* protocol-relative `//host`, a bare hostname — is rejected outright rather than normalized,
* because a configuration value is frequently handed straight to `fetch`, and the difference
* between "an endpoint an operator meant" and "a local file the server can read" must not be a
* parsing accident.
*/
export function isSupportedConfigurationUrl(value: string): boolean {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return parsed.protocol === "http:" || parsed.protocol === "https:";
}
/** Every value a resolved configuration can hold. `secret` resolves to a `string` like the others —
* the difference is entirely in where it comes from and where it may go, never in its shape. */
export type ModuleConfigurationValue = string | number | boolean;
/**
* A module's own validated configuration, frozen at startup.
*
* **This is the whole module-facing contract.** It has no reference to PostgreSQL, no environment
* access, no module id but its own, and no way to reach another module's values: the object simply
* does not contain them, and no accessor takes a namespace. A future standalone host implements
* this exact interface from a YAML file, its own database, or anything else, and the module's
* feature code cannot tell the difference — which is the point (ADR 0029).
*
* Every accessor fails closed: an undeclared key, a key whose declared type does not match the
* accessor, and an absent optional value are all `undefined`, deliberately indistinguishable, in the
* same style as `PermissionRegistry.get`. A key the manifest declares `required`, or gives a
* `default`, is guaranteed present by the time any module code runs — the platform refuses to start
* a module whose required configuration is missing — so those accessors return `undefined` only for
* a key the module never declared.
*/
export interface ModuleConfiguration {
/** Every key this module declared *and* that resolved to a value, sorted. Never another module's,
* and never a key that only exists in storage. */
keys(): readonly string[];
has(key: string): boolean;
/** The validated value under its declared type, or `undefined`. Secrets are reachable here too —
* a module reading its own secret is the entire point — which is exactly why this object must
* never be serialized into a response. Core never does; see `module-configuration-routes.ts`. */
get(key: string): ModuleConfigurationValue | undefined;
getString(key: string): string | undefined;
getInteger(key: string): number | undefined;
getBoolean(key: string): boolean | undefined;
/** The resolved member of a `type: "enum"` key. Separate from `getString` so a module cannot
* silently start reading a free-text key as though it were a closed choice. */
getEnum(key: string): string | undefined;
/** A validated absolute `http:`/`https:` URL string. Returned as a string rather than a `URL` so
* the contract stays JSON-shaped and identical across hosts. */
getUrl(key: string): string | undefined;
/** A deployment-supplied credential. Never persisted by Ekklesia, never returned by any HTTP
* route, and never printed by diagnostics. */
getSecret(key: string): string | undefined;
}
/** The empty configuration every module without declarations receives. `context.config` is therefore
* always present and never `undefined`, so module code never branches on whether the platform
* supplied one. */
export const EMPTY_MODULE_CONFIGURATION: ModuleConfiguration = Object.freeze({
get: (): undefined => undefined,
getBoolean: (): undefined => undefined,
getEnum: (): undefined => undefined,
getInteger: (): undefined => undefined,
getSecret: (): undefined => undefined,
getString: (): undefined => undefined,
getUrl: (): undefined => undefined,
has: (): boolean => false,
keys: (): readonly string[] => Object.freeze([]),
});
/**
* How a host supplies deployment-owned secret material for one module's declared `secret` keys.
*
* Scoped by construction: a lookup takes a module id **and** a key the platform already validated
* against that module's own manifest, so there is no call shape that could read another module's
* secret and no way to ask for an arbitrary environment variable. A module never sees this
* interface at all — the host resolves secrets into the module's frozen snapshot before any module
* code runs.
*/
export interface ModuleSecretSource {
get(moduleId: string, key: string): string | undefined;
}
/** A secret source that supplies nothing. The explicit "this deployment provides no module secrets"
* value, so a caller states that rather than passing `undefined` and hoping. */
export const EMPTY_MODULE_SECRET_SOURCE: ModuleSecretSource = Object.freeze({
get: (): undefined => undefined,
});