feat: add the module configuration and secrets contract
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s

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:
ekklesia
2026-08-22 21:44:43 -05:00
co-authored by Claude Opus 5
parent fdaad58848
commit baa689b2a7
76 changed files with 6650 additions and 131 deletions
+1
View File
@@ -1,6 +1,7 @@
export * from "./authorization.js";
export * from "./identifiers.js";
export * from "./identity.js";
export * from "./module-configuration.js";
export * from "./module-database.js";
export * from "./module-http.js";
export * from "./module-lifecycle.js";
@@ -0,0 +1,344 @@
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,
});
+7
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { EkklesiaPrincipal } from "./identity.js";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleRuntimeServices } from "./module-database.js";
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
@@ -220,6 +221,12 @@ export interface ModuleRouteHandlerContext {
readonly principal: EkklesiaPrincipal;
readonly params: Readonly<Record<string, string>>;
readonly services: ModuleRuntimeServices;
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
* same object, not a re-read — so a handler and a lifecycle hook can never disagree about a value
* (ADR 0029). Never serialized into a response by Core; a module that chooses to expose part of
* its own configuration does so through its own route, deliberately, and must never expose a
* secret. */
readonly config: ModuleConfiguration;
}
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
+43 -5
View File
@@ -43,24 +43,62 @@ export const moduleRuntimeStateSchema = z.enum(MODULE_RUNTIME_STATES);
export type ModuleRuntimeState = z.infer<typeof moduleRuntimeStateSchema>;
/**
* Why an enabled module is not running. A structured reason plus the exact dependency it names,
* rather than one state name per case — four states would encode the same two facts twice.
* Why an enabled module is not running. A structured reason plus the exact thing it names — a
* dependency, or a configuration key — rather than one state name per case, which would encode the
* same two facts twice.
*
* `required_dependency_disabled` means the dependency an administrator turned off is the direct
* cause. `required_dependency_blocked` means the named dependency is itself blocked, so the cause
* is transitive; following the chain always terminates at a `..._disabled` entry, because a
* dependency that is merely absent or incompatible is rejected by discovery long before
* eligibility is resolved.
* is transitive; following the chain always terminates at a reason that is not
* `required_dependency_blocked`, because a dependency that is merely absent or incompatible is
* rejected by discovery long before eligibility is resolved. Since ADR 0029 that terminal reason
* may also be one of the three configuration reasons below — a module blocked by its own
* configuration blocks its required dependents exactly like a disabled one does.
*/
export const MODULE_BLOCKED_REASONS = [
"required_dependency_disabled",
"required_dependency_blocked",
"configuration_missing",
"configuration_invalid",
"secret_missing",
] as const;
export const moduleBlockedReasonSchema = z.enum(MODULE_BLOCKED_REASONS);
export type ModuleBlockedReason = z.infer<typeof moduleBlockedReasonSchema>;
/**
* The three configuration reasons an enabled module may fail to become eligible (ADR 0029). They
* are separate because each has a **different operational fix**, which is the only justification
* for a distinct reason code:
*
* - `configuration_missing` — a required key has no stored value and no default. Fix: set a value
* through the configuration API.
* - `configuration_invalid` — a stored value exists but does not satisfy the module's own declared
* type or constraints. Fix: correct the value. A persisted value is never silently coerced,
* truncated, or replaced by a default.
* - `secret_missing` — a required `secret` key has no deployment-supplied value. Fix: set it in the
* deployment environment, not in the database — Ekklesia never stores secret material
* (ADR 0029).
*
* Each names the offending **key**, never the value. A key is public — it is declared in an
* installed module's manifest — while a value may be a credential.
*/
export const MODULE_CONFIGURATION_BLOCKED_REASONS = [
"configuration_missing",
"configuration_invalid",
"secret_missing",
] as const;
export type ModuleConfigurationBlockedReason =
(typeof MODULE_CONFIGURATION_BLOCKED_REASONS)[number];
export function isModuleConfigurationBlockedReason(
reason: ModuleBlockedReason,
): reason is ModuleConfigurationBlockedReason {
return (MODULE_CONFIGURATION_BLOCKED_REASONS as readonly string[]).includes(reason);
}
/** Both kinds of edge a module's manifest can declare as required: another module, or a capability
* some other module provides. Both block identically — a required capability is exactly as
* required as a required module — and both name the *providing module* so an operator has
+37 -8
View File
@@ -1,5 +1,9 @@
import { z } from "zod";
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
import {
type ModuleConfigurationDeclaration,
moduleConfigurationDeclarationSchema,
} from "./module-configuration.js";
import {
capabilityIdSchema,
logicalBucketIdSchema,
@@ -102,17 +106,25 @@ function reportDuplicates(
}
/**
* The one manifest shape Core accepts. Adding `entrypoints.ui` is an incompatible shape change —
* version 3's `entrypoints` object is `.strict()` and rejects `ui` outright — so the canonical
* manifest moves to `manifestVersion: 4` rather than reinterpreting version 3 (ADR 0021).
* The one manifest shape Core accepts. Adding a top-level `configuration` array is an incompatible
* shape change — version 4's manifest object is `.strict()` and rejects `configuration` outright —
* so the canonical manifest moves to `manifestVersion: 5` rather than reinterpreting version 4
* (ADR 0029).
*
* Version 3, and every other version, fails validation: there is no compatibility adapter, no
* Configuration is declared **statically, in this inert document**, rather than registered by
* executable module code, for one decisive reason: the platform validates a module's configuration
* *before* deciding whether it may start, so an enabled module whose required configuration is
* missing is reported as `blocked` with a structured reason instead of taking API startup down from
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
* importing a single line of module code, exactly as they always have.
*
* Version 4, and every other version, fails validation: there is no compatibility adapter, no
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
* (ADR 0006) and version 2 by version 3 (ADR 0014). One manifest document can therefore never be
* read as two different versions. Upgrading a backend-only version 3 manifest is a one-character
* edit: no field added by version 4 is required.
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
* manifest document can therefore never be read as two different versions. Upgrading a version 4
* manifest is a one-character edit: no field added by version 5 is required.
*/
export const MODULE_MANIFEST_VERSION = 4;
export const MODULE_MANIFEST_VERSION = 5;
export const moduleManifestSchema = z
.object({
@@ -130,6 +142,10 @@ export const moduleManifestSchema = z
dependencies: dependenciesSchema,
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
permissions: z.array(permissionDeclarationSchema).default(() => []),
/** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert
* data: declaring configuration never causes Core to read a database, contact a host, or run
* module code during discovery. A module that needs nothing configured omits the field. */
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
storage: moduleStorageSchema,
entrypoints: entrypointsSchema,
})
@@ -153,6 +169,15 @@ export const moduleManifestSchema = z
context,
"id",
);
// One namespace for every configuration key a module owns, so an ordinary key and a secret key
// can never collide: a module cannot declare `apiToken` as both a defaultable string and a
// deployment-supplied secret and leave the platform to guess which one a stored row means.
reportDuplicates(
manifest.configuration.map((declaration) => declaration.key),
["configuration"],
context,
"key",
);
manifest.capabilities.forEach((capability, index) => {
if (!capability.id.startsWith(`${manifest.id}.`)) {
@@ -228,6 +253,10 @@ export function safeParseModuleManifest(input: unknown) {
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
* here for discoverability alongside the manifest's other per-field declaration types. */
export type ModuleManifestConfigurationDeclaration = ModuleConfigurationDeclaration;
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
/** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's
* authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for
+23
View File
@@ -1,3 +1,4 @@
import type { ModuleConfiguration } from "./module-configuration.js";
import type { CapabilityId, ModuleId } from "./identifiers.js";
import { moduleIdSchema } from "./identifiers.js";
import type { ModuleRuntimeServices } from "./module-database.js";
@@ -28,6 +29,15 @@ export interface ModuleRegistrationContext {
* afterward from `start`, `stop`, or any other post-registration code path. */
readonly http: ModuleHttpRouteRegistrar;
readonly moduleId: ModuleId;
/**
* This module's own validated configuration, available here as well as at runtime (ADR 0029).
*
* Present during `register` deliberately, and without reopening the rule that registration stays
* declarative and I/O-free: this is an already-resolved frozen snapshot held in memory, not a
* database-backed getter, so reading it performs no query and cannot fail. That is exactly why
* `services` — which *is* a live database facade — remains absent from this context.
*/
readonly config: ModuleConfiguration;
}
/** The same context shape for both `start` and `stop`: whichever hook runs, it receives the exact
@@ -38,6 +48,19 @@ export interface ModuleRuntimeContext {
readonly capabilities: CapabilityResolver;
readonly moduleId: ModuleId;
readonly services: ModuleRuntimeServices;
/**
* This module's own validated configuration, frozen for the lifetime of the process.
*
* Deliberately **not** a field on `ModuleRuntimeServices`: that object is one shared instance
* every module receives, and configuration is per-module by definition. It sits beside `moduleId`
* — the context's other per-module fact — so the scoping is visible in the shape rather than
* asserted in a comment.
*
* Immutable for the process lifetime: a configuration change is applied by the next restart, so a
* value read during `start` and the same value read in a request handler an hour later are the
* same value (ADR 0029).
*/
readonly config: ModuleConfiguration;
}
export interface ModuleRuntimeDefinition {
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
isVersionCompatible,
isModuleConfigurationBlockedReason,
MODULE_BLOCKED_REASONS,
MODULE_CONFIGURATION_BLOCKED_REASONS,
MODULE_DEPENDENCY_KINDS,
MODULE_MANIFEST_VERSION,
MODULE_RUNTIME_STATES,
@@ -13,7 +15,7 @@ import {
function validManifest(): Record<string, unknown> {
return {
manifestVersion: 4,
manifestVersion: 5,
id: "calendar",
name: "Calendar",
version: "0.1.0",
@@ -209,17 +211,23 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
});
it("rejects a version 3 manifest rather than silently interpreting it as version 4", () => {
// A backend-only version 3 manifest is otherwise byte-identical to a valid version 4 one:
// only the version literal separates them, and it is not reinterpreted.
it("rejects a version 3 manifest rather than silently interpreting it as a later version", () => {
const version3Manifest = { ...validManifest(), manifestVersion: 3 };
expect(safeParseModuleManifest(version3Manifest).success).toBe(false);
});
it("rejects a version 4 manifest rather than silently interpreting it as version 5", () => {
// A manifest declaring no configuration is otherwise byte-identical between version 4 and
// version 5: only the version literal separates them, and it is not reinterpreted (ADR 0029).
const version4Manifest = { ...validManifest(), manifestVersion: 4 };
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(4);
for (const manifestVersion of [1, 2, 3, 5, "4", 4.5, null]) {
expect(MODULE_MANIFEST_VERSION).toBe(5);
for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
}
expect(
@@ -353,7 +361,21 @@ describe("module manifest contract", () => {
expect(MODULE_BLOCKED_REASONS).toEqual([
"required_dependency_disabled",
"required_dependency_blocked",
"configuration_missing",
"configuration_invalid",
"secret_missing",
]);
// The three configuration reasons are a subset, and each corresponds to a real implemented
// check with a distinct operator fix (ADR 0029) — the only justification for a separate code.
expect(MODULE_CONFIGURATION_BLOCKED_REASONS).toEqual([
"configuration_missing",
"configuration_invalid",
"secret_missing",
]);
for (const reason of MODULE_CONFIGURATION_BLOCKED_REASONS) {
expect(isModuleConfigurationBlockedReason(reason)).toBe(true);
}
expect(isModuleConfigurationBlockedReason("required_dependency_disabled")).toBe(false);
expect(MODULE_DEPENDENCY_KINDS).toEqual(["module", "capability"]);
});
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
EMPTY_MODULE_CONFIGURATION,
defineModuleRuntime,
isModuleHttpMethod,
MODULE_HTTP_METHODS,
@@ -59,6 +60,9 @@ describe("defineModuleRuntime", () => {
provide: () => undefined,
resolve: () => undefined,
},
// A module declaring no configuration still receives an empty snapshot rather than
// `undefined`, so module code never branches on whether the platform supplied one.
config: EMPTY_MODULE_CONFIGURATION,
http,
moduleId: "example",
};
@@ -28,7 +28,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
const manifest =
options.manifest ??
JSON.stringify({
manifestVersion: 4,
manifestVersion: 5,
id: "example",
name: "Example",
version: "0.1.0",
@@ -1,5 +1,5 @@
{
"manifestVersion": 4,
"manifestVersion": 5,
"id": "example",
"name": "Example",
"version": "0.1.0",