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
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
"./src/authorization/schema.ts",
|
||||
"./src/dashboard/schema.ts",
|
||||
"./src/modules/lifecycle-schema.ts",
|
||||
"./src/modules/configuration-schema.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "ekklesia_core_module_configuration" (
|
||||
"module_id" text NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_module_configuration_module_id_key_pk" PRIMARY KEY("module_id","key"),
|
||||
CONSTRAINT "ekklesia_core_module_configuration_module_check" CHECK (length("ekklesia_core_module_configuration"."module_id") > 0),
|
||||
CONSTRAINT "ekklesia_core_module_configuration_key_check" CHECK (length("ekklesia_core_module_configuration"."key") > 0),
|
||||
CONSTRAINT "ekklesia_core_module_configuration_value_check" CHECK (length("ekklesia_core_module_configuration"."value") <= 2048)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_module_configuration_module_idx" ON "ekklesia_core_module_configuration" USING btree ("module_id");
|
||||
@@ -0,0 +1,993 @@
|
||||
{
|
||||
"id": "d41e490c-dbe1-4d73-9e11-6e79baa91908",
|
||||
"prevId": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ekklesia_core_account": {
|
||||
"name": "ekklesia_core_account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_account_user_id_idx": {
|
||||
"name": "ekklesia_core_account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_account",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_session": {
|
||||
"name": "ekklesia_core_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_session_user_id_idx": {
|
||||
"name": "ekklesia_core_session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_session",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_session_token_unique": {
|
||||
"name": "ekklesia_core_session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_user": {
|
||||
"name": "ekklesia_core_user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_user_email_unique": {
|
||||
"name": "ekklesia_core_user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_verification": {
|
||||
"name": "ekklesia_core_verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_verification_identifier_idx": {
|
||||
"name": "ekklesia_core_verification_identifier_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_authorization_audit": {
|
||||
"name": "ekklesia_core_authorization_audit",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"subject_user_id": {
|
||||
"name": "subject_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_authorization_audit_subject_idx": {
|
||||
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "subject_user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_authorization_audit_action_check": {
|
||||
"name": "ekklesia_core_authorization_audit_action_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_scope_check": {
|
||||
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_grant": {
|
||||
"name": "ekklesia_core_grant",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_kind": {
|
||||
"name": "source_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_ref": {
|
||||
"name": "source_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"created_by_actor_type": {
|
||||
"name": "created_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by_actor_id": {
|
||||
"name": "created_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_grant_user_permission_idx": {
|
||||
"name": "ekklesia_core_grant_user_permission_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "permission_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_grant",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_grant_source_kind_check": {
|
||||
"name": "ekklesia_core_grant_source_kind_check",
|
||||
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||
},
|
||||
"ekklesia_core_grant_created_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner": {
|
||||
"name": "ekklesia_core_platform_owner",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_at": {
|
||||
"name": "granted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"granted_by_actor_type": {
|
||||
"name": "granted_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_by_actor_id": {
|
||||
"name": "granted_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_platform_owner",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"consumed_for_user_id": {
|
||||
"name": "consumed_for_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_group": {
|
||||
"name": "ekklesia_core_dashboard_group",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_group_order_idx": {
|
||||
"name": "ekklesia_core_dashboard_group_order_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_group_id_check": {
|
||||
"name": "ekklesia_core_dashboard_group_id_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
|
||||
},
|
||||
"ekklesia_core_dashboard_group_label_check": {
|
||||
"name": "ekklesia_core_dashboard_group_label_check",
|
||||
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_placement": {
|
||||
"name": "ekklesia_core_dashboard_placement",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"group_id": {
|
||||
"name": "group_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_placement_group_idx": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "group_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
|
||||
"tableFrom": "ekklesia_core_dashboard_placement",
|
||||
"tableTo": "ekklesia_core_dashboard_group",
|
||||
"columnsFrom": ["group_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_placement_module_check": {
|
||||
"name": "ekklesia_core_dashboard_placement_module_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_module_state": {
|
||||
"name": "ekklesia_core_module_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_module_state_module_check": {
|
||||
"name": "ekklesia_core_module_state_module_check",
|
||||
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_module_configuration": {
|
||||
"name": "ekklesia_core_module_configuration",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_module_configuration_module_idx": {
|
||||
"name": "ekklesia_core_module_configuration_module_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "module_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"ekklesia_core_module_configuration_module_id_key_pk": {
|
||||
"name": "ekklesia_core_module_configuration_module_id_key_pk",
|
||||
"columns": ["module_id", "key"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_module_configuration_module_check": {
|
||||
"name": "ekklesia_core_module_configuration_module_check",
|
||||
"value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0"
|
||||
},
|
||||
"ekklesia_core_module_configuration_key_check": {
|
||||
"name": "ekklesia_core_module_configuration_key_check",
|
||||
"value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0"
|
||||
},
|
||||
"ekklesia_core_module_configuration_value_check": {
|
||||
"name": "ekklesia_core_module_configuration_value_check",
|
||||
"value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0005_module_configuration",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1787423121678,
|
||||
"tag": "0004_module_lifecycle_state",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1787451496425,
|
||||
"tag": "0005_module_configuration",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -129,6 +129,29 @@ export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.modules.lifecycle.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* Restricted, and deliberately **separate from `core.modules.lifecycle.manage`**. The two authorize
|
||||
* genuinely different actions: lifecycle decides *whether a module runs at all*, while this decides
|
||||
* *how a running module behaves*. Someone trusted to tune a slideshow's timing need not be trusted
|
||||
* to take modules offline, and the reverse is equally true, so folding them into one permission
|
||||
* would grant authority nobody asked for in whichever direction it was used (ADR 0029).
|
||||
*
|
||||
* It gates reading as well as writing, because the read returns the module's declared schema
|
||||
* together with every stored value — endpoints, bucket names, and whatever else an operator has
|
||||
* configured. That is deployment detail, not something every signed-in user needs.
|
||||
*
|
||||
* **It never exposes secret material.** A `secret` key's value is deployment-supplied, is never
|
||||
* persisted, and is never returned by any route; the API reports only whether one is configured.
|
||||
* Holding this permission therefore confers no ability to read a credential — there is no code path
|
||||
* that would serve one.
|
||||
*
|
||||
* Like every other Core permission it is a **platform** permission a module can neither declare,
|
||||
* gate, nor observe, and holding it confers no authority inside any module.
|
||||
*/
|
||||
export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.modules.configuration.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||
@@ -157,6 +180,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
||||
description: "Read and change which installed modules are enabled.",
|
||||
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
}),
|
||||
Object.freeze({
|
||||
baseline: "restricted" as const,
|
||||
description: "Read and change an installed module's own configuration values.",
|
||||
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,11 +127,15 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`,
|
||||
);
|
||||
const blocked = resolution.entries.filter((entry) => entry.blocked !== undefined);
|
||||
for (const entry of blocked) {
|
||||
console.log(
|
||||
`- "${entry.moduleId}" will not start: ${entry.blocked?.reason} (${entry.blocked?.dependency.moduleId}).`,
|
||||
);
|
||||
for (const entry of resolution.entries) {
|
||||
const blocked = entry.blocked;
|
||||
if (!blocked) {
|
||||
continue;
|
||||
}
|
||||
// Names the dependency or the configuration key, never a configuration value.
|
||||
const cause =
|
||||
"dependency" in blocked ? blocked.dependency.moduleId : blocked.configurationKey;
|
||||
console.log(`- "${entry.moduleId}" will not start: ${blocked.reason} (${cause}).`);
|
||||
}
|
||||
} finally {
|
||||
await close().catch(() => undefined);
|
||||
|
||||
@@ -16,6 +16,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
|
||||
"modules.dependencies",
|
||||
"modules.permissions",
|
||||
"modules.lifecycle",
|
||||
"modules.configuration",
|
||||
]),
|
||||
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
isVersionCompatible,
|
||||
moduleConfigurationKeySchema,
|
||||
moduleIdSchema,
|
||||
type ModuleSecretSource,
|
||||
permissionIdSchema,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -41,6 +44,8 @@ import {
|
||||
} from "../migrations/index.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleConfigurationDatabase,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleDatabase,
|
||||
createModuleLifecycleStore,
|
||||
type DiscoveredModule,
|
||||
@@ -49,6 +54,7 @@ import {
|
||||
type ModuleEligibility,
|
||||
ModulePermissionCompositionError,
|
||||
type ModuleDiscoveryOptions,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleLifecycleConfiguration,
|
||||
type ModuleMigrationAggregate,
|
||||
type ModuleSchemaAggregate,
|
||||
@@ -62,6 +68,7 @@ export type DoctorCheckId =
|
||||
| "modules.dependencies"
|
||||
| "modules.permissions"
|
||||
| "modules.lifecycle"
|
||||
| "modules.configuration"
|
||||
| "schemas.discovery"
|
||||
| "migrations.discovery"
|
||||
| "postgresql.connection"
|
||||
@@ -102,6 +109,10 @@ export interface DoctorOptions {
|
||||
readonly databaseUrl?: string | undefined;
|
||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
|
||||
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required
|
||||
* secret as missing, which is the honest answer for a deployment that has not set it. */
|
||||
readonly moduleSecrets?: ModuleSecretSource;
|
||||
readonly runtimeRange: string;
|
||||
readonly runtimeVersion: string;
|
||||
}
|
||||
@@ -128,6 +139,12 @@ export interface DoctorDependencies {
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
) => Promise<ModuleLifecycleConfiguration>;
|
||||
/** Reads the stored module configuration read-only, behind the same dependency boundary as every
|
||||
* other I/O Doctor performs. */
|
||||
readonly readModuleConfiguration: (
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
) => Promise<ModuleConfigurationSettings>;
|
||||
readonly planMigrations: (options: {
|
||||
readonly aggregate: ModuleMigrationAggregate;
|
||||
readonly database: MigrationDatabase;
|
||||
@@ -163,6 +180,20 @@ async function readModuleLifecycleForRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens its own short-lived connection, reads, and always closes it — the same discipline
|
||||
* `readModuleLifecycleForRuntime` follows. */
|
||||
async function readModuleConfigurationForRuntime(
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
): Promise<ModuleConfigurationSettings> {
|
||||
const { close, db } = createModuleConfigurationDatabase(connectionString, options);
|
||||
try {
|
||||
return await createModuleConfigurationStore(db).readSettings();
|
||||
} finally {
|
||||
await close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
composePermissions: composePlatformPermissionRegistry,
|
||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||
@@ -170,6 +201,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
readModuleConfiguration: readModuleConfigurationForRuntime,
|
||||
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||
runtimeSatisfies: isVersionCompatible,
|
||||
});
|
||||
@@ -301,6 +333,16 @@ function safeLifecycleDetail(entry: ModuleEligibility): string {
|
||||
if (!entry.blocked) {
|
||||
return `${identity} · enabled`;
|
||||
}
|
||||
if (!("dependency" in entry.blocked)) {
|
||||
// A configuration block names the offending **key** and never its value: a key is declared in a
|
||||
// public manifest, while a value may be a credential or arbitrary operator-entered text. The key
|
||||
// is re-validated against the same grammar the manifest accepted it under, for the same
|
||||
// defense-in-depth reason every other interpolation here is.
|
||||
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
|
||||
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||
}
|
||||
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
|
||||
}
|
||||
const dependency = entry.blocked.dependency;
|
||||
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
|
||||
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||
@@ -343,6 +385,79 @@ function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorChec
|
||||
return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether each installed module's configuration would actually let it run — and
|
||||
* deliberately reports **nothing about the values themselves**.
|
||||
*
|
||||
* Not one configuration value is printed, secret or otherwise. A value is operator-entered text that
|
||||
* may be an endpoint, a bucket name, or a credential that ended up in the wrong field; a *key* is
|
||||
* declared in a public manifest and is safe. So a healthy module renders as a count, and an unhealthy
|
||||
* one names the reason and the offending key. That is everything an operator needs to act, and
|
||||
* nothing that turns `pnpm doctor` output — which people paste into chat — into a disclosure channel.
|
||||
*/
|
||||
function safeConfigurationDetail(entry: ModuleEligibility, declaredCount: number): string {
|
||||
if (
|
||||
!moduleIdSchema.safeParse(entry.moduleId).success ||
|
||||
!semanticVersionSchema.safeParse(entry.version).success
|
||||
) {
|
||||
throw new TypeError("Unsafe module configuration diagnostic value.");
|
||||
}
|
||||
const identity = `${entry.moduleId} ${entry.version}`;
|
||||
if (declaredCount === 0) {
|
||||
return `${identity} · no configuration declared`;
|
||||
}
|
||||
const declared = `${declaredCount} ${plural(declaredCount, "key")} declared`;
|
||||
if (entry.blocked && !("dependency" in entry.blocked)) {
|
||||
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
|
||||
throw new TypeError("Unsafe module configuration diagnostic value.");
|
||||
}
|
||||
return `${identity} · ${declared} · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
|
||||
}
|
||||
if (!entry.configured.enabled) {
|
||||
// Not evaluated: a disabled module's configuration is not resolved at all, and claiming it is
|
||||
// valid would be asserting something nothing checked.
|
||||
return `${identity} · ${declared} · not evaluated (disabled)`;
|
||||
}
|
||||
return `${identity} · ${declared} · valid`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration counterpart to `modules.lifecycle`: which installed modules could not run
|
||||
* because of their own configuration, and which key is at fault.
|
||||
*
|
||||
* A module whose configuration is fine, or which declares none, is a `pass` line. A module blocked
|
||||
* by configuration is a `warning`, for the same reason a dependency-blocked module is: an enabled
|
||||
* module that cannot run is an anomaly an operator should see without reading logs.
|
||||
*/
|
||||
function moduleConfigurationCheck(
|
||||
entries: readonly ModuleEligibility[],
|
||||
declaredCounts: ReadonlyMap<string, number>,
|
||||
): DoctorCheck {
|
||||
const blockedByConfiguration = entries.filter(
|
||||
(entry) => entry.blocked !== undefined && !("dependency" in entry.blocked),
|
||||
).length;
|
||||
const declaringModules = entries.filter(
|
||||
(entry) => (declaredCounts.get(entry.moduleId) ?? 0) > 0,
|
||||
).length;
|
||||
const details = [
|
||||
`${declaringModules} ${plural(declaringModules, "module")} declaring configuration · ${blockedByConfiguration} blocked`,
|
||||
...entries.map((entry) =>
|
||||
safeConfigurationDetail(entry, declaredCounts.get(entry.moduleId) ?? 0),
|
||||
),
|
||||
];
|
||||
if (blockedByConfiguration > 0) {
|
||||
return freezeCheck(
|
||||
"modules.configuration",
|
||||
"warning",
|
||||
blockedByConfiguration === 1
|
||||
? "1 module cannot run with its current configuration"
|
||||
: `${blockedByConfiguration} modules cannot run with their current configuration`,
|
||||
details,
|
||||
);
|
||||
}
|
||||
return freezeCheck("modules.configuration", "pass", "Module configuration valid", details);
|
||||
}
|
||||
|
||||
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||
const details =
|
||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
||||
@@ -672,31 +787,61 @@ export async function diagnoseEkklesia(
|
||||
// operator asking "is this module running?" must still get an answer when an unrelated check
|
||||
// failed. It needs only a successful discovery and a configured database.
|
||||
let lifecycleCheck: DoctorCheck;
|
||||
let configurationCheck: DoctorCheck;
|
||||
const skippedConfiguration = (detail: string): DoctorCheck =>
|
||||
freezeCheck("modules.configuration", "skipped", "Module configuration skipped", [detail]);
|
||||
if (!discoveredModules) {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
configurationCheck = skippedConfiguration("Complete module discovery is required.");
|
||||
} else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||
"DATABASE_URL is not configured.",
|
||||
]);
|
||||
configurationCheck = skippedConfiguration("DATABASE_URL is not configured.");
|
||||
} else {
|
||||
try {
|
||||
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
||||
environment: options.databaseEnvironment ?? "production",
|
||||
});
|
||||
const configuration = await dependencies.readModuleLifecycle(options.databaseUrl, {
|
||||
const poolOptions = {
|
||||
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||
transport: lifecycleTransport,
|
||||
};
|
||||
// Both axes read and resolved in the one pass the platform itself uses, so Doctor can never
|
||||
// report a module as runnable that startup would block, or the reverse.
|
||||
// Sequential for the same reason `startApi` reads them sequentially: a deterministic failure
|
||||
// beats two tiny queries racing.
|
||||
const configuration = await dependencies.readModuleLifecycle(
|
||||
options.databaseUrl,
|
||||
poolOptions,
|
||||
);
|
||||
const settings = await dependencies.readModuleConfiguration(options.databaseUrl, poolOptions);
|
||||
const resolution = resolveModuleEligibility(discoveredModules, configuration, {
|
||||
secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE,
|
||||
settings,
|
||||
});
|
||||
lifecycleCheck = moduleLifecycleCheck(
|
||||
resolveModuleEligibility(discoveredModules, configuration).entries,
|
||||
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
|
||||
configurationCheck = moduleConfigurationCheck(
|
||||
resolution.entries,
|
||||
new Map(
|
||||
discoveredModules.map(
|
||||
(module) => [module.manifest.id, module.manifest.configuration.length] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
|
||||
"The stored module enablement configuration could not be read safely.",
|
||||
]);
|
||||
configurationCheck = freezeCheck(
|
||||
"modules.configuration",
|
||||
"failure",
|
||||
"Module configuration unavailable",
|
||||
["The stored module configuration could not be read safely."],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,6 +940,7 @@ export async function diagnoseEkklesia(
|
||||
dependencyCheck,
|
||||
permissionsCheck,
|
||||
lifecycleCheck,
|
||||
configurationCheck,
|
||||
schemaCheck,
|
||||
migrationCheck,
|
||||
transportCheck,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, index, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* Core-owned storage for the ordinary, non-secret configuration an operator has set for an
|
||||
* installed module.
|
||||
*
|
||||
* **Secret material is never stored here, and there is nowhere to put it.** Ekklesia has no
|
||||
* encryption at rest and no key management, so a `secret` key's value is supplied by the deployment
|
||||
* environment and resolved into a module's snapshot without ever touching PostgreSQL (ADR 0029).
|
||||
* The store refuses to write a key a module declared as `secret`; this table has no column, flag, or
|
||||
* convention that would make a plaintext credential look acceptable.
|
||||
*
|
||||
* **One row per (module, key), not one JSON document per module.** A `jsonb` blob would put every
|
||||
* invariant in application code, make a single key impossible to address, and invite exactly the
|
||||
* arbitrary-settings-bag this pass exists to avoid — the same reasoning ADR 0027 used to reject a
|
||||
* generic settings framework in favour of purpose-built tables.
|
||||
*
|
||||
* Platform-global, like the Dashboard and lifecycle tables, because Ekklesia has no organization or
|
||||
* tenant model to scope it to. When one exists, this table gains a scope column.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One stored configuration value.
|
||||
*
|
||||
* `key` is **data, never an SQL identifier**. It is a value in a parameterized column, so a module
|
||||
* declaring an outlandish key can produce a strange row and nothing else — no column, no table, and
|
||||
* no dynamic SQL anywhere in this domain.
|
||||
*
|
||||
* `value` is `text` holding the unparsed literal. Every declared type has one canonical text form,
|
||||
* and keeping storage untyped is what makes "a persisted value is never silently trusted" a
|
||||
* structural property: nothing can read this as a number until `resolveModuleConfigurationFor` has
|
||||
* validated it against the module's own current declaration.
|
||||
*
|
||||
* `module_id` carries **no foreign key**, for the same reason the lifecycle and Dashboard tables do
|
||||
* not: modules are packages discovered on disk, not rows. A value for a module that is not currently
|
||||
* deployed is therefore normal and is retained, so redeploying restores an operator's settings
|
||||
* rather than resetting them.
|
||||
*/
|
||||
export const ekklesiaCoreModuleConfiguration = pgTable(
|
||||
"ekklesia_core_module_configuration",
|
||||
{
|
||||
moduleId: text("module_id").notNull(),
|
||||
key: text("key").notNull(),
|
||||
value: text("value").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.moduleId, table.key] }),
|
||||
index("ekklesia_core_module_configuration_module_idx").on(table.moduleId),
|
||||
// Defense in depth beneath the application-layer identifier contracts, which never produce
|
||||
// either: an empty module id or key would be an unaddressable, permanently inert row.
|
||||
check("ekklesia_core_module_configuration_module_check", sql`length(${table.moduleId}) > 0`),
|
||||
check("ekklesia_core_module_configuration_key_check", sql`length(${table.key}) > 0`),
|
||||
// The same 2048-character ceiling the contract and the store enforce, restated where the data
|
||||
// actually lives, so no caller — including a future one — can store a value the resolver would
|
||||
// then reject as invalid on every boot.
|
||||
check("ekklesia_core_module_configuration_value_check", sql`length(${table.value}) <= 2048`),
|
||||
],
|
||||
);
|
||||
|
||||
/** Explicit map passed to Drizzle, mirroring `authorizationSchema`, `dashboardSchema`, and
|
||||
* `moduleLifecycleSchema`. */
|
||||
export const moduleConfigurationSchema = {
|
||||
moduleConfiguration: ekklesiaCoreModuleConfiguration,
|
||||
} as const;
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
|
||||
moduleConfigurationKeySchema,
|
||||
moduleIdSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "../database/index.js";
|
||||
import type { ModuleConfigurationRecord, ModuleConfigurationSettings } from "./configuration.js";
|
||||
import {
|
||||
ekklesiaCoreModuleConfiguration,
|
||||
moduleConfigurationSchema,
|
||||
} from "./configuration-schema.js";
|
||||
|
||||
export type ModuleConfigurationDrizzleDatabase = NodePgDatabase<typeof moduleConfigurationSchema>;
|
||||
|
||||
/** A typed Drizzle view over an externally owned pool, exactly like `createModuleLifecycleView` and
|
||||
* `createDashboardDrizzleView`: it never constructs, registers an error listener on, or closes a
|
||||
* pool. */
|
||||
export function createModuleConfigurationView(pool: Pool): ModuleConfigurationDrizzleDatabase {
|
||||
return drizzle(pool, { schema: moduleConfigurationSchema });
|
||||
}
|
||||
|
||||
export interface ModuleConfigurationDatabaseHandle {
|
||||
readonly close: () => Promise<void>;
|
||||
readonly db: ModuleConfigurationDrizzleDatabase;
|
||||
}
|
||||
|
||||
/** A self-contained handle for a caller that needs only this domain for the length of one command —
|
||||
* Doctor's read-only configuration check. Composes the shared validated pool factory rather than
|
||||
* repeating connection-string and timeout validation. `apps/api` shares one pool and uses
|
||||
* `createModuleConfigurationView` instead. */
|
||||
export function createModuleConfigurationDatabase(
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions = {},
|
||||
): ModuleConfigurationDatabaseHandle {
|
||||
const { close, pool } = createValidatedPostgresPool(connectionString, options);
|
||||
return Object.freeze({ close, db: createModuleConfigurationView(pool) });
|
||||
}
|
||||
|
||||
export type ModuleConfigurationStoreErrorCode =
|
||||
| "MODULE_CONFIGURATION_KEY_INVALID"
|
||||
| "MODULE_CONFIGURATION_MODULE_ID_INVALID"
|
||||
| "MODULE_CONFIGURATION_READ_FAILED"
|
||||
| "MODULE_CONFIGURATION_VALUE_INVALID"
|
||||
| "MODULE_CONFIGURATION_WRITE_FAILED";
|
||||
|
||||
const storeErrorMessages: Readonly<Record<ModuleConfigurationStoreErrorCode, string>> =
|
||||
Object.freeze({
|
||||
MODULE_CONFIGURATION_KEY_INVALID: "A module configuration key is invalid.",
|
||||
MODULE_CONFIGURATION_MODULE_ID_INVALID: "A module identifier is invalid.",
|
||||
MODULE_CONFIGURATION_READ_FAILED: "The module configuration could not be read.",
|
||||
MODULE_CONFIGURATION_VALUE_INVALID: "A module configuration value is invalid.",
|
||||
MODULE_CONFIGURATION_WRITE_FAILED: "The module configuration could not be saved.",
|
||||
});
|
||||
|
||||
/**
|
||||
* Never carries the underlying database error, SQL text, host, connection string, stack — **or the
|
||||
* offending value**.
|
||||
*
|
||||
* That last exclusion is specific to this domain and deliberate: a configuration value can be
|
||||
* operator-entered text of any kind, and an error message is one of the easiest ways for a value to
|
||||
* end up in a log. The key is safe to name (it is declared in a public manifest); the value never
|
||||
* is.
|
||||
*/
|
||||
export class ModuleConfigurationStoreError extends Error {
|
||||
readonly code: ModuleConfigurationStoreErrorCode;
|
||||
|
||||
constructor(code: ModuleConfigurationStoreErrorCode) {
|
||||
super(storeErrorMessages[code]);
|
||||
this.name = "ModuleConfigurationStoreError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReplaceModuleConfigurationInput {
|
||||
readonly moduleId: string;
|
||||
/** The complete set of ordinary values for this module. Replaces, rather than merges: a key
|
||||
* absent here is deleted, so an operator clearing a value does not have to discover a separate
|
||||
* delete verb, and the stored document always matches what was last submitted. */
|
||||
readonly values: readonly { readonly key: string; readonly value: string }[];
|
||||
}
|
||||
|
||||
export interface ModuleConfigurationStore {
|
||||
/** Every stored value, in deterministic (module, key) order. Small by nature — a handful of rows
|
||||
* per configured module — so it is read whole rather than paginated. */
|
||||
readSettings(): Promise<ModuleConfigurationSettings>;
|
||||
/**
|
||||
* Replaces one module's stored values atomically.
|
||||
*
|
||||
* Scoped to a single module id by signature: there is no call shape that writes across modules,
|
||||
* and no verb that takes a raw table, column, or predicate. Writing configuration never starts,
|
||||
* stops, or reconfigures anything in the running process — the change is applied by the next
|
||||
* restart.
|
||||
*/
|
||||
replace(input: ReplaceModuleConfigurationInput): Promise<readonly ModuleConfigurationRecord[]>;
|
||||
}
|
||||
|
||||
function assertValidModuleId(value: string): void {
|
||||
if (!moduleIdSchema.safeParse(value).success) {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_MODULE_ID_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidKey(value: string): void {
|
||||
if (!moduleConfigurationKeySchema.safeParse(value).success) {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidValue(value: string): void {
|
||||
if (typeof value !== "string" || value.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_VALUE_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function rethrowAsStoreError(error: unknown, code: ModuleConfigurationStoreErrorCode): never {
|
||||
if (error instanceof ModuleConfigurationStoreError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ModuleConfigurationStoreError(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's persistence for ordinary module configuration.
|
||||
*
|
||||
* Validation lives here rather than only at the HTTP boundary, so the invariants hold for every
|
||||
* caller: identifiers are checked against the platform's one module-id grammar and one
|
||||
* configuration-key grammar rather than second copies invented here, and values are bounded exactly
|
||||
* as the contract and the database bound them.
|
||||
*
|
||||
* Nothing in this file reads a manifest, resolves a declaration, applies a default, or decides
|
||||
* whether a value is *meaningful* — it stores text under a validated key.
|
||||
* `resolveModuleConfigurationFor` is the one place a value is interpreted, which is what keeps the
|
||||
* store from acquiring a second, drifting copy of the type rules. It equally never sees a secret:
|
||||
* the HTTP boundary rejects a `secret` key before a write reaches here, and secret material has no
|
||||
* storage path at all.
|
||||
*/
|
||||
export function createModuleConfigurationStore(
|
||||
db: ModuleConfigurationDrizzleDatabase,
|
||||
): ModuleConfigurationStore {
|
||||
return Object.freeze({
|
||||
async readSettings(): Promise<ModuleConfigurationSettings> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
key: ekklesiaCoreModuleConfiguration.key,
|
||||
moduleId: ekklesiaCoreModuleConfiguration.moduleId,
|
||||
value: ekklesiaCoreModuleConfiguration.value,
|
||||
})
|
||||
.from(ekklesiaCoreModuleConfiguration)
|
||||
.orderBy(
|
||||
asc(ekklesiaCoreModuleConfiguration.moduleId),
|
||||
asc(ekklesiaCoreModuleConfiguration.key),
|
||||
);
|
||||
return Object.freeze({
|
||||
records: Object.freeze(rows.map((row) => Object.freeze({ ...row }))),
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error, "MODULE_CONFIGURATION_READ_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
async replace(
|
||||
input: ReplaceModuleConfigurationInput,
|
||||
): Promise<readonly ModuleConfigurationRecord[]> {
|
||||
assertValidModuleId(input.moduleId);
|
||||
const seen = new Set<string>();
|
||||
for (const entry of input.values) {
|
||||
assertValidKey(entry.key);
|
||||
assertValidValue(entry.value);
|
||||
if (seen.has(entry.key)) {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID");
|
||||
}
|
||||
seen.add(entry.key);
|
||||
}
|
||||
try {
|
||||
// One transaction, so a replace is never observed half-applied: a concurrent boot either
|
||||
// sees the previous complete document or the new one, never a mixture that would validate
|
||||
// differently from both.
|
||||
return await db.transaction(async (tx) => {
|
||||
// Delete this module's rows, then insert the submitted ones. Expressed as "delete all,
|
||||
// insert submitted" rather than a key-wise diff: the whole document is being replaced, so
|
||||
// the outcome is identical and there is one predicate to audit instead of two. The
|
||||
// predicate is scoped to `input.moduleId`, so another module's rows are unreachable from
|
||||
// here by construction, not by care.
|
||||
await tx
|
||||
.delete(ekklesiaCoreModuleConfiguration)
|
||||
.where(eq(ekklesiaCoreModuleConfiguration.moduleId, input.moduleId));
|
||||
if (input.values.length === 0) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(ekklesiaCoreModuleConfiguration)
|
||||
.values(
|
||||
input.values.map((entry) => ({
|
||||
key: entry.key,
|
||||
moduleId: input.moduleId,
|
||||
value: entry.value,
|
||||
})),
|
||||
)
|
||||
.returning({
|
||||
key: ekklesiaCoreModuleConfiguration.key,
|
||||
moduleId: ekklesiaCoreModuleConfiguration.moduleId,
|
||||
value: ekklesiaCoreModuleConfiguration.value,
|
||||
});
|
||||
return Object.freeze(
|
||||
[...inserted]
|
||||
.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0))
|
||||
.map((row) => Object.freeze({ ...row })),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error, "MODULE_CONFIGURATION_WRITE_FAILED");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
isSupportedConfigurationUrl,
|
||||
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
|
||||
type ModuleConfiguration,
|
||||
type ModuleConfigurationBlockedReason,
|
||||
type ModuleConfigurationValue,
|
||||
type ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { ReadonlyModuleManifest } from "./discovery.js";
|
||||
|
||||
/**
|
||||
* One declaration exactly as discovery hands it over: deeply frozen, so an `enum`'s `values` arrive
|
||||
* as a `readonly string[]`. Derived from the manifest type rather than restated, so a change to the
|
||||
* declaration contract becomes a compile error here instead of a silently divergent second copy.
|
||||
*/
|
||||
type DiscoveredConfigurationDeclaration = ReadonlyModuleManifest["configuration"][number];
|
||||
|
||||
/**
|
||||
* One persisted configuration value, exactly as it is stored: an owning module, a key, and an
|
||||
* **unparsed text literal**.
|
||||
*
|
||||
* The value is text rather than a typed column or a JSON document on purpose. Every declared type
|
||||
* has one canonical text form, values are small and bounded, and — most importantly — keeping the
|
||||
* stored form untyped makes "a persisted value is never silently trusted" structural rather than
|
||||
* aspirational: nothing can read this as a number until it has been validated against the module's
|
||||
* own current declaration.
|
||||
*/
|
||||
export interface ModuleConfigurationRecord {
|
||||
readonly moduleId: string;
|
||||
readonly key: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
/** The complete stored configuration for every module anyone has ever configured. */
|
||||
export interface ModuleConfigurationSettings {
|
||||
readonly records: readonly ModuleConfigurationRecord[];
|
||||
}
|
||||
|
||||
/** Everything resolution needs that Core cannot derive from a manifest: what an operator stored,
|
||||
* and what the deployment supplies for secrets. */
|
||||
export interface ModuleConfigurationInput {
|
||||
readonly settings: ModuleConfigurationSettings;
|
||||
/** Defaults to supplying nothing, so a caller that provides no secrets says so by omission and a
|
||||
* module requiring one is reported `secret_missing` rather than silently starting without it. */
|
||||
readonly secrets?: ModuleSecretSource;
|
||||
}
|
||||
|
||||
/** Why one module's configuration could not be resolved, naming the offending key. Never the value:
|
||||
* a key is public (it is declared in an installed manifest), a value may be a credential. */
|
||||
export interface ModuleConfigurationIssue {
|
||||
readonly reason: ModuleConfigurationBlockedReason;
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
export interface ResolvedModuleConfiguration {
|
||||
/** The frozen snapshot the module receives, present only when every declared requirement was
|
||||
* satisfied. */
|
||||
readonly configuration?: ModuleConfiguration;
|
||||
/** The first unsatisfied requirement, in declaration order, or `undefined` when resolution
|
||||
* succeeded. Reporting one rather than all of them matches how a blocked dependency is reported:
|
||||
* an operator fixes the named problem and re-runs, and a list of cascading complaints from one
|
||||
* missing value would be noise. */
|
||||
readonly issue?: ModuleConfigurationIssue;
|
||||
/** Stored keys this module's *current* manifest no longer declares. Retained by the store and
|
||||
* ignored here — never an error, never passed to the module. Surfaced so an operator can see that
|
||||
* a downgrade or a renamed key left something behind. */
|
||||
readonly unknownKeys: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The module-facing snapshot, built over a `Map`.
|
||||
*
|
||||
* A `Map` rather than a plain object is deliberate and load-bearing: every lookup key here
|
||||
* originates in a module's manifest, and indexing a plain object with such a key is the exact shape
|
||||
* of a prototype-pollution bug. With a `Map` there is no prototype to reach, so `constructor` or
|
||||
* `__proto__` would be ordinary absent keys even if the contract's own reserved-name check were
|
||||
* ever removed.
|
||||
*/
|
||||
function createModuleConfiguration(
|
||||
values: ReadonlyMap<string, { readonly type: string; readonly value: ModuleConfigurationValue }>,
|
||||
): ModuleConfiguration {
|
||||
const sortedKeys = Object.freeze([...values.keys()].sort());
|
||||
const typed = (key: string, type: string): ModuleConfigurationValue | undefined => {
|
||||
const entry = values.get(key);
|
||||
// Fails closed identically for an undeclared key and a declared key of another type, in the
|
||||
// same style as `PermissionRegistry.get`: a caller can never distinguish "you did not declare
|
||||
// this" from "you declared it as something else", and neither one silently returns a value.
|
||||
return entry?.type === type ? entry.value : undefined;
|
||||
};
|
||||
return Object.freeze({
|
||||
get: (key: string): ModuleConfigurationValue | undefined => values.get(key)?.value,
|
||||
getBoolean: (key: string): boolean | undefined => {
|
||||
const value = typed(key, "boolean");
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
},
|
||||
getEnum: (key: string): string | undefined => {
|
||||
const value = typed(key, "enum");
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
getInteger: (key: string): number | undefined => {
|
||||
const value = typed(key, "integer");
|
||||
return typeof value === "number" ? value : undefined;
|
||||
},
|
||||
getSecret: (key: string): string | undefined => {
|
||||
const value = typed(key, "secret");
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
getString: (key: string): string | undefined => {
|
||||
const value = typed(key, "string");
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
getUrl: (key: string): string | undefined => {
|
||||
const value = typed(key, "url");
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
has: (key: string): boolean => values.has(key),
|
||||
keys: (): readonly string[] => sortedKeys,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one stored text literal against its declaration, or reports that it does not satisfy it.
|
||||
*
|
||||
* Never coerces, truncates, rounds, or falls back to the default: a stored value that does not
|
||||
* validate is an operator-visible error, not something to quietly repair. `"7 "`, `"07"`, `"yes"`,
|
||||
* and `"1"` are all rejected for the types where they are not the canonical form, so exactly one
|
||||
* spelling of any value is ever accepted and two deployments can never resolve the same stored row
|
||||
* differently.
|
||||
*/
|
||||
function parseStoredValue(
|
||||
declaration: DiscoveredConfigurationDeclaration,
|
||||
raw: string,
|
||||
): ModuleConfigurationValue | undefined {
|
||||
if (raw.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) {
|
||||
return undefined;
|
||||
}
|
||||
switch (declaration.type) {
|
||||
case "string": {
|
||||
if (declaration.minLength !== undefined && raw.length < declaration.minLength) {
|
||||
return undefined;
|
||||
}
|
||||
if (declaration.maxLength !== undefined && raw.length > declaration.maxLength) {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
case "integer": {
|
||||
if (!/^-?(?:0|[1-9]\d*)$/u.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
if (declaration.minimum !== undefined && parsed < declaration.minimum) {
|
||||
return undefined;
|
||||
}
|
||||
if (declaration.maximum !== undefined && parsed > declaration.maximum) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
case "boolean": {
|
||||
return raw === "true" ? true : raw === "false" ? false : undefined;
|
||||
}
|
||||
case "enum": {
|
||||
return declaration.values.includes(raw) ? raw : undefined;
|
||||
}
|
||||
case "url": {
|
||||
return isSupportedConfigurationUrl(raw) ? raw : undefined;
|
||||
}
|
||||
case "secret": {
|
||||
// Unreachable: a secret is never read from storage, and the store refuses to write one. The
|
||||
// branch exists so adding a type to the union is a compile error here rather than a silent
|
||||
// acceptance of persisted secret material.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one module's complete configuration from its own manifest declarations, the operator's
|
||||
* stored values, and the deployment's secrets.
|
||||
*
|
||||
* Pure and synchronous: no database, no filesystem, no `process.env`, no clock, and no module code.
|
||||
* It is handed everything it needs and returns either a frozen snapshot or the first unsatisfied
|
||||
* requirement, so every rule below is testable without PostgreSQL.
|
||||
*
|
||||
* ## Precedence, exactly
|
||||
*
|
||||
* 1. A stored value that validates against the current declaration wins.
|
||||
* 2. A stored value that does **not** validate is `configuration_invalid` — never replaced by the
|
||||
* default, because silently falling back would run the module on a value nobody chose while the
|
||||
* operator believes theirs took effect.
|
||||
* 3. Otherwise the declared `default` applies, if there is one.
|
||||
* 4. Otherwise a `required` key is `configuration_missing` (or `secret_missing`), and an optional
|
||||
* key is simply absent.
|
||||
*
|
||||
* Secrets never participate in steps 1–3: they are never stored and never defaulted, so a secret is
|
||||
* whatever the deployment supplies, or missing.
|
||||
*
|
||||
* ## Keys the manifest no longer declares
|
||||
*
|
||||
* Ignored, and retained by the store. A stored key the current manifest does not declare is exactly
|
||||
* the situation a module downgrade or a renamed key produces, and deleting the row would destroy an
|
||||
* operator's value on a temporary rollback — the same reasoning ADR 0027 applied to a Dashboard
|
||||
* placement for an uninstalled module.
|
||||
*/
|
||||
export function resolveModuleConfigurationFor(
|
||||
manifest: ReadonlyModuleManifest,
|
||||
input: ModuleConfigurationInput,
|
||||
): ResolvedModuleConfiguration {
|
||||
const secrets = input.secrets ?? EMPTY_MODULE_SECRET_SOURCE;
|
||||
const declarations = manifest.configuration;
|
||||
const declaredKeys = new Set(declarations.map((declaration) => declaration.key));
|
||||
const stored = new Map<string, string>();
|
||||
for (const record of input.settings.records) {
|
||||
if (record.moduleId === manifest.id) {
|
||||
stored.set(record.key, record.value);
|
||||
}
|
||||
}
|
||||
const unknownKeys = Object.freeze(
|
||||
[...stored.keys()].filter((key) => !declaredKeys.has(key)).sort(),
|
||||
);
|
||||
|
||||
const values = new Map<string, { type: string; value: ModuleConfigurationValue }>();
|
||||
for (const declaration of declarations) {
|
||||
if (declaration.type === "secret") {
|
||||
// Scoped by construction: the source is asked for this module's id and a key that module's
|
||||
// own manifest declares. There is no call shape that reaches another module's secret, and no
|
||||
// way to ask the host for an arbitrary environment variable.
|
||||
const supplied = secrets.get(manifest.id, declaration.key);
|
||||
if (typeof supplied === "string" && supplied.length > 0) {
|
||||
values.set(declaration.key, { type: "secret", value: supplied });
|
||||
continue;
|
||||
}
|
||||
if (declaration.required) {
|
||||
return Object.freeze({
|
||||
issue: Object.freeze({ key: declaration.key, reason: "secret_missing" as const }),
|
||||
unknownKeys,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = stored.get(declaration.key);
|
||||
if (raw !== undefined) {
|
||||
const parsed = parseStoredValue(declaration, raw);
|
||||
if (parsed === undefined) {
|
||||
return Object.freeze({
|
||||
issue: Object.freeze({ key: declaration.key, reason: "configuration_invalid" as const }),
|
||||
unknownKeys,
|
||||
});
|
||||
}
|
||||
values.set(declaration.key, { type: declaration.type, value: parsed });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (declaration.default !== undefined) {
|
||||
values.set(declaration.key, { type: declaration.type, value: declaration.default });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (declaration.required) {
|
||||
return Object.freeze({
|
||||
issue: Object.freeze({ key: declaration.key, reason: "configuration_missing" as const }),
|
||||
unknownKeys,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ configuration: createModuleConfiguration(values), unknownKeys });
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment variable one module's declared secret is read from, in an Ekklesia deployment.
|
||||
*
|
||||
* `EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`, with the module id's kebab-case and the key's
|
||||
* camelCase each folded to `UPPER_SNAKE`. A double underscore separates the three parts so a single
|
||||
* underscore inside a converted name can never make two different (module, key) pairs collide.
|
||||
*
|
||||
* **A module never sees this name.** It calls `config.getSecret("storageCredential")`; the mapping
|
||||
* is entirely the host's, which is exactly what lets a standalone host read the same declared secret
|
||||
* from a YAML file or a secret manager without the module's feature code changing (ADR 0029).
|
||||
*/
|
||||
export function moduleSecretEnvironmentVariableName(moduleId: string, key: string): string {
|
||||
const upperSnake = (value: string): string =>
|
||||
value
|
||||
.replace(/-/gu, "_")
|
||||
.replace(/([a-z0-9])([A-Z])/gu, "$1_$2")
|
||||
.toUpperCase();
|
||||
return `EKKLESIA_MODULE_SECRET__${upperSnake(moduleId)}__${upperSnake(key)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A secret source backed by a deployment's environment.
|
||||
*
|
||||
* Takes the environment **as an argument** rather than reading `process.env` itself, preserving the
|
||||
* repository rule that reusable Core code never touches the process environment — only executables
|
||||
* do (`core/src/cli/database-transport-environment.ts` states the same rule for database
|
||||
* transport). It is also what makes this trivially testable without mutating global state.
|
||||
*
|
||||
* There is deliberately no method that takes an arbitrary variable name. The only reachable values
|
||||
* are those a module declared as `secret` in its own manifest, looked up under a name this function
|
||||
* derives — a module cannot read `DATABASE_URL`, another module's secret, or anything else about
|
||||
* the host.
|
||||
*/
|
||||
export function createEnvironmentModuleSecretSource(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): ModuleSecretSource {
|
||||
return Object.freeze({
|
||||
get: (moduleId: string, key: string): string | undefined =>
|
||||
environment[moduleSecretEnvironmentVariableName(moduleId, key)],
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
export * from "./configuration-schema.js";
|
||||
export * from "./configuration-store.js";
|
||||
export * from "./configuration.js";
|
||||
export * from "./database.js";
|
||||
export * from "./discovery.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./lifecycle-schema.js";
|
||||
export * from "./lifecycle-store.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
+108
-20
@@ -1,9 +1,12 @@
|
||||
import type {
|
||||
ModuleBlockedReason,
|
||||
ModuleBlockingDependency,
|
||||
ModuleConfiguration,
|
||||
ModuleEnablementRecord,
|
||||
ModuleRuntimeState,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { ModuleConfigurationInput } from "./configuration.js";
|
||||
import { resolveModuleConfigurationFor } from "./configuration.js";
|
||||
import type { DiscoveredModule } from "./discovery.js";
|
||||
|
||||
/**
|
||||
@@ -35,14 +38,32 @@ export interface ModuleEligibility {
|
||||
readonly version: string;
|
||||
readonly configured: ModuleConfiguredState;
|
||||
readonly eligible: boolean;
|
||||
/** Present exactly when the module is configured enabled but not eligible. A disabled module is
|
||||
* not "blocked" — it is doing what it was told. */
|
||||
readonly blocked?: {
|
||||
readonly reason: ModuleBlockedReason;
|
||||
readonly dependency: ModuleBlockingDependency;
|
||||
};
|
||||
/**
|
||||
* Present exactly when the module is configured enabled but not eligible. A disabled module is
|
||||
* not "blocked" — it is doing what it was told.
|
||||
*
|
||||
* A discriminated pair rather than one shape with optional halves: a dependency reason always
|
||||
* names a dependency and a configuration reason always names a key, so neither can be
|
||||
* accidentally omitted and no consumer has to handle a `blocked` record that explains nothing.
|
||||
*/
|
||||
readonly blocked?: ModuleBlockedDetail;
|
||||
}
|
||||
|
||||
/** A block caused by the dependency graph: some module this one *requires* cannot run. */
|
||||
export interface ModuleDependencyBlockedDetail {
|
||||
readonly reason: "required_dependency_disabled" | "required_dependency_blocked";
|
||||
readonly dependency: ModuleBlockingDependency;
|
||||
}
|
||||
|
||||
/** A block caused by this module's own configuration (ADR 0029), naming the offending key — never
|
||||
* the value, which for a secret is a credential and for anything else is operator-entered text. */
|
||||
export interface ModuleConfigurationBlockedDetail {
|
||||
readonly reason: "configuration_missing" | "configuration_invalid" | "secret_missing";
|
||||
readonly configurationKey: string;
|
||||
}
|
||||
|
||||
export type ModuleBlockedDetail = ModuleDependencyBlockedDetail | ModuleConfigurationBlockedDetail;
|
||||
|
||||
export interface ModuleEligibilityResolution {
|
||||
/**
|
||||
* The modules the platform may start, **in the exact discovery order they were given**. Filtered,
|
||||
@@ -54,6 +75,15 @@ export interface ModuleEligibilityResolution {
|
||||
/** Every discovered module, in discovery order, eligible or not. This is the explanation surface;
|
||||
* `eligibleModules` is the executable one. */
|
||||
readonly entries: readonly ModuleEligibility[];
|
||||
/**
|
||||
* The frozen configuration snapshot for each **eligible** module, keyed by module id (ADR 0029).
|
||||
*
|
||||
* Produced by the same pass that decided eligibility, so a module's configuration is validated
|
||||
* exactly once and the value that made it eligible is the identical value it later receives —
|
||||
* never revalidated, and never re-read from the database, between those two moments. An
|
||||
* ineligible module has no entry: there is nothing to hand a module that is not going to start.
|
||||
*/
|
||||
readonly configurations: ReadonlyMap<string, ModuleConfiguration>;
|
||||
}
|
||||
|
||||
interface MutableEligibility {
|
||||
@@ -61,10 +91,7 @@ interface MutableEligibility {
|
||||
readonly version: string;
|
||||
readonly configured: ModuleConfiguredState;
|
||||
eligible: boolean;
|
||||
blocked?: {
|
||||
readonly reason: ModuleBlockedReason;
|
||||
readonly dependency: ModuleBlockingDependency;
|
||||
};
|
||||
blocked?: ModuleBlockedDetail;
|
||||
}
|
||||
|
||||
function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
||||
@@ -75,10 +102,16 @@ function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
||||
version: entry.version,
|
||||
...(entry.blocked
|
||||
? {
|
||||
blocked: Object.freeze({
|
||||
dependency: Object.freeze({ ...entry.blocked.dependency }),
|
||||
reason: entry.blocked.reason,
|
||||
}),
|
||||
blocked:
|
||||
"dependency" in entry.blocked
|
||||
? Object.freeze({
|
||||
dependency: Object.freeze({ ...entry.blocked.dependency }),
|
||||
reason: entry.blocked.reason,
|
||||
})
|
||||
: Object.freeze({
|
||||
configurationKey: entry.blocked.configurationKey,
|
||||
reason: entry.blocked.reason,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
@@ -128,6 +161,7 @@ function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
||||
export function resolveModuleEligibility(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
configuration: ModuleLifecycleConfiguration,
|
||||
moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } },
|
||||
): ModuleEligibilityResolution {
|
||||
const configuredByModuleId = new Map(
|
||||
configuration.modules.map((record) => [record.moduleId, record.enabled] as const),
|
||||
@@ -146,6 +180,31 @@ export function resolveModuleEligibility(
|
||||
});
|
||||
}
|
||||
|
||||
// Validate each enabled module's own configuration before the dependency pass, so a module
|
||||
// blocked by its own configuration propagates to its required dependents through exactly the
|
||||
// existing mechanism rather than a second one. Runs only for modules that are still eligible: a
|
||||
// disabled module's configuration is not evaluated at all, because it is not going to start and
|
||||
// reporting a configuration problem for something nobody asked to run would be noise.
|
||||
const configurations = new Map<string, ModuleConfiguration>();
|
||||
for (const discovered of discoveredModules) {
|
||||
const entry = entries.get(discovered.manifest.id);
|
||||
if (!entry?.eligible) {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveModuleConfigurationFor(discovered.manifest, moduleConfiguration);
|
||||
if (resolved.issue) {
|
||||
entry.eligible = false;
|
||||
entry.blocked = {
|
||||
configurationKey: resolved.issue.key,
|
||||
reason: resolved.issue.reason,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (resolved.configuration) {
|
||||
configurations.set(discovered.manifest.id, resolved.configuration);
|
||||
}
|
||||
}
|
||||
|
||||
// The provider of each capability, taken from the same manifests discovery already validated.
|
||||
// A capability has exactly one provider in a valid inventory (discovery rejects a duplicate
|
||||
// module id, and a manifest may only declare capabilities inside its own namespace).
|
||||
@@ -214,7 +273,17 @@ export function resolveModuleEligibility(
|
||||
resolvedEntries.filter((entry) => entry.eligible).map((entry) => entry.moduleId),
|
||||
);
|
||||
|
||||
// A module whose own configuration resolved may still have lost eligibility in the dependency
|
||||
// pass above. Dropping its snapshot here keeps one invariant true: this map contains exactly the
|
||||
// modules that are going to start.
|
||||
for (const moduleId of [...configurations.keys()]) {
|
||||
if (!eligibleIds.has(moduleId)) {
|
||||
configurations.delete(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
configurations: configurations as ReadonlyMap<string, ModuleConfiguration>,
|
||||
eligibleModules: Object.freeze(
|
||||
discoveredModules.filter((discovered) => eligibleIds.has(discovered.manifest.id)),
|
||||
),
|
||||
@@ -233,7 +302,10 @@ export interface ModuleLifecycleView {
|
||||
readonly runtime: {
|
||||
readonly state: ModuleRuntimeState;
|
||||
readonly reason?: ModuleBlockedReason;
|
||||
/** Present for a dependency reason. */
|
||||
readonly dependency?: ModuleBlockingDependency;
|
||||
/** Present for a configuration reason (ADR 0029) — the offending key, never its value. */
|
||||
readonly configurationKey?: string;
|
||||
};
|
||||
/**
|
||||
* `true` when applying the *current* configuration would produce a different running set than
|
||||
@@ -274,12 +346,22 @@ export interface ModuleLifecycleSnapshot {
|
||||
export function resolveModuleLifecycleView(
|
||||
snapshot: ModuleLifecycleSnapshot,
|
||||
configuration: ModuleLifecycleConfiguration,
|
||||
moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } },
|
||||
): readonly ModuleLifecycleView[] {
|
||||
const started = new Set(snapshot.startedModuleIds);
|
||||
const bootEligibility = new Map(
|
||||
snapshot.eligibility.map((entry) => [entry.moduleId, entry] as const),
|
||||
);
|
||||
const pending = resolveModuleEligibility(snapshot.discoveredModules, configuration);
|
||||
// Re-resolved against *both* live axes: enablement and module configuration. Since ADR 0029 a
|
||||
// module's own configuration can decide eligibility, so correcting a missing required value must
|
||||
// show up as a pending restart exactly like re-enabling a module does. Passing only the
|
||||
// enablement half here would have quietly reported `restartRequired: false` for a module the next
|
||||
// boot really will start.
|
||||
const pending = resolveModuleEligibility(
|
||||
snapshot.discoveredModules,
|
||||
configuration,
|
||||
moduleConfiguration,
|
||||
);
|
||||
const wouldRun = new Set(pending.eligibleModules.map((module) => module.manifest.id));
|
||||
|
||||
return Object.freeze(
|
||||
@@ -291,11 +373,17 @@ export function resolveModuleLifecycleView(
|
||||
: boot === undefined || !boot.configured.enabled
|
||||
? { state: "disabled" as const }
|
||||
: boot.blocked
|
||||
? {
|
||||
dependency: boot.blocked.dependency,
|
||||
reason: boot.blocked.reason,
|
||||
state: "blocked" as const,
|
||||
}
|
||||
? "dependency" in boot.blocked
|
||||
? {
|
||||
dependency: boot.blocked.dependency,
|
||||
reason: boot.blocked.reason,
|
||||
state: "blocked" as const,
|
||||
}
|
||||
: {
|
||||
configurationKey: boot.blocked.configurationKey,
|
||||
reason: boot.blocked.reason,
|
||||
state: "blocked" as const,
|
||||
}
|
||||
: // Enabled and unblocked at boot, yet not started: unreachable while startup stays
|
||||
// all-or-nothing, and reported as blocked-by-nothing rather than as `running`, so a
|
||||
// future change to startup atomicity can never make this silently claim success.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
type CapabilityId,
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
type ModuleConfiguration,
|
||||
type CapabilityRegistrar,
|
||||
type CapabilityResolver,
|
||||
capabilityIdSchema,
|
||||
@@ -76,6 +78,24 @@ export interface StartedModuleRuntime {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
|
||||
*
|
||||
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
|
||||
* shared object identity every module receives** — a property this runtime has a test for — while
|
||||
* configuration is per-module by definition. A module with no entry receives
|
||||
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
|
||||
* module code never branches on whether the platform supplied one.
|
||||
*/
|
||||
export type ModuleConfigurationLookup = ReadonlyMap<string, ModuleConfiguration>;
|
||||
|
||||
function configurationFor(
|
||||
configurations: ModuleConfigurationLookup,
|
||||
moduleId: string,
|
||||
): ModuleConfiguration {
|
||||
return configurations.get(moduleId) ?? EMPTY_MODULE_CONFIGURATION;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeTimeoutOptions {
|
||||
/** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS`, applied to both normal stop and startup
|
||||
* rollback, so a deadline can be proven in milliseconds instead of ten seconds. Production
|
||||
@@ -83,9 +103,23 @@ export interface ModuleRuntimeTimeoutOptions {
|
||||
readonly hookTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOptions {
|
||||
/**
|
||||
* The per-module configuration snapshots this runtime hands to each module's `register`, `start`,
|
||||
* `stop`, and HTTP handler contexts (ADR 0029). Production composition supplies exactly the map
|
||||
* `resolveModuleEligibility` produced while deciding which modules may run, so the configuration
|
||||
* that made a module eligible is the identical object it receives — never revalidated, and never
|
||||
* re-read, between those two moments.
|
||||
*
|
||||
* Omitted by a caller exercising no configuration; every module then receives
|
||||
* `EMPTY_MODULE_CONFIGURATION`.
|
||||
*/
|
||||
readonly configurations?: ModuleConfigurationLookup;
|
||||
}
|
||||
|
||||
export interface StartModuleRuntimeOptions
|
||||
extends ModuleDiscoveryOptions,
|
||||
ModuleRuntimeTimeoutOptions {
|
||||
ModuleRuntimeCompositionOptions {
|
||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
||||
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
||||
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
||||
@@ -222,6 +256,7 @@ class StagedCapabilityRegistry {
|
||||
module: DiscoveredModule,
|
||||
phase: "rollback" | "start" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
config: ModuleConfiguration,
|
||||
): ModuleRuntimeContext {
|
||||
const capabilities: CapabilityResolver = Object.freeze({
|
||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||
@@ -229,7 +264,7 @@ class StagedCapabilityRegistry {
|
||||
return this.#registered.get(capabilityId);
|
||||
},
|
||||
});
|
||||
return Object.freeze({ capabilities, moduleId: module.manifest.id, services });
|
||||
return Object.freeze({ capabilities, config, moduleId: module.manifest.id, services });
|
||||
}
|
||||
|
||||
publish(): CapabilityRegistry {
|
||||
@@ -464,12 +499,14 @@ async function registerModules(
|
||||
modules: readonly LoadedModule[],
|
||||
capabilityRegistry: StagedCapabilityRegistry,
|
||||
httpRoutes: StagedModuleHttpRoutes,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
): Promise<void> {
|
||||
for (const module of modules) {
|
||||
const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
|
||||
const routeStage = httpRoutes.createRegistrationStage(module.discovered);
|
||||
const context: ModuleRegistrationContext = Object.freeze({
|
||||
capabilities: capabilityStage.capabilities,
|
||||
config: configurationFor(configurations, module.discovered.manifest.id),
|
||||
http: routeStage.http,
|
||||
moduleId: module.discovered.manifest.id,
|
||||
});
|
||||
@@ -561,6 +598,7 @@ async function stopModules(
|
||||
registry: StagedCapabilityRegistry,
|
||||
phase: "rollback" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
options: StopModulesOptions = {},
|
||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
|
||||
@@ -574,7 +612,16 @@ async function stopModules(
|
||||
}
|
||||
try {
|
||||
const outcome = await awaitHookWithinDeadline(
|
||||
Promise.resolve(stop(registry.createRuntimeContext(module.discovered, phase, services))),
|
||||
Promise.resolve(
|
||||
stop(
|
||||
registry.createRuntimeContext(
|
||||
module.discovered,
|
||||
phase,
|
||||
services,
|
||||
configurationFor(configurations, moduleId),
|
||||
),
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
if (outcome === "timed-out") {
|
||||
@@ -609,6 +656,7 @@ async function startModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
services: ModuleRuntimeServices,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
options: StopModulesOptions = {},
|
||||
): Promise<void> {
|
||||
// A module joins the rollback set the moment its `start` hook is *invoked*, not when it
|
||||
@@ -622,7 +670,12 @@ async function startModules(
|
||||
rollbackCandidates.push(module);
|
||||
try {
|
||||
await module.definition.start?.(
|
||||
registry.createRuntimeContext(module.discovered, "start", services),
|
||||
registry.createRuntimeContext(
|
||||
module.discovered,
|
||||
"start",
|
||||
services,
|
||||
configurationFor(configurations, module.discovered.manifest.id),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const startIssue =
|
||||
@@ -639,6 +692,7 @@ async function startModules(
|
||||
registry,
|
||||
"rollback",
|
||||
services,
|
||||
configurations,
|
||||
options,
|
||||
);
|
||||
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||
@@ -657,13 +711,14 @@ async function startModules(
|
||||
export async function startDiscoveredModuleRuntime(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
services: ModuleRuntimeServices,
|
||||
timeouts: ModuleRuntimeTimeoutOptions = {},
|
||||
timeouts: ModuleRuntimeCompositionOptions = {},
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||
const httpRoutes = new StagedModuleHttpRoutes();
|
||||
await registerModules(modules, capabilityRegistry, httpRoutes);
|
||||
await startModules(modules, capabilityRegistry, services, timeouts);
|
||||
const configurations = timeouts.configurations ?? new Map<string, ModuleConfiguration>();
|
||||
await registerModules(modules, capabilityRegistry, httpRoutes, configurations);
|
||||
await startModules(modules, capabilityRegistry, services, configurations, timeouts);
|
||||
|
||||
const capabilities = capabilityRegistry.publish();
|
||||
const routes = httpRoutes.publish();
|
||||
@@ -683,7 +738,14 @@ export async function startDiscoveredModuleRuntime(
|
||||
routes,
|
||||
stop: (): Promise<void> => {
|
||||
stopPromise ??= (async () => {
|
||||
const issues = await stopModules(modules, capabilityRegistry, "stop", services, timeouts);
|
||||
const issues = await stopModules(
|
||||
modules,
|
||||
capabilityRegistry,
|
||||
"stop",
|
||||
services,
|
||||
configurations,
|
||||
timeouts,
|
||||
);
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleRuntimeError(issues);
|
||||
}
|
||||
@@ -698,6 +760,7 @@ export async function startModuleRuntime(
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const discovered = await discoverModules(options);
|
||||
return startDiscoveredModuleRuntime(discovered, options.services, {
|
||||
...(options.configurations === undefined ? {} : { configurations: options.configurations }),
|
||||
...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
@@ -121,12 +122,23 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
expect(declaration?.id).toBe("core.modules.lifecycle.manage");
|
||||
});
|
||||
|
||||
it("contains exactly these four permissions and no others", () => {
|
||||
it("registers core.modules.configuration.manage as restricted, separately from lifecycle", () => {
|
||||
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_MODULES_CONFIGURATION_MANAGE);
|
||||
expect(declaration).toBeDefined();
|
||||
expect(declaration?.baseline).toBe("restricted");
|
||||
expect(declaration?.id).toBe("core.modules.configuration.manage");
|
||||
// Deliberately a different permission from lifecycle management: tuning how a module behaves
|
||||
// and deciding whether it runs at all are different authorities (ADR 0029).
|
||||
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
||||
});
|
||||
|
||||
it("contains exactly these five permissions and no others", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -154,6 +166,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||
description: "Read and change which installed modules are enabled.",
|
||||
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
},
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "Read and change an installed module's own configuration values.",
|
||||
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
},
|
||||
]);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||
|
||||
@@ -175,6 +175,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -229,6 +230,7 @@ describe.sequential("live Core authorization store", () => {
|
||||
"ekklesia_core_dashboard_placement",
|
||||
"ekklesia_core_grant",
|
||||
"ekklesia_core_migrations",
|
||||
"ekklesia_core_module_configuration",
|
||||
"ekklesia_core_module_state",
|
||||
"ekklesia_core_platform_owner",
|
||||
"ekklesia_core_platform_owner_bootstrap",
|
||||
|
||||
@@ -91,7 +91,7 @@ async function createModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
: {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -174,6 +174,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
|
||||
planMigrations: async () => {
|
||||
throw new Error("database must not be planned");
|
||||
},
|
||||
readModuleConfiguration: async () => {
|
||||
throw new Error("module configuration must not be read");
|
||||
},
|
||||
readModuleLifecycle: async () => {
|
||||
throw new Error("module lifecycle configuration must not be read");
|
||||
},
|
||||
@@ -81,9 +84,11 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
4 permissions registered
|
||||
5 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
DATABASE_URL is not configured.
|
||||
|
||||
Data definitions
|
||||
✓ Schemas validated
|
||||
|
||||
+193
-1
@@ -35,13 +35,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
manifest: {
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
configuration: [],
|
||||
dependencies: {
|
||||
capabilities: { optional: {}, required: {} },
|
||||
modules: { optional: {}, required: {} },
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -137,6 +138,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||
planMigrations: vi.fn(async () => plan()),
|
||||
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
runtimeSatisfies: vi.fn(() => true),
|
||||
...overrides,
|
||||
@@ -206,6 +208,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"modules.dependencies",
|
||||
"modules.permissions",
|
||||
"modules.lifecycle",
|
||||
"modules.configuration",
|
||||
"schemas.discovery",
|
||||
"migrations.discovery",
|
||||
"database.transport",
|
||||
@@ -1016,3 +1019,192 @@ describe("Ekklesia Doctor — module lifecycle", () => {
|
||||
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia Doctor — module configuration", () => {
|
||||
function configuringModule(
|
||||
id: string,
|
||||
configuration: readonly Record<string, unknown>[],
|
||||
): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
manifest: { ...base.manifest, configuration } as unknown as ModuleManifest,
|
||||
};
|
||||
}
|
||||
|
||||
it("skips the check, rather than guessing, when DATABASE_URL is not configured", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("announcements")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(), services);
|
||||
|
||||
expect(check(report, "modules.configuration").status).toBe("skipped");
|
||||
expect(services.readModuleConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes and says so plainly when a module declares no configuration", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.configuration").status).toBe("pass");
|
||||
expect(check(report, "modules.configuration").details).toEqual([
|
||||
"0 modules declaring configuration · 0 blocked",
|
||||
"calendar 0.1.0 · no configuration declared",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes with a key count when configuration is satisfied, and prints no value", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [
|
||||
{ key: "slideSeconds", type: "integer", default: 7, required: false },
|
||||
{ key: "bucket", type: "string", required: true },
|
||||
]),
|
||||
]),
|
||||
),
|
||||
readModuleConfiguration: vi.fn(async () =>
|
||||
Object.freeze({
|
||||
records: Object.freeze([
|
||||
{ key: "bucket", moduleId: "announcements", value: "very-specific-bucket-name" },
|
||||
]),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
expect(configuration.status).toBe("pass");
|
||||
expect(configuration.details).toEqual([
|
||||
"1 module declaring configuration · 0 blocked",
|
||||
"announcements 0.1.0 · 2 keys declared · valid",
|
||||
]);
|
||||
// Counts and keys, never values: `pnpm doctor` output gets pasted into chat.
|
||||
expect(JSON.stringify(configuration)).not.toContain("very-specific-bucket-name");
|
||||
});
|
||||
|
||||
it("warns and names the offending key when required configuration is missing", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
expect(configuration.status).toBe("warning");
|
||||
expect(configuration.summary).toBe("1 module cannot run with its current configuration");
|
||||
expect(configuration.details).toEqual([
|
||||
"1 module declaring configuration · 1 blocked",
|
||||
"announcements 0.1.0 · 1 key declared · configuration_missing · bucket",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names a missing secret's key and never its value", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [
|
||||
{ key: "storageCredential", type: "secret", required: true },
|
||||
]),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.configuration").details).toContain(
|
||||
"announcements 0.1.0 · 1 key declared · secret_missing · storageCredential",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a supplied secret as satisfied, without printing it", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [
|
||||
{ key: "storageCredential", type: "secret", required: true },
|
||||
]),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(databaseUrl),
|
||||
moduleSecrets: { get: () => "super-secret-value" },
|
||||
},
|
||||
services,
|
||||
);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
expect(configuration.status).toBe("pass");
|
||||
expect(JSON.stringify(configuration)).not.toContain("super-secret-value");
|
||||
});
|
||||
|
||||
it("does not evaluate a disabled module's configuration, and says so", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]),
|
||||
]),
|
||||
),
|
||||
readModuleLifecycle: vi.fn(async () =>
|
||||
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "announcements" }]) }),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
// Claiming it is valid would assert something nothing checked; claiming it is broken would be
|
||||
// noise about a module nobody asked to run.
|
||||
expect(configuration.status).toBe("pass");
|
||||
expect(configuration.details).toContain(
|
||||
"announcements 0.1.0 · 1 key declared · not evaluated (disabled)",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails safely, without a driver detail, when the configuration cannot be read", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("announcements")])),
|
||||
readModuleConfiguration: vi.fn(async () => {
|
||||
throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
expect(configuration.status).toBe("failure");
|
||||
expect(JSON.stringify(configuration)).not.toContain("hunter2");
|
||||
expect(JSON.stringify(configuration)).not.toContain("10.40.0.2");
|
||||
});
|
||||
|
||||
it("agrees with the lifecycle check, because both come from one resolution", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
// Doctor can never report a module as runnable that startup would block, or the reverse.
|
||||
expect(check(report, "modules.lifecycle").status).toBe("warning");
|
||||
expect(check(report, "modules.configuration").status).toBe("warning");
|
||||
expect(check(report, "modules.lifecycle").details).toContain(
|
||||
"announcements 0.1.0 · blocked · configuration_missing · bucket",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 4,
|
||||
"manifestVersion": 5,
|
||||
"id": "db-facade",
|
||||
"name": "Database Facade Integration Test Module",
|
||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
type ModuleConfigurationDeclarationInput,
|
||||
type ModuleSecretSource,
|
||||
parseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
||||
import { resolveModuleEligibility, resolveModuleLifecycleView } from "../src/modules/lifecycle.js";
|
||||
|
||||
/** A minimal in-memory `DiscoveredModule`, built through `parseModuleManifest` so a fixture real
|
||||
* discovery would reject can never stand in for one it would accept. */
|
||||
function fakeModule(
|
||||
id: string,
|
||||
options: {
|
||||
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: options.configuration ?? [],
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
return {
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function enablement(...records: readonly (readonly [string, boolean])[]) {
|
||||
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
|
||||
}
|
||||
|
||||
function settings(...records: readonly (readonly [string, string, string])[]) {
|
||||
return {
|
||||
records: records.map(([moduleId, key, value]) => ({ key, moduleId, value })),
|
||||
};
|
||||
}
|
||||
|
||||
function secretsFor(
|
||||
map: Readonly<Record<string, Readonly<Record<string, string>>>>,
|
||||
): ModuleSecretSource {
|
||||
return { get: (moduleId, key) => map[moduleId]?.[key] };
|
||||
}
|
||||
|
||||
function ids(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||
return modules.map((module) => module.manifest.id);
|
||||
}
|
||||
|
||||
function entry(
|
||||
resolution: ReturnType<typeof resolveModuleEligibility>,
|
||||
moduleId: string,
|
||||
): ReturnType<typeof resolveModuleEligibility>["entries"][number] {
|
||||
const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId);
|
||||
if (!found) {
|
||||
throw new Error(`No eligibility entry for ${moduleId}.`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("configuration decides eligibility, through the existing lifecycle resolver", () => {
|
||||
it("keeps a module with satisfied configuration eligible, and hands it a snapshot", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "bucket", type: "string", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "bucket", "announcements"]) },
|
||||
);
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
||||
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
||||
// The same pass that decided eligibility produced the snapshot, so the configuration that made
|
||||
// the module eligible is the identical object it will receive.
|
||||
expect(resolution.configurations.get("announcements")?.getString("bucket")).toBe(
|
||||
"announcements",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks an enabled module whose required configuration is missing, naming the key", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "bucket", type: "string", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
{ settings: settings() },
|
||||
);
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
configurationKey: "bucket",
|
||||
reason: "configuration_missing",
|
||||
});
|
||||
// No snapshot for a module that is not going to start.
|
||||
expect(resolution.configurations.has("announcements")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks on an invalid stored value rather than starting on a default nobody chose", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "slideSeconds", type: "integer", default: 7, maximum: 600 }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "slideSeconds", "100000"]) },
|
||||
);
|
||||
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
configurationKey: "slideSeconds",
|
||||
reason: "configuration_invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks on a missing required secret, distinctly from a missing ordinary value", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "storageCredential", type: "secret", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
{ settings: settings() },
|
||||
);
|
||||
|
||||
// A different reason because it has a different fix: set an environment variable on the
|
||||
// deployment, not a value in the database.
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
configurationKey: "storageCredential",
|
||||
reason: "secret_missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("becomes eligible once the deployment supplies the secret, with no stored value involved", () => {
|
||||
const module = fakeModule("announcements", {
|
||||
configuration: [{ key: "storageCredential", type: "secret", required: true }],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveModuleEligibility([module], enablement(), { settings: settings() }).eligibleModules,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
ids(
|
||||
resolveModuleEligibility([module], enablement(), {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(),
|
||||
}).eligibleModules,
|
||||
),
|
||||
).toEqual(["announcements"]);
|
||||
});
|
||||
|
||||
it("does not evaluate a disabled module's configuration at all", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "bucket", type: "string", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(["announcements", false]),
|
||||
{ settings: settings() },
|
||||
);
|
||||
|
||||
// Disabled is not blocked: it is doing what it was told, and reporting a configuration problem
|
||||
// for something nobody asked to run would be noise.
|
||||
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
||||
expect(entry(resolution, "announcements").configured.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a module declaring no configuration exactly as it was", () => {
|
||||
const resolution = resolveModuleEligibility([fakeModule("calendar")], enablement(), {
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
||||
expect(resolution.configurations.get("calendar")?.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves identically when no configuration input is supplied at all", () => {
|
||||
// The third argument is optional, so every pre-existing caller keeps its exact behaviour.
|
||||
const withoutInput = resolveModuleEligibility([fakeModule("calendar")], enablement());
|
||||
const withEmptyInput = resolveModuleEligibility([fakeModule("calendar")], enablement(), {
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(withoutInput.entries).toEqual(withEmptyInput.entries);
|
||||
expect(ids(withoutInput.eligibleModules)).toEqual(["calendar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration blocking propagates through the dependency graph", () => {
|
||||
const graph = [
|
||||
fakeModule("storage", {
|
||||
configuration: [{ key: "bucket", type: "string", required: true }],
|
||||
}),
|
||||
fakeModule("media", { dependencies: { modules: { required: { storage: "^1.0.0" } } } }),
|
||||
fakeModule("announcements", { dependencies: { modules: { required: { media: "^1.0.0" } } } }),
|
||||
];
|
||||
|
||||
it("blocks required dependents of a configuration-blocked module, transitively", () => {
|
||||
const resolution = resolveModuleEligibility(graph, enablement(), { settings: settings() });
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
// The chain terminates at the module whose own configuration is at fault, exactly as it
|
||||
// terminates at a disabled module — one mechanism, not two.
|
||||
expect(entry(resolution, "storage").blocked).toEqual({
|
||||
configurationKey: "bucket",
|
||||
reason: "configuration_missing",
|
||||
});
|
||||
expect(entry(resolution, "media").blocked).toEqual({
|
||||
dependency: { kind: "module", moduleId: "storage" },
|
||||
reason: "required_dependency_blocked",
|
||||
});
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
dependency: { kind: "module", moduleId: "media" },
|
||||
reason: "required_dependency_blocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("unblocks the whole chain once the configuration is supplied", () => {
|
||||
const resolution = resolveModuleEligibility(graph, enablement(), {
|
||||
settings: settings(["storage", "bucket", "media-objects"]),
|
||||
});
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]);
|
||||
});
|
||||
|
||||
it("never blocks an unrelated module because of another module's configuration", () => {
|
||||
const resolution = resolveModuleEligibility([...graph, fakeModule("calendar")], enablement(), {
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
||||
expect(entry(resolution, "calendar").blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the snapshot of a module that resolved its own configuration but lost eligibility", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("storage"),
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "bucket", type: "string", default: "b" }],
|
||||
dependencies: { modules: { required: { storage: "^1.0.0" } } },
|
||||
}),
|
||||
],
|
||||
enablement(["storage", false]),
|
||||
{ settings: settings() },
|
||||
);
|
||||
|
||||
// Its own configuration was fine; it is blocked by its dependency. The map must still contain
|
||||
// exactly the modules that are going to start.
|
||||
expect(entry(resolution, "announcements").blocked?.reason).toBe("required_dependency_disabled");
|
||||
expect(resolution.configurations.has("announcements")).toBe(false);
|
||||
expect([...resolution.configurations.keys()]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration changes are restart-required, and reported honestly", () => {
|
||||
const module = fakeModule("announcements", {
|
||||
configuration: [{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 }],
|
||||
});
|
||||
const discoveredModules = [module];
|
||||
|
||||
function bootSnapshot(bootSettings: ReturnType<typeof settings>) {
|
||||
const resolution = resolveModuleEligibility(discoveredModules, enablement(), {
|
||||
settings: bootSettings,
|
||||
});
|
||||
return {
|
||||
discoveredModules,
|
||||
eligibility: resolution.entries,
|
||||
startedModuleIds: resolution.eligibleModules.map((entry) => entry.manifest.id),
|
||||
};
|
||||
}
|
||||
|
||||
it("does not claim a running module changed when only stored configuration did", () => {
|
||||
const view = resolveModuleLifecycleView(bootSnapshot(settings()), enablement(), {
|
||||
settings: settings(["announcements", "slideSeconds", "12"]),
|
||||
});
|
||||
|
||||
// The module really is still running with 7; a configuration change is applied by the next
|
||||
// restart, and this reports exactly that rather than pretending the value took effect.
|
||||
expect(view[0]?.runtime).toEqual({ state: "running" });
|
||||
expect(view[0]?.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a pending restart when new configuration would change whether the module runs", () => {
|
||||
// Boot with a value that blocked the module, then correct it.
|
||||
const blocked = resolveModuleEligibility(discoveredModules, enablement(), {
|
||||
settings: settings(["announcements", "slideSeconds", "0"]),
|
||||
});
|
||||
expect(blocked.eligibleModules).toEqual([]);
|
||||
|
||||
const view = resolveModuleLifecycleView(
|
||||
{
|
||||
discoveredModules,
|
||||
eligibility: blocked.entries,
|
||||
startedModuleIds: [],
|
||||
},
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "slideSeconds", "12"]) },
|
||||
);
|
||||
|
||||
// Correcting the value must show as restart-pending: the next boot really will start it.
|
||||
expect(view[0]?.runtime).toEqual({
|
||||
configurationKey: "slideSeconds",
|
||||
reason: "configuration_invalid",
|
||||
state: "blocked",
|
||||
});
|
||||
expect(view[0]?.restartRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the boot-time configuration reason, not a recomputed one", () => {
|
||||
const blocked = resolveModuleEligibility(discoveredModules, enablement(), {
|
||||
settings: settings(["announcements", "slideSeconds", "0"]),
|
||||
});
|
||||
const view = resolveModuleLifecycleView(
|
||||
{ discoveredModules, eligibility: blocked.entries, startedModuleIds: [] },
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "slideSeconds", "0"]) },
|
||||
);
|
||||
|
||||
expect(view[0]?.runtime).toEqual({
|
||||
configurationKey: "slideSeconds",
|
||||
reason: "configuration_invalid",
|
||||
state: "blocked",
|
||||
});
|
||||
expect(view[0]?.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("never puts a configuration value into the lifecycle view, only the key", () => {
|
||||
const blocked = resolveModuleEligibility(discoveredModules, enablement(), {
|
||||
settings: settings(["announcements", "slideSeconds", "0"]),
|
||||
});
|
||||
const view = resolveModuleLifecycleView(
|
||||
{ discoveredModules, eligibility: blocked.entries, startedModuleIds: [] },
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "slideSeconds", "0"]) },
|
||||
);
|
||||
|
||||
expect(JSON.stringify(view)).toContain("slideSeconds");
|
||||
expect(JSON.stringify(view)).not.toContain('"0"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createModuleConfigurationStore,
|
||||
createModuleConfigurationView,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
resolveModuleConfigurationFor,
|
||||
} from "../src/modules/index.js";
|
||||
import { parseModuleManifest } from "@ekklesia/contracts";
|
||||
import { createValidatedPostgresPool } from "../src/database/index.js";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "../src/migrations/index.js";
|
||||
|
||||
/**
|
||||
* The module configuration store against real PostgreSQL.
|
||||
*
|
||||
* `core/tests/module-configuration.test.ts` proves the validation and defaulting rules with no
|
||||
* database at all; this proves the things only a database can: that migration `0005` really creates
|
||||
* the table with the composite primary key and value ceiling the schema declares, that a replace is
|
||||
* transactional and scoped to one module, that stored values survive a completely new connection,
|
||||
* and — the security-relevant one — that a module id or key is only ever *data* in a parameterized
|
||||
* column, never an SQL identifier. The in-memory double the API route tests use reproduces none of
|
||||
* that, which is why it lives here.
|
||||
*/
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const coreMigrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
const trackedDatabases = new Set<string>();
|
||||
let databaseCounter = 0;
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw harnessError("The module configuration integration database is not configured.");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new TypeError("Unsupported protocol.");
|
||||
}
|
||||
} catch {
|
||||
throw harnessError("The module configuration integration database configuration is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function quoteDatabaseName(databaseName: string): string {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||
throw harnessError("The generated integration database name is invalid.");
|
||||
}
|
||||
return `"${databaseName}"`;
|
||||
}
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
const safeLabel = label
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||
.replaceAll(/^_+|_+$/gu, "")
|
||||
.slice(0, 10);
|
||||
databaseCounter += 1;
|
||||
return `ekk_modcfg_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
function databaseUrl(databaseName: string): string {
|
||||
const parsed = new URL(baseDatabaseUrl());
|
||||
parsed.pathname = `/${databaseName}`;
|
||||
parsed.searchParams.delete("application_name");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||
const client = new Client({ connectionString });
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The public harness failure stays static when connection cleanup also fails.
|
||||
}
|
||||
throw harnessError("The module configuration integration database connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeQuery<Row extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: unknown[] = [],
|
||||
): Promise<readonly Row[]> {
|
||||
try {
|
||||
const result = await client.query<Row>(text, values);
|
||||
return result.rows;
|
||||
} catch {
|
||||
throw harnessError("An integration database administration operation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeEnd(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
throw harnessError("An integration database connection could not be closed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(
|
||||
admin,
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||
[databaseName],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||
);
|
||||
trackedDatabases.delete(databaseName);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
trackedDatabases.add(name);
|
||||
return { name, url: databaseUrl(name) };
|
||||
}
|
||||
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-modcfg-no-modules-"));
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const databaseName of [...trackedDatabases]) {
|
||||
await dropDatabase(databaseName).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration,
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
}
|
||||
|
||||
describe.sequential("live Core module configuration store", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let store: ModuleConfigurationStore;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
it("creates exactly the one configuration table, with its composite key and declared constraints", async () => {
|
||||
database = await createDisposableDatabase("fresh");
|
||||
await applyCoreMigrations(database.url);
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
|
||||
const columns = await safeQuery<{ column_name: string; is_nullable: string }>(
|
||||
admin,
|
||||
`select column_name, is_nullable
|
||||
from information_schema.columns
|
||||
where table_name = 'ekklesia_core_module_configuration'
|
||||
order by column_name`,
|
||||
);
|
||||
const primaryKey = await safeQuery<{ column_name: string }>(
|
||||
admin,
|
||||
`select kcu.column_name
|
||||
from information_schema.table_constraints tc
|
||||
join information_schema.key_column_usage kcu
|
||||
on kcu.constraint_name = tc.constraint_name
|
||||
where tc.table_name = 'ekklesia_core_module_configuration'
|
||||
and tc.constraint_type = 'PRIMARY KEY'
|
||||
order by kcu.ordinal_position`,
|
||||
);
|
||||
// Only the explicitly declared CHECKs: PostgreSQL 17 also materializes a system `..._not_null`
|
||||
// CHECK per NOT NULL column, which is not something this schema declares.
|
||||
const checks = await safeQuery<{ constraint_name: string }>(
|
||||
admin,
|
||||
`select tc.constraint_name
|
||||
from information_schema.table_constraints tc
|
||||
where tc.table_name = 'ekklesia_core_module_configuration'
|
||||
and tc.constraint_type = 'CHECK'
|
||||
and tc.constraint_name not like '%_not_null'
|
||||
order by tc.constraint_name`,
|
||||
);
|
||||
|
||||
expect(columns.map((column) => column.column_name)).toEqual([
|
||||
"created_at",
|
||||
"key",
|
||||
"module_id",
|
||||
"updated_at",
|
||||
"value",
|
||||
]);
|
||||
// One row per (module, key): a module can never have two values for one key, enforced by the
|
||||
// database rather than by application care.
|
||||
expect(primaryKey.map((column) => column.column_name)).toEqual(["module_id", "key"]);
|
||||
expect(checks.map((check) => check.constraint_name)).toEqual([
|
||||
"ekklesia_core_module_configuration_key_check",
|
||||
"ekklesia_core_module_configuration_module_check",
|
||||
"ekklesia_core_module_configuration_value_check",
|
||||
]);
|
||||
// Deliberately absent: there is no column that could hold secret material, and none that
|
||||
// records where a value came from. Secrets are deployment-supplied and never persisted.
|
||||
expect(columns.map((column) => column.column_name)).not.toContain("secret");
|
||||
expect(columns.map((column) => column.column_name)).not.toContain("encrypted");
|
||||
});
|
||||
|
||||
it("reads an empty configuration from a freshly migrated database", async () => {
|
||||
const view = createValidatedPostgresPool(database.url, {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
});
|
||||
close = view.close;
|
||||
store = createModuleConfigurationStore(createModuleConfigurationView(view.pool));
|
||||
|
||||
expect(await store.readSettings()).toEqual({ records: [] });
|
||||
});
|
||||
|
||||
it("persists values and reads them back over a completely new connection", async () => {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [
|
||||
{ key: "bucket", value: "slides" },
|
||||
{ key: "slideSeconds", value: "12" },
|
||||
],
|
||||
});
|
||||
|
||||
const separate = createValidatedPostgresPool(database.url, {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
});
|
||||
try {
|
||||
const reread = await createModuleConfigurationStore(
|
||||
createModuleConfigurationView(separate.pool),
|
||||
).readSettings();
|
||||
expect(reread.records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
]);
|
||||
} finally {
|
||||
await separate.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces the whole document for one module, clearing keys the submission omits", async () => {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "slides" }],
|
||||
});
|
||||
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never touches another module's rows during a replace", async () => {
|
||||
await store.replace({ moduleId: "calendar", values: [{ key: "bucket", value: "events" }] });
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "renamed" }],
|
||||
});
|
||||
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "renamed" },
|
||||
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears a module's document entirely when given no values", async () => {
|
||||
await store.replace({ moduleId: "calendar", values: [] });
|
||||
|
||||
expect(
|
||||
(await store.readSettings()).records.filter((record) => record.moduleId === "calendar"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats a module id and a key as data, never as an SQL identifier", async () => {
|
||||
// The store validates identifiers before they reach PostgreSQL, so this is rejected by the
|
||||
// application. The row count afterwards proves nothing was executed either way.
|
||||
await expect(
|
||||
store.replace({
|
||||
moduleId: 'announcements"; drop table ekklesia_core_module_configuration; --',
|
||||
values: [{ key: "bucket", value: "x" }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_MODULE_ID_INVALID" });
|
||||
await expect(
|
||||
store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: 'bucket"; drop table ekklesia_core_module_configuration; --', value: "x" }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_KEY_INVALID" });
|
||||
|
||||
const tables = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
`select count(*)::text as count from information_schema.tables
|
||||
where table_name = 'ekklesia_core_module_configuration'`,
|
||||
);
|
||||
expect(tables[0]?.count).toBe("1");
|
||||
});
|
||||
|
||||
it("rejects a duplicate key in one submission before reaching the database", async () => {
|
||||
await expect(
|
||||
store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [
|
||||
{ key: "bucket", value: "a" },
|
||||
{ key: "bucket", value: "b" },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ModuleConfigurationStoreError);
|
||||
});
|
||||
|
||||
it("rejects an oversized value at the application boundary and at the database", async () => {
|
||||
await expect(
|
||||
store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "x".repeat(2049) }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_VALUE_INVALID" });
|
||||
|
||||
// Defense in depth: the same ceiling restated where the data lives, so no future caller can
|
||||
// store a value the resolver would then reject as invalid on every boot.
|
||||
await expect(
|
||||
safeQuery(
|
||||
admin,
|
||||
`insert into ekklesia_core_module_configuration (module_id, key, value)
|
||||
values ('announcements', 'oversized', repeat('x', 2049))`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("never carries a configuration value in a store error", async () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "a-value-nobody-should-see".repeat(200) }],
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
const rendered = `${String(captured)} ${JSON.stringify(captured)}`;
|
||||
expect(rendered).not.toContain("a-value-nobody-should-see");
|
||||
});
|
||||
|
||||
it("retains a value for a module that is no longer deployed, and resolution ignores it", async () => {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "slides" }],
|
||||
});
|
||||
await store.replace({ moduleId: "retired", values: [{ key: "bucket", value: "old" }] });
|
||||
const settings = await store.readSettings();
|
||||
|
||||
const resolved = resolveModuleConfigurationFor(
|
||||
manifestWith([{ key: "bucket", type: "string", required: true }]),
|
||||
{ settings },
|
||||
);
|
||||
|
||||
expect(resolved.configuration?.getString("bucket")).toBe("slides");
|
||||
// Retained rather than deleted: redeploying `retired` restores its value with no new write.
|
||||
expect(settings.records.some((record) => record.moduleId === "retired")).toBe(true);
|
||||
});
|
||||
|
||||
it("drives real validation and defaulting from real stored rows", async () => {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [
|
||||
{ key: "bucket", value: "slides" },
|
||||
{ key: "slideSeconds", value: "900" },
|
||||
],
|
||||
});
|
||||
|
||||
const manifest = manifestWith([
|
||||
{ key: "bucket", type: "string", required: true },
|
||||
{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 },
|
||||
{ key: "pollIntervalSeconds", type: "integer", default: 30 },
|
||||
]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: await store.readSettings(),
|
||||
});
|
||||
|
||||
// 900 exceeds the module's own declared maximum: reported, never clamped and never silently
|
||||
// replaced by the default.
|
||||
expect(resolved.configuration).toBeUndefined();
|
||||
expect(resolved.issue).toEqual({ key: "slideSeconds", reason: "configuration_invalid" });
|
||||
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [
|
||||
{ key: "bucket", value: "slides" },
|
||||
{ key: "slideSeconds", value: "12" },
|
||||
],
|
||||
});
|
||||
const fixed = resolveModuleConfigurationFor(manifest, { settings: await store.readSettings() });
|
||||
expect(fixed.configuration?.getInteger("slideSeconds")).toBe(12);
|
||||
expect(fixed.configuration?.getInteger("pollIntervalSeconds")).toBe(30);
|
||||
|
||||
await close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,667 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
type ModuleConfigurationDeclarationInput,
|
||||
type ModuleSecretSource,
|
||||
parseModuleManifest,
|
||||
safeParseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createEnvironmentModuleSecretSource,
|
||||
moduleSecretEnvironmentVariableName,
|
||||
resolveModuleConfigurationFor,
|
||||
} from "../src/modules/configuration.js";
|
||||
import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js";
|
||||
|
||||
function manifestWith(
|
||||
configuration: readonly ModuleConfigurationDeclarationInput[],
|
||||
id = "announcements",
|
||||
) {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration,
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
}
|
||||
|
||||
/** The stored document, in the shape the store produces. */
|
||||
function settings(...records: readonly (readonly [string, string, string])[]) {
|
||||
return {
|
||||
records: records.map(([moduleId, key, value]) => ({ key, moduleId, value })),
|
||||
};
|
||||
}
|
||||
|
||||
function secretsFor(
|
||||
map: Readonly<Record<string, Readonly<Record<string, string>>>>,
|
||||
): ModuleSecretSource {
|
||||
return { get: (moduleId, key) => map[moduleId]?.[key] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration set modelled on what the real Announcements product would need as an Ekklesia
|
||||
* module — a slide duration with a default, a poll interval with a default, a bucket name, and a
|
||||
* deployment-supplied storage credential.
|
||||
*
|
||||
* Modelled, never imported: the Announcements repository is a reference consumer for this design and
|
||||
* is not a dependency of Ekklesia's tests. Nothing Announcements-specific exists in Core; this
|
||||
* fixture only proves the *shape* of settings a real product needs can be declared, validated,
|
||||
* defaulted, and delivered without the module reaching any platform infrastructure.
|
||||
*/
|
||||
const announcementsShapedDeclarations: readonly ModuleConfigurationDeclarationInput[] = [
|
||||
{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 },
|
||||
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
|
||||
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
|
||||
{ key: "storageCredential", type: "secret", required: true },
|
||||
];
|
||||
|
||||
describe("module configuration declarations", () => {
|
||||
it("accepts a module that declares no configuration at all", () => {
|
||||
const manifest = manifestWith([]);
|
||||
|
||||
expect(manifest.configuration).toEqual([]);
|
||||
// A backend-only module stays exactly as valid as it was before configuration existed.
|
||||
expect(resolveModuleConfigurationFor(manifest, { settings: settings() }).issue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts every declared type with its own constraints", () => {
|
||||
const manifest = manifestWith([
|
||||
{ key: "bucket", type: "string", minLength: 1, maxLength: 63 },
|
||||
{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 },
|
||||
{ key: "shuffle", type: "boolean", default: false },
|
||||
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
|
||||
{ key: "webhook", type: "url" },
|
||||
{ key: "apiToken", type: "secret" },
|
||||
]);
|
||||
|
||||
expect(manifest.configuration.map((entry) => entry.type)).toEqual([
|
||||
"string",
|
||||
"integer",
|
||||
"boolean",
|
||||
"enum",
|
||||
"url",
|
||||
"secret",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [
|
||||
{ key: "apiToken", type: "string", default: "not-a-secret" },
|
||||
{ key: "apiToken", type: "secret", required: true },
|
||||
],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"configuration.1.key",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [{ key: "apiToken", type: "secret", default: "hunter2" }],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
// `.strict()` on the secret shape means there is no `default` field to populate at all — the
|
||||
// rejection is structural rather than a rule someone has to remember to apply.
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [{ key: "slideSeconds", type: "integer", required: true, default: 7 }],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an integer default below its own minimum",
|
||||
{ key: "n", type: "integer", default: 0, minimum: 1 },
|
||||
],
|
||||
[
|
||||
"an integer default above its own maximum",
|
||||
{ key: "n", type: "integer", default: 9, maximum: 5 },
|
||||
],
|
||||
[
|
||||
"a string default shorter than its own minLength",
|
||||
{ key: "s", type: "string", default: "", minLength: 1 },
|
||||
],
|
||||
[
|
||||
"an enum default outside its own values",
|
||||
{ key: "e", type: "enum", values: ["a"], default: "b" },
|
||||
],
|
||||
[
|
||||
"a url default that is not an absolute http(s) URL",
|
||||
{ key: "u", type: "url", default: "/relative" },
|
||||
],
|
||||
[
|
||||
"a url default with a dangerous protocol",
|
||||
{ key: "u", type: "url", default: "file:///etc/passwd" },
|
||||
],
|
||||
])("rejects %s", (_label, declaration) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [declaration],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
"toString",
|
||||
"valueOf",
|
||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [{ key, type: "string" }],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Slide",
|
||||
"slide_seconds",
|
||||
"slide.seconds",
|
||||
"slide-seconds",
|
||||
"9lives",
|
||||
"",
|
||||
])("rejects the malformed configuration key %s", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [{ key, type: "string" }],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleConfigurationFor — defaults and precedence", () => {
|
||||
it("applies declared defaults when nothing is stored", () => {
|
||||
const manifest = manifestWith(announcementsShapedDeclarations);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(["announcements", "bucket", "announcements"]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(7);
|
||||
expect(resolved.configuration?.getInteger("pollIntervalSeconds")).toBe(30);
|
||||
});
|
||||
|
||||
it("lets a stored value override a default", () => {
|
||||
const manifest = manifestWith(announcementsShapedDeclarations);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(
|
||||
["announcements", "bucket", "announcements"],
|
||||
["announcements", "slideSeconds", "12"],
|
||||
),
|
||||
});
|
||||
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(12);
|
||||
});
|
||||
|
||||
it("never falls back to the default when a stored value is invalid", () => {
|
||||
const manifest = manifestWith(announcementsShapedDeclarations);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(
|
||||
["announcements", "bucket", "announcements"],
|
||||
["announcements", "slideSeconds", "99999"],
|
||||
),
|
||||
});
|
||||
|
||||
// Silently substituting 7 here would run the slideshow at a duration nobody chose while the
|
||||
// operator believes theirs took effect.
|
||||
expect(resolved.configuration).toBeUndefined();
|
||||
expect(resolved.issue).toEqual({ key: "slideSeconds", reason: "configuration_invalid" });
|
||||
});
|
||||
|
||||
it("reports a required key with no value and no default as missing", () => {
|
||||
const manifest = manifestWith(announcementsShapedDeclarations);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "bucket", reason: "configuration_missing" });
|
||||
});
|
||||
|
||||
it("leaves an optional key with no value and no default simply absent", () => {
|
||||
const manifest = manifestWith([{ key: "webhook", type: "url" }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() });
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.has("webhook")).toBe(false);
|
||||
expect(resolved.configuration?.getUrl("webhook")).toBeUndefined();
|
||||
expect(resolved.configuration?.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-canonical integer", "integer", "07"],
|
||||
["an integer with whitespace", "integer", " 7 "],
|
||||
["a non-numeric integer", "integer", "seven"],
|
||||
["a float for an integer", "integer", "7.5"],
|
||||
["a non-canonical boolean", "boolean", "yes"],
|
||||
["a numeric boolean", "boolean", "1"],
|
||||
["a capitalized boolean", "boolean", "True"],
|
||||
])("rejects %s rather than coercing it", (_label, type, stored) => {
|
||||
const manifest = manifestWith([{ key: "value", type } as ModuleConfigurationDeclarationInput]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(["announcements", "value", stored]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "value", reason: "configuration_invalid" });
|
||||
});
|
||||
|
||||
it("validates a stored value against every declared constraint", () => {
|
||||
const manifest = manifestWith([
|
||||
{ key: "fit", type: "enum", values: ["contain", "cover"] },
|
||||
{ key: "webhook", type: "url" },
|
||||
{ key: "bucket", type: "string", maxLength: 4 },
|
||||
]);
|
||||
|
||||
for (const [key, bad] of [
|
||||
["fit", "stretch"],
|
||||
["webhook", "javascript:alert(1)"],
|
||||
["bucket", "far-too-long"],
|
||||
] as const) {
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(["announcements", key, bad]),
|
||||
});
|
||||
expect(resolved.issue).toEqual({ key, reason: "configuration_invalid" });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a valid stored value for every declared type", () => {
|
||||
const manifest = manifestWith([
|
||||
{ key: "bucket", type: "string" },
|
||||
{ key: "slideSeconds", type: "integer" },
|
||||
{ key: "shuffle", type: "boolean" },
|
||||
{ key: "fit", type: "enum", values: ["contain", "cover"] },
|
||||
{ key: "webhook", type: "url" },
|
||||
]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(
|
||||
["announcements", "bucket", "announcements"],
|
||||
["announcements", "slideSeconds", "12"],
|
||||
["announcements", "shuffle", "true"],
|
||||
["announcements", "fit", "cover"],
|
||||
["announcements", "webhook", "https://hooks.example.test/notify"],
|
||||
),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.getString("bucket")).toBe("announcements");
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(12);
|
||||
expect(resolved.configuration?.getBoolean("shuffle")).toBe(true);
|
||||
expect(resolved.configuration?.getEnum("fit")).toBe("cover");
|
||||
expect(resolved.configuration?.getUrl("webhook")).toBe("https://hooks.example.test/notify");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleConfigurationFor — keys the manifest no longer declares", () => {
|
||||
it("ignores an undeclared stored key rather than failing, and reports it", () => {
|
||||
const manifest = manifestWith([{ key: "slideSeconds", type: "integer", default: 7 }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(
|
||||
["announcements", "slideSeconds", "9"],
|
||||
["announcements", "transitionMilliseconds", "1100"],
|
||||
),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.keys()).toEqual(["slideSeconds"]);
|
||||
// Retained by the store and surfaced here, so a downgrade or a rename does not destroy an
|
||||
// operator's value and is visible rather than merely tolerated.
|
||||
expect(resolved.unknownKeys).toEqual(["transitionMilliseconds"]);
|
||||
expect(resolved.configuration?.get("transitionMilliseconds")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the default for a key a newer version added, without any migration step", () => {
|
||||
// v1 stored only `slideSeconds`; v2 adds `transitionMilliseconds` with a default.
|
||||
const v2 = manifestWith([
|
||||
{ key: "slideSeconds", type: "integer", default: 7 },
|
||||
{ key: "transitionMilliseconds", type: "integer", default: 1100 },
|
||||
]);
|
||||
const resolved = resolveModuleConfigurationFor(v2, {
|
||||
settings: settings(["announcements", "slideSeconds", "9"]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(9);
|
||||
expect(resolved.configuration?.getInteger("transitionMilliseconds")).toBe(1100);
|
||||
});
|
||||
|
||||
it("blocks rather than guesses when a newer version adds a required key", () => {
|
||||
const v2 = manifestWith([
|
||||
{ key: "slideSeconds", type: "integer", default: 7 },
|
||||
{ key: "bucket", type: "string", required: true },
|
||||
]);
|
||||
const resolved = resolveModuleConfigurationFor(v2, {
|
||||
settings: settings(["announcements", "slideSeconds", "9"]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "bucket", reason: "configuration_missing" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleConfigurationFor — isolation", () => {
|
||||
it("reads only its own module's records, never another module's", () => {
|
||||
const manifest = manifestWith([{ key: "bucket", type: "string" }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(["announcements", "bucket", "mine"], ["calendar", "bucket", "not-mine"]),
|
||||
});
|
||||
|
||||
expect(resolved.configuration?.getString("bucket")).toBe("mine");
|
||||
expect(resolved.configuration?.keys()).toEqual(["bucket"]);
|
||||
});
|
||||
|
||||
it("asks the secret source only for its own module id", () => {
|
||||
const asked: string[] = [];
|
||||
const manifest = manifestWith([{ key: "apiToken", type: "secret", required: true }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: {
|
||||
get: (moduleId, key) => {
|
||||
asked.push(`${moduleId}:${key}`);
|
||||
return moduleId === "announcements" ? "s3cret" : "another-module-secret";
|
||||
},
|
||||
},
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(asked).toEqual(["announcements:apiToken"]);
|
||||
expect(resolved.configuration?.getSecret("apiToken")).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("gives a module no way to reach a key it never declared", () => {
|
||||
const manifest = manifestWith([{ key: "bucket", type: "string", default: "b" }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(["announcements", "somebodyElsesKey", "value"]),
|
||||
});
|
||||
|
||||
for (const probe of ["somebodyElsesKey", "__proto__", "constructor", "toString"]) {
|
||||
expect(resolved.configuration?.get(probe)).toBeUndefined();
|
||||
expect(resolved.configuration?.has(probe)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("module configuration accessors", () => {
|
||||
it("fails closed when the accessor does not match the declared type", () => {
|
||||
const manifest = manifestWith([{ key: "slideSeconds", type: "integer", default: 7 }]);
|
||||
const config = resolveModuleConfigurationFor(manifest, { settings: settings() }).configuration;
|
||||
|
||||
expect(config?.getInteger("slideSeconds")).toBe(7);
|
||||
// An undeclared key and a declared key of another type are deliberately indistinguishable, in
|
||||
// the same style as `PermissionRegistry.get`.
|
||||
expect(config?.getString("slideSeconds")).toBeUndefined();
|
||||
expect(config?.getBoolean("slideSeconds")).toBeUndefined();
|
||||
expect(config?.getSecret("slideSeconds")).toBeUndefined();
|
||||
expect(config?.getString("neverDeclared")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not expose a secret through any non-secret accessor", () => {
|
||||
const manifest = manifestWith([{ key: "apiToken", type: "secret", required: true }]);
|
||||
const config = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { apiToken: "s3cret" } }),
|
||||
settings: settings(),
|
||||
}).configuration;
|
||||
|
||||
expect(config?.getSecret("apiToken")).toBe("s3cret");
|
||||
expect(config?.getString("apiToken")).toBeUndefined();
|
||||
expect(config?.getUrl("apiToken")).toBeUndefined();
|
||||
expect(config?.getEnum("apiToken")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is frozen, sorted, and has no other surface", () => {
|
||||
const manifest = manifestWith([
|
||||
{ key: "zulu", type: "string", default: "z" },
|
||||
{ key: "alpha", type: "string", default: "a" },
|
||||
]);
|
||||
const config = resolveModuleConfigurationFor(manifest, { settings: settings() }).configuration;
|
||||
|
||||
expect(Object.isFrozen(config)).toBe(true);
|
||||
expect(config?.keys()).toEqual(["alpha", "zulu"]);
|
||||
expect(Object.keys(config ?? {}).sort()).toEqual([
|
||||
"get",
|
||||
"getBoolean",
|
||||
"getEnum",
|
||||
"getInteger",
|
||||
"getSecret",
|
||||
"getString",
|
||||
"getUrl",
|
||||
"has",
|
||||
"keys",
|
||||
]);
|
||||
});
|
||||
|
||||
it("supplies an empty configuration that behaves like any other, for a module declaring none", () => {
|
||||
expect(EMPTY_MODULE_CONFIGURATION.keys()).toEqual([]);
|
||||
expect(EMPTY_MODULE_CONFIGURATION.has("anything")).toBe(false);
|
||||
expect(EMPTY_MODULE_CONFIGURATION.get("anything")).toBeUndefined();
|
||||
expect(EMPTY_MODULE_CONFIGURATION.getSecret("anything")).toBeUndefined();
|
||||
expect(Object.isFrozen(EMPTY_MODULE_CONFIGURATION)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deployment-supplied secrets", () => {
|
||||
it("reports a required secret the deployment does not supply", () => {
|
||||
const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() });
|
||||
|
||||
expect(resolved.configuration).toBeUndefined();
|
||||
expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" });
|
||||
});
|
||||
|
||||
it("treats an empty-string secret as not supplied", () => {
|
||||
const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "" } }),
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" });
|
||||
});
|
||||
|
||||
it("leaves an optional secret absent rather than blocking", () => {
|
||||
const manifest = manifestWith([{ key: "storageCredential", type: "secret" }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() });
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.has("storageCredential")).toBe(false);
|
||||
});
|
||||
|
||||
it("never reads a secret from stored configuration, even if a row exists for that key", () => {
|
||||
const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
// A row that should be unreachable — the HTTP boundary refuses to write one, and this proves
|
||||
// the resolver would ignore it even if one arrived by another route.
|
||||
settings: settings(["announcements", "storageCredential", "planted-in-the-database"]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" });
|
||||
expect(resolved.configuration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives a deterministic, collision-free environment variable name", () => {
|
||||
expect(moduleSecretEnvironmentVariableName("announcements", "storageCredential")).toBe(
|
||||
"EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL",
|
||||
);
|
||||
expect(moduleSecretEnvironmentVariableName("media-storage", "accessKeyId")).toBe(
|
||||
"EKKLESIA_MODULE_SECRET__MEDIA_STORAGE__ACCESS_KEY_ID",
|
||||
);
|
||||
// The double underscore is what keeps two different (module, key) pairs from folding onto one
|
||||
// name once kebab-case and camelCase are both flattened to underscores.
|
||||
expect(moduleSecretEnvironmentVariableName("media", "storageAccessKey")).not.toBe(
|
||||
moduleSecretEnvironmentVariableName("media-storage", "accessKey"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads only the derived name from the supplied environment, never an arbitrary variable", () => {
|
||||
const environment = {
|
||||
DATABASE_URL: "postgresql://secret@host/db",
|
||||
EKKLESIA_IDENTITY_SECRET: "platform-secret",
|
||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "s3cret",
|
||||
};
|
||||
const source = createEnvironmentModuleSecretSource(environment);
|
||||
|
||||
expect(source.get("announcements", "storageCredential")).toBe("s3cret");
|
||||
// There is no accessor that takes a raw variable name, so a module can never reach these.
|
||||
expect(source.get("announcements", "databaseUrl")).toBeUndefined();
|
||||
expect(source.get("calendar", "storageCredential")).toBeUndefined();
|
||||
expect(Object.keys(source)).toEqual(["get"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration over a real discovered inventory", () => {
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function writeModule(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
configuration: readonly ModuleConfigurationDeclarationInput[],
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: `@test/module-${id}`,
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration,
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
'throw new Error("configuration discovery must never import a module server entrypoint");',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
it("discovers declarations without importing a single line of module code", async () => {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-config-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await writeModule(modulesDirectory, "announcements", announcementsShapedDeclarations);
|
||||
|
||||
const discovered: readonly DiscoveredModule[] = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
// Every fixture entrypoint throws on import; reaching these assertions is direct evidence that
|
||||
// discovery stayed inert, exactly as it was before configuration existed.
|
||||
expect(discovered).toHaveLength(1);
|
||||
expect(discovered[0]?.manifest.configuration.map((entry) => entry.key)).toEqual([
|
||||
"slideSeconds",
|
||||
"pollIntervalSeconds",
|
||||
"bucket",
|
||||
"storageCredential",
|
||||
]);
|
||||
|
||||
const manifest = discovered[0]?.manifest;
|
||||
if (!manifest) {
|
||||
throw new Error("expected a discovered module");
|
||||
}
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(["announcements", "bucket", "announcements"]),
|
||||
});
|
||||
|
||||
// The whole point, end to end: a real product's settings arrive validated and defaulted, and the
|
||||
// module reached no database, no environment, and no platform internal to get them.
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(7);
|
||||
expect(resolved.configuration?.getInteger("pollIntervalSeconds")).toBe(30);
|
||||
expect(resolved.configuration?.getString("bucket")).toBe("announcements");
|
||||
expect(resolved.configuration?.getSecret("storageCredential")).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("rejects a manifest whose configuration declaration is malformed, at discovery", async () => {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-config-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await writeModule(modulesDirectory, "announcements", [
|
||||
{ key: "apiToken", type: "secret", required: true },
|
||||
// A duplicate key: the manifest is invalid, so the whole inventory is rejected before any
|
||||
// configuration is ever resolved.
|
||||
{ key: "apiToken", type: "string" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "ModuleDiscoveryError" });
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -197,7 +198,7 @@ afterAll(async () => {
|
||||
* by eligibility resolution. */
|
||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -228,7 +229,7 @@ describe.sequential("live Core module lifecycle store", () => {
|
||||
const tables = await safeQuery<{ table_name: string }>(
|
||||
admin,
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = 'public' and table_name like 'ekklesia_core_module%'
|
||||
where table_schema = 'public' and table_name = 'ekklesia_core_module_state'
|
||||
order by table_name`,
|
||||
);
|
||||
const columns = await safeQuery<{
|
||||
|
||||
@@ -22,7 +22,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -343,7 +343,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -65,7 +65,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
@@ -26,7 +27,7 @@ interface PermissionInput {
|
||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -71,8 +72,9 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
expect(registry.has("people.directory.read")).toBe(true);
|
||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||
// Core's own four declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(6);
|
||||
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
||||
// Core's own five declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||
@@ -91,6 +93,7 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
"calendar.events.read",
|
||||
"calendar.events.write",
|
||||
"people.directory.read",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import type {
|
||||
ModuleConfiguration,
|
||||
ModuleDatabase,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { discoverModules } from "../src/modules/discovery.js";
|
||||
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||
import {
|
||||
ModuleDiscoveryError,
|
||||
ModuleRuntimeError,
|
||||
@@ -52,7 +57,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -1351,3 +1356,148 @@ describe("module startup rollback and bounded stop hooks", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("module configuration delivery (ADR 0029)", () => {
|
||||
it("hands each module its own frozen configuration in register, start, and stop", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "announcements", {
|
||||
manifest: {
|
||||
...manifestFor("announcements"),
|
||||
configuration: [
|
||||
{ key: "slideSeconds", type: "integer", default: 7 },
|
||||
{ key: "bucket", type: "string", required: true },
|
||||
],
|
||||
},
|
||||
serverSource: `export default {
|
||||
id: "announcements", version: "0.1.0",
|
||||
register(context) { ${recorder.expression}.push(context.config); },
|
||||
start(context) { ${recorder.expression}.push(context.config); },
|
||||
stop(context) { ${recorder.expression}.push(context.config); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
const resolution = resolveModuleEligibility(
|
||||
discovered,
|
||||
{ modules: [] },
|
||||
{ settings: { records: [{ key: "bucket", moduleId: "announcements", value: "slides" }] } },
|
||||
);
|
||||
const runtime = await startDiscoveredModuleRuntime(
|
||||
resolution.eligibleModules,
|
||||
fakeModuleRuntimeServices(),
|
||||
{ configurations: resolution.configurations },
|
||||
);
|
||||
await runtime.stop();
|
||||
|
||||
expect(recorder.values).toHaveLength(3);
|
||||
const [registerConfig, startConfig, stopConfig] = recorder.values as ModuleConfiguration[];
|
||||
// Configuration is available during `register` too — it is an already-resolved in-memory
|
||||
// snapshot, not a database-backed getter, so it reintroduces no I/O into a hook that must stay
|
||||
// declarative.
|
||||
expect(registerConfig?.getInteger("slideSeconds")).toBe(7);
|
||||
expect(registerConfig?.getString("bucket")).toBe("slides");
|
||||
// The exact same object across every hook, so two hooks can never disagree about a value.
|
||||
expect(startConfig).toBe(registerConfig);
|
||||
expect(stopConfig).toBe(registerConfig);
|
||||
expect(Object.isFrozen(registerConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it("gives each module only its own configuration, never another module's", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createReferenceRecorder();
|
||||
for (const id of ["alpha", "bravo"]) {
|
||||
await createRuntimeModule(modulesDirectory, id, {
|
||||
manifest: {
|
||||
...manifestFor(id),
|
||||
configuration: [{ key: "label", type: "string", required: true }],
|
||||
},
|
||||
serverSource: `export default {
|
||||
id: ${JSON.stringify(id)}, version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push([context.moduleId, context.config.getString("label"), context.config.keys()]); }
|
||||
};`,
|
||||
});
|
||||
}
|
||||
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
const resolution = resolveModuleEligibility(
|
||||
discovered,
|
||||
{ modules: [] },
|
||||
{
|
||||
settings: {
|
||||
records: [
|
||||
{ key: "label", moduleId: "alpha", value: "alpha-value" },
|
||||
{ key: "label", moduleId: "bravo", value: "bravo-value" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
const runtime = await startDiscoveredModuleRuntime(
|
||||
resolution.eligibleModules,
|
||||
fakeModuleRuntimeServices(),
|
||||
{ configurations: resolution.configurations },
|
||||
);
|
||||
await runtime.stop();
|
||||
|
||||
expect(recorder.values).toEqual([
|
||||
["alpha", "alpha-value", ["label"]],
|
||||
["bravo", "bravo-value", ["label"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("supplies an empty configuration, never undefined, to a module that declares none", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "plain", {
|
||||
serverSource: `export default {
|
||||
id: "plain", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push([typeof context.config, context.config.keys()]); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
await runtime.stop();
|
||||
|
||||
// Module code never has to branch on whether the platform supplied a configuration.
|
||||
expect(recorder.values).toEqual([["object", []]]);
|
||||
});
|
||||
|
||||
it("keeps the services object one shared identity even though configuration is per-module", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
for (const id of ["alpha", "bravo"]) {
|
||||
await createRuntimeModule(modulesDirectory, id, {
|
||||
manifest: {
|
||||
...manifestFor(id),
|
||||
configuration: [{ key: "label", type: "string", default: id }],
|
||||
},
|
||||
serverSource: `export default {
|
||||
id: ${JSON.stringify(id)}, version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); ${recorder.expression}.push(context.config); }
|
||||
};`,
|
||||
});
|
||||
}
|
||||
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory, services));
|
||||
const resolution = resolveModuleEligibility(
|
||||
discovered,
|
||||
{ modules: [] },
|
||||
{ settings: { records: [] } },
|
||||
);
|
||||
const runtime = await startDiscoveredModuleRuntime(resolution.eligibleModules, services, {
|
||||
configurations: resolution.configurations,
|
||||
});
|
||||
await runtime.stop();
|
||||
|
||||
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
|
||||
// This is the reason configuration is a sibling of `services` rather than a field inside it:
|
||||
// `services` is deliberately one shared object for the whole runtime, while configuration must
|
||||
// differ per module. Both properties hold simultaneously.
|
||||
expect(alphaServices).toBe(services);
|
||||
expect(bravoServices).toBe(services);
|
||||
expect(alphaConfig).not.toBe(bravoConfig);
|
||||
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
|
||||
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ function manifestFor(
|
||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -51,7 +51,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
version,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user