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; /** * 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 = 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; /** 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; /** * 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, });