Merge pull request 'feat: add the module configuration and secrets contract' (#28) from phase-7a/module-configuration into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/28
This commit was merged in pull request #28.
This commit is contained in:
@@ -12,8 +12,8 @@ transport), and Phase 5C (module database access) have landed. **Phase 5D (secur
|
|||||||
against an independent security audit) is complete: all five passes have landed and the final
|
against an independent security audit) is complete: all five passes have landed and the final
|
||||||
independent whole-repository re-audit has closed, under the current trusted-contributor,
|
independent whole-repository re-audit has closed, under the current trusted-contributor,
|
||||||
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
|
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
|
||||||
completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) has
|
completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) and
|
||||||
landed.** The operational
|
pass 2 (the module configuration and secrets contract) have landed.** The operational
|
||||||
requirements that remain are prerequisites for two specific *future* activities — executing
|
requirements that remain are prerequisites for two specific *future* activities — executing
|
||||||
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
||||||
pass 5 closure below and
|
pass 5 closure below and
|
||||||
@@ -449,9 +449,67 @@ logs. Reading and changing both require the new restricted platform permission
|
|||||||
code is still on disk and still trusted. There is still **no installer**. See
|
code is still on disk and still trusted. There is still **no installer**. See
|
||||||
[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md).
|
[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md).
|
||||||
|
|
||||||
|
Phase 7A pass 2 adds the **module configuration and secrets contract** — the thing standing between
|
||||||
|
Announcements and the module system, since that product currently has zero environment variables and
|
||||||
|
compiles a LAN IP address into its source.
|
||||||
|
|
||||||
|
A module declares what it needs configured in its manifest, and receives it as a frozen, validated
|
||||||
|
snapshot:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"configuration": [
|
||||||
|
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600 },
|
||||||
|
{ "key": "bucket", "type": "string", "required": true },
|
||||||
|
{ "key": "storageCredential", "type": "secret", "required": true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
start({ config }) {
|
||||||
|
const slideSeconds = config.getInteger("slideSeconds");
|
||||||
|
const credential = config.getSecret("storageCredential");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Declaration is static, and that is the whole point.** The platform validates configuration
|
||||||
|
*before* deciding whether a module may start, so an enabled module missing a required value is
|
||||||
|
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
|
||||||
|
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||||
|
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||||
|
module code. That forces `manifestVersion: 5`, a hard cutover in this repository's usual style.
|
||||||
|
|
||||||
|
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||||
|
no key management, so a `secret` key's value is supplied by the deployment
|
||||||
|
(`EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`) and never touches PostgreSQL. The module never sees
|
||||||
|
that variable name — it calls `config.getSecret("storageCredential")` — which is exactly what lets a
|
||||||
|
future standalone host supply the same declared secret from somewhere else. A `secret` declaration has
|
||||||
|
no `default` field, the write API refuses secret material outright, and the HTTP view has no `value`
|
||||||
|
field for one, so a secret is absent from responses *by construction* rather than filtered by a branch
|
||||||
|
somebody could delete.
|
||||||
|
|
||||||
|
Six types (`string`, `integer`, `boolean`, `enum`, `url`, `secret`), bounded constraints, no nesting,
|
||||||
|
no regular expressions — a validation vocabulary, not a form-builder DSL. Nothing is coerced:
|
||||||
|
`{"slideSeconds": "7"}` is a `400`, and a stored value that fails its own declared constraints blocks
|
||||||
|
the module rather than being quietly replaced by the default. Ordinary values live in one Core table
|
||||||
|
(migration `0005`), one row per `(module_id, key)`; a key the current manifest no longer declares is
|
||||||
|
retained and ignored so a downgrade never destroys an operator's value.
|
||||||
|
|
||||||
|
Changes take effect at the **next restart**, and `GET`/`PUT /api/v1/module-configuration/<id>` report
|
||||||
|
`restartRequired` rather than pretending otherwise — a sibling prefix, because `config` is a legal
|
||||||
|
module route segment. Both verbs need the new restricted `core.modules.configuration.manage`,
|
||||||
|
deliberately separate from the lifecycle permission: running a module and configuring it are different
|
||||||
|
authorities. A setting that must change *without* a restart is the module's own domain state — like
|
||||||
|
Announcements' `mode` toggle — not platform configuration.
|
||||||
|
|
||||||
|
**There is no settings UI**, and module UI receives no configuration at all in this phase. Endpoints
|
||||||
|
and credentials for shared infrastructure stay platform concerns: a module declares which *bucket* it
|
||||||
|
needs, not an S3 endpoint, leaving room for the object storage service Core still lacks. See
|
||||||
|
[ADR 0029](docs/architecture/decisions/0029-module-configuration-and-secrets.md).
|
||||||
|
|
||||||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
||||||
and no module-management UI — module enablement is an API and a CLI, not a screen. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
|
and no module-management or settings UI — module enablement and configuration are an API and a CLI,
|
||||||
|
not a screen. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
|
||||||
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do
|
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do
|
||||||
not exist.
|
not exist.
|
||||||
|
|
||||||
@@ -509,20 +567,23 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
4 permissions registered
|
5 permissions registered
|
||||||
○ Module lifecycle skipped
|
○ Module lifecycle skipped
|
||||||
DATABASE_URL is not configured.
|
DATABASE_URL is not configured.
|
||||||
|
○ Module configuration skipped
|
||||||
|
DATABASE_URL is not configured.
|
||||||
|
|
||||||
Data definitions
|
Data definitions
|
||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
0 modules · 0 tables
|
0 modules · 0 tables
|
||||||
✓ Migrations validated
|
✓ Migrations validated
|
||||||
5 migrations · checksums valid
|
6 migrations · checksums valid
|
||||||
core:0000_identity_core_tables · f8f2086ef06f
|
core:0000_identity_core_tables · f8f2086ef06f
|
||||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||||
core:0002_authorization_integrity · 634294731180
|
core:0002_authorization_integrity · 634294731180
|
||||||
core:0003_dashboard_navigation_tables · 2cde27807aa4
|
core:0003_dashboard_navigation_tables · 2cde27807aa4
|
||||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||||
|
core:0005_module_configuration · c8d57f24f84b
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
✓ Database TLS verified
|
✓ Database TLS verified
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
isAuthenticatedPrincipal,
|
isAuthenticatedPrincipal,
|
||||||
|
type ModuleSecretSource,
|
||||||
type ModuleRuntimeServices,
|
type ModuleRuntimeServices,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -11,7 +12,11 @@ import type {
|
|||||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import type {
|
import type {
|
||||||
|
DiscoveredModule,
|
||||||
|
ModuleConfigurationSettings,
|
||||||
|
ModuleConfigurationStore,
|
||||||
ModuleLifecycleSnapshot,
|
ModuleLifecycleSnapshot,
|
||||||
|
ModuleConfigurationLookup,
|
||||||
ModuleLifecycleStore,
|
ModuleLifecycleStore,
|
||||||
ModuleUiPublicationRegistry,
|
ModuleUiPublicationRegistry,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
@@ -22,6 +27,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
|
|||||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||||
|
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||||
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||||
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||||
@@ -68,6 +74,25 @@ export interface CreateApiAppOptions {
|
|||||||
readonly snapshot: ModuleLifecycleSnapshot;
|
readonly snapshot: ModuleLifecycleSnapshot;
|
||||||
readonly store: ModuleLifecycleStore;
|
readonly store: ModuleLifecycleStore;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* The platform's module configuration resource (ADR 0029): the discovered inventory whose
|
||||||
|
* manifests declare what may be configured, the settings this process resolved at boot (used only
|
||||||
|
* to compute `restartRequired`), the deployment's secret source, and the store.
|
||||||
|
*
|
||||||
|
* Required rather than defaulted, like every other module option here. `secrets` is explicit for
|
||||||
|
* the same reason: a caller that supplies no module secrets says so, and a module requiring one is
|
||||||
|
* then reported `secret_missing` rather than silently starting without it.
|
||||||
|
*/
|
||||||
|
readonly moduleConfiguration: {
|
||||||
|
readonly discoveredModules: readonly DiscoveredModule[];
|
||||||
|
readonly bootSettings: ModuleConfigurationSettings;
|
||||||
|
readonly secrets: ModuleSecretSource;
|
||||||
|
readonly store: ModuleConfigurationStore;
|
||||||
|
/** The per-module frozen snapshots the started runtime is actually using — the identical map
|
||||||
|
* `startDiscoveredModuleRuntime` received, so a module's HTTP handler and its lifecycle hooks
|
||||||
|
* read the same object rather than two independently resolved values. */
|
||||||
|
readonly snapshots: ModuleConfigurationLookup;
|
||||||
|
};
|
||||||
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
|
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
|
||||||
* rather than defaulted, like every other module option here: a caller that exercises no
|
* rather than defaulted, like every other module option here: a caller that exercises no
|
||||||
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
|
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
|
||||||
@@ -244,13 +269,34 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
evaluator: options.authorization.evaluator,
|
evaluator: options.authorization.evaluator,
|
||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
|
configuration: {
|
||||||
|
secrets: options.moduleConfiguration.secrets,
|
||||||
|
store: options.moduleConfiguration.store,
|
||||||
|
},
|
||||||
snapshot: options.moduleLifecycle.snapshot,
|
snapshot: options.moduleLifecycle.snapshot,
|
||||||
store: options.moduleLifecycle.store,
|
store: options.moduleLifecycle.store,
|
||||||
trustedOrigins: allowedOrigins,
|
trustedOrigins: allowedOrigins,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Same sibling-prefix rule as the lifecycle resource above: `/api/v1/modules/` belongs entirely
|
||||||
|
// to installed modules. See `./module-configuration-routes.js`.
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
createModuleConfigurationRoutes({
|
||||||
|
bootSettings: options.moduleConfiguration.bootSettings,
|
||||||
|
discoveredModules: options.moduleConfiguration.discoveredModules,
|
||||||
|
evaluator: options.authorization.evaluator,
|
||||||
|
registry: options.authorization.registry,
|
||||||
|
runtime,
|
||||||
|
secrets: options.moduleConfiguration.secrets,
|
||||||
|
store: options.moduleConfiguration.store,
|
||||||
|
trustedOrigins: allowedOrigins,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
||||||
|
configurations: options.moduleConfiguration.snapshots,
|
||||||
evaluator: options.authorization.evaluator,
|
evaluator: options.authorization.evaluator,
|
||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
|
createModuleConfigurationView,
|
||||||
createModuleDatabaseView,
|
createModuleDatabaseView,
|
||||||
createModuleLifecycleView,
|
createModuleLifecycleView,
|
||||||
|
type ModuleConfigurationDrizzleDatabase,
|
||||||
type ModuleLifecycleDrizzleDatabase,
|
type ModuleLifecycleDrizzleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ export interface ApiDatabaseHandle {
|
|||||||
readonly dashboard: DashboardDrizzleDatabase;
|
readonly dashboard: DashboardDrizzleDatabase;
|
||||||
readonly identity: IdentityDrizzleDatabase;
|
readonly identity: IdentityDrizzleDatabase;
|
||||||
readonly module: ModuleDatabase;
|
readonly module: ModuleDatabase;
|
||||||
|
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||||
@@ -31,7 +34,8 @@ export interface ApiDatabaseHandle {
|
|||||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
||||||
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||||
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleDatabaseView`). No domain
|
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleConfigurationView`,
|
||||||
|
* `createModuleDatabaseView`). No domain
|
||||||
* owns or closes the pool in this composition — `close()`
|
* owns or closes the pool in this composition — `close()`
|
||||||
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||||
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||||
@@ -49,6 +53,7 @@ export function createApiDatabase(
|
|||||||
dashboard: createDashboardDrizzleView(pool),
|
dashboard: createDashboardDrizzleView(pool),
|
||||||
identity: createIdentityDrizzleView(pool),
|
identity: createIdentityDrizzleView(pool),
|
||||||
module: createModuleDatabaseView(pool),
|
module: createModuleDatabaseView(pool),
|
||||||
|
moduleConfiguration: createModuleConfigurationView(pool),
|
||||||
moduleLifecycle: createModuleLifecycleView(pool),
|
moduleLifecycle: createModuleLifecycleView(pool),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import {
|
||||||
|
type ModuleConfigurationDeclaration,
|
||||||
|
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
|
||||||
|
moduleConfigurationKeySchema,
|
||||||
|
moduleIdSchema,
|
||||||
|
type ModuleSecretSource,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { CORE_MODULES_CONFIGURATION_MANAGE } from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
type DiscoveredModule,
|
||||||
|
type ModuleConfigurationSettings,
|
||||||
|
type ModuleConfigurationStore,
|
||||||
|
ModuleConfigurationStoreError,
|
||||||
|
resolveModuleConfigurationFor,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
requirePermission,
|
||||||
|
requireTrustedOrigin,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **sibling** of `/api/v1/modules/`, never a child — the third application of the rule ADR 0025
|
||||||
|
* established and ADR 0028 reapplied. `config` and `configuration` are both ordinary lowercase
|
||||||
|
* static segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately
|
||||||
|
* register `PUT /config` and land on exactly `/api/v1/modules/announcements/config`. Publishing the
|
||||||
|
* platform's own configuration mutation there would put Core and an installed module in one URL
|
||||||
|
* namespace with registration order deciding which one answers — for a route that changes how
|
||||||
|
* modules behave.
|
||||||
|
*/
|
||||||
|
export const MODULE_CONFIGURATION_PATH = "/api/v1/module-configuration";
|
||||||
|
const MODULE_CONFIGURATION_MODULE_PATH = `${MODULE_CONFIGURATION_PATH}/:moduleId`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One declared key as an operator sees it: the module's own declaration, plus what is currently
|
||||||
|
* stored, plus what the running process actually used.
|
||||||
|
*
|
||||||
|
* `value` is **absent for every `secret` key, always**. Not null, not redacted, not a placeholder —
|
||||||
|
* absent, because there is no code path that could put secret material here: Ekklesia never persists
|
||||||
|
* it, and the resolver's snapshot is never serialized. `configured` reports whether the deployment
|
||||||
|
* supplied one, which is the only thing an operator needs to know from here.
|
||||||
|
*/
|
||||||
|
export interface ModuleConfigurationKeyView {
|
||||||
|
readonly key: string;
|
||||||
|
readonly type: ModuleConfigurationDeclaration["type"];
|
||||||
|
readonly required: boolean;
|
||||||
|
readonly description?: string;
|
||||||
|
/** The declared default, for non-secret keys that have one. Part of the module's public manifest. */
|
||||||
|
readonly default?: string | number | boolean;
|
||||||
|
/** Present only for `type: "enum"`. */
|
||||||
|
readonly values?: readonly string[];
|
||||||
|
/** The stored value, when one is stored and the key is not a secret. Absent means "no value
|
||||||
|
* stored" — the default applies, or the key is unset. */
|
||||||
|
readonly value?: string;
|
||||||
|
/** For a secret: whether the deployment supplies it. For anything else: whether a value is stored.
|
||||||
|
* Never implies the value is valid — see the module's `runtime` entry for that. */
|
||||||
|
readonly configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleConfigurationView {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly keys: readonly ModuleConfigurationKeyView[];
|
||||||
|
/** Stored keys this module's current manifest no longer declares. Retained rather than deleted, so
|
||||||
|
* a downgrade or a rename does not destroy an operator's value, and surfaced here so it is visible
|
||||||
|
* rather than merely tolerated. */
|
||||||
|
readonly unknownKeys: readonly string[];
|
||||||
|
/** `true` when the stored configuration no longer matches what this process resolved at boot. A
|
||||||
|
* configuration change is applied by the next restart; nothing is hot-reloaded (ADR 0029). */
|
||||||
|
readonly restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModuleConfigurationRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The discovered inventory this process booted from — the source of every declaration this route
|
||||||
|
* validates against. Never re-discovered: a manifest that changed on disk since boot is not what
|
||||||
|
* the running platform is using. */
|
||||||
|
readonly discoveredModules: readonly DiscoveredModule[];
|
||||||
|
/** The stored settings this process resolved at boot, used only to compute `restartRequired` by
|
||||||
|
* comparison with what is stored now. */
|
||||||
|
readonly bootSettings: ModuleConfigurationSettings;
|
||||||
|
readonly secrets: ModuleSecretSource;
|
||||||
|
readonly store: ModuleConfigurationStore;
|
||||||
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. The store's
|
||||||
|
* errors never carry a value, so nothing here can leak one either. */
|
||||||
|
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
|
||||||
|
if (error instanceof ModuleConfigurationStoreError) {
|
||||||
|
if (
|
||||||
|
error.code === "MODULE_CONFIGURATION_READ_FAILED" ||
|
||||||
|
error.code === "MODULE_CONFIGURATION_WRITE_FAILED"
|
||||||
|
) {
|
||||||
|
return sanitizedFailure(c, 503, error.code);
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
return sanitizedFailure(
|
||||||
|
c,
|
||||||
|
503,
|
||||||
|
fallback === "read" ? "MODULE_CONFIGURATION_READ_FAILED" : "MODULE_CONFIGURATION_WRITE_FAILED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BODY_KEYS = new Set(["values"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical text form of a submitted value, or `undefined` when it is not one this key accepts.
|
||||||
|
*
|
||||||
|
* Accepts JSON's own types — a number for `integer`, a boolean for `boolean`, a string for the rest
|
||||||
|
* — and converts to the single canonical text form the store persists and the resolver parses. It
|
||||||
|
* never accepts a string for a number (`"7"`), so exactly one spelling reaches storage and a stored
|
||||||
|
* row can never validate differently from the request that created it.
|
||||||
|
*/
|
||||||
|
function canonicalStoredValue(
|
||||||
|
declaration: ModuleConfigurationDeclaration,
|
||||||
|
submitted: unknown,
|
||||||
|
): string | undefined {
|
||||||
|
switch (declaration.type) {
|
||||||
|
case "integer": {
|
||||||
|
return typeof submitted === "number" && Number.isSafeInteger(submitted)
|
||||||
|
? String(submitted)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
case "boolean": {
|
||||||
|
return typeof submitted === "boolean" ? String(submitted) : undefined;
|
||||||
|
}
|
||||||
|
case "string":
|
||||||
|
case "enum":
|
||||||
|
case "url": {
|
||||||
|
return typeof submitted === "string" &&
|
||||||
|
submitted.length <= MODULE_CONFIGURATION_VALUE_MAX_LENGTH
|
||||||
|
? submitted
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
case "secret": {
|
||||||
|
// Unreachable: a secret key is rejected before this is called. The branch exists so adding a
|
||||||
|
// type to the union is a compile error rather than a silent acceptance of secret material.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(c: Context): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return (await c.req.json()) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function declarationView(
|
||||||
|
declaration: ModuleConfigurationDeclaration,
|
||||||
|
storedValue: string | undefined,
|
||||||
|
secretConfigured: boolean,
|
||||||
|
): ModuleConfigurationKeyView {
|
||||||
|
const isSecret = declaration.type === "secret";
|
||||||
|
return {
|
||||||
|
// A secret is `configured` when the deployment supplies it; anything else when a value is
|
||||||
|
// stored. The two are different questions with one honest answer each.
|
||||||
|
configured: isSecret ? secretConfigured : storedValue !== undefined,
|
||||||
|
key: declaration.key,
|
||||||
|
required: declaration.required,
|
||||||
|
type: declaration.type,
|
||||||
|
...(declaration.description === undefined ? {} : { description: declaration.description }),
|
||||||
|
...(isSecret || declaration.default === undefined ? {} : { default: declaration.default }),
|
||||||
|
...(declaration.type === "enum" ? { values: declaration.values } : {}),
|
||||||
|
// The one place a stored value reaches a response — and structurally never for a secret.
|
||||||
|
...(isSecret || storedValue === undefined ? {} : { value: storedValue }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's module configuration resource: what each installed module declares it needs, what
|
||||||
|
* an operator has stored, and whether the running process is still using it.
|
||||||
|
*
|
||||||
|
* ## Secrets are absent, not filtered
|
||||||
|
*
|
||||||
|
* There is no branch here that redacts a secret, because there is nothing to redact: Ekklesia never
|
||||||
|
* persists secret material, so the store this route reads from contains none. A `secret` key's
|
||||||
|
* `value` field is omitted by construction and `configured` is computed by asking the deployment's
|
||||||
|
* secret source a yes/no question. A future code change that tried to serve a secret value would
|
||||||
|
* have to first invent somewhere to read one from.
|
||||||
|
*
|
||||||
|
* ## Changing configuration does not change the running process
|
||||||
|
*
|
||||||
|
* `PUT` replaces the stored document. It does not restart, reload, or reconfigure anything: modules
|
||||||
|
* receive a frozen snapshot resolved once at startup (ADR 0029), so the response reports
|
||||||
|
* `restartRequired` rather than pretending the change took effect.
|
||||||
|
*
|
||||||
|
* ## Closed validation against the module's own declaration
|
||||||
|
*
|
||||||
|
* A submitted key the module does not declare is rejected, as is a value of the wrong JSON type or a
|
||||||
|
* value failing the module's declared constraints. Nothing is coerced, truncated, or dropped:
|
||||||
|
* `{"slideSeconds": "7"}` is a `400`, not a silent `7`. A secret key is rejected outright — there is
|
||||||
|
* no supported way to submit secret material to this platform.
|
||||||
|
*/
|
||||||
|
export function createModuleConfigurationRoutes(
|
||||||
|
options: CreateModuleConfigurationRoutesOptions,
|
||||||
|
): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
const requireManage = requirePermission(
|
||||||
|
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||||
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
|
);
|
||||||
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
const modulesById = new Map(
|
||||||
|
options.discoveredModules.map((module) => [module.manifest.id, module] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
const storedFor = (
|
||||||
|
settings: ModuleConfigurationSettings,
|
||||||
|
moduleId: string,
|
||||||
|
): ReadonlyMap<string, string> =>
|
||||||
|
new Map(
|
||||||
|
settings.records
|
||||||
|
.filter((record) => record.moduleId === moduleId)
|
||||||
|
.map((record) => [record.key, record.value] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Two documents differ when the exact set of (key, value) pairs differs. Compared against the
|
||||||
|
* settings this process actually resolved at boot, so `restartRequired` is an observation rather
|
||||||
|
* than a guess. */
|
||||||
|
const differsFromBoot = (moduleId: string, current: ReadonlyMap<string, string>): boolean => {
|
||||||
|
const boot = storedFor(options.bootSettings, moduleId);
|
||||||
|
if (boot.size !== current.size) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const [key, value] of current) {
|
||||||
|
if (boot.get(key) !== value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildView = (
|
||||||
|
module: DiscoveredModule,
|
||||||
|
settings: ModuleConfigurationSettings,
|
||||||
|
): ModuleConfigurationView => {
|
||||||
|
const stored = storedFor(settings, module.manifest.id);
|
||||||
|
const resolved = resolveModuleConfigurationFor(module.manifest, {
|
||||||
|
secrets: options.secrets,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
keys: module.manifest.configuration.map((declaration) =>
|
||||||
|
declarationView(
|
||||||
|
declaration as ModuleConfigurationDeclaration,
|
||||||
|
stored.get(declaration.key),
|
||||||
|
// Asked, never read into the response: the answer is a boolean, and the value it was
|
||||||
|
// derived from never leaves the resolver.
|
||||||
|
declaration.type === "secret"
|
||||||
|
? options.secrets.get(module.manifest.id, declaration.key) !== undefined
|
||||||
|
: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
moduleId: module.manifest.id,
|
||||||
|
restartRequired: differsFromBoot(module.manifest.id, stored),
|
||||||
|
unknownKeys: resolved.unknownKeys,
|
||||||
|
version: module.manifest.version,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
routes.get(MODULE_CONFIGURATION_MODULE_PATH, requireManage, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
const moduleId = c.req.param("moduleId");
|
||||||
|
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
|
||||||
|
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
|
||||||
|
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return c.json(buildView(module, await options.store.readSettings()), 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error, "read");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
MODULE_CONFIGURATION_MODULE_PATH,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const moduleId = c.req.param("moduleId");
|
||||||
|
const raw = await readJsonBody(c);
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !BODY_KEYS.has(key))) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const submitted = raw.values;
|
||||||
|
if (!isRecord(submitted)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
|
||||||
|
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
|
||||||
|
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarations = new Map(
|
||||||
|
module.manifest.configuration.map(
|
||||||
|
(declaration) =>
|
||||||
|
[declaration.key, declaration as ModuleConfigurationDeclaration] as const,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const values: { key: string; value: string }[] = [];
|
||||||
|
// `Object.keys` on a parsed JSON object never yields inherited properties, and every key is
|
||||||
|
// re-validated against the platform's one configuration-key grammar before it is used, so a
|
||||||
|
// body carrying `__proto__` or `constructor` is a plain `400` rather than anything stranger.
|
||||||
|
for (const key of Object.keys(submitted)) {
|
||||||
|
if (!moduleConfigurationKeySchema.safeParse(key).success) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const declaration = declarations.get(key);
|
||||||
|
if (!declaration) {
|
||||||
|
return sanitizedFailure(c, 400, "CONFIGURATION_KEY_UNDECLARED");
|
||||||
|
}
|
||||||
|
if (declaration.type === "secret") {
|
||||||
|
// Deliberate and permanent for this pass: Ekklesia has no encryption at rest, so there is
|
||||||
|
// no honest way to accept secret material over HTTP and store it. Secrets are supplied by
|
||||||
|
// the deployment environment (ADR 0029).
|
||||||
|
return sanitizedFailure(c, 400, "CONFIGURATION_SECRET_NOT_WRITABLE");
|
||||||
|
}
|
||||||
|
const canonical = canonicalStoredValue(declaration, submitted[key]);
|
||||||
|
if (canonical === undefined) {
|
||||||
|
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
|
||||||
|
}
|
||||||
|
values.push({ key, value: canonical });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constraint validation runs through the **same resolver the platform boots with**, over the
|
||||||
|
// exact document about to be stored, rather than a second copy of the type rules here. That
|
||||||
|
// is what guarantees the API and startup can never disagree about whether a value is valid:
|
||||||
|
// there is one implementation, and this is it.
|
||||||
|
//
|
||||||
|
// An *incomplete* document is accepted deliberately — an operator may save partial work, and
|
||||||
|
// the module is then reported `blocked · configuration_missing` until they finish. An
|
||||||
|
// *invalid* one never is: storing a value that could not survive the next boot would hand
|
||||||
|
// back a success for a change that silently breaks the module.
|
||||||
|
const candidate = resolveModuleConfigurationFor(module.manifest, {
|
||||||
|
secrets: options.secrets,
|
||||||
|
settings: {
|
||||||
|
records: values.map((entry) => ({ ...entry, moduleId: module.manifest.id })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (candidate.issue?.reason === "configuration_invalid") {
|
||||||
|
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await options.store.replace({ moduleId: module.manifest.id, values });
|
||||||
|
// Re-read rather than predict, so this response and a later GET can never disagree.
|
||||||
|
return c.json(buildView(module, await options.store.readSettings()), 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error, "write");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type {
|
import {
|
||||||
ModuleId,
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
ModuleRouteHandlerContext,
|
type ModuleId,
|
||||||
ModuleRuntimeServices,
|
type ModuleRouteHandlerContext,
|
||||||
RegisteredModuleHttpRoute,
|
type ModuleRuntimeServices,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import type { ModuleConfigurationLookup } from "@ekklesia/core/modules";
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
type AuthorizationMiddlewareOptions,
|
type AuthorizationMiddlewareOptions,
|
||||||
@@ -31,6 +33,16 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
|||||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||||
* after authorization succeeds. */
|
* after authorization succeeds. */
|
||||||
readonly services: ModuleRuntimeServices;
|
readonly services: ModuleRuntimeServices;
|
||||||
|
/**
|
||||||
|
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
||||||
|
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
||||||
|
* rather than two independently resolved values. A module with no entry receives
|
||||||
|
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
|
||||||
|
*
|
||||||
|
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
|
||||||
|
* ever reach its own module's configuration, because that is the only entry looked up for it.
|
||||||
|
*/
|
||||||
|
readonly configurations: ModuleConfigurationLookup;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Never carries the offending permission id, route path, module id, or any other configuration
|
/** Never carries the offending permission id, route path, module id, or any other configuration
|
||||||
@@ -212,6 +224,7 @@ export function mountModuleHttpRoutes(
|
|||||||
const path = moduleRoutePath(route.moduleId, route.path);
|
const path = moduleRoutePath(route.moduleId, route.path);
|
||||||
const finalHandler = async (c: Context): Promise<Response> => {
|
const finalHandler = async (c: Context): Promise<Response> => {
|
||||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||||
|
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
||||||
moduleId: route.moduleId,
|
moduleId: route.moduleId,
|
||||||
params: Object.freeze({ ...c.req.param() }),
|
params: Object.freeze({ ...c.req.param() }),
|
||||||
principal: getAuthorizedPrincipal(c),
|
principal: getAuthorizedPrincipal(c),
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import type {
|
|||||||
ModuleBlockedReason,
|
ModuleBlockedReason,
|
||||||
ModuleBlockingDependency,
|
ModuleBlockingDependency,
|
||||||
ModuleRuntimeState,
|
ModuleRuntimeState,
|
||||||
|
ModuleSecretSource,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
|
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
|
||||||
import {
|
import {
|
||||||
|
type ModuleConfigurationStore,
|
||||||
type ModuleLifecycleSnapshot,
|
type ModuleLifecycleSnapshot,
|
||||||
type ModuleLifecycleStore,
|
type ModuleLifecycleStore,
|
||||||
ModuleLifecycleStoreError,
|
ModuleLifecycleStoreError,
|
||||||
@@ -58,7 +60,11 @@ export interface ModuleLifecycleModuleView {
|
|||||||
readonly runtime: {
|
readonly runtime: {
|
||||||
readonly state: ModuleRuntimeState;
|
readonly state: ModuleRuntimeState;
|
||||||
readonly reason?: ModuleBlockedReason;
|
readonly reason?: ModuleBlockedReason;
|
||||||
|
/** Present for a dependency reason. */
|
||||||
readonly dependency?: ModuleBlockingDependency;
|
readonly dependency?: ModuleBlockingDependency;
|
||||||
|
/** Present for a configuration reason (ADR 0029): the offending key, never its value. A key is
|
||||||
|
* declared in a public manifest; a value may be a credential or arbitrary operator text. */
|
||||||
|
readonly configurationKey?: string;
|
||||||
};
|
};
|
||||||
readonly restartRequired: boolean;
|
readonly restartRequired: boolean;
|
||||||
}
|
}
|
||||||
@@ -74,6 +80,16 @@ export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlew
|
|||||||
* if it were restarted right now. */
|
* if it were restarted right now. */
|
||||||
readonly snapshot: ModuleLifecycleSnapshot;
|
readonly snapshot: ModuleLifecycleSnapshot;
|
||||||
readonly store: ModuleLifecycleStore;
|
readonly store: ModuleLifecycleStore;
|
||||||
|
/**
|
||||||
|
* How to re-read the *other* axis that decides eligibility (ADR 0029). Since a module's own
|
||||||
|
* configuration can block it, `restartRequired` must be computed against live configuration as
|
||||||
|
* well as live enablement — otherwise correcting a missing required value would report no pending
|
||||||
|
* restart for a module the next boot really will start.
|
||||||
|
*/
|
||||||
|
readonly configuration: {
|
||||||
|
readonly store: ModuleConfigurationStore;
|
||||||
|
readonly secrets: ModuleSecretSource;
|
||||||
|
};
|
||||||
readonly trustedOrigins: ReadonlySet<string>;
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +153,9 @@ function present(view: ModuleLifecycleView): ModuleLifecycleModuleView {
|
|||||||
state: view.runtime.state,
|
state: view.runtime.state,
|
||||||
...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }),
|
...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }),
|
||||||
...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }),
|
...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }),
|
||||||
|
...(view.runtime.configurationKey === undefined
|
||||||
|
? {}
|
||||||
|
: { configurationKey: view.runtime.configurationKey }),
|
||||||
},
|
},
|
||||||
version: view.version,
|
version: view.version,
|
||||||
};
|
};
|
||||||
@@ -197,8 +216,14 @@ export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutes
|
|||||||
// every other authorized Core read does.
|
// every other authorized Core read does.
|
||||||
c.header("Vary", "Origin, Cookie");
|
c.header("Vary", "Origin, Cookie");
|
||||||
try {
|
try {
|
||||||
const configuration = await options.store.readConfiguration();
|
const [configuration, settings] = await Promise.all([
|
||||||
const modules = resolveModuleLifecycleView(options.snapshot, configuration).map(present);
|
options.store.readConfiguration(),
|
||||||
|
options.configuration.store.readSettings(),
|
||||||
|
]);
|
||||||
|
const modules = resolveModuleLifecycleView(options.snapshot, configuration, {
|
||||||
|
secrets: options.configuration.secrets,
|
||||||
|
settings,
|
||||||
|
}).map(present);
|
||||||
return c.json({ modules } satisfies ModuleLifecycleResponse, 200);
|
return c.json({ modules } satisfies ModuleLifecycleResponse, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return storeFailure(c, error, "read");
|
return storeFailure(c, error, "read");
|
||||||
@@ -225,10 +250,14 @@ export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutes
|
|||||||
// Re-read rather than predict: the response describes the configuration that is actually
|
// Re-read rather than predict: the response describes the configuration that is actually
|
||||||
// stored now, resolved against the same boot snapshot the collection read uses, so the two
|
// stored now, resolved against the same boot snapshot the collection read uses, so the two
|
||||||
// endpoints can never disagree about `restartRequired`.
|
// endpoints can never disagree about `restartRequired`.
|
||||||
const configuration = await options.store.readConfiguration();
|
const [configuration, settings] = await Promise.all([
|
||||||
const view = resolveModuleLifecycleView(options.snapshot, configuration).find(
|
options.store.readConfiguration(),
|
||||||
(entry) => entry.moduleId === moduleId,
|
options.configuration.store.readSettings(),
|
||||||
);
|
]);
|
||||||
|
const view = resolveModuleLifecycleView(options.snapshot, configuration, {
|
||||||
|
secrets: options.configuration.secrets,
|
||||||
|
settings,
|
||||||
|
}).find((entry) => entry.moduleId === moduleId);
|
||||||
if (!view) {
|
if (!view) {
|
||||||
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
|
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-7
@@ -1,7 +1,11 @@
|
|||||||
import type { Socket } from "node:net";
|
import type { Socket } from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
import {
|
||||||
|
EMPTY_MODULE_SECRET_SOURCE,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
type ModuleSecretSource,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
createAuthorizationEvaluator,
|
createAuthorizationEvaluator,
|
||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
@@ -9,9 +13,12 @@ import {
|
|||||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
|
createEnvironmentModuleSecretSource,
|
||||||
|
createModuleConfigurationStore,
|
||||||
createModuleLifecycleStore,
|
createModuleLifecycleStore,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
|
type ModuleConfigurationSettings,
|
||||||
type ModuleEligibilityResolution,
|
type ModuleEligibilityResolution,
|
||||||
resolveModuleEligibility,
|
resolveModuleEligibility,
|
||||||
type StartedModuleRuntime,
|
type StartedModuleRuntime,
|
||||||
@@ -142,6 +149,17 @@ export interface StartApiOptions {
|
|||||||
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
|
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
|
||||||
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
|
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
|
||||||
readonly moduleHookTimeoutMsOverride?: number;
|
readonly moduleHookTimeoutMsOverride?: number;
|
||||||
|
/**
|
||||||
|
* Where deployment-supplied module secrets come from (ADR 0029) — the one *production* dependency
|
||||||
|
* in this options object rather than a test-only escape hatch.
|
||||||
|
*
|
||||||
|
* It is a dependency rather than a field on `ApiRuntimeConfig` because it is a lookup, not a
|
||||||
|
* validated value, and because the set of secrets depends on which modules are discovered — which
|
||||||
|
* is not known when configuration is validated. The executable entry point supplies an
|
||||||
|
* environment-backed source; omitting it supplies nothing, so a module declaring a required secret
|
||||||
|
* is reported `secret_missing` rather than silently starting without it.
|
||||||
|
*/
|
||||||
|
readonly moduleSecrets?: ModuleSecretSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||||
@@ -405,12 +423,28 @@ export async function startApi(
|
|||||||
// falling back to "start everything": the platform cannot know what it was told, and starting a
|
// falling back to "start everything": the platform cannot know what it was told, and starting a
|
||||||
// module an administrator disabled is worse than not starting at all.
|
// module an administrator disabled is worse than not starting at all.
|
||||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||||
let eligibility: ModuleEligibilityResolution;
|
const moduleConfigurationStore = createModuleConfigurationStore(
|
||||||
try {
|
databaseHandle.moduleConfiguration,
|
||||||
eligibility = resolveModuleEligibility(
|
|
||||||
inventory.modules,
|
|
||||||
await moduleLifecycleStore.readConfiguration(),
|
|
||||||
);
|
);
|
||||||
|
const moduleSecrets = options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE;
|
||||||
|
let eligibility: ModuleEligibilityResolution;
|
||||||
|
let moduleConfigurationSettings: ModuleConfigurationSettings;
|
||||||
|
try {
|
||||||
|
// Both axes are read before a single module entrypoint is imported, and resolved together in
|
||||||
|
// one pass (ADR 0029): a module is eligible only if an administrator enabled it, its own
|
||||||
|
// required configuration is satisfiable, and every module it requires is eligible too. The pass
|
||||||
|
// also produces each eligible module's frozen configuration snapshot, so the configuration that
|
||||||
|
// made a module eligible is the identical object it later receives.
|
||||||
|
// Read sequentially rather than concurrently, deliberately: these are two tiny queries, and
|
||||||
|
// racing them would make the reported failure depend on which rejected first. An operator
|
||||||
|
// debugging a broken deployment must get the same error code every time.
|
||||||
|
const lifecycleConfiguration = await moduleLifecycleStore.readConfiguration();
|
||||||
|
const settings = await moduleConfigurationStore.readSettings();
|
||||||
|
moduleConfigurationSettings = settings;
|
||||||
|
eligibility = resolveModuleEligibility(inventory.modules, lifecycleConfiguration, {
|
||||||
|
secrets: moduleSecrets,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -427,6 +461,7 @@ export async function startApi(
|
|||||||
let moduleRuntime: StartedModuleRuntime;
|
let moduleRuntime: StartedModuleRuntime;
|
||||||
try {
|
try {
|
||||||
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||||
|
configurations: eligibility.configurations,
|
||||||
...(options.moduleHookTimeoutMsOverride === undefined
|
...(options.moduleHookTimeoutMsOverride === undefined
|
||||||
? {}
|
? {}
|
||||||
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||||
@@ -463,6 +498,15 @@ export async function startApi(
|
|||||||
}),
|
}),
|
||||||
store: moduleLifecycleStore,
|
store: moduleLifecycleStore,
|
||||||
},
|
},
|
||||||
|
moduleConfiguration: {
|
||||||
|
// The settings this process actually resolved from, so `restartRequired` is a comparison
|
||||||
|
// against what is running rather than a guess.
|
||||||
|
bootSettings: moduleConfigurationSettings,
|
||||||
|
discoveredModules: inventory.modules,
|
||||||
|
secrets: moduleSecrets,
|
||||||
|
snapshots: eligibility.configurations,
|
||||||
|
store: moduleConfigurationStore,
|
||||||
|
},
|
||||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -615,7 +659,12 @@ function readConfigFromEnvironment(): {
|
|||||||
export async function main(): Promise<void> {
|
export async function main(): Promise<void> {
|
||||||
const { environment, input } = readConfigFromEnvironment();
|
const { environment, input } = readConfigFromEnvironment();
|
||||||
const config = createApiRuntimeConfig(input, { environment });
|
const config = createApiRuntimeConfig(input, { environment });
|
||||||
const running = await startApi(config);
|
// The one place this process turns its environment into module secrets. Core's factory takes the
|
||||||
|
// environment as an argument and never touches `process.env` itself, preserving the rule that
|
||||||
|
// only executables read the environment.
|
||||||
|
const running = await startApi(config, {
|
||||||
|
moduleSecrets: createEnvironmentModuleSecretSource(process.env),
|
||||||
|
});
|
||||||
|
|
||||||
const address = running.server.address();
|
const address = running.server.address();
|
||||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
|
"core:0005_module_configuration",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
@@ -301,7 +302,7 @@ async function createGatedRouteModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -342,7 +343,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -434,7 +435,7 @@ async function createUiModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
@@ -1102,12 +1103,14 @@ describe.sequential("live API — startup and migration guarantees", () => {
|
|||||||
{ environment: "development" },
|
{ environment: "development" },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Startup reads the stored module enablement configuration before importing any module
|
// Startup reads the stored module enablement configuration and then the stored module
|
||||||
// entrypoint (ADR 0028). Against an unmigrated database that read cannot succeed, and the
|
// configuration, both before importing any module entrypoint (ADR 0028, ADR 0029). Against an
|
||||||
// platform fails closed rather than starting every discovered module: it cannot know what an
|
// unmigrated database neither read can succeed, and the platform fails closed rather than
|
||||||
// administrator decided, and starting a module they disabled is worse than not starting.
|
// starting every discovered module: it cannot know what an administrator decided, and starting
|
||||||
// The failure is Core's own sanitized store error — no connection string, host, SQL, driver
|
// a module they disabled — or one whose required configuration is absent — is worse than not
|
||||||
// message, or stack.
|
// starting. The two reads are sequential rather than raced precisely so this error code is
|
||||||
|
// deterministic; the failure is Core's own sanitized store error, with no connection string,
|
||||||
|
// host, SQL, driver message, or stack.
|
||||||
let captured: unknown;
|
let captured: unknown;
|
||||||
try {
|
try {
|
||||||
await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||||
@@ -2189,10 +2192,10 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||||
expect(permissionsCheck).toMatchObject({
|
expect(permissionsCheck).toMatchObject({
|
||||||
// Core's own four (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
// Core's own five (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`) plus the fixture
|
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
||||||
// module's one.
|
// `core.modules.configuration.manage`) plus the fixture module's one.
|
||||||
details: ["5 permissions registered"],
|
details: ["6 permissions registered"],
|
||||||
status: "pass",
|
status: "pass",
|
||||||
});
|
});
|
||||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-stor
|
|||||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -90,6 +91,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -110,6 +112,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -131,6 +134,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -154,6 +158,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -179,6 +184,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -204,6 +210,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -226,6 +233,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -250,6 +258,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -271,6 +280,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -294,6 +304,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -324,6 +335,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -355,6 +367,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -391,6 +404,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -409,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -429,6 +444,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -448,6 +464,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -465,6 +482,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -489,6 +507,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -516,6 +535,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
@@ -546,6 +566,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
@@ -567,6 +588,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
moduleLifecycleSnapshotFor,
|
moduleLifecycleSnapshotFor,
|
||||||
} from "./support/in-memory-module-lifecycle.js";
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -72,7 +73,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -145,6 +146,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
|||||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
store: createInMemoryModuleLifecycleStore(),
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
},
|
},
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -215,7 +215,7 @@ function fakeDiscoveredModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions,
|
permissions,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
type ModuleConfigurationDeclarationInput,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationStore,
|
||||||
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModules,
|
||||||
|
type ModuleConfigurationRecord,
|
||||||
|
type ModuleConfigurationStore,
|
||||||
|
ModuleConfigurationStoreError,
|
||||||
|
resolveModuleEligibility,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
|
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||||
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import {
|
||||||
|
createFakeModuleSecretSource,
|
||||||
|
createInMemoryModuleConfigurationStore,
|
||||||
|
} from "./support/in-memory-module-configuration.js";
|
||||||
|
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
|
const viewerId = parseEkklesiaUserId("55555555-5555-4555-8555-555555555555");
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configuration shape the real Announcements product would need as an Ekklesia module: a slide
|
||||||
|
* duration and poll interval with defaults, a bucket name it must be told, and a deployment-supplied
|
||||||
|
* storage credential.
|
||||||
|
*
|
||||||
|
* Modelled from the reference repository, never imported from it. Nothing Announcements-specific
|
||||||
|
* exists in Core or in `apps/api`; this fixture exists to prove a real product's settings survive
|
||||||
|
* the whole path — manifest, validation, storage, API, snapshot — without the module reaching any
|
||||||
|
* platform infrastructure.
|
||||||
|
*/
|
||||||
|
const announcementsConfiguration: readonly ModuleConfigurationDeclarationInput[] = [
|
||||||
|
{
|
||||||
|
key: "slideSeconds",
|
||||||
|
type: "integer",
|
||||||
|
default: 7,
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 600,
|
||||||
|
description: "How long each slide is shown.",
|
||||||
|
},
|
||||||
|
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
|
||||||
|
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
|
||||||
|
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
|
||||||
|
{ key: "storageCredential", type: "secret", required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly id: string;
|
||||||
|
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${fixture.id}`,
|
||||||
|
type: "module",
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
configuration: fixture.configuration ?? [],
|
||||||
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
|
id: fixture.id,
|
||||||
|
manifestVersion: 5,
|
||||||
|
name: `${fixture.id} module`,
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallOptions {
|
||||||
|
readonly fixtures?: readonly ModuleFixture[];
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
readonly platformOwner?: boolean;
|
||||||
|
readonly grants?: readonly string[];
|
||||||
|
readonly records?: readonly ModuleConfigurationRecord[];
|
||||||
|
readonly secrets?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
||||||
|
readonly store?: ModuleConfigurationStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly app: Hono;
|
||||||
|
readonly modules: readonly DiscoveredModule[];
|
||||||
|
readonly store: ModuleConfigurationStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
|
||||||
|
* composition: read the stored settings, resolve eligibility and configuration in one pass, then
|
||||||
|
* build the UI registry and every option from that same resolution. Doing it the same way here is
|
||||||
|
* what makes these assertions evidence about production composition rather than a bespoke wiring.
|
||||||
|
*/
|
||||||
|
async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-config-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
for (const fixture of options.fixtures ?? [
|
||||||
|
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||||
|
]) {
|
||||||
|
await createModule(modulesDirectory, fixture);
|
||||||
|
}
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = options.store ?? createInMemoryModuleConfigurationStore(options.records ?? []);
|
||||||
|
const secrets = createFakeModuleSecretSource(options.secrets ?? {});
|
||||||
|
const lifecycleStore = createInMemoryModuleLifecycleStore();
|
||||||
|
const bootSettings = await store.readSettings();
|
||||||
|
const eligibility = resolveModuleEligibility(modules, await lifecycleStore.readConfiguration(), {
|
||||||
|
secrets,
|
||||||
|
settings: bootSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const held = new Set(options.grants ?? []);
|
||||||
|
const evaluator = createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
|
||||||
|
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||||
|
});
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const authorizationStore = {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: composePlatformPermissionRegistry(modules),
|
||||||
|
store: authorizationStore,
|
||||||
|
},
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
moduleConfiguration: {
|
||||||
|
bootSettings,
|
||||||
|
discoveredModules: modules,
|
||||||
|
secrets,
|
||||||
|
snapshots: eligibility.configurations,
|
||||||
|
store,
|
||||||
|
},
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: {
|
||||||
|
discoveredModules: modules,
|
||||||
|
eligibility: eligibility.entries,
|
||||||
|
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
|
||||||
|
},
|
||||||
|
store: lifecycleStore,
|
||||||
|
},
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: Object.freeze({
|
||||||
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
|
}) as ModuleRuntimeServices,
|
||||||
|
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app, modules, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
function owner(): InstallOptions {
|
||||||
|
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigurationBody {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly keys: readonly {
|
||||||
|
readonly key: string;
|
||||||
|
readonly type: string;
|
||||||
|
readonly required: boolean;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly default?: string | number | boolean;
|
||||||
|
readonly values?: readonly string[];
|
||||||
|
readonly value?: string;
|
||||||
|
readonly configured: boolean;
|
||||||
|
}[];
|
||||||
|
readonly unknownKeys: readonly string[];
|
||||||
|
readonly restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readConfiguration(
|
||||||
|
app: Hono,
|
||||||
|
moduleId = "announcements",
|
||||||
|
): Promise<ConfigurationBody> {
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
return (await response.json()) as ConfigurationBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function putConfiguration(
|
||||||
|
app: Hono,
|
||||||
|
values: unknown,
|
||||||
|
moduleId = "announcements",
|
||||||
|
origin = trustedOrigin,
|
||||||
|
) {
|
||||||
|
return app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||||
|
body: JSON.stringify({ values }),
|
||||||
|
headers: { "content-type": "application/json", origin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /api/v1/module-configuration/:moduleId", () => {
|
||||||
|
it("returns the module's declared schema with defaults and stored values", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await readConfiguration(app);
|
||||||
|
|
||||||
|
expect(body.moduleId).toBe("announcements");
|
||||||
|
expect(body.keys.map((entry) => entry.key)).toEqual([
|
||||||
|
"slideSeconds",
|
||||||
|
"pollIntervalSeconds",
|
||||||
|
"bucket",
|
||||||
|
"fit",
|
||||||
|
"storageCredential",
|
||||||
|
]);
|
||||||
|
expect(body.keys[0]).toEqual({
|
||||||
|
configured: false,
|
||||||
|
default: 7,
|
||||||
|
description: "How long each slide is shown.",
|
||||||
|
key: "slideSeconds",
|
||||||
|
required: false,
|
||||||
|
type: "integer",
|
||||||
|
});
|
||||||
|
expect(body.keys.find((entry) => entry.key === "bucket")).toMatchObject({
|
||||||
|
configured: true,
|
||||||
|
required: true,
|
||||||
|
value: "slides",
|
||||||
|
});
|
||||||
|
expect(body.keys.find((entry) => entry.key === "fit")?.values).toEqual(["contain", "cover"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a secret value, only whether the deployment supplies one", async () => {
|
||||||
|
const configured = await install({
|
||||||
|
...owner(),
|
||||||
|
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||||
|
});
|
||||||
|
const body = await readConfiguration(configured.app);
|
||||||
|
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||||
|
|
||||||
|
expect(secretEntry).toEqual({
|
||||||
|
configured: true,
|
||||||
|
key: "storageCredential",
|
||||||
|
required: true,
|
||||||
|
type: "secret",
|
||||||
|
});
|
||||||
|
// Not redacted, not null, not a placeholder — the field is absent entirely, and the secret
|
||||||
|
// string appears nowhere in the payload.
|
||||||
|
expect(secretEntry).not.toHaveProperty("value");
|
||||||
|
expect(secretEntry).not.toHaveProperty("default");
|
||||||
|
expect(JSON.stringify(body)).not.toContain("s3cret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unsupplied secret as not configured, still without a value field", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const body = await readConfiguration(app);
|
||||||
|
|
||||||
|
expect(body.keys.find((entry) => entry.key === "storageCredential")).toEqual({
|
||||||
|
configured: false,
|
||||||
|
key: "storageCredential",
|
||||||
|
required: true,
|
||||||
|
type: "secret",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists stored keys the current manifest no longer declares, without failing", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "removedInThisVersion", moduleId: "announcements", value: "kept" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await readConfiguration(app);
|
||||||
|
|
||||||
|
expect(body.unknownKeys).toEqual(["removedInThisVersion"]);
|
||||||
|
expect(body.keys.map((entry) => entry.key)).not.toContain("removedInThisVersion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a module declaring no configuration as an empty, valid document", async () => {
|
||||||
|
const { app } = await install({ ...owner(), fixtures: [{ id: "calendar" }] });
|
||||||
|
const body = await readConfiguration(app, "calendar");
|
||||||
|
|
||||||
|
expect(body.keys).toEqual([]);
|
||||||
|
expect(body.unknownKeys).toEqual([]);
|
||||||
|
expect(body.restartRequired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a module this process did not discover", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/not-installed`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not cached and varies on origin and cookie", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("Vary")).toContain("Cookie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a sanitized 503 when the store cannot be read, with no value or driver detail", async () => {
|
||||||
|
const failing: ModuleConfigurationStore = {
|
||||||
|
readSettings: async () => {
|
||||||
|
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_READ_FAILED");
|
||||||
|
},
|
||||||
|
replace: async () => {
|
||||||
|
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_WRITE_FAILED");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let bootRead = false;
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
store: {
|
||||||
|
readSettings: async () => {
|
||||||
|
if (!bootRead) {
|
||||||
|
bootRead = true;
|
||||||
|
return { records: [] };
|
||||||
|
}
|
||||||
|
return failing.readSettings();
|
||||||
|
},
|
||||||
|
replace: (input) => failing.replace(input),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_CONFIGURATION_READ_FAILED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/v1/module-configuration/:moduleId", () => {
|
||||||
|
it("stores values and reports that a restart is required", async () => {
|
||||||
|
const { app, store } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, { bucket: "slides", slideSeconds: 12 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = (await response.json()) as ConfigurationBody;
|
||||||
|
expect(body.keys.find((entry) => entry.key === "slideSeconds")).toMatchObject({
|
||||||
|
configured: true,
|
||||||
|
value: "12",
|
||||||
|
});
|
||||||
|
// The running process still holds the boot snapshot; claiming otherwise would be a lie.
|
||||||
|
expect(body.restartRequired).toBe(true);
|
||||||
|
expect((await store.readSettings()).records).toEqual([
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no pending restart when the submitted document matches what booted", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, { bucket: "slides" });
|
||||||
|
|
||||||
|
expect(((await response.json()) as ConfigurationBody).restartRequired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the whole document, so an omitted key is cleared", async () => {
|
||||||
|
const { app, store } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await putConfiguration(app, { bucket: "slides" });
|
||||||
|
|
||||||
|
expect((await store.readSettings()).records.map((record) => record.key)).toEqual(["bucket"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches another module's stored values", async () => {
|
||||||
|
const { app, store } = await install({
|
||||||
|
...owner(),
|
||||||
|
fixtures: [
|
||||||
|
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||||
|
{ configuration: [{ key: "bucket", type: "string" }], id: "calendar" },
|
||||||
|
],
|
||||||
|
records: [
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await putConfiguration(app, { bucket: "renamed" });
|
||||||
|
|
||||||
|
expect((await store.readSettings()).records).toEqual([
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "renamed" },
|
||||||
|
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to accept secret material over HTTP", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, {
|
||||||
|
bucket: "slides",
|
||||||
|
storageCredential: "s3cret",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "CONFIGURATION_SECRET_NOT_WRITABLE" });
|
||||||
|
// Nothing was written at all — not the secret, and not the valid key beside it.
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a key the module does not declare", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, { bucket: "slides", mqttEndpoint: "mqtt://x" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "CONFIGURATION_KEY_UNDECLARED" });
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a string for an integer", { bucket: "slides", slideSeconds: "12" }],
|
||||||
|
["a float for an integer", { bucket: "slides", slideSeconds: 12.5 }],
|
||||||
|
["an integer below its declared minimum", { bucket: "slides", slideSeconds: 0 }],
|
||||||
|
["an integer above its declared maximum", { bucket: "slides", slideSeconds: 100000 }],
|
||||||
|
["a value outside a declared enum", { bucket: "slides", fit: "stretch" }],
|
||||||
|
["a string shorter than its declared minLength", { bucket: "" }],
|
||||||
|
["a nested object", { bucket: { name: "slides" } }],
|
||||||
|
["an array", { bucket: ["slides"] }],
|
||||||
|
["null", { bucket: null }],
|
||||||
|
])("rejects %s without storing anything", async (_label, values) => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, values);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a body with an unexpected top-level key", { values: {}, force: true }],
|
||||||
|
["a body with no values object", {}],
|
||||||
|
["a bare array body", []],
|
||||||
|
["a bare string body", "bucket=slides"],
|
||||||
|
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"__proto__",
|
||||||
|
"constructor",
|
||||||
|
"prototype",
|
||||||
|
])("rejects the reserved property name %s as a submitted key", async (key) => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
body: `{"values":{${JSON.stringify(key)}:"polluted"}}`,
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
// Nothing about the request reached `Object.prototype`.
|
||||||
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an incomplete document, leaving the module blocked rather than refusing the save", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
// `bucket` is required and absent: an operator may save partial work.
|
||||||
|
const response = await putConfiguration(app, { slideSeconds: 12 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((await store.readSettings()).records).toEqual([
|
||||||
|
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an untrusted origin before authorization or the store runs", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await putConfiguration(
|
||||||
|
app,
|
||||||
|
{ bucket: "slides" },
|
||||||
|
"announcements",
|
||||||
|
untrustedOrigin,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing origin", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
body: JSON.stringify({ values: { bucket: "slides" } }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const oversized = JSON.stringify({
|
||||||
|
values: { bucket: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) },
|
||||||
|
});
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
body: oversized,
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module configuration authority", () => {
|
||||||
|
it("refuses an anonymous caller on both verbs", async () => {
|
||||||
|
const { app } = await install();
|
||||||
|
|
||||||
|
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||||
|
expect((await putConfiguration(app, { bucket: "slides" })).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an ordinary authenticated user who holds no configuration permission", async () => {
|
||||||
|
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||||
|
|
||||||
|
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits a non-owner holding exactly core.modules.configuration.manage", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let the lifecycle permission configure a module, or the reverse", async () => {
|
||||||
|
// The two authorities are deliberately separate: tuning how a module behaves and deciding
|
||||||
|
// whether it runs at all are different decisions (ADR 0029).
|
||||||
|
const lifecycleOnly = await install({
|
||||||
|
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await lifecycleOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||||
|
).toBe(403);
|
||||||
|
expect((await lifecycleOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
|
||||||
|
|
||||||
|
const configurationOnly = await install({
|
||||||
|
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
expect((await configurationOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await configurationOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configuration state is visible through the lifecycle resource", () => {
|
||||||
|
it("reports a module blocked by missing required configuration, naming the key not the value", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
readonly modules: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly runtime: {
|
||||||
|
readonly state: string;
|
||||||
|
readonly reason?: string;
|
||||||
|
readonly configurationKey?: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.modules[0]?.runtime).toEqual({
|
||||||
|
configurationKey: "bucket",
|
||||||
|
reason: "configuration_missing",
|
||||||
|
state: "blocked",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a module blocked by a missing secret, without naming the secret's value", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
const raw = await response.text();
|
||||||
|
|
||||||
|
expect(raw).toContain("secret_missing");
|
||||||
|
expect(raw).toContain("storageCredential");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a configuration-blocked module out of the mountable UI inventory", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
fixtures: [
|
||||||
|
{ configuration: [{ key: "bucket", type: "string", required: true }], id: "announcements" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const inventory = (await (await app.request("/api/v1/module-ui")).json()) as {
|
||||||
|
readonly modules: readonly { readonly id: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Blocked by its own configuration behaves exactly like blocked by a disabled dependency: it is
|
||||||
|
// not eligible, so it is not published.
|
||||||
|
expect(inventory.modules).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no secret or configuration value leaks through any surface", () => {
|
||||||
|
it("keeps secret material out of every response body this resource produces", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
secrets: { announcements: { storageCredential: "super-secret-value" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = await (
|
||||||
|
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
})
|
||||||
|
).text();
|
||||||
|
const mutated = await (await putConfiguration(app, { bucket: "slides" })).text();
|
||||||
|
const lifecycle = await (
|
||||||
|
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
|
||||||
|
).text();
|
||||||
|
|
||||||
|
for (const payload of [listed, mutated, lifecycle]) {
|
||||||
|
expect(payload).not.toContain("super-secret-value");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not serve a secret value even if a row for that key somehow exists in storage", async () => {
|
||||||
|
// Unreachable through the API, which refuses to write a secret key, and unreachable through the
|
||||||
|
// resolver, which never reads one. This plants the row directly to prove the *serialization*
|
||||||
|
// boundary holds independently: a secret's `value` is omitted by construction, not filtered by a
|
||||||
|
// branch someone could remove.
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "storageCredential", moduleId: "announcements", value: "planted-in-the-database" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await readConfiguration(app);
|
||||||
|
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||||
|
|
||||||
|
expect(secretEntry).not.toHaveProperty("value");
|
||||||
|
expect(secretEntry?.configured).toBe(false);
|
||||||
|
expect(JSON.stringify(body)).not.toContain("planted-in-the-database");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never leaks a filesystem path, package name, or manifest path", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = await (
|
||||||
|
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
})
|
||||||
|
).text();
|
||||||
|
|
||||||
|
expect(listed).not.toContain("/tmp");
|
||||||
|
expect(listed).not.toContain("@test/module-");
|
||||||
|
expect(listed).not.toContain("ekklesia.module.json");
|
||||||
|
expect(listed).not.toContain("dist/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the configuration prefix is a sibling of the module route namespace", () => {
|
||||||
|
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
|
||||||
|
// `config` and `configuration` are ordinary lowercase static segments under
|
||||||
|
// `moduleRoutePathSchema`, so a module could legitimately register `PUT /config` and land on
|
||||||
|
// `/api/v1/modules/announcements/config`. A sibling prefix cannot collide.
|
||||||
|
expect(MODULE_CONFIGURATION_PATH.startsWith("/api/v1/modules/")).toBe(false);
|
||||||
|
expect(MODULE_CONFIGURATION_PATH).toBe("/api/v1/module-configuration");
|
||||||
|
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const collision = await app.request("/api/v1/modules/announcements/config", {
|
||||||
|
body: JSON.stringify({ values: {} }),
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(collision.status).toBe(404);
|
||||||
|
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -222,6 +222,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
|
"core:0005_module_configuration",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -249,6 +250,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
|
"core:0005_module_configuration",
|
||||||
"db-facade:0000_initial",
|
"db-facade:0000_initial",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
@@ -517,7 +519,13 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
|||||||
const readHandlerEvent = [...events]
|
const readHandlerEvent = [...events]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
||||||
expect(readHandlerEvent?.contextKeys).toEqual(["moduleId", "params", "principal", "services"]);
|
expect(readHandlerEvent?.contextKeys).toEqual([
|
||||||
|
"config",
|
||||||
|
"moduleId",
|
||||||
|
"params",
|
||||||
|
"principal",
|
||||||
|
"services",
|
||||||
|
]);
|
||||||
expect(readHandlerEvent?.services).toBeDefined();
|
expect(readHandlerEvent?.services).toBeDefined();
|
||||||
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
|
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
ModuleRouteConfigurationError,
|
ModuleRouteConfigurationError,
|
||||||
} from "../src/module-http-routes.js";
|
} from "../src/module-http-routes.js";
|
||||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -111,6 +112,7 @@ function buildModuleApp(options: {
|
|||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry([]),
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||||
@@ -337,9 +339,13 @@ describe("module route handler adaptation", () => {
|
|||||||
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
||||||
expect(capturedRequest).toBeInstanceOf(Request);
|
expect(capturedRequest).toBeInstanceOf(Request);
|
||||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
||||||
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
|
// Exactly config, moduleId, params, principal, services — and nothing else. `config` is this
|
||||||
// or configuration reaches a module handler.
|
// module's own frozen configuration snapshot, added deliberately by ADR 0029; a Hono Context,
|
||||||
|
// the authorization store or evaluator, raw PostgreSQL, platform configuration, and another
|
||||||
|
// module's configuration all remain unreachable, which is what this exact-shape assertion
|
||||||
|
// exists to keep true.
|
||||||
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
||||||
|
"config",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"params",
|
"params",
|
||||||
"principal",
|
"principal",
|
||||||
@@ -356,6 +362,10 @@ describe("module route handler adaptation", () => {
|
|||||||
expect(Object.isFrozen(capturedContext)).toBe(true);
|
expect(Object.isFrozen(capturedContext)).toBe(true);
|
||||||
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
||||||
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
||||||
|
expect(Object.isFrozen(capturedContext?.config)).toBe(true);
|
||||||
|
// This module declared no configuration, so its snapshot is empty rather than absent — module
|
||||||
|
// code never has to branch on whether the platform supplied one.
|
||||||
|
expect(capturedContext?.config.keys()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives static and root routes a frozen, empty params object", async () => {
|
it("gives static and root routes a frozen, empty params object", async () => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
|||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -74,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -168,6 +169,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
snapshot: {
|
snapshot: {
|
||||||
discoveredModules: modules,
|
discoveredModules: modules,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
moduleLifecycleSnapshotFor,
|
moduleLifecycleSnapshotFor,
|
||||||
} from "./support/in-memory-module-lifecycle.js";
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The whole chain, with nothing between the halves faked:
|
* The whole chain, with nothing between the halves faked:
|
||||||
@@ -102,7 +103,7 @@ async function createFixtureModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name,
|
name,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
@@ -221,6 +222,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
store: createInMemoryModuleLifecycleStore(),
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
},
|
},
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: ["https://dashboard.example.test"],
|
trustedOrigins: ["https://dashboard.example.test"],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
moduleLifecycleSnapshotFor,
|
moduleLifecycleSnapshotFor,
|
||||||
} from "./support/in-memory-module-lifecycle.js";
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||||
@@ -79,7 +80,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||||
...permission,
|
...permission,
|
||||||
@@ -177,6 +178,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
store: createInMemoryModuleLifecycleStore(),
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
},
|
},
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
moduleLifecycleSnapshotFor,
|
moduleLifecycleSnapshotFor,
|
||||||
} from "./support/in-memory-module-lifecycle.js";
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
@@ -177,6 +178,7 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
|||||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
store: createInMemoryModuleLifecycleStore(),
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
},
|
},
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
Binary file not shown.
@@ -7,6 +7,7 @@ export default defineConfig({
|
|||||||
"./src/authorization/schema.ts",
|
"./src/authorization/schema.ts",
|
||||||
"./src/dashboard/schema.ts",
|
"./src/dashboard/schema.ts",
|
||||||
"./src/modules/lifecycle-schema.ts",
|
"./src/modules/lifecycle-schema.ts",
|
||||||
|
"./src/modules/configuration-schema.ts",
|
||||||
],
|
],
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
migrations: {
|
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": {
|
"destructive": {
|
||||||
"mode": "none"
|
"mode": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0005_module_configuration",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
"when": 1787423121678,
|
"when": 1787423121678,
|
||||||
"tag": "0004_module_lifecycle_state",
|
"tag": "0004_module_lifecycle_state",
|
||||||
"breakpoints": true
|
"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",
|
"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
|
* 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
|
* 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.",
|
description: "Read and change which installed modules are enabled.",
|
||||||
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
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(
|
console.log(
|
||||||
`Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`,
|
`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 resolution.entries) {
|
||||||
for (const entry of blocked) {
|
const blocked = entry.blocked;
|
||||||
console.log(
|
if (!blocked) {
|
||||||
`- "${entry.moduleId}" will not start: ${entry.blocked?.reason} (${entry.blocked?.dependency.moduleId}).`,
|
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 {
|
} finally {
|
||||||
await close().catch(() => undefined);
|
await close().catch(() => undefined);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
|
|||||||
"modules.dependencies",
|
"modules.dependencies",
|
||||||
"modules.permissions",
|
"modules.permissions",
|
||||||
"modules.lifecycle",
|
"modules.lifecycle",
|
||||||
|
"modules.configuration",
|
||||||
]),
|
]),
|
||||||
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||||
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
capabilityIdSchema,
|
capabilityIdSchema,
|
||||||
|
EMPTY_MODULE_SECRET_SOURCE,
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
|
moduleConfigurationKeySchema,
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
|
type ModuleSecretSource,
|
||||||
permissionIdSchema,
|
permissionIdSchema,
|
||||||
semanticVersionSchema,
|
semanticVersionSchema,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -41,6 +44,8 @@ import {
|
|||||||
} from "../migrations/index.js";
|
} from "../migrations/index.js";
|
||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleConfigurationDatabase,
|
||||||
|
createModuleConfigurationStore,
|
||||||
createModuleLifecycleDatabase,
|
createModuleLifecycleDatabase,
|
||||||
createModuleLifecycleStore,
|
createModuleLifecycleStore,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
@@ -49,6 +54,7 @@ import {
|
|||||||
type ModuleEligibility,
|
type ModuleEligibility,
|
||||||
ModulePermissionCompositionError,
|
ModulePermissionCompositionError,
|
||||||
type ModuleDiscoveryOptions,
|
type ModuleDiscoveryOptions,
|
||||||
|
type ModuleConfigurationSettings,
|
||||||
type ModuleLifecycleConfiguration,
|
type ModuleLifecycleConfiguration,
|
||||||
type ModuleMigrationAggregate,
|
type ModuleMigrationAggregate,
|
||||||
type ModuleSchemaAggregate,
|
type ModuleSchemaAggregate,
|
||||||
@@ -62,6 +68,7 @@ export type DoctorCheckId =
|
|||||||
| "modules.dependencies"
|
| "modules.dependencies"
|
||||||
| "modules.permissions"
|
| "modules.permissions"
|
||||||
| "modules.lifecycle"
|
| "modules.lifecycle"
|
||||||
|
| "modules.configuration"
|
||||||
| "schemas.discovery"
|
| "schemas.discovery"
|
||||||
| "migrations.discovery"
|
| "migrations.discovery"
|
||||||
| "postgresql.connection"
|
| "postgresql.connection"
|
||||||
@@ -102,6 +109,10 @@ export interface DoctorOptions {
|
|||||||
readonly databaseUrl?: string | undefined;
|
readonly databaseUrl?: string | undefined;
|
||||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
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 runtimeRange: string;
|
||||||
readonly runtimeVersion: string;
|
readonly runtimeVersion: string;
|
||||||
}
|
}
|
||||||
@@ -128,6 +139,12 @@ export interface DoctorDependencies {
|
|||||||
connectionString: string,
|
connectionString: string,
|
||||||
options: PostgresPoolOptions,
|
options: PostgresPoolOptions,
|
||||||
) => Promise<ModuleLifecycleConfiguration>;
|
) => 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 planMigrations: (options: {
|
||||||
readonly aggregate: ModuleMigrationAggregate;
|
readonly aggregate: ModuleMigrationAggregate;
|
||||||
readonly database: MigrationDatabase;
|
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({
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||||
composePermissions: composePlatformPermissionRegistry,
|
composePermissions: composePlatformPermissionRegistry,
|
||||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||||
@@ -170,6 +201,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
|||||||
discoverModules,
|
discoverModules,
|
||||||
discoverSchemas: discoverModuleSchemas,
|
discoverSchemas: discoverModuleSchemas,
|
||||||
planMigrations: planModuleMigrations,
|
planMigrations: planModuleMigrations,
|
||||||
|
readModuleConfiguration: readModuleConfigurationForRuntime,
|
||||||
readModuleLifecycle: readModuleLifecycleForRuntime,
|
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||||
runtimeSatisfies: isVersionCompatible,
|
runtimeSatisfies: isVersionCompatible,
|
||||||
});
|
});
|
||||||
@@ -301,6 +333,16 @@ function safeLifecycleDetail(entry: ModuleEligibility): string {
|
|||||||
if (!entry.blocked) {
|
if (!entry.blocked) {
|
||||||
return `${identity} · enabled`;
|
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;
|
const dependency = entry.blocked.dependency;
|
||||||
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
|
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
|
||||||
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
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);
|
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 {
|
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||||
const details =
|
const details =
|
||||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
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
|
// 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.
|
// failed. It needs only a successful discovery and a configured database.
|
||||||
let lifecycleCheck: DoctorCheck;
|
let lifecycleCheck: DoctorCheck;
|
||||||
|
let configurationCheck: DoctorCheck;
|
||||||
|
const skippedConfiguration = (detail: string): DoctorCheck =>
|
||||||
|
freezeCheck("modules.configuration", "skipped", "Module configuration skipped", [detail]);
|
||||||
if (!discoveredModules) {
|
if (!discoveredModules) {
|
||||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||||
"Complete module discovery is required.",
|
"Complete module discovery is required.",
|
||||||
]);
|
]);
|
||||||
|
configurationCheck = skippedConfiguration("Complete module discovery is required.");
|
||||||
} else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") {
|
} else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") {
|
||||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||||
"DATABASE_URL is not configured.",
|
"DATABASE_URL is not configured.",
|
||||||
]);
|
]);
|
||||||
|
configurationCheck = skippedConfiguration("DATABASE_URL is not configured.");
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
||||||
environment: options.databaseEnvironment ?? "production",
|
environment: options.databaseEnvironment ?? "production",
|
||||||
});
|
});
|
||||||
const configuration = await dependencies.readModuleLifecycle(options.databaseUrl, {
|
const poolOptions = {
|
||||||
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
operationTimeoutMs: doctorPostgresTimeoutMs,
|
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
transport: lifecycleTransport,
|
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(
|
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
|
||||||
resolveModuleEligibility(discoveredModules, configuration).entries,
|
configurationCheck = moduleConfigurationCheck(
|
||||||
|
resolution.entries,
|
||||||
|
new Map(
|
||||||
|
discoveredModules.map(
|
||||||
|
(module) => [module.manifest.id, module.manifest.configuration.length] as const,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
|
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
|
||||||
"The stored module enablement configuration could not be read safely.",
|
"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,
|
dependencyCheck,
|
||||||
permissionsCheck,
|
permissionsCheck,
|
||||||
lifecycleCheck,
|
lifecycleCheck,
|
||||||
|
configurationCheck,
|
||||||
schemaCheck,
|
schemaCheck,
|
||||||
migrationCheck,
|
migrationCheck,
|
||||||
transportCheck,
|
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 "./database.js";
|
||||||
export * from "./discovery.js";
|
export * from "./discovery.js";
|
||||||
export * from "./lifecycle.js";
|
|
||||||
export * from "./lifecycle-schema.js";
|
export * from "./lifecycle-schema.js";
|
||||||
export * from "./lifecycle-store.js";
|
export * from "./lifecycle-store.js";
|
||||||
|
export * from "./lifecycle.js";
|
||||||
export * from "./migration-discovery.js";
|
export * from "./migration-discovery.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
|
|||||||
+100
-12
@@ -1,9 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
ModuleBlockedReason,
|
ModuleBlockedReason,
|
||||||
ModuleBlockingDependency,
|
ModuleBlockingDependency,
|
||||||
|
ModuleConfiguration,
|
||||||
ModuleEnablementRecord,
|
ModuleEnablementRecord,
|
||||||
ModuleRuntimeState,
|
ModuleRuntimeState,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import type { ModuleConfigurationInput } from "./configuration.js";
|
||||||
|
import { resolveModuleConfigurationFor } from "./configuration.js";
|
||||||
import type { DiscoveredModule } from "./discovery.js";
|
import type { DiscoveredModule } from "./discovery.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,14 +38,32 @@ export interface ModuleEligibility {
|
|||||||
readonly version: string;
|
readonly version: string;
|
||||||
readonly configured: ModuleConfiguredState;
|
readonly configured: ModuleConfiguredState;
|
||||||
readonly eligible: boolean;
|
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. */
|
* Present exactly when the module is configured enabled but not eligible. A disabled module is
|
||||||
readonly blocked?: {
|
* not "blocked" — it is doing what it was told.
|
||||||
readonly reason: ModuleBlockedReason;
|
*
|
||||||
readonly dependency: ModuleBlockingDependency;
|
* 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 {
|
export interface ModuleEligibilityResolution {
|
||||||
/**
|
/**
|
||||||
* The modules the platform may start, **in the exact discovery order they were given**. Filtered,
|
* 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;
|
/** Every discovered module, in discovery order, eligible or not. This is the explanation surface;
|
||||||
* `eligibleModules` is the executable one. */
|
* `eligibleModules` is the executable one. */
|
||||||
readonly entries: readonly ModuleEligibility[];
|
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 {
|
interface MutableEligibility {
|
||||||
@@ -61,10 +91,7 @@ interface MutableEligibility {
|
|||||||
readonly version: string;
|
readonly version: string;
|
||||||
readonly configured: ModuleConfiguredState;
|
readonly configured: ModuleConfiguredState;
|
||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
blocked?: {
|
blocked?: ModuleBlockedDetail;
|
||||||
readonly reason: ModuleBlockedReason;
|
|
||||||
readonly dependency: ModuleBlockingDependency;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
||||||
@@ -75,9 +102,15 @@ function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
|||||||
version: entry.version,
|
version: entry.version,
|
||||||
...(entry.blocked
|
...(entry.blocked
|
||||||
? {
|
? {
|
||||||
blocked: Object.freeze({
|
blocked:
|
||||||
|
"dependency" in entry.blocked
|
||||||
|
? Object.freeze({
|
||||||
dependency: Object.freeze({ ...entry.blocked.dependency }),
|
dependency: Object.freeze({ ...entry.blocked.dependency }),
|
||||||
reason: entry.blocked.reason,
|
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(
|
export function resolveModuleEligibility(
|
||||||
discoveredModules: readonly DiscoveredModule[],
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
configuration: ModuleLifecycleConfiguration,
|
configuration: ModuleLifecycleConfiguration,
|
||||||
|
moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } },
|
||||||
): ModuleEligibilityResolution {
|
): ModuleEligibilityResolution {
|
||||||
const configuredByModuleId = new Map(
|
const configuredByModuleId = new Map(
|
||||||
configuration.modules.map((record) => [record.moduleId, record.enabled] as const),
|
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.
|
// 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
|
// 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).
|
// 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),
|
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({
|
return Object.freeze({
|
||||||
|
configurations: configurations as ReadonlyMap<string, ModuleConfiguration>,
|
||||||
eligibleModules: Object.freeze(
|
eligibleModules: Object.freeze(
|
||||||
discoveredModules.filter((discovered) => eligibleIds.has(discovered.manifest.id)),
|
discoveredModules.filter((discovered) => eligibleIds.has(discovered.manifest.id)),
|
||||||
),
|
),
|
||||||
@@ -233,7 +302,10 @@ export interface ModuleLifecycleView {
|
|||||||
readonly runtime: {
|
readonly runtime: {
|
||||||
readonly state: ModuleRuntimeState;
|
readonly state: ModuleRuntimeState;
|
||||||
readonly reason?: ModuleBlockedReason;
|
readonly reason?: ModuleBlockedReason;
|
||||||
|
/** Present for a dependency reason. */
|
||||||
readonly dependency?: ModuleBlockingDependency;
|
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
|
* `true` when applying the *current* configuration would produce a different running set than
|
||||||
@@ -274,12 +346,22 @@ export interface ModuleLifecycleSnapshot {
|
|||||||
export function resolveModuleLifecycleView(
|
export function resolveModuleLifecycleView(
|
||||||
snapshot: ModuleLifecycleSnapshot,
|
snapshot: ModuleLifecycleSnapshot,
|
||||||
configuration: ModuleLifecycleConfiguration,
|
configuration: ModuleLifecycleConfiguration,
|
||||||
|
moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } },
|
||||||
): readonly ModuleLifecycleView[] {
|
): readonly ModuleLifecycleView[] {
|
||||||
const started = new Set(snapshot.startedModuleIds);
|
const started = new Set(snapshot.startedModuleIds);
|
||||||
const bootEligibility = new Map(
|
const bootEligibility = new Map(
|
||||||
snapshot.eligibility.map((entry) => [entry.moduleId, entry] as const),
|
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));
|
const wouldRun = new Set(pending.eligibleModules.map((module) => module.manifest.id));
|
||||||
|
|
||||||
return Object.freeze(
|
return Object.freeze(
|
||||||
@@ -291,11 +373,17 @@ export function resolveModuleLifecycleView(
|
|||||||
: boot === undefined || !boot.configured.enabled
|
: boot === undefined || !boot.configured.enabled
|
||||||
? { state: "disabled" as const }
|
? { state: "disabled" as const }
|
||||||
: boot.blocked
|
: boot.blocked
|
||||||
|
? "dependency" in boot.blocked
|
||||||
? {
|
? {
|
||||||
dependency: boot.blocked.dependency,
|
dependency: boot.blocked.dependency,
|
||||||
reason: boot.blocked.reason,
|
reason: boot.blocked.reason,
|
||||||
state: "blocked" as const,
|
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
|
: // 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
|
// 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.
|
// future change to startup atomicity can never make this silently claim success.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
type CapabilityId,
|
type CapabilityId,
|
||||||
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
|
type ModuleConfiguration,
|
||||||
type CapabilityRegistrar,
|
type CapabilityRegistrar,
|
||||||
type CapabilityResolver,
|
type CapabilityResolver,
|
||||||
capabilityIdSchema,
|
capabilityIdSchema,
|
||||||
@@ -76,6 +78,24 @@ export interface StartedModuleRuntime {
|
|||||||
stop(): Promise<void>;
|
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 {
|
export interface ModuleRuntimeTimeoutOptions {
|
||||||
/** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS`, applied to both normal stop and startup
|
/** 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
|
* rollback, so a deadline can be proven in milliseconds instead of ten seconds. Production
|
||||||
@@ -83,9 +103,23 @@ export interface ModuleRuntimeTimeoutOptions {
|
|||||||
readonly hookTimeoutMs?: number;
|
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
|
export interface StartModuleRuntimeOptions
|
||||||
extends ModuleDiscoveryOptions,
|
extends ModuleDiscoveryOptions,
|
||||||
ModuleRuntimeTimeoutOptions {
|
ModuleRuntimeCompositionOptions {
|
||||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
/** 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
|
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
||||||
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
||||||
@@ -222,6 +256,7 @@ class StagedCapabilityRegistry {
|
|||||||
module: DiscoveredModule,
|
module: DiscoveredModule,
|
||||||
phase: "rollback" | "start" | "stop",
|
phase: "rollback" | "start" | "stop",
|
||||||
services: ModuleRuntimeServices,
|
services: ModuleRuntimeServices,
|
||||||
|
config: ModuleConfiguration,
|
||||||
): ModuleRuntimeContext {
|
): ModuleRuntimeContext {
|
||||||
const capabilities: CapabilityResolver = Object.freeze({
|
const capabilities: CapabilityResolver = Object.freeze({
|
||||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||||
@@ -229,7 +264,7 @@ class StagedCapabilityRegistry {
|
|||||||
return this.#registered.get(capabilityId);
|
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 {
|
publish(): CapabilityRegistry {
|
||||||
@@ -464,12 +499,14 @@ async function registerModules(
|
|||||||
modules: readonly LoadedModule[],
|
modules: readonly LoadedModule[],
|
||||||
capabilityRegistry: StagedCapabilityRegistry,
|
capabilityRegistry: StagedCapabilityRegistry,
|
||||||
httpRoutes: StagedModuleHttpRoutes,
|
httpRoutes: StagedModuleHttpRoutes,
|
||||||
|
configurations: ModuleConfigurationLookup,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
|
const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
|
||||||
const routeStage = httpRoutes.createRegistrationStage(module.discovered);
|
const routeStage = httpRoutes.createRegistrationStage(module.discovered);
|
||||||
const context: ModuleRegistrationContext = Object.freeze({
|
const context: ModuleRegistrationContext = Object.freeze({
|
||||||
capabilities: capabilityStage.capabilities,
|
capabilities: capabilityStage.capabilities,
|
||||||
|
config: configurationFor(configurations, module.discovered.manifest.id),
|
||||||
http: routeStage.http,
|
http: routeStage.http,
|
||||||
moduleId: module.discovered.manifest.id,
|
moduleId: module.discovered.manifest.id,
|
||||||
});
|
});
|
||||||
@@ -561,6 +598,7 @@ async function stopModules(
|
|||||||
registry: StagedCapabilityRegistry,
|
registry: StagedCapabilityRegistry,
|
||||||
phase: "rollback" | "stop",
|
phase: "rollback" | "stop",
|
||||||
services: ModuleRuntimeServices,
|
services: ModuleRuntimeServices,
|
||||||
|
configurations: ModuleConfigurationLookup,
|
||||||
options: StopModulesOptions = {},
|
options: StopModulesOptions = {},
|
||||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||||
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
|
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
|
||||||
@@ -574,7 +612,16 @@ async function stopModules(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const outcome = await awaitHookWithinDeadline(
|
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,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
if (outcome === "timed-out") {
|
if (outcome === "timed-out") {
|
||||||
@@ -609,6 +656,7 @@ async function startModules(
|
|||||||
modules: readonly LoadedModule[],
|
modules: readonly LoadedModule[],
|
||||||
registry: StagedCapabilityRegistry,
|
registry: StagedCapabilityRegistry,
|
||||||
services: ModuleRuntimeServices,
|
services: ModuleRuntimeServices,
|
||||||
|
configurations: ModuleConfigurationLookup,
|
||||||
options: StopModulesOptions = {},
|
options: StopModulesOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// A module joins the rollback set the moment its `start` hook is *invoked*, not when it
|
// 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);
|
rollbackCandidates.push(module);
|
||||||
try {
|
try {
|
||||||
await module.definition.start?.(
|
await module.definition.start?.(
|
||||||
registry.createRuntimeContext(module.discovered, "start", services),
|
registry.createRuntimeContext(
|
||||||
|
module.discovered,
|
||||||
|
"start",
|
||||||
|
services,
|
||||||
|
configurationFor(configurations, module.discovered.manifest.id),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const startIssue =
|
const startIssue =
|
||||||
@@ -639,6 +692,7 @@ async function startModules(
|
|||||||
registry,
|
registry,
|
||||||
"rollback",
|
"rollback",
|
||||||
services,
|
services,
|
||||||
|
configurations,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||||
@@ -657,13 +711,14 @@ async function startModules(
|
|||||||
export async function startDiscoveredModuleRuntime(
|
export async function startDiscoveredModuleRuntime(
|
||||||
discoveredModules: readonly DiscoveredModule[],
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
services: ModuleRuntimeServices,
|
services: ModuleRuntimeServices,
|
||||||
timeouts: ModuleRuntimeTimeoutOptions = {},
|
timeouts: ModuleRuntimeCompositionOptions = {},
|
||||||
): Promise<StartedModuleRuntime> {
|
): Promise<StartedModuleRuntime> {
|
||||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||||
const capabilityRegistry = new StagedCapabilityRegistry();
|
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||||
const httpRoutes = new StagedModuleHttpRoutes();
|
const httpRoutes = new StagedModuleHttpRoutes();
|
||||||
await registerModules(modules, capabilityRegistry, httpRoutes);
|
const configurations = timeouts.configurations ?? new Map<string, ModuleConfiguration>();
|
||||||
await startModules(modules, capabilityRegistry, services, timeouts);
|
await registerModules(modules, capabilityRegistry, httpRoutes, configurations);
|
||||||
|
await startModules(modules, capabilityRegistry, services, configurations, timeouts);
|
||||||
|
|
||||||
const capabilities = capabilityRegistry.publish();
|
const capabilities = capabilityRegistry.publish();
|
||||||
const routes = httpRoutes.publish();
|
const routes = httpRoutes.publish();
|
||||||
@@ -683,7 +738,14 @@ export async function startDiscoveredModuleRuntime(
|
|||||||
routes,
|
routes,
|
||||||
stop: (): Promise<void> => {
|
stop: (): Promise<void> => {
|
||||||
stopPromise ??= (async () => {
|
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) {
|
if (issues.length > 0) {
|
||||||
throw new ModuleRuntimeError(issues);
|
throw new ModuleRuntimeError(issues);
|
||||||
}
|
}
|
||||||
@@ -698,6 +760,7 @@ export async function startModuleRuntime(
|
|||||||
): Promise<StartedModuleRuntime> {
|
): Promise<StartedModuleRuntime> {
|
||||||
const discovered = await discoverModules(options);
|
const discovered = await discoverModules(options);
|
||||||
return startDiscoveredModuleRuntime(discovered, options.services, {
|
return startDiscoveredModuleRuntime(discovered, options.services, {
|
||||||
|
...(options.configurations === undefined ? {} : { configurations: options.configurations }),
|
||||||
...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }),
|
...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_PERMISSION_DECLARATIONS,
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
@@ -121,12 +122,23 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
|||||||
expect(declaration?.id).toBe("core.modules.lifecycle.manage");
|
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([
|
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_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.",
|
description: "Read and change which installed modules are enabled.",
|
||||||
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
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);
|
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
|
"core:0005_module_configuration",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -229,6 +230,7 @@ describe.sequential("live Core authorization store", () => {
|
|||||||
"ekklesia_core_dashboard_placement",
|
"ekklesia_core_dashboard_placement",
|
||||||
"ekklesia_core_grant",
|
"ekklesia_core_grant",
|
||||||
"ekklesia_core_migrations",
|
"ekklesia_core_migrations",
|
||||||
|
"ekklesia_core_module_configuration",
|
||||||
"ekklesia_core_module_state",
|
"ekklesia_core_module_state",
|
||||||
"ekklesia_core_platform_owner",
|
"ekklesia_core_platform_owner",
|
||||||
"ekklesia_core_platform_owner_bootstrap",
|
"ekklesia_core_platform_owner_bootstrap",
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function createModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
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" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
|
"core:0005_module_configuration",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
|
|||||||
planMigrations: async () => {
|
planMigrations: async () => {
|
||||||
throw new Error("database must not be planned");
|
throw new Error("database must not be planned");
|
||||||
},
|
},
|
||||||
|
readModuleConfiguration: async () => {
|
||||||
|
throw new Error("module configuration must not be read");
|
||||||
|
},
|
||||||
readModuleLifecycle: async () => {
|
readModuleLifecycle: async () => {
|
||||||
throw new Error("module lifecycle configuration must not be read");
|
throw new Error("module lifecycle configuration must not be read");
|
||||||
},
|
},
|
||||||
@@ -81,9 +84,11 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
4 permissions registered
|
5 permissions registered
|
||||||
○ Module lifecycle skipped
|
○ Module lifecycle skipped
|
||||||
DATABASE_URL is not configured.
|
DATABASE_URL is not configured.
|
||||||
|
○ Module configuration skipped
|
||||||
|
DATABASE_URL is not configured.
|
||||||
|
|
||||||
Data definitions
|
Data definitions
|
||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
|
|||||||
+193
-1
@@ -35,13 +35,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
|||||||
manifest: {
|
manifest: {
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
configuration: [],
|
||||||
dependencies: {
|
dependencies: {
|
||||||
capabilities: { optional: {}, required: {} },
|
capabilities: { optional: {}, required: {} },
|
||||||
modules: { optional: {}, required: {} },
|
modules: { optional: {}, required: {} },
|
||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
@@ -137,6 +138,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
|||||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||||
planMigrations: vi.fn(async () => plan()),
|
planMigrations: vi.fn(async () => plan()),
|
||||||
|
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
||||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||||
runtimeSatisfies: vi.fn(() => true),
|
runtimeSatisfies: vi.fn(() => true),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -206,6 +208,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
"modules.dependencies",
|
"modules.dependencies",
|
||||||
"modules.permissions",
|
"modules.permissions",
|
||||||
"modules.lifecycle",
|
"modules.lifecycle",
|
||||||
|
"modules.configuration",
|
||||||
"schemas.discovery",
|
"schemas.discovery",
|
||||||
"migrations.discovery",
|
"migrations.discovery",
|
||||||
"database.transport",
|
"database.transport",
|
||||||
@@ -1016,3 +1019,192 @@ describe("Ekklesia Doctor — module lifecycle", () => {
|
|||||||
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
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",
|
"id": "db-facade",
|
||||||
"name": "Database Facade Integration Test Module",
|
"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.",
|
"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> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
|
"core:0005_module_configuration",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -197,7 +198,7 @@ afterAll(async () => {
|
|||||||
* by eligibility resolution. */
|
* by eligibility resolution. */
|
||||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -228,7 +229,7 @@ describe.sequential("live Core module lifecycle store", () => {
|
|||||||
const tables = await safeQuery<{ table_name: string }>(
|
const tables = await safeQuery<{ table_name: string }>(
|
||||||
admin,
|
admin,
|
||||||
`select table_name from information_schema.tables
|
`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`,
|
order by table_name`,
|
||||||
);
|
);
|
||||||
const columns = await safeQuery<{
|
const columns = await safeQuery<{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -343,7 +343,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_PERMISSION_DECLARATIONS,
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
@@ -26,7 +27,7 @@ interface PermissionInput {
|
|||||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -71,8 +72,9 @@ describe("composePlatformPermissionRegistry", () => {
|
|||||||
expect(registry.has("people.directory.read")).toBe(true);
|
expect(registry.has("people.directory.read")).toBe(true);
|
||||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||||
// Core's own four declarations plus one from each module.
|
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
||||||
expect(registry.declarations).toHaveLength(6);
|
// 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", () => {
|
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_MODULES_UI_READ,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
"calendar.events.read",
|
"calendar.events.read",
|
||||||
"calendar.events.write",
|
"calendar.events.write",
|
||||||
"people.directory.read",
|
"people.directory.read",
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { discoverModules } from "../src/modules/discovery.js";
|
import { discoverModules } from "../src/modules/discovery.js";
|
||||||
|
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||||
import {
|
import {
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
ModuleRuntimeError,
|
ModuleRuntimeError,
|
||||||
@@ -52,7 +57,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
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 } = {},
|
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -169,6 +169,13 @@ otherwise be a deadlock — the HTTP lifecycle API is served by the process a ba
|
|||||||
so `pnpm modules:disable -- --module <id>` exists as an out-of-band operator command, in the same
|
so `pnpm modules:disable -- --module <id>` exists as an out-of-band operator command, in the same
|
||||||
shape as `pnpm authorization:bootstrap-owner`: explicit, never HTTP-exposed, never inferred.
|
shape as `pnpm authorization:bootstrap-owner`: explicit, never HTTP-exposed, never inferred.
|
||||||
|
|
||||||
|
> **Cross-reference — see [ADR 0029](0029-module-configuration-and-secrets.md).**
|
||||||
|
> The blocked-reason vocabulary this ADR introduced has since gained three configuration reasons
|
||||||
|
> (`configuration_missing`, `configuration_invalid`, `secret_missing`), and `blocked` became a
|
||||||
|
> discriminated union so a configuration reason names a **key** where a dependency reason names a
|
||||||
|
> dependency. Everything else here is unchanged: startup atomicity, the absence of a `failed` state,
|
||||||
|
> the default-enabled policy, and the dependency propagation those new reasons flow through.
|
||||||
|
|
||||||
There is consequently **no `failed` runtime state**, because no live process could ever report one.
|
There is consequently **no `failed` runtime state**, because no live process could ever report one.
|
||||||
Naming a state nothing can produce is exactly the mistake `MODULE_LIFECYCLE_STAGES` made. It is the
|
Naming a state nothing can produce is exactly the mistake `MODULE_LIFECYCLE_STAGES` made. It is the
|
||||||
first thing a future isolated-startup pass adds.
|
first thing a future isolated-startup pass adds.
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
# 0029: Module configuration is manifest-declared, host-resolved, and secret-free at rest
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-22
|
||||||
|
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||||
|
[0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||||
|
[0016](0016-module-database-access.md),
|
||||||
|
[0021](0021-modules-as-hostable-products.md),
|
||||||
|
[0028](0028-module-operational-lifecycle.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0028 gave the platform an operational lifecycle: a module is enabled or disabled, eligible or
|
||||||
|
blocked, running or not. It answered *whether* a module runs. It said nothing about *how it should
|
||||||
|
behave once it does*, and the repository had no answer at all — no configuration API, no settings
|
||||||
|
table, no schema, no concept.
|
||||||
|
|
||||||
|
That gap has a concrete shape. The Announcements product (`The_River/Announcements`, commit
|
||||||
|
`9aab486`) is the first real module Ekklesia will absorb, and it currently contains **zero
|
||||||
|
environment variables and zero configuration files**. Every deployment-specific value is a literal
|
||||||
|
in source:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const PORT = 7532; // server/index.js:17
|
||||||
|
const slideSeconds = 7; // server/index.js:20
|
||||||
|
fetch("http://10.40.0.2:9000/announcements?list-type=2") // server/index.js:29
|
||||||
|
mqtt.connect("mqtt://10.40.0.2") // server/index.js:73
|
||||||
|
setInterval(() => loadSlides(), 30000) // server/index.js:127
|
||||||
|
const socket = io("http://10.40.0.2:7532"); // client/src/App.jsx:5
|
||||||
|
```
|
||||||
|
|
||||||
|
A module cannot become portable while a LAN IP address is compiled into it. But the fix is not "let
|
||||||
|
modules read `process.env`" — that would weld every module to one host's variable names and destroy
|
||||||
|
the standalone-hostability ADR 0021 exists to protect.
|
||||||
|
|
||||||
|
Two facts from the repository shaped what was possible.
|
||||||
|
|
||||||
|
**Nothing reads the environment except executables.** Every `process.env` access lives in
|
||||||
|
`apps/api/src/server.ts` or `core/src/cli/*`; reusable Core constructors take typed inputs and
|
||||||
|
`core/src/cli/database-transport-environment.ts` states the rule explicitly. Modules read the
|
||||||
|
environment nowhere, and that was worth keeping.
|
||||||
|
|
||||||
|
**There is no encryption at rest, and no key management.** No cipher, no KMS, no keyring, nothing.
|
||||||
|
The only secret-shaped values PostgreSQL holds are Better Auth's own hashed passwords and session
|
||||||
|
tokens, which Better Auth owns. That single fact decides the secrets question below, and it decides
|
||||||
|
it against the convenient answer.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Three categories, kept apart
|
||||||
|
|
||||||
|
```text
|
||||||
|
enabled / disabled lifecycle configuration (ADR 0028)
|
||||||
|
slideSeconds = 7 module configuration (this ADR)
|
||||||
|
currentSlide = 4, mode=off runtime / domain state (the module's own, never the platform's)
|
||||||
|
```
|
||||||
|
|
||||||
|
The lifecycle table gains no feature settings, and the configuration table holds nothing transient.
|
||||||
|
The test for the third category is not "does it change?" but **"does the module own its meaning?"**
|
||||||
|
Announcements' `mode` toggle blanks the screen, is flipped mid-service, and must take effect
|
||||||
|
instantly; it is domain state the module stores and exposes through its own route. A value that must
|
||||||
|
change without a restart is, by that fact, telling you it is domain state rather than configuration.
|
||||||
|
|
||||||
|
### Configuration is declared in the manifest, and that forces version 5
|
||||||
|
|
||||||
|
A module declares what it needs in `ekklesia.module.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"manifestVersion": 5,
|
||||||
|
"configuration": [
|
||||||
|
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600 },
|
||||||
|
{ "key": "bucket", "type": "string", "required": true },
|
||||||
|
{ "key": "storageCredential", "type": "secret", "required": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Static declaration was chosen over runtime registration for one decisive reason: **the platform must
|
||||||
|
validate configuration before deciding whether a module may start.** An enabled module whose required
|
||||||
|
configuration is missing is reported `blocked · configuration_missing` — it is never imported, and it
|
||||||
|
never takes API startup down from inside its own `start` hook. Runtime registration cannot do that,
|
||||||
|
because the module's code would have to run first, and under the preserved all-or-nothing startup
|
||||||
|
model a throw at that point kills the platform. Doctor and `pnpm modules:validate` get the same
|
||||||
|
benefit: they report configuration health while remaining manifest-only, importing nothing.
|
||||||
|
|
||||||
|
Version 4's manifest object is `.strict()`, so adding a field is an incompatible shape change and the
|
||||||
|
repository's convention is a hard cutover per version (ADR 0006, 0014, 0021). Version 5 it is.
|
||||||
|
Upgrading a version 4 manifest is a one-character edit — `configuration` is optional and defaults to
|
||||||
|
empty, so a backend-only module needing no settings stays exactly as simple as it was. A separate
|
||||||
|
`ekklesia.configuration.json` was rejected: it would create a second static-metadata mechanism and a
|
||||||
|
second discovery path for something the manifest already is.
|
||||||
|
|
||||||
|
### Six types, and no seventh without a real module needing it
|
||||||
|
|
||||||
|
`string`, `integer`, `boolean`, `enum`, `url`, `secret`. No `object`, no `array`, no nesting, no
|
||||||
|
recursion, no regular expressions.
|
||||||
|
|
||||||
|
Each earns its place against a real need — a bucket name, a poll interval, a toggle, a fixed choice,
|
||||||
|
an endpoint, a credential. `number` was dropped for lack of one. Regular-expression constraints were
|
||||||
|
rejected outright: a module-supplied pattern evaluated by the platform is a ReDoS vector for no
|
||||||
|
benefit that `minLength`/`maxLength` does not already cover. `url` accepts only absolute
|
||||||
|
`http:`/`https:`, because a configuration value is frequently handed straight to `fetch` and the
|
||||||
|
difference between an endpoint and `file:///etc/passwd` must not be a parsing accident.
|
||||||
|
|
||||||
|
This is a validation vocabulary, not a form-builder DSL. A module needing structured data owns a
|
||||||
|
table for it (ADR 0016).
|
||||||
|
|
||||||
|
### `secret` is a type, not a flag — and Ekklesia never stores one
|
||||||
|
|
||||||
|
Strategy A, without apology: **ordinary configuration is persisted in PostgreSQL; secret values are
|
||||||
|
supplied by the deployment and never persisted at all.**
|
||||||
|
|
||||||
|
There is no encryption at rest in this repository. Storing credentials in plaintext because a column
|
||||||
|
is easy to add would be the single worst thing this pass could do, and dressing it up as a "secret
|
||||||
|
store" would be worse. The table has no column, flag, or convention that would make it look
|
||||||
|
acceptable, and the write API returns `CONFIGURATION_SECRET_NOT_WRITABLE` rather than accepting
|
||||||
|
material it cannot protect.
|
||||||
|
|
||||||
|
Making `secret` a distinct *type* rather than `string` + `secret: true` is what makes every
|
||||||
|
consequence structural instead of remembered:
|
||||||
|
|
||||||
|
- it has no `default` field to populate, so a checked-in default credential is unrepresentable;
|
||||||
|
- the resolver never reads one from storage, so a row planted directly in the database is ignored;
|
||||||
|
- the HTTP view has no `value` field for it, so a secret is **absent from responses by construction
|
||||||
|
rather than filtered by a branch** somebody could later delete.
|
||||||
|
|
||||||
|
The host maps a declared secret to a deployment value:
|
||||||
|
|
||||||
|
```text
|
||||||
|
EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>
|
||||||
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL
|
||||||
|
```
|
||||||
|
|
||||||
|
**A module never sees that name.** It calls `config.getSecret("storageCredential")`. The mapping is
|
||||||
|
the host's, which is exactly what lets a standalone host read the same declared secret from YAML, a
|
||||||
|
file, or a secret manager without the module's feature code changing. Core's factory takes the
|
||||||
|
environment *as an argument* and never touches `process.env` itself, preserving the rule that only
|
||||||
|
executables read the environment.
|
||||||
|
|
||||||
|
There is deliberately **no** `context.secrets.getEnv("ANYTHING")`. The only reachable values are the
|
||||||
|
module's own declared keys, looked up by a source that requires both a module id and a key — so a
|
||||||
|
module cannot read `DATABASE_URL`, the identity signing secret, or another module's credential.
|
||||||
|
|
||||||
|
### A frozen snapshot, resolved once, before any module code runs
|
||||||
|
|
||||||
|
```text
|
||||||
|
stored values + deployment secrets + manifest declarations
|
||||||
|
↓ validate, apply defaults (one pass, one implementation)
|
||||||
|
frozen per-module snapshot ─────────────→ eligible?
|
||||||
|
↓
|
||||||
|
register · start · stop · HTTP handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
`context.config` is a frozen object present on the registration context, the runtime context, and
|
||||||
|
every authorized HTTP handler context. The *same object identity* across all of them, so two hooks
|
||||||
|
can never disagree about a value, and no module ever queries a database for a setting.
|
||||||
|
|
||||||
|
It sits **beside** `services` rather than inside it, and that is load-bearing: `services` is
|
||||||
|
deliberately one shared instance every module receives — a property the runtime has a test for —
|
||||||
|
while configuration is per-module by definition. Putting it in `services` would have forced one of
|
||||||
|
those two properties to give.
|
||||||
|
|
||||||
|
It is available during `register` even though `services` is not. Registration must stay declarative
|
||||||
|
and I/O-free; an already-resolved in-memory snapshot reintroduces no I/O, whereas a live database
|
||||||
|
facade would.
|
||||||
|
|
||||||
|
Every accessor fails closed. An undeclared key, a declared key of a different type, and an absent
|
||||||
|
optional value are all `undefined` and deliberately indistinguishable, in the same style as
|
||||||
|
`PermissionRegistry.get`. A `required` key, or one with a `default`, is guaranteed present by the
|
||||||
|
time module code runs — the platform refuses to start the module otherwise.
|
||||||
|
|
||||||
|
### Validation rules, stated 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. 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.
|
||||||
|
4. Otherwise a `required` key is `configuration_missing` (or `secret_missing`); an optional key is
|
||||||
|
simply absent.
|
||||||
|
|
||||||
|
Nothing is coerced. `"7"` is not `7`, `"yes"` is not `true`, `"07"` is not `7`. Exactly one spelling
|
||||||
|
of any value is ever accepted, so a stored row can never validate differently from the request that
|
||||||
|
created it, and two deployments can never read the same row differently.
|
||||||
|
|
||||||
|
**Schema evolution** needs no migration engine. A key a newer version adds takes its default. A key a
|
||||||
|
newer version made required blocks until set. A stored key the current manifest no longer declares is
|
||||||
|
**retained and ignored**, and reported as `unknownKeys` — deleting it would destroy an operator's
|
||||||
|
value on a temporary rollback, the same reasoning ADR 0027 applied to a Dashboard placement for an
|
||||||
|
uninstalled module.
|
||||||
|
|
||||||
|
### Configuration decides eligibility, through the lifecycle resolver that already exists
|
||||||
|
|
||||||
|
Three reasons join ADR 0028's two, each corresponding to a real check with a **different operational
|
||||||
|
fix** — the only justification for a distinct code:
|
||||||
|
|
||||||
|
| reason | fix |
|
||||||
|
| --- | --- |
|
||||||
|
| `configuration_missing` | set a value through the configuration API |
|
||||||
|
| `configuration_invalid` | correct the value |
|
||||||
|
| `secret_missing` | set an environment variable on the deployment |
|
||||||
|
|
||||||
|
`ModuleEligibility.blocked` becomes a discriminated union: a dependency reason always names a
|
||||||
|
dependency, a configuration reason always names a **key**. Neither can be accidentally omitted, and
|
||||||
|
no consumer has to handle a `blocked` record that explains nothing. A key is safe to name — it is
|
||||||
|
declared in a public manifest — while a value may be a credential or arbitrary operator text.
|
||||||
|
|
||||||
|
Validation runs *before* the dependency pass, so a module blocked by its own configuration blocks its
|
||||||
|
required dependents through the existing mechanism rather than a parallel one:
|
||||||
|
|
||||||
|
```text
|
||||||
|
storage blocked · configuration_missing · bucket
|
||||||
|
↓
|
||||||
|
media blocked · required_dependency_blocked · storage
|
||||||
|
↓
|
||||||
|
announcements blocked · required_dependency_blocked · media
|
||||||
|
```
|
||||||
|
|
||||||
|
A **disabled** module's configuration is not evaluated at all. Reporting a configuration problem for
|
||||||
|
something nobody asked to run would be noise, and claiming it is valid would assert something nothing
|
||||||
|
checked.
|
||||||
|
|
||||||
|
Startup atomicity is unchanged: still all-or-nothing across the eligible set, still no `failed`
|
||||||
|
runtime state.
|
||||||
|
|
||||||
|
### Changes take effect at the next restart, and the API says so
|
||||||
|
|
||||||
|
Modules hold a snapshot resolved at boot, so a `PUT` writes a decision and changes nothing about the
|
||||||
|
running process. Both the configuration resource and the lifecycle resource report `restartRequired`
|
||||||
|
by comparing stored state against what this process actually resolved — an observation, not a guess.
|
||||||
|
No hot reload, no watchers. A value that genuinely needs to change live is domain state, not
|
||||||
|
configuration (see the three categories above).
|
||||||
|
|
||||||
|
### One row per (module, key)
|
||||||
|
|
||||||
|
`ekklesia_core_module_configuration`: `module_id`, `key`, `value` (text), timestamps, primary key
|
||||||
|
`(module_id, key)`.
|
||||||
|
|
||||||
|
A `jsonb` blob per module was rejected for the reason ADR 0027 rejected a generic settings framework:
|
||||||
|
it would put every invariant in application code and make a single key unaddressable. `value` is
|
||||||
|
`text` holding the unparsed literal, which is what makes "a persisted value is never silently
|
||||||
|
trusted" structural — nothing can read it as a number until it has been validated against the
|
||||||
|
module's current declaration.
|
||||||
|
|
||||||
|
`key` is **data in a parameterized column, never an SQL identifier.** There is no dynamic SQL in this
|
||||||
|
domain, so an outlandish declared key can produce a strange row and nothing else. `module_id` carries
|
||||||
|
no foreign key, like the lifecycle and Dashboard tables, so a value for an undeployed module is
|
||||||
|
retained and redeploying restores it.
|
||||||
|
|
||||||
|
### `/api/v1/module-configuration/:moduleId`, a sibling prefix
|
||||||
|
|
||||||
|
The third application of ADR 0025's rule. `config` and `configuration` are ordinary lowercase static
|
||||||
|
segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately register
|
||||||
|
`PUT /config` and land on `/api/v1/modules/announcements/config`. Publishing the platform's own
|
||||||
|
mutation there would put Core and an installed module in one URL namespace with registration order
|
||||||
|
deciding which answers.
|
||||||
|
|
||||||
|
`GET` returns the declared schema, stored values, `unknownKeys`, and `restartRequired`. `PUT`
|
||||||
|
replaces the whole document with closed validation — an undeclared key, a wrong JSON type, a
|
||||||
|
constraint violation, or a secret key each fail with a distinct sanitized code and store nothing.
|
||||||
|
Constraint checking runs through **the same resolver the platform boots with**, over the exact
|
||||||
|
document about to be stored, so the API and startup can never disagree about validity.
|
||||||
|
|
||||||
|
An *incomplete* document is accepted (an operator may save partial work; the module reports
|
||||||
|
`configuration_missing` until they finish). An *invalid* one never is.
|
||||||
|
|
||||||
|
Both verbs require a new restricted permission, `core.modules.configuration.manage`, deliberately
|
||||||
|
**separate from** `core.modules.lifecycle.manage`: deciding whether a module runs and deciding how it
|
||||||
|
behaves are different authorities, and folding them together would grant authority nobody asked for
|
||||||
|
in whichever direction it was used. Mutations additionally require an exact trusted `Origin` and a
|
||||||
|
bounded body.
|
||||||
|
|
||||||
|
### Browser editing is deferred, and no secret ever reaches `ModuleUiContext`
|
||||||
|
|
||||||
|
`ModuleUiHostContext` is unchanged. A settings UI is not in this pass, and module UI receives no
|
||||||
|
configuration at all. When it does, it will receive non-secret values only — the server-only/browser
|
||||||
|
split already exists in the shape of the API, which never serves a secret to anyone.
|
||||||
|
|
||||||
|
### The object-storage boundary, stated before it is crossed
|
||||||
|
|
||||||
|
Announcements reads `http://10.40.0.2:9000/announcements`. It would be easy, and wrong, to let it
|
||||||
|
declare `storageEndpoint` and `storageAccessKey` as module configuration. That would make **every**
|
||||||
|
module separately configure S3, and the eventual platform storage service would arrive to find a
|
||||||
|
dozen competing endpoint settings already in production.
|
||||||
|
|
||||||
|
The boundary this ADR draws:
|
||||||
|
|
||||||
|
```text
|
||||||
|
module declares which bucket it needs → host supplies the storage service
|
||||||
|
```
|
||||||
|
|
||||||
|
not
|
||||||
|
|
||||||
|
```text
|
||||||
|
every module configures its own endpoint, region, and credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
A storage *endpoint* and its credentials are **platform infrastructure**, and they belong to the
|
||||||
|
storage service Core does not have yet — not to Announcements. A **bucket name** is arguably module
|
||||||
|
configuration today, and is accepted as such transitionally, but note that `manifest.storage.buckets`
|
||||||
|
already declares logical buckets: when the storage service exists, the bucket binding should move
|
||||||
|
there and the configuration key should be retired. That overlap is recorded here deliberately so it
|
||||||
|
is resolved by decision rather than by accident.
|
||||||
|
|
||||||
|
## Validated against the real product
|
||||||
|
|
||||||
|
Every configuration-shaped literal in Announcements at commit `9aab486`, classified:
|
||||||
|
|
||||||
|
| value | classification | under Ekklesia |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `slideSeconds = 7` | module configuration | `integer`, default 7 |
|
||||||
|
| `30000` poll interval | module configuration | `pollIntervalSeconds`, default 30 |
|
||||||
|
| bucket `announcements` | module configuration (transitional — see above) | `string`, required |
|
||||||
|
| `10.40.0.2:9000` storage endpoint | **platform infrastructure** | future storage service, not module config |
|
||||||
|
| storage credentials | **secret** (none exist today — the bucket is read anonymously) | deployment-supplied |
|
||||||
|
| `PORT = 7532` | platform infrastructure | disappears; the host mounts routes |
|
||||||
|
| Socket.IO `origin: "*"` | platform infrastructure | Ekklesia owns CORS |
|
||||||
|
| `io("http://10.40.0.2:7532")` client URL | legacy | disappears; the host supplies the API base URL |
|
||||||
|
| `mqtt://10.40.0.2` + topic | **legacy integration to remove** | removed; a future Companion module speaks the module API |
|
||||||
|
| `mode = "on"` | **runtime/domain state** | the module's own state, not configuration |
|
||||||
|
| `currentSlide`, `slides[]` | runtime/domain state | not configuration |
|
||||||
|
| `1100` fade milliseconds | module-internal constant (coupled to CSS) | not platform configuration |
|
||||||
|
|
||||||
|
Two findings worth recording. **Announcements has no credentials at all today** — the bucket is read
|
||||||
|
anonymously over plain HTTP — so `storageCredential` is a future need, and the secret mechanism is
|
||||||
|
proven here with a fixture rather than by inventing a credential the product does not yet have. And
|
||||||
|
**`slideSeconds` being restart-required is a real operational wrinkle**: a church may want to tweak it
|
||||||
|
mid-service. The honest answer is the one above — if it must change live, it is domain state the
|
||||||
|
module owns, exactly like `mode`. This ADR does not paper over that by adding hot reload.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Runtime/SDK schema registration.** Rejected: configuration must be validated before module code
|
||||||
|
runs, or an unconfigured module takes startup down instead of being reported blocked.
|
||||||
|
- **A separate `ekklesia.configuration.json`.** Rejected: a second static-metadata mechanism and a
|
||||||
|
second discovery path for something the manifest already is.
|
||||||
|
- **Extending manifest v4 additively.** Not available — the manifest object is `.strict()`, and the
|
||||||
|
repository's convention is a hard cutover per version.
|
||||||
|
- **Persisting secrets in PostgreSQL.** Rejected outright: there is no encryption at rest, and
|
||||||
|
plaintext credentials in a database are not made acceptable by a column named `secret`.
|
||||||
|
- **Secret *references* (`secretRef: "..."`) resolved from a vault.** Premature: there is no vault
|
||||||
|
to reference. The declared-key-plus-host-mapping model already gives a standalone host the same
|
||||||
|
freedom without inventing an indirection layer nothing implements.
|
||||||
|
- **`jsonb` document per module.** Rejected: unaddressable keys and invariants living only in
|
||||||
|
application code.
|
||||||
|
- **A live, database-backed `config.get()`.** Rejected: it would make every read fallible and
|
||||||
|
mid-request-mutable, and would reintroduce I/O into `register`.
|
||||||
|
- **Configuration inside `ModuleRuntimeServices`.** Rejected: `services` is deliberately one shared
|
||||||
|
object identity; configuration is per-module.
|
||||||
|
- **Hot configuration reload.** Rejected for the same reason ADR 0028 rejected hot enable/disable:
|
||||||
|
the platform must not claim a change took effect when the running process still holds the old one.
|
||||||
|
- **Reusing `core.modules.lifecycle.manage`.** Rejected: running a module and configuring it are
|
||||||
|
different authorities.
|
||||||
|
- **Regular-expression constraints, nested objects, arrays.** Rejected: a validation vocabulary, not
|
||||||
|
a UI DSL — and a module-supplied regex is a ReDoS vector.
|
||||||
|
- **Letting modules read `process.env` directly.** Rejected: it welds a module to one host's variable
|
||||||
|
names and destroys standalone hostability.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A module declares what it needs, an operator sets it, the platform validates it before anything runs,
|
||||||
|
and the module receives a frozen, host-neutral snapshot. Announcements has a clear path away from
|
||||||
|
every hardcoded value it currently contains, and none of that path requires it to know that Ekklesia
|
||||||
|
uses PostgreSQL.
|
||||||
|
|
||||||
|
`createApiApp` takes a required `moduleConfiguration` option. Core gains a fifth permission and a
|
||||||
|
sixth migration. The manifest moves to version 5, a hard cutover affecting the template and every
|
||||||
|
fixture. `startApi` now reads two tables before importing any module, sequentially rather than
|
||||||
|
concurrently so a broken deployment reports the same error code every time.
|
||||||
|
|
||||||
|
Costs worth naming. Configuration is platform-global and will need a scope column when an
|
||||||
|
organization model exists, like every other Core table. Ordinary configuration changes require a
|
||||||
|
restart, which is honest but not convenient, and modules with genuinely live settings must model them
|
||||||
|
as domain state. And there is still **no settings UI** — configuration is an API, not a screen.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Not implemented or promised here: a settings Dashboard UI, form generation, or any
|
||||||
|
administrator-facing affordance beyond the HTTP API; browser-visible configuration in
|
||||||
|
`ModuleUiContext`; secret persistence, a vault, encryption at rest, or key management; secret
|
||||||
|
rotation; a `secretRef` indirection; hot configuration reload or watchers; per-organization or
|
||||||
|
per-environment configuration scoping; configuration history, audit, or diff; a migration engine for
|
||||||
|
renamed keys; object storage, and the eventual move of bucket binding from configuration to
|
||||||
|
`manifest.storage.buckets`; and the Announcements migration itself.
|
||||||
@@ -37,3 +37,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted
|
- [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted
|
||||||
- [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted
|
- [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted
|
||||||
- [0028: Module operational lifecycle — enablement, eligibility, and what "running" means](0028-module-operational-lifecycle.md) — Accepted
|
- [0028: Module operational lifecycle — enablement, eligibility, and what "running" means](0028-module-operational-lifecycle.md) — Accepted
|
||||||
|
- [0029: Module configuration is manifest-declared, host-resolved, and secret-free at rest](0029-module-configuration-and-secrets.md) — Accepted
|
||||||
|
|||||||
@@ -329,3 +329,30 @@ Migration discovery still spans the deployed inventory regardless of enablement,
|
|||||||
composition still spans the whole discovered set. `running` means a module's `start` hook completed
|
composition still spans the whole discovered set. `running` means a module's `start` hook completed
|
||||||
and nothing more; there is no runtime health-probe contract. Modules remain trusted compiled code —
|
and nothing more; there is no runtime health-probe contract. Modules remain trusted compiled code —
|
||||||
disabling one is an operational control, never a sandbox. No installer exists.
|
disabling one is an operational control, never a sandbox. No installer exists.
|
||||||
|
|
||||||
|
Phase 7A pass 2 adds the module configuration and secrets contract
|
||||||
|
([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). A module declares what it needs
|
||||||
|
configured in its manifest — `manifestVersion: 5`, a hard cutover — using six types (`string`,
|
||||||
|
`integer`, `boolean`, `enum`, `url`, `secret`) with bounded constraints and no nesting. Static
|
||||||
|
declaration is what lets the platform validate configuration **before** deciding whether a module may
|
||||||
|
start, so an unconfigured module is reported `blocked · configuration_missing` rather than taking
|
||||||
|
startup down from inside its own `start` hook; discovery, `pnpm modules:validate`, and Doctor all read
|
||||||
|
it while remaining manifest-only.
|
||||||
|
|
||||||
|
Ordinary values are persisted in one Core table, one row per `(module_id, key)`, stored as unvalidated
|
||||||
|
text until checked against the declaring module's current manifest. **Secret values are never
|
||||||
|
persisted**: Ekklesia has no encryption at rest, so a `secret` key is supplied by the deployment and
|
||||||
|
mapped into the module's snapshot by the host, under a name the module never sees. A module receives a
|
||||||
|
frozen `context.config` on its registration, runtime, and HTTP handler contexts — the same object
|
||||||
|
identity across all of them — with no environment access, no database reference, and no way to reach
|
||||||
|
another module's values, which is what keeps the same module implementation runnable under a future
|
||||||
|
standalone host.
|
||||||
|
|
||||||
|
Configuration decides eligibility through ADR 0028's existing resolver rather than a parallel concept:
|
||||||
|
three new blocked reasons (`configuration_missing`, `configuration_invalid`, `secret_missing`) name
|
||||||
|
the offending key, and a configuration-blocked module blocks its required dependents exactly like a
|
||||||
|
disabled one. Changes take effect at the next restart and the API reports `restartRequired`; nothing
|
||||||
|
is hot-reloaded. Migration discovery remains independent of configuration validity, exactly as it is
|
||||||
|
independent of enabled state. Endpoints and credentials for shared infrastructure stay platform
|
||||||
|
concerns — a module declares which bucket it needs, not an S3 endpoint — leaving room for the object
|
||||||
|
storage service Core still does not have. There is no settings UI.
|
||||||
|
|||||||
+120
-4
@@ -82,6 +82,8 @@ No real values are shown here — configure per environment and never commit the
|
|||||||
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
||||||
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
|
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
|
||||||
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
|
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
|
||||||
|
| `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). |
|
||||||
|
| `PUT /api/v1/module-configuration/<module-id>` | Ekklesia | Replace one module's stored configuration. Takes effect at the next API start. Requires `core.modules.configuration.manage` and a trusted `Origin`. See [Module configuration](#module-configuration). |
|
||||||
|
|
||||||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||||
deterministic `404 {"code":"NOT_FOUND"}`.
|
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||||
@@ -387,9 +389,12 @@ account.
|
|||||||
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||||||
pool (see [Module database access](#module-database-access)).
|
pool (see [Module database access](#module-database-access)).
|
||||||
4. Construct the Core identity runtime, authorization store, and evaluator.
|
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||||||
5. Read the stored module enablement configuration and resolve, over that exact same discovered
|
5. Read the stored module enablement configuration and then the stored module configuration, and
|
||||||
inventory, which modules are **eligible** to run (`resolveModuleEligibility`,
|
resolve, over that exact same discovered inventory, which modules are **eligible** to run and
|
||||||
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle)).
|
what each eligible module's frozen configuration snapshot is (`resolveModuleEligibility`,
|
||||||
|
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle) and
|
||||||
|
[Module configuration](#module-configuration)). The two reads are sequential rather than raced so
|
||||||
|
a broken deployment reports the same error code every time.
|
||||||
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
|
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
|
||||||
never rediscovered for this step — passing it the services object every module's `start`/`stop`
|
never rediscovered for this step — passing it the services object every module's `start`/`stop`
|
||||||
hook receives.
|
hook receives.
|
||||||
@@ -417,7 +422,8 @@ disabled, or blocked by a disabled required dependency, is never imported at all
|
|||||||
|
|
||||||
Step 5 makes startup depend on a reachable, migrated database: if the enablement configuration
|
Step 5 makes startup depend on a reachable, migrated database: if the enablement configuration
|
||||||
cannot be read, `startApi` **refuses to start** with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED`
|
cannot be read, `startApi` **refuses to start** with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED`
|
||||||
rather than starting every discovered module. The platform cannot know what an administrator
|
(and `MODULE_CONFIGURATION_READ_FAILED` if the enablement read succeeded and the configuration read
|
||||||
|
did not) rather than starting every discovered module. The platform cannot know what an administrator
|
||||||
decided, and starting a module they disabled is worse than not starting. Nothing is created or
|
decided, and starting a module they disabled is worse than not starting. Nothing is created or
|
||||||
repaired on that path.
|
repaired on that path.
|
||||||
|
|
||||||
@@ -548,6 +554,116 @@ pnpm modules:enable -- --module announcements
|
|||||||
It reads `DATABASE_URL`, runs the same manifest-only discovery the API does (so it refuses a module
|
It reads `DATABASE_URL`, runs the same manifest-only discovery the API does (so it refuses a module
|
||||||
this deployment cannot see), writes the decision, and starts nothing. Restart the API to apply it.
|
this deployment cannot see), writes the decision, and starts nothing. Restart the API to apply it.
|
||||||
|
|
||||||
|
## Module configuration
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/module-configuration/<module-id>
|
||||||
|
PUT /api/v1/module-configuration/<module-id> { "values": { "slideSeconds": 12 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
What one installed module declares it needs configured, what an operator has stored, and whether the
|
||||||
|
running process is still using it
|
||||||
|
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
|
||||||
|
|
||||||
|
A **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` and
|
||||||
|
`/api/v1/module-lifecycle` are: `config` and `configuration` are legal module route segments, so a
|
||||||
|
module could otherwise collide with the platform at `/api/v1/modules/<id>/config`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"moduleId": "announcements",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"keys": [
|
||||||
|
{ "key": "slideSeconds", "type": "integer", "required": false, "default": 7,
|
||||||
|
"description": "How long each slide is shown.", "configured": false },
|
||||||
|
{ "key": "bucket", "type": "string", "required": true, "value": "slides", "configured": true },
|
||||||
|
{ "key": "storageCredential", "type": "secret", "required": true, "configured": true }
|
||||||
|
],
|
||||||
|
"unknownKeys": [],
|
||||||
|
"restartRequired": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### A secret's value is absent, not redacted
|
||||||
|
|
||||||
|
Note what the `storageCredential` entry above does **not** have: a `value` field. Not `null`, not
|
||||||
|
`"***"` — absent. Ekklesia never persists secret material, so the store this route reads from
|
||||||
|
contains none, and there is no code path that could serve one. `configured` answers the only question
|
||||||
|
an operator needs from here: does the deployment supply it?
|
||||||
|
|
||||||
|
### The declared types
|
||||||
|
|
||||||
|
| type | notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `string` | optional `minLength` / `maxLength` |
|
||||||
|
| `integer` | optional `minimum` / `maximum`; submitted as a JSON number, never a string |
|
||||||
|
| `boolean` | submitted as a JSON boolean |
|
||||||
|
| `enum` | closed `values` list |
|
||||||
|
| `url` | absolute `http:`/`https:` only |
|
||||||
|
| `secret` | deployment-supplied; never stored, never returned, never defaulted |
|
||||||
|
|
||||||
|
No nesting, no arrays, no regular expressions. This is a validation vocabulary, not a form-builder
|
||||||
|
DSL; a module needing structured data owns a table for it.
|
||||||
|
|
||||||
|
### Writing is a whole-document replace, with closed validation
|
||||||
|
|
||||||
|
A key the module does not declare is `400 CONFIGURATION_KEY_UNDECLARED`. A wrong JSON type or a value
|
||||||
|
failing the module's own declared constraints is `400 CONFIGURATION_VALUE_INVALID`. A `secret` key is
|
||||||
|
`400 CONFIGURATION_SECRET_NOT_WRITABLE` — there is no supported way to submit secret material to this
|
||||||
|
platform. Nothing is coerced: `{"slideSeconds": "7"}` is a `400`, not a silent `7`.
|
||||||
|
|
||||||
|
Constraint checking runs through **the same resolver startup uses**, over the exact document about to
|
||||||
|
be stored, so the API and startup can never disagree about whether a value is valid.
|
||||||
|
|
||||||
|
An *incomplete* document is accepted deliberately — an operator may save partial work, and the module
|
||||||
|
is reported `blocked · configuration_missing` until they finish. An *invalid* one never is.
|
||||||
|
|
||||||
|
A key omitted from the submission is deleted. Stored keys the module's current manifest no longer
|
||||||
|
declares are **retained and ignored**, and listed as `unknownKeys`, so a downgrade or a rename does
|
||||||
|
not destroy an operator's value.
|
||||||
|
|
||||||
|
### Changes take effect at the next start
|
||||||
|
|
||||||
|
Modules receive a configuration snapshot resolved once at startup, so a `PUT` changes nothing about
|
||||||
|
the running process and the response reports `restartRequired` rather than pretending otherwise.
|
||||||
|
Nothing is hot-reloaded. A setting that genuinely must change live is the module's own domain state,
|
||||||
|
stored in the module's own tables and exposed through its own route — not platform configuration.
|
||||||
|
|
||||||
|
### Missing or invalid configuration blocks the module
|
||||||
|
|
||||||
|
An enabled module whose required configuration cannot be satisfied is never imported. It appears in
|
||||||
|
`GET /api/v1/module-lifecycle` as `blocked` with a structured reason naming the offending **key**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "runtime": { "state": "blocked", "reason": "configuration_missing", "configurationKey": "bucket" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
`configuration_missing` (set a value), `configuration_invalid` (correct the value), and
|
||||||
|
`secret_missing` (set it in the deployment environment) are separate reasons because each has a
|
||||||
|
different fix. A blocked module blocks its required dependents exactly like a disabled one does.
|
||||||
|
|
||||||
|
### Secrets come from the deployment
|
||||||
|
|
||||||
|
A module declares a `secret` key; the host maps it to a deployment-supplied value. In an Ekklesia
|
||||||
|
deployment that mapping is an environment variable:
|
||||||
|
|
||||||
|
```text
|
||||||
|
EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>
|
||||||
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL
|
||||||
|
```
|
||||||
|
|
||||||
|
The module never sees that name — it calls `config.getSecret("storageCredential")` — which is what
|
||||||
|
lets a future standalone host supply the same declared secret from somewhere else entirely.
|
||||||
|
|
||||||
|
### Authority
|
||||||
|
|
||||||
|
Both verbs require `core.modules.configuration.manage` (baseline `restricted`), deliberately separate
|
||||||
|
from `core.modules.lifecycle.manage`: deciding whether a module runs and deciding how it behaves are
|
||||||
|
different authorities. Mutations additionally require an exact trusted `Origin` and a bounded body.
|
||||||
|
|
||||||
|
No response carries a filesystem path, package name, manifest path, caught error, stack, or secret
|
||||||
|
value.
|
||||||
|
|
||||||
## Module HTTP routes
|
## Module HTTP routes
|
||||||
|
|
||||||
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ list:
|
|||||||
| `core.modules.ui.read` | `authenticated` | List the installed module UIs a browser host may mount. |
|
| `core.modules.ui.read` | `authenticated` | List the installed module UIs a browser host may mount. |
|
||||||
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
|
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
|
||||||
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
|
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
|
||||||
|
| `core.modules.configuration.manage` | `restricted` | Read and change an installed module's own configuration values. |
|
||||||
|
|
||||||
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
|
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
|
||||||
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
|
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
|
||||||
@@ -121,6 +122,14 @@ milder version of the write, because the lifecycle view is the only place a *dis
|
|||||||
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
|
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
|
||||||
inside any module.
|
inside any module.
|
||||||
|
|
||||||
|
`core.modules.configuration.manage` is deliberately **separate from** the lifecycle permission
|
||||||
|
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): deciding whether a
|
||||||
|
module runs and deciding how it behaves are different authorities, and someone trusted to tune a
|
||||||
|
module's settings need not be trusted to take modules offline. It gates reading as well as writing,
|
||||||
|
because the read returns every stored value — endpoints, bucket names, and whatever else an operator
|
||||||
|
configured. It never exposes secret material: a secret is deployment-supplied, never persisted, and
|
||||||
|
never returned by any route, so holding this permission confers no ability to read a credential.
|
||||||
|
|
||||||
Enablement never changes how a permission is enforced. A **disabled** module's permissions stay
|
Enablement never changes how a permission is enforced. A **disabled** module's permissions stay
|
||||||
registered and grantable — composition spans the whole discovered inventory, not the eligible subset
|
registered and grantable — composition spans the whole discovered inventory, not the eligible subset
|
||||||
— so a grant survives an enable/disable cycle and can be issued ahead of enabling a module. Nothing
|
— so a grant survives an enable/disable cycle and can be issued ahead of enabling a module. Nothing
|
||||||
@@ -223,7 +232,7 @@ generic explicit-grant HTTP API, enforcement middleware, and now its own mounted
|
|||||||
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
||||||
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
||||||
it never re-discovers modules itself. A successful composition reports a pass with a safe
|
it never re-discovers modules itself. A successful composition reports a pass with a safe
|
||||||
declaration count (for example `4 permissions registered` with zero installed modules, since Core's
|
declaration count (for example `5 permissions registered` with zero installed modules, since Core's
|
||||||
own declarations are always present); zero installed modules still composes successfully. If module
|
own declarations are always present); zero installed modules still composes successfully. If module
|
||||||
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
|
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
|
||||||
pattern; if discovery succeeded but composition failed, schema and migration checks still run
|
pattern; if discovery succeeded but composition failed, schema and migration checks still run
|
||||||
|
|||||||
@@ -195,6 +195,56 @@ enforced against that module's own declared permission, and a disabled module's
|
|||||||
registered and grantable precisely so that an enable/disable cycle changes nothing about who holds
|
registered and grantable precisely so that an enable/disable cycle changes nothing about who holds
|
||||||
what.
|
what.
|
||||||
|
|
||||||
|
### Ekklesia does not store module secrets, and does not pretend to
|
||||||
|
|
||||||
|
A module declares a `secret` configuration key; the **deployment** supplies its value, and Ekklesia
|
||||||
|
never persists it ([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
|
||||||
|
|
||||||
|
This is a deliberate limit, not an oversight. **There is no encryption at rest in this repository and
|
||||||
|
no key management** — no cipher, no KMS, no keyring. Storing credentials in a PostgreSQL column
|
||||||
|
because one is easy to add would be worse than not offering the feature, and calling such a column a
|
||||||
|
"secret store" would be worse still.
|
||||||
|
|
||||||
|
What that buys, structurally rather than by discipline:
|
||||||
|
|
||||||
|
- a `secret` declaration has no `default` field, so a checked-in default credential is
|
||||||
|
unrepresentable;
|
||||||
|
- the resolver never reads a secret from storage, so a row planted directly in the database by
|
||||||
|
someone with database access is ignored rather than used;
|
||||||
|
- the HTTP view has no `value` field for a secret, so a secret is **absent from responses by
|
||||||
|
construction**, not filtered by a branch someone could later delete;
|
||||||
|
- the write API refuses a `secret` key outright (`CONFIGURATION_SECRET_NOT_WRITABLE`);
|
||||||
|
- Doctor prints keys and reasons, never values.
|
||||||
|
|
||||||
|
A module reaches its own secrets through `config.getSecret("key")` and nothing else. There is no
|
||||||
|
`getEnv`, no arbitrary variable lookup, and no cross-module accessor: the secret source is asked for a
|
||||||
|
module id **and** a key that module's own manifest declares, so a module cannot read `DATABASE_URL`,
|
||||||
|
the identity signing secret, or another module's credential.
|
||||||
|
|
||||||
|
**What this does not claim.** A module is still trusted code in the API process (see above), so a
|
||||||
|
malicious module could read `process.env` directly and obtain anything the process can. Scoped secret
|
||||||
|
delivery is a correctness and portability boundary, exactly like every other module-facing mechanism
|
||||||
|
here — not a sandbox. What it does guarantee is that Ekklesia itself never writes a credential to
|
||||||
|
disk, never serves one over HTTP, and never prints one in diagnostics.
|
||||||
|
|
||||||
|
### Module configuration is operator input, and is treated as such
|
||||||
|
|
||||||
|
Ordinary configuration values *are* persisted, and are validated against the declaring module's own
|
||||||
|
manifest before anything reads them. A stored value that does not satisfy its declaration blocks the
|
||||||
|
module rather than being coerced, truncated, or silently replaced by a default.
|
||||||
|
|
||||||
|
Configuration keys are **data, never SQL identifiers** — there is no dynamic SQL in this domain — and
|
||||||
|
are rejected unless they match a closed lower-camelCase grammar that also excludes reserved object
|
||||||
|
property names (`__proto__`, `constructor`, `prototype`, ...). Core additionally never indexes a plain
|
||||||
|
object with a module-supplied key; every lookup goes through a `Map`. Store errors never carry the
|
||||||
|
offending value, because an operator-entered value may be a credential that ended up in the wrong
|
||||||
|
field.
|
||||||
|
|
||||||
|
Reading or changing configuration requires `core.modules.configuration.manage` (baseline
|
||||||
|
`restricted`), separately from `core.modules.lifecycle.manage`. Mutations require an exact trusted
|
||||||
|
`Origin` and a bounded body, like every other Core mutation. Module UI receives no configuration at
|
||||||
|
all in this phase.
|
||||||
|
|
||||||
### Client-side permission checks are not a security boundary
|
### Client-side permission checks are not a security boundary
|
||||||
|
|
||||||
A host may tell a module UI what permissions it believes the viewer holds
|
A host may tell a module UI what permissions it believes the viewer holds
|
||||||
|
|||||||
@@ -174,6 +174,25 @@ demonstrably keeps serving, and is applied only by the next start; and a disable
|
|||||||
are still discovered, because deployed schema ownership is not runtime enabled state. See
|
are still discovered, because deployed schema ownership is not runtime enabled state. See
|
||||||
[ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md).
|
[ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md).
|
||||||
|
|
||||||
|
A sixth dedicated suite (`core/tests/module-configuration-store.integration.ts`, included in the same
|
||||||
|
`pnpm test:migrations:integration` run) proves the module configuration store against real
|
||||||
|
PostgreSQL: that migration `0005` really creates `ekklesia_core_module_configuration` with the
|
||||||
|
composite `(module_id, key)` primary key, the declared CHECK constraints, and **no column that could
|
||||||
|
hold secret material**; that a replace is transactional, whole-document, and scoped to one module so
|
||||||
|
another module's rows are untouched; that stored values survive a completely new connection; that an
|
||||||
|
oversized value is refused by the application *and* by the database; that a store error never carries
|
||||||
|
the offending value; and — the security-relevant one — that a module id or key is only ever data in a
|
||||||
|
parameterized column, so an injection attempt is a validation error and the table still exists
|
||||||
|
afterwards.
|
||||||
|
|
||||||
|
The API suite additionally proves the configuration boundary end to end: a secret's value never
|
||||||
|
appears in any response (including when a row for that key is planted directly in storage), a
|
||||||
|
declared-but-undeclared key, a wrong JSON type, a constraint violation, and a `secret` key each fail
|
||||||
|
with a distinct sanitized code and store nothing, a reserved property name such as `__proto__` is
|
||||||
|
rejected without touching `Object.prototype`, and a module blocked by its own configuration is absent
|
||||||
|
from the mountable UI inventory exactly like a disabled one. See
|
||||||
|
[ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md).
|
||||||
|
|
||||||
### Which kind of timing proof lives where
|
### Which kind of timing proof lives where
|
||||||
|
|
||||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||||
|
|||||||
@@ -969,6 +969,10 @@ write one row, and start nothing. The change takes effect at the next `pnpm api:
|
|||||||
so a module that breaks startup can be turned off without the API — the HTTP lifecycle API is served
|
so a module that breaks startup can be turned off without the API — the HTTP lifecycle API is served
|
||||||
by the process such a module takes down.
|
by the process such a module takes down.
|
||||||
|
|
||||||
|
`pnpm doctor`'s `modules.configuration` check reports whether each installed module's configuration
|
||||||
|
would let it run, naming the offending key when it would not. It prints **no configuration values** —
|
||||||
|
secret or otherwise — only counts, keys, and reasons.
|
||||||
|
|
||||||
`pnpm doctor` builds first and presents the same production discovery results as a concise,
|
`pnpm doctor` builds first and presents the same production discovery results as a concise,
|
||||||
read-only dashboard. It lists discovered module IDs and versions in dependency order, reports
|
read-only dashboard. It lists discovered module IDs and versions in dependency order, reports
|
||||||
schema and migration counts, and shows safe migration identities with truncated checksums. Schema
|
schema and migration counts, and shows safe migration identities with truncated checksums. Schema
|
||||||
@@ -982,6 +986,116 @@ PostgreSQL. A deliberately disabled module is a pass with a detail line; an *ena
|
|||||||
cannot run (blocked by a disabled required dependency) is a warning, because that is the anomaly. A skip or pending migration is visible as `READY WITH WARNINGS`,
|
cannot run (blocked by a disabled required dependency) is a warning, because that is the anomaly. A skip or pending migration is visible as `READY WITH WARNINGS`,
|
||||||
not misreported as a successful check.
|
not misreported as a successful check.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Your module declares what it needs configured in `ekklesia.module.json`, and receives it as a frozen,
|
||||||
|
already-validated snapshot ([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"configuration": [
|
||||||
|
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600,
|
||||||
|
"description": "How long each slide is shown." },
|
||||||
|
{ "key": "bucket", "type": "string", "required": true, "minLength": 1, "maxLength": 63 },
|
||||||
|
{ "key": "storageCredential", "type": "secret", "required": true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export default defineModuleRuntime({
|
||||||
|
id: "announcements",
|
||||||
|
version: "1.0.0",
|
||||||
|
start({ config }) {
|
||||||
|
const slideSeconds = config.getInteger("slideSeconds");
|
||||||
|
const bucket = config.getString("bucket");
|
||||||
|
const credential = config.getSecret("storageCredential");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never read `process.env`, and never query a platform table for a setting.** `context.config` is the
|
||||||
|
whole contract. It has no module id but your own, no environment access, and no way to reach another
|
||||||
|
module's values — which is exactly what keeps your module runnable under a future standalone host
|
||||||
|
that stores configuration somewhere else entirely.
|
||||||
|
|
||||||
|
### The types you may declare
|
||||||
|
|
||||||
|
| type | constraints | notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `string` | `minLength`, `maxLength` | |
|
||||||
|
| `integer` | `minimum`, `maximum` | |
|
||||||
|
| `boolean` | — | |
|
||||||
|
| `enum` | `values` (1–32, unique) | |
|
||||||
|
| `url` | — | absolute `http:`/`https:` only |
|
||||||
|
| `secret` | — | deployment-supplied; no `default` |
|
||||||
|
|
||||||
|
No nested objects, arrays, or regular expressions. If you need structured data, own a table for it
|
||||||
|
(see [Database access](#database-access)).
|
||||||
|
|
||||||
|
### Defaults, required, and the difference
|
||||||
|
|
||||||
|
Give a `default` to anything with a safe one — a slide duration, a poll interval. Mark `required`
|
||||||
|
only what genuinely has no safe default, like a bucket name. A key cannot be both: a key with a
|
||||||
|
default can never be missing.
|
||||||
|
|
||||||
|
**A `secret` can never have a default.** A checked-in default credential is a leaked credential, and
|
||||||
|
the contract has no field to put one in.
|
||||||
|
|
||||||
|
If a `required` key has no value when the platform starts, your module is **not started** — it is
|
||||||
|
reported `blocked` with a structured reason naming the key, and its dependents are blocked too. Your
|
||||||
|
`start` hook never runs and never has to defend against missing configuration.
|
||||||
|
|
||||||
|
### Where values come from
|
||||||
|
|
||||||
|
Ordinary values are stored by the platform and set through
|
||||||
|
`PUT /api/v1/module-configuration/<module-id>` (there is no settings UI yet).
|
||||||
|
|
||||||
|
Secrets are **supplied by the deployment and never stored by Ekklesia**, because Ekklesia has no
|
||||||
|
encryption at rest. In an Ekklesia deployment an operator sets
|
||||||
|
`EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`. You never write that name anywhere — you call
|
||||||
|
`config.getSecret("storageCredential")`.
|
||||||
|
|
||||||
|
### Reading values
|
||||||
|
|
||||||
|
Every accessor returns `T | undefined` and fails closed: an undeclared key, a key of a different
|
||||||
|
declared type, and an absent optional value are all `undefined`. For a `required` key, or one with a
|
||||||
|
`default`, the platform guarantees a value exists by the time your code runs.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
config.getString(key); config.getInteger(key); config.getBoolean(key);
|
||||||
|
config.getEnum(key); config.getUrl(key); config.getSecret(key);
|
||||||
|
config.get(key); config.has(key); config.keys();
|
||||||
|
```
|
||||||
|
|
||||||
|
The same frozen object reaches `register`, `start`, `stop`, and every authorized route handler, so
|
||||||
|
two parts of your module can never disagree about a value.
|
||||||
|
|
||||||
|
### Configuration is not runtime state
|
||||||
|
|
||||||
|
```text
|
||||||
|
slideDurationSeconds = 7 configuration — an operator sets it, it survives restarts
|
||||||
|
currentSlide = 4 domain state — your module owns it
|
||||||
|
mode = "off" domain state — your module owns it
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration is **immutable for the life of the process**: a change takes effect at the next restart,
|
||||||
|
and the API tells the operator so. If a value must change *without* a restart — a display toggle
|
||||||
|
flipped mid-service, say — that is telling you it is domain state, not configuration. Store it in your
|
||||||
|
own table and expose it through your own route.
|
||||||
|
|
||||||
|
### Never put infrastructure in your configuration
|
||||||
|
|
||||||
|
Declare the **bucket** you need, not an S3 endpoint, region, and access key. Endpoints and credentials
|
||||||
|
for shared infrastructure are the platform's to supply; if every module configured its own, the
|
||||||
|
eventual platform storage service would arrive to find a dozen competing endpoint settings in
|
||||||
|
production. (Core has no storage service yet — see
|
||||||
|
[ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md) for the boundary this
|
||||||
|
leaves room for.)
|
||||||
|
|
||||||
|
### Never ask whether you are enabled
|
||||||
|
|
||||||
|
Configuration tells you *how to behave*, never *whether to run*. Whether your module runs is its
|
||||||
|
host's decision, described next.
|
||||||
|
|
||||||
### Whether your module runs at all is your host's decision
|
### Whether your module runs at all is your host's decision
|
||||||
|
|
||||||
Between discovery and startup, the platform consults its own **enablement configuration** and
|
Between discovery and startup, the platform consults its own **enablement configuration** and
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from "./authorization.js";
|
export * from "./authorization.js";
|
||||||
export * from "./identifiers.js";
|
export * from "./identifiers.js";
|
||||||
export * from "./identity.js";
|
export * from "./identity.js";
|
||||||
|
export * from "./module-configuration.js";
|
||||||
export * from "./module-database.js";
|
export * from "./module-database.js";
|
||||||
export * from "./module-http.js";
|
export * from "./module-http.js";
|
||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The complete set of value shapes a module may declare as configuration.
|
||||||
|
*
|
||||||
|
* Deliberately six, each earning its place against a real module need rather than anticipating one:
|
||||||
|
* a bucket name (`string`), a poll interval or slide duration (`integer`), a feature toggle
|
||||||
|
* (`boolean`), a fixed choice (`enum`), an endpoint (`url`), and a deployment-supplied credential
|
||||||
|
* (`secret`). There is no `object`, no `array`, no nesting, and no recursion — this is a validation
|
||||||
|
* vocabulary, not a form-builder DSL, and a module that genuinely needs structured data owns a table
|
||||||
|
* for it (ADR 0029).
|
||||||
|
*
|
||||||
|
* `secret` is a *type* rather than a flag on `string` because it is not a string with an extra
|
||||||
|
* property: it is never persisted by Ekklesia, never defaulted, never returned by any API, and
|
||||||
|
* never printed by diagnostics. Making that a separate declaration is what lets every one of those
|
||||||
|
* rules be enforced structurally instead of remembered.
|
||||||
|
*/
|
||||||
|
export const MODULE_CONFIGURATION_TYPES = [
|
||||||
|
"string",
|
||||||
|
"integer",
|
||||||
|
"boolean",
|
||||||
|
"enum",
|
||||||
|
"url",
|
||||||
|
"secret",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const moduleConfigurationTypeSchema = z.enum(MODULE_CONFIGURATION_TYPES);
|
||||||
|
|
||||||
|
export type ModuleConfigurationType = z.infer<typeof moduleConfigurationTypeSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names JavaScript can carry safely and JSON can round-trip without surprise: lower camelCase,
|
||||||
|
* letters and digits only, bounded.
|
||||||
|
*
|
||||||
|
* No dots, so a configuration key can never be mistaken for a permission or capability identifier,
|
||||||
|
* which are dot-namespaced. No underscores or leading capitals, so there is exactly one spelling of
|
||||||
|
* any key. And an explicit reserved-name rejection below, because `constructor`, `prototype`, and
|
||||||
|
* friends satisfy this pattern while being exactly the property names that turn an ordinary object
|
||||||
|
* write into prototype pollution. Core additionally never indexes a plain object with a
|
||||||
|
* module-supplied key — every lookup goes through a `Map` — so this check is defense in depth over a
|
||||||
|
* structural guarantee rather than the only thing standing between a manifest and `Object.prototype`.
|
||||||
|
*/
|
||||||
|
const RESERVED_CONFIGURATION_KEYS: ReadonlySet<string> = new Set([
|
||||||
|
"__proto__",
|
||||||
|
"constructor",
|
||||||
|
"hasOwnProperty",
|
||||||
|
"isPrototypeOf",
|
||||||
|
"propertyIsEnumerable",
|
||||||
|
"prototype",
|
||||||
|
"toLocaleString",
|
||||||
|
"toString",
|
||||||
|
"valueOf",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const moduleConfigurationKeySchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[a-z][a-zA-Z0-9]*$/u, "Must be a lower camelCase configuration key.")
|
||||||
|
.refine(
|
||||||
|
(key) => !RESERVED_CONFIGURATION_KEYS.has(key),
|
||||||
|
"Must not be a reserved JavaScript object property name.",
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ModuleConfigurationKey = z.infer<typeof moduleConfigurationKeySchema>;
|
||||||
|
|
||||||
|
/** The one bound on any stored configuration literal. Generous for a bucket name, an endpoint, or a
|
||||||
|
* short list, and far below anything that would make the per-module document expensive to read at
|
||||||
|
* startup. Enforced by the contract, by the store, and by the database. */
|
||||||
|
export const MODULE_CONFIGURATION_VALUE_MAX_LENGTH = 2048;
|
||||||
|
|
||||||
|
/** Operator-facing text only. Never parsed, never interpreted, and never used to decide anything —
|
||||||
|
* changing it can never change what a value means, exactly like a permission's `description`. */
|
||||||
|
const descriptionSchema = z.string().trim().min(1).max(240).optional();
|
||||||
|
|
||||||
|
const commonFields = {
|
||||||
|
key: moduleConfigurationKeySchema,
|
||||||
|
description: descriptionSchema,
|
||||||
|
/** When true and no value resolves, the module is not eligible to start and the platform reports
|
||||||
|
* a structured reason naming this key. Mutually exclusive with `default`: a key that always has a
|
||||||
|
* value can never be missing, so declaring both states an intent the platform cannot honour. */
|
||||||
|
required: z.boolean().default(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
...commonFields,
|
||||||
|
type: z.literal("string"),
|
||||||
|
default: z.string().max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
|
||||||
|
minLength: z.number().int().min(0).max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
|
||||||
|
maxLength: z.number().int().min(1).max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const integerDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
...commonFields,
|
||||||
|
type: z.literal("integer"),
|
||||||
|
default: z.number().int().safe().optional(),
|
||||||
|
minimum: z.number().int().safe().optional(),
|
||||||
|
maximum: z.number().int().safe().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const booleanDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
...commonFields,
|
||||||
|
type: z.literal("boolean"),
|
||||||
|
default: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const enumDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
...commonFields,
|
||||||
|
type: z.literal("enum"),
|
||||||
|
/** Bounded and non-empty: an enum with no members can never be satisfied, and one with hundreds
|
||||||
|
* is a lookup table that belongs in the module's own storage. */
|
||||||
|
values: z.array(z.string().min(1).max(120)).min(1).max(32),
|
||||||
|
default: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const urlDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
...commonFields,
|
||||||
|
type: z.literal("url"),
|
||||||
|
default: z.string().max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deployment-supplied credential. Note what this shape does **not** have: no `default`, because a
|
||||||
|
* checked-in default credential is a leaked credential, and no constraints, because Ekklesia never
|
||||||
|
* inspects the value.
|
||||||
|
*/
|
||||||
|
const secretDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
...commonFields,
|
||||||
|
type: z.literal("secret"),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const moduleConfigurationDeclarationSchema = z
|
||||||
|
.discriminatedUnion("type", [
|
||||||
|
stringDeclarationSchema,
|
||||||
|
integerDeclarationSchema,
|
||||||
|
booleanDeclarationSchema,
|
||||||
|
enumDeclarationSchema,
|
||||||
|
urlDeclarationSchema,
|
||||||
|
secretDeclarationSchema,
|
||||||
|
])
|
||||||
|
.superRefine((declaration, context) => {
|
||||||
|
if (
|
||||||
|
declaration.type !== "secret" &&
|
||||||
|
declaration.required &&
|
||||||
|
declaration.default !== undefined
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A key with a default can never be missing, so it must not also be required.",
|
||||||
|
path: ["required"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (declaration.type === "string") {
|
||||||
|
if (
|
||||||
|
declaration.minLength !== undefined &&
|
||||||
|
declaration.maxLength !== undefined &&
|
||||||
|
declaration.minLength > declaration.maxLength
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "minLength must not exceed maxLength.",
|
||||||
|
path: ["minLength"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (declaration.default !== undefined) {
|
||||||
|
if (
|
||||||
|
(declaration.minLength !== undefined &&
|
||||||
|
declaration.default.length < declaration.minLength) ||
|
||||||
|
(declaration.maxLength !== undefined &&
|
||||||
|
declaration.default.length > declaration.maxLength)
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A default value must satisfy its own declared constraints.",
|
||||||
|
path: ["default"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (declaration.type === "integer") {
|
||||||
|
if (
|
||||||
|
declaration.minimum !== undefined &&
|
||||||
|
declaration.maximum !== undefined &&
|
||||||
|
declaration.minimum > declaration.maximum
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "minimum must not exceed maximum.",
|
||||||
|
path: ["minimum"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (declaration.default !== undefined) {
|
||||||
|
if (
|
||||||
|
(declaration.minimum !== undefined && declaration.default < declaration.minimum) ||
|
||||||
|
(declaration.maximum !== undefined && declaration.default > declaration.maximum)
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A default value must satisfy its own declared constraints.",
|
||||||
|
path: ["default"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (declaration.type === "enum") {
|
||||||
|
if (new Set(declaration.values).size !== declaration.values.length) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Enum values must be unique.",
|
||||||
|
path: ["values"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (declaration.default !== undefined && !declaration.values.includes(declaration.default)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A default value must be one of the declared enum values.",
|
||||||
|
path: ["default"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (declaration.type === "url" && declaration.default !== undefined) {
|
||||||
|
if (!isSupportedConfigurationUrl(declaration.default)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A default URL must be an absolute http: or https: URL.",
|
||||||
|
path: ["default"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ModuleConfigurationDeclarationInput = z.input<
|
||||||
|
typeof moduleConfigurationDeclarationSchema
|
||||||
|
>;
|
||||||
|
export type ModuleConfigurationDeclaration = z.output<typeof moduleConfigurationDeclarationSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one URL rule for `type: "url"`, shared by declaration validation and value validation so the
|
||||||
|
* two can never disagree.
|
||||||
|
*
|
||||||
|
* Only absolute `http:`/`https:` URLs. Everything else — `file:`, `javascript:`, `data:`, a
|
||||||
|
* protocol-relative `//host`, a bare hostname — is rejected outright rather than normalized,
|
||||||
|
* because a configuration value is frequently handed straight to `fetch`, and the difference
|
||||||
|
* between "an endpoint an operator meant" and "a local file the server can read" must not be a
|
||||||
|
* parsing accident.
|
||||||
|
*/
|
||||||
|
export function isSupportedConfigurationUrl(value: string): boolean {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(value);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every value a resolved configuration can hold. `secret` resolves to a `string` like the others —
|
||||||
|
* the difference is entirely in where it comes from and where it may go, never in its shape. */
|
||||||
|
export type ModuleConfigurationValue = string | number | boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A module's own validated configuration, frozen at startup.
|
||||||
|
*
|
||||||
|
* **This is the whole module-facing contract.** It has no reference to PostgreSQL, no environment
|
||||||
|
* access, no module id but its own, and no way to reach another module's values: the object simply
|
||||||
|
* does not contain them, and no accessor takes a namespace. A future standalone host implements
|
||||||
|
* this exact interface from a YAML file, its own database, or anything else, and the module's
|
||||||
|
* feature code cannot tell the difference — which is the point (ADR 0029).
|
||||||
|
*
|
||||||
|
* Every accessor fails closed: an undeclared key, a key whose declared type does not match the
|
||||||
|
* accessor, and an absent optional value are all `undefined`, deliberately indistinguishable, in the
|
||||||
|
* same style as `PermissionRegistry.get`. A key the manifest declares `required`, or gives a
|
||||||
|
* `default`, is guaranteed present by the time any module code runs — the platform refuses to start
|
||||||
|
* a module whose required configuration is missing — so those accessors return `undefined` only for
|
||||||
|
* a key the module never declared.
|
||||||
|
*/
|
||||||
|
export interface ModuleConfiguration {
|
||||||
|
/** Every key this module declared *and* that resolved to a value, sorted. Never another module's,
|
||||||
|
* and never a key that only exists in storage. */
|
||||||
|
keys(): readonly string[];
|
||||||
|
has(key: string): boolean;
|
||||||
|
/** The validated value under its declared type, or `undefined`. Secrets are reachable here too —
|
||||||
|
* a module reading its own secret is the entire point — which is exactly why this object must
|
||||||
|
* never be serialized into a response. Core never does; see `module-configuration-routes.ts`. */
|
||||||
|
get(key: string): ModuleConfigurationValue | undefined;
|
||||||
|
getString(key: string): string | undefined;
|
||||||
|
getInteger(key: string): number | undefined;
|
||||||
|
getBoolean(key: string): boolean | undefined;
|
||||||
|
/** The resolved member of a `type: "enum"` key. Separate from `getString` so a module cannot
|
||||||
|
* silently start reading a free-text key as though it were a closed choice. */
|
||||||
|
getEnum(key: string): string | undefined;
|
||||||
|
/** A validated absolute `http:`/`https:` URL string. Returned as a string rather than a `URL` so
|
||||||
|
* the contract stays JSON-shaped and identical across hosts. */
|
||||||
|
getUrl(key: string): string | undefined;
|
||||||
|
/** A deployment-supplied credential. Never persisted by Ekklesia, never returned by any HTTP
|
||||||
|
* route, and never printed by diagnostics. */
|
||||||
|
getSecret(key: string): string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The empty configuration every module without declarations receives. `context.config` is therefore
|
||||||
|
* always present and never `undefined`, so module code never branches on whether the platform
|
||||||
|
* supplied one. */
|
||||||
|
export const EMPTY_MODULE_CONFIGURATION: ModuleConfiguration = Object.freeze({
|
||||||
|
get: (): undefined => undefined,
|
||||||
|
getBoolean: (): undefined => undefined,
|
||||||
|
getEnum: (): undefined => undefined,
|
||||||
|
getInteger: (): undefined => undefined,
|
||||||
|
getSecret: (): undefined => undefined,
|
||||||
|
getString: (): undefined => undefined,
|
||||||
|
getUrl: (): undefined => undefined,
|
||||||
|
has: (): boolean => false,
|
||||||
|
keys: (): readonly string[] => Object.freeze([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a host supplies deployment-owned secret material for one module's declared `secret` keys.
|
||||||
|
*
|
||||||
|
* Scoped by construction: a lookup takes a module id **and** a key the platform already validated
|
||||||
|
* against that module's own manifest, so there is no call shape that could read another module's
|
||||||
|
* secret and no way to ask for an arbitrary environment variable. A module never sees this
|
||||||
|
* interface at all — the host resolves secrets into the module's frozen snapshot before any module
|
||||||
|
* code runs.
|
||||||
|
*/
|
||||||
|
export interface ModuleSecretSource {
|
||||||
|
get(moduleId: string, key: string): string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A secret source that supplies nothing. The explicit "this deployment provides no module secrets"
|
||||||
|
* value, so a caller states that rather than passing `undefined` and hoping. */
|
||||||
|
export const EMPTY_MODULE_SECRET_SOURCE: ModuleSecretSource = Object.freeze({
|
||||||
|
get: (): undefined => undefined,
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { EkklesiaPrincipal } from "./identity.js";
|
import type { EkklesiaPrincipal } from "./identity.js";
|
||||||
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
|
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
|
||||||
|
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||||
|
|
||||||
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
|
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
|
||||||
@@ -220,6 +221,12 @@ export interface ModuleRouteHandlerContext {
|
|||||||
readonly principal: EkklesiaPrincipal;
|
readonly principal: EkklesiaPrincipal;
|
||||||
readonly params: Readonly<Record<string, string>>;
|
readonly params: Readonly<Record<string, string>>;
|
||||||
readonly services: ModuleRuntimeServices;
|
readonly services: ModuleRuntimeServices;
|
||||||
|
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
|
||||||
|
* same object, not a re-read — so a handler and a lifecycle hook can never disagree about a value
|
||||||
|
* (ADR 0029). Never serialized into a response by Core; a module that chooses to expose part of
|
||||||
|
* its own configuration does so through its own route, deliberately, and must never expose a
|
||||||
|
* secret. */
|
||||||
|
readonly config: ModuleConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
||||||
|
|||||||
@@ -43,24 +43,62 @@ export const moduleRuntimeStateSchema = z.enum(MODULE_RUNTIME_STATES);
|
|||||||
export type ModuleRuntimeState = z.infer<typeof moduleRuntimeStateSchema>;
|
export type ModuleRuntimeState = z.infer<typeof moduleRuntimeStateSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why an enabled module is not running. A structured reason plus the exact dependency it names,
|
* Why an enabled module is not running. A structured reason plus the exact thing it names — a
|
||||||
* rather than one state name per case — four states would encode the same two facts twice.
|
* dependency, or a configuration key — rather than one state name per case, which would encode the
|
||||||
|
* same two facts twice.
|
||||||
*
|
*
|
||||||
* `required_dependency_disabled` means the dependency an administrator turned off is the direct
|
* `required_dependency_disabled` means the dependency an administrator turned off is the direct
|
||||||
* cause. `required_dependency_blocked` means the named dependency is itself blocked, so the cause
|
* cause. `required_dependency_blocked` means the named dependency is itself blocked, so the cause
|
||||||
* is transitive; following the chain always terminates at a `..._disabled` entry, because a
|
* is transitive; following the chain always terminates at a reason that is not
|
||||||
* dependency that is merely absent or incompatible is rejected by discovery long before
|
* `required_dependency_blocked`, because a dependency that is merely absent or incompatible is
|
||||||
* eligibility is resolved.
|
* rejected by discovery long before eligibility is resolved. Since ADR 0029 that terminal reason
|
||||||
|
* may also be one of the three configuration reasons below — a module blocked by its own
|
||||||
|
* configuration blocks its required dependents exactly like a disabled one does.
|
||||||
*/
|
*/
|
||||||
export const MODULE_BLOCKED_REASONS = [
|
export const MODULE_BLOCKED_REASONS = [
|
||||||
"required_dependency_disabled",
|
"required_dependency_disabled",
|
||||||
"required_dependency_blocked",
|
"required_dependency_blocked",
|
||||||
|
"configuration_missing",
|
||||||
|
"configuration_invalid",
|
||||||
|
"secret_missing",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const moduleBlockedReasonSchema = z.enum(MODULE_BLOCKED_REASONS);
|
export const moduleBlockedReasonSchema = z.enum(MODULE_BLOCKED_REASONS);
|
||||||
|
|
||||||
export type ModuleBlockedReason = z.infer<typeof moduleBlockedReasonSchema>;
|
export type ModuleBlockedReason = z.infer<typeof moduleBlockedReasonSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three configuration reasons an enabled module may fail to become eligible (ADR 0029). They
|
||||||
|
* are separate because each has a **different operational fix**, which is the only justification
|
||||||
|
* for a distinct reason code:
|
||||||
|
*
|
||||||
|
* - `configuration_missing` — a required key has no stored value and no default. Fix: set a value
|
||||||
|
* through the configuration API.
|
||||||
|
* - `configuration_invalid` — a stored value exists but does not satisfy the module's own declared
|
||||||
|
* type or constraints. Fix: correct the value. A persisted value is never silently coerced,
|
||||||
|
* truncated, or replaced by a default.
|
||||||
|
* - `secret_missing` — a required `secret` key has no deployment-supplied value. Fix: set it in the
|
||||||
|
* deployment environment, not in the database — Ekklesia never stores secret material
|
||||||
|
* (ADR 0029).
|
||||||
|
*
|
||||||
|
* Each names the offending **key**, never the value. A key is public — it is declared in an
|
||||||
|
* installed module's manifest — while a value may be a credential.
|
||||||
|
*/
|
||||||
|
export const MODULE_CONFIGURATION_BLOCKED_REASONS = [
|
||||||
|
"configuration_missing",
|
||||||
|
"configuration_invalid",
|
||||||
|
"secret_missing",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ModuleConfigurationBlockedReason =
|
||||||
|
(typeof MODULE_CONFIGURATION_BLOCKED_REASONS)[number];
|
||||||
|
|
||||||
|
export function isModuleConfigurationBlockedReason(
|
||||||
|
reason: ModuleBlockedReason,
|
||||||
|
): reason is ModuleConfigurationBlockedReason {
|
||||||
|
return (MODULE_CONFIGURATION_BLOCKED_REASONS as readonly string[]).includes(reason);
|
||||||
|
}
|
||||||
|
|
||||||
/** Both kinds of edge a module's manifest can declare as required: another module, or a capability
|
/** Both kinds of edge a module's manifest can declare as required: another module, or a capability
|
||||||
* some other module provides. Both block identically — a required capability is exactly as
|
* some other module provides. Both block identically — a required capability is exactly as
|
||||||
* required as a required module — and both name the *providing module* so an operator has
|
* required as a required module — and both name the *providing module* so an operator has
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
||||||
|
import {
|
||||||
|
type ModuleConfigurationDeclaration,
|
||||||
|
moduleConfigurationDeclarationSchema,
|
||||||
|
} from "./module-configuration.js";
|
||||||
import {
|
import {
|
||||||
capabilityIdSchema,
|
capabilityIdSchema,
|
||||||
logicalBucketIdSchema,
|
logicalBucketIdSchema,
|
||||||
@@ -102,17 +106,25 @@ function reportDuplicates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one manifest shape Core accepts. Adding `entrypoints.ui` is an incompatible shape change —
|
* The one manifest shape Core accepts. Adding a top-level `configuration` array is an incompatible
|
||||||
* version 3's `entrypoints` object is `.strict()` and rejects `ui` outright — so the canonical
|
* shape change — version 4's manifest object is `.strict()` and rejects `configuration` outright —
|
||||||
* manifest moves to `manifestVersion: 4` rather than reinterpreting version 3 (ADR 0021).
|
* so the canonical manifest moves to `manifestVersion: 5` rather than reinterpreting version 4
|
||||||
|
* (ADR 0029).
|
||||||
*
|
*
|
||||||
* Version 3, and every other version, fails validation: there is no compatibility adapter, no
|
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||||
|
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||||
|
* *before* deciding whether it may start, so an enabled module whose required configuration is
|
||||||
|
* missing is reported as `blocked` with a structured reason instead of taking API startup down from
|
||||||
|
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||||
|
* importing a single line of module code, exactly as they always have.
|
||||||
|
*
|
||||||
|
* Version 4, and every other version, fails validation: there is no compatibility adapter, no
|
||||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||||
* (ADR 0006) and version 2 by version 3 (ADR 0014). One manifest document can therefore never be
|
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
|
||||||
* read as two different versions. Upgrading a backend-only version 3 manifest is a one-character
|
* manifest document can therefore never be read as two different versions. Upgrading a version 4
|
||||||
* edit: no field added by version 4 is required.
|
* manifest is a one-character edit: no field added by version 5 is required.
|
||||||
*/
|
*/
|
||||||
export const MODULE_MANIFEST_VERSION = 4;
|
export const MODULE_MANIFEST_VERSION = 5;
|
||||||
|
|
||||||
export const moduleManifestSchema = z
|
export const moduleManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -130,6 +142,10 @@ export const moduleManifestSchema = z
|
|||||||
dependencies: dependenciesSchema,
|
dependencies: dependenciesSchema,
|
||||||
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||||
permissions: z.array(permissionDeclarationSchema).default(() => []),
|
permissions: z.array(permissionDeclarationSchema).default(() => []),
|
||||||
|
/** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert
|
||||||
|
* data: declaring configuration never causes Core to read a database, contact a host, or run
|
||||||
|
* module code during discovery. A module that needs nothing configured omits the field. */
|
||||||
|
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
||||||
storage: moduleStorageSchema,
|
storage: moduleStorageSchema,
|
||||||
entrypoints: entrypointsSchema,
|
entrypoints: entrypointsSchema,
|
||||||
})
|
})
|
||||||
@@ -153,6 +169,15 @@ export const moduleManifestSchema = z
|
|||||||
context,
|
context,
|
||||||
"id",
|
"id",
|
||||||
);
|
);
|
||||||
|
// One namespace for every configuration key a module owns, so an ordinary key and a secret key
|
||||||
|
// can never collide: a module cannot declare `apiToken` as both a defaultable string and a
|
||||||
|
// deployment-supplied secret and leave the platform to guess which one a stored row means.
|
||||||
|
reportDuplicates(
|
||||||
|
manifest.configuration.map((declaration) => declaration.key),
|
||||||
|
["configuration"],
|
||||||
|
context,
|
||||||
|
"key",
|
||||||
|
);
|
||||||
|
|
||||||
manifest.capabilities.forEach((capability, index) => {
|
manifest.capabilities.forEach((capability, index) => {
|
||||||
if (!capability.id.startsWith(`${manifest.id}.`)) {
|
if (!capability.id.startsWith(`${manifest.id}.`)) {
|
||||||
@@ -228,6 +253,10 @@ export function safeParseModuleManifest(input: unknown) {
|
|||||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||||
|
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
||||||
|
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
||||||
|
* here for discoverability alongside the manifest's other per-field declaration types. */
|
||||||
|
export type ModuleManifestConfigurationDeclaration = ModuleConfigurationDeclaration;
|
||||||
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
||||||
/** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's
|
/** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's
|
||||||
* authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for
|
* authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||||
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||||
import { moduleIdSchema } from "./identifiers.js";
|
import { moduleIdSchema } from "./identifiers.js";
|
||||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||||
@@ -28,6 +29,15 @@ export interface ModuleRegistrationContext {
|
|||||||
* afterward from `start`, `stop`, or any other post-registration code path. */
|
* afterward from `start`, `stop`, or any other post-registration code path. */
|
||||||
readonly http: ModuleHttpRouteRegistrar;
|
readonly http: ModuleHttpRouteRegistrar;
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
|
/**
|
||||||
|
* This module's own validated configuration, available here as well as at runtime (ADR 0029).
|
||||||
|
*
|
||||||
|
* Present during `register` deliberately, and without reopening the rule that registration stays
|
||||||
|
* declarative and I/O-free: this is an already-resolved frozen snapshot held in memory, not a
|
||||||
|
* database-backed getter, so reading it performs no query and cannot fail. That is exactly why
|
||||||
|
* `services` — which *is* a live database facade — remains absent from this context.
|
||||||
|
*/
|
||||||
|
readonly config: ModuleConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The same context shape for both `start` and `stop`: whichever hook runs, it receives the exact
|
/** The same context shape for both `start` and `stop`: whichever hook runs, it receives the exact
|
||||||
@@ -38,6 +48,19 @@ export interface ModuleRuntimeContext {
|
|||||||
readonly capabilities: CapabilityResolver;
|
readonly capabilities: CapabilityResolver;
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
readonly services: ModuleRuntimeServices;
|
readonly services: ModuleRuntimeServices;
|
||||||
|
/**
|
||||||
|
* This module's own validated configuration, frozen for the lifetime of the process.
|
||||||
|
*
|
||||||
|
* Deliberately **not** a field on `ModuleRuntimeServices`: that object is one shared instance
|
||||||
|
* every module receives, and configuration is per-module by definition. It sits beside `moduleId`
|
||||||
|
* — the context's other per-module fact — so the scoping is visible in the shape rather than
|
||||||
|
* asserted in a comment.
|
||||||
|
*
|
||||||
|
* Immutable for the process lifetime: a configuration change is applied by the next restart, so a
|
||||||
|
* value read during `start` and the same value read in a request handler an hour later are the
|
||||||
|
* same value (ADR 0029).
|
||||||
|
*/
|
||||||
|
readonly config: ModuleConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleRuntimeDefinition {
|
export interface ModuleRuntimeDefinition {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
|
isModuleConfigurationBlockedReason,
|
||||||
MODULE_BLOCKED_REASONS,
|
MODULE_BLOCKED_REASONS,
|
||||||
|
MODULE_CONFIGURATION_BLOCKED_REASONS,
|
||||||
MODULE_DEPENDENCY_KINDS,
|
MODULE_DEPENDENCY_KINDS,
|
||||||
MODULE_MANIFEST_VERSION,
|
MODULE_MANIFEST_VERSION,
|
||||||
MODULE_RUNTIME_STATES,
|
MODULE_RUNTIME_STATES,
|
||||||
@@ -13,7 +15,7 @@ import {
|
|||||||
|
|
||||||
function validManifest(): Record<string, unknown> {
|
function validManifest(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
name: "Calendar",
|
name: "Calendar",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -209,17 +211,23 @@ describe("module manifest contract", () => {
|
|||||||
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
|
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a version 3 manifest rather than silently interpreting it as version 4", () => {
|
it("rejects a version 3 manifest rather than silently interpreting it as a later version", () => {
|
||||||
// A backend-only version 3 manifest is otherwise byte-identical to a valid version 4 one:
|
|
||||||
// only the version literal separates them, and it is not reinterpreted.
|
|
||||||
const version3Manifest = { ...validManifest(), manifestVersion: 3 };
|
const version3Manifest = { ...validManifest(), manifestVersion: 3 };
|
||||||
|
|
||||||
expect(safeParseModuleManifest(version3Manifest).success).toBe(false);
|
expect(safeParseModuleManifest(version3Manifest).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a version 4 manifest rather than silently interpreting it as version 5", () => {
|
||||||
|
// A manifest declaring no configuration is otherwise byte-identical between version 4 and
|
||||||
|
// version 5: only the version literal separates them, and it is not reinterpreted (ADR 0029).
|
||||||
|
const version4Manifest = { ...validManifest(), manifestVersion: 4 };
|
||||||
|
|
||||||
|
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||||
expect(MODULE_MANIFEST_VERSION).toBe(4);
|
expect(MODULE_MANIFEST_VERSION).toBe(5);
|
||||||
for (const manifestVersion of [1, 2, 3, 5, "4", 4.5, null]) {
|
for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) {
|
||||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
@@ -353,7 +361,21 @@ describe("module manifest contract", () => {
|
|||||||
expect(MODULE_BLOCKED_REASONS).toEqual([
|
expect(MODULE_BLOCKED_REASONS).toEqual([
|
||||||
"required_dependency_disabled",
|
"required_dependency_disabled",
|
||||||
"required_dependency_blocked",
|
"required_dependency_blocked",
|
||||||
|
"configuration_missing",
|
||||||
|
"configuration_invalid",
|
||||||
|
"secret_missing",
|
||||||
]);
|
]);
|
||||||
|
// The three configuration reasons are a subset, and each corresponds to a real implemented
|
||||||
|
// check with a distinct operator fix (ADR 0029) — the only justification for a separate code.
|
||||||
|
expect(MODULE_CONFIGURATION_BLOCKED_REASONS).toEqual([
|
||||||
|
"configuration_missing",
|
||||||
|
"configuration_invalid",
|
||||||
|
"secret_missing",
|
||||||
|
]);
|
||||||
|
for (const reason of MODULE_CONFIGURATION_BLOCKED_REASONS) {
|
||||||
|
expect(isModuleConfigurationBlockedReason(reason)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(isModuleConfigurationBlockedReason("required_dependency_disabled")).toBe(false);
|
||||||
expect(MODULE_DEPENDENCY_KINDS).toEqual(["module", "capability"]);
|
expect(MODULE_DEPENDENCY_KINDS).toEqual(["module", "capability"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
defineModuleRuntime,
|
defineModuleRuntime,
|
||||||
isModuleHttpMethod,
|
isModuleHttpMethod,
|
||||||
MODULE_HTTP_METHODS,
|
MODULE_HTTP_METHODS,
|
||||||
@@ -59,6 +60,9 @@ describe("defineModuleRuntime", () => {
|
|||||||
provide: () => undefined,
|
provide: () => undefined,
|
||||||
resolve: () => undefined,
|
resolve: () => undefined,
|
||||||
},
|
},
|
||||||
|
// A module declaring no configuration still receives an empty snapshot rather than
|
||||||
|
// `undefined`, so module code never branches on whether the platform supplied one.
|
||||||
|
config: EMPTY_MODULE_CONFIGURATION,
|
||||||
http,
|
http,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
|
|||||||
const manifest =
|
const manifest =
|
||||||
options.manifest ??
|
options.manifest ??
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 4,
|
manifestVersion: 5,
|
||||||
id: "example",
|
id: "example",
|
||||||
name: "Example",
|
name: "Example",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 4,
|
"manifestVersion": 5,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example",
|
"name": "Example",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 4,
|
"manifestVersion": 5,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example Module",
|
"name": "Example Module",
|
||||||
"description": "A minimal Ekklesia module template with backend behavior and optional UI.",
|
"description": "A minimal Ekklesia module template with backend behavior and optional UI.",
|
||||||
@@ -21,6 +21,15 @@
|
|||||||
"description": "Read example records."
|
"description": "Read example records."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"configuration": [
|
||||||
|
{
|
||||||
|
"key": "greeting",
|
||||||
|
"type": "string",
|
||||||
|
"default": "Hello",
|
||||||
|
"maxLength": 120,
|
||||||
|
"description": "Prefix this module puts in front of a record value."
|
||||||
|
}
|
||||||
|
],
|
||||||
"storage": {
|
"storage": {
|
||||||
"buckets": [
|
"buckets": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,7 +31,17 @@ export default defineModuleRuntime({
|
|||||||
const rows = await context.services.database
|
const rows = await context.services.database
|
||||||
.select({ id: records.id, value: records.value })
|
.select({ id: records.id, value: records.value })
|
||||||
.from(records);
|
.from(records);
|
||||||
return Response.json(rows);
|
// The accepted module configuration boundary: `context.config` is a frozen, already
|
||||||
|
// validated snapshot of exactly this module's own declared keys (ADR 0029). It reaches
|
||||||
|
// `register`, `start`, `stop`, and every handler as the same object. This module never
|
||||||
|
// reads `process.env`, never queries a platform table for a setting, and has no way to see
|
||||||
|
// another module's configuration — which is what keeps it runnable under a future
|
||||||
|
// standalone host that stores configuration somewhere else entirely.
|
||||||
|
//
|
||||||
|
// `greeting` is declared with a default in `ekklesia.module.json`, so it is always present;
|
||||||
|
// the `??` is only here because accessors are typed `T | undefined` for undeclared keys.
|
||||||
|
const greeting = context.config.getString("greeting") ?? "Hello";
|
||||||
|
return Response.json(rows.map((row) => ({ ...row, value: `${greeting}, ${row.value}` })));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { readFile } from "node:fs/promises";
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
type CapabilityId,
|
type CapabilityId,
|
||||||
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
|
type ModuleConfiguration,
|
||||||
type ModuleDatabase,
|
type ModuleDatabase,
|
||||||
type ModuleRouteDefinitionInput,
|
type ModuleRouteDefinitionInput,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
@@ -31,6 +33,9 @@ async function registerAndCaptureRoutes(): Promise<{
|
|||||||
const provided = new Map<CapabilityId, unknown>();
|
const provided = new Map<CapabilityId, unknown>();
|
||||||
const routes: ModuleRouteDefinitionInput[] = [];
|
const routes: ModuleRouteDefinitionInput[] = [];
|
||||||
await moduleRuntime.register?.({
|
await moduleRuntime.register?.({
|
||||||
|
// The template declares no configuration, so it receives an empty snapshot — proof that a
|
||||||
|
// backend module needing no settings stays exactly as simple as it was (ADR 0029).
|
||||||
|
config: EMPTY_MODULE_CONFIGURATION,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
provide(capabilityId, implementation) {
|
provide(capabilityId, implementation) {
|
||||||
provided.set(capabilityId, implementation);
|
provided.set(capabilityId, implementation);
|
||||||
@@ -124,6 +129,7 @@ describe("module template", () => {
|
|||||||
database: fakeModuleDatabase(fakeRows),
|
database: fakeModuleDatabase(fakeRows),
|
||||||
});
|
});
|
||||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||||
|
config: EMPTY_MODULE_CONFIGURATION,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
params: Object.freeze({}),
|
params: Object.freeze({}),
|
||||||
principal: ANONYMOUS_PRINCIPAL,
|
principal: ANONYMOUS_PRINCIPAL,
|
||||||
@@ -133,7 +139,69 @@ describe("module template", () => {
|
|||||||
const response = await route.handler(new Request("http://localhost/records"), context);
|
const response = await route.handler(new Request("http://localhost/records"), context);
|
||||||
|
|
||||||
expect(response).toBeInstanceOf(Response);
|
expect(response).toBeInstanceOf(Response);
|
||||||
expect(await response.json()).toEqual(fakeRows);
|
// With no configuration supplied, the handler falls back to the same default the manifest
|
||||||
|
// declares. In a real Ekklesia deployment that fallback is unreachable — the platform resolves
|
||||||
|
// the declared default before any module code runs — but the accessor is typed `T | undefined`
|
||||||
|
// for undeclared keys, so module code still writes it.
|
||||||
|
expect(await response.json()).toEqual([{ ...fakeRows[0], value: "Hello, hello" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads its own declared configuration through context.config, never the environment", async () => {
|
||||||
|
const { routes } = await registerAndCaptureRoutes();
|
||||||
|
const route = routes[0];
|
||||||
|
if (!route) {
|
||||||
|
throw new Error("Expected the example route to be registered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeRows = [{ id: "9d3f9f7e-3b1a-4c9a-8b8e-2b6b1e6f9a11", value: "hello" }];
|
||||||
|
// A hand-rolled `ModuleConfiguration`: it is a `@ekklesia/contracts` interface, and a module may
|
||||||
|
// never import Core, so this is what a module-layer test can legitimately construct. It also
|
||||||
|
// demonstrates the portability claim directly — the host that produces this object could be
|
||||||
|
// Ekklesia, or a future standalone host reading YAML, and this module cannot tell.
|
||||||
|
const config: ModuleConfiguration = Object.freeze({
|
||||||
|
get: (key: string) => (key === "greeting" ? "Welcome" : undefined),
|
||||||
|
getBoolean: () => undefined,
|
||||||
|
getEnum: () => undefined,
|
||||||
|
getInteger: () => undefined,
|
||||||
|
getSecret: () => undefined,
|
||||||
|
getString: (key: string) => (key === "greeting" ? "Welcome" : undefined),
|
||||||
|
getUrl: () => undefined,
|
||||||
|
has: (key: string) => key === "greeting",
|
||||||
|
keys: () => Object.freeze(["greeting"]),
|
||||||
|
});
|
||||||
|
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||||
|
config,
|
||||||
|
moduleId: "example",
|
||||||
|
params: Object.freeze({}),
|
||||||
|
principal: ANONYMOUS_PRINCIPAL,
|
||||||
|
services: Object.freeze({ database: fakeModuleDatabase(fakeRows) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await route.handler(new Request("http://localhost/records"), context);
|
||||||
|
|
||||||
|
expect(await response.json()).toEqual([{ ...fakeRows[0], value: "Welcome, hello" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares exactly one configuration key, with a default, and no secret", async () => {
|
||||||
|
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
||||||
|
const manifest = parseModuleManifest(
|
||||||
|
JSON.parse(await readFile(manifestUrl, "utf8")) as unknown,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The template stays the smallest thing that demonstrates the contract: one ordinary key with a
|
||||||
|
// safe default. A backend module needing no settings omits `configuration` entirely and remains
|
||||||
|
// valid, which the manifest schema's own default proves.
|
||||||
|
expect(manifest.configuration).toEqual([
|
||||||
|
{
|
||||||
|
default: "Hello",
|
||||||
|
description: "Prefix this module puts in front of a record value.",
|
||||||
|
key: "greeting",
|
||||||
|
maxLength: 120,
|
||||||
|
required: false,
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(manifest.configuration.some((entry) => entry.type === "secret")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exports a namespaced module-owned Drizzle schema", () => {
|
it("exports a namespaced module-owned Drizzle schema", () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
"core/tests/authorization-bootstrap.integration.ts",
|
"core/tests/authorization-bootstrap.integration.ts",
|
||||||
"core/tests/dashboard-store.integration.ts",
|
"core/tests/dashboard-store.integration.ts",
|
||||||
"core/tests/module-lifecycle-store.integration.ts",
|
"core/tests/module-lifecycle-store.integration.ts",
|
||||||
|
"core/tests/module-configuration-store.integration.ts",
|
||||||
"apps/api/tests/api-http.integration.ts",
|
"apps/api/tests/api-http.integration.ts",
|
||||||
"apps/api/tests/module-database.integration.ts",
|
"apps/api/tests/module-database.integration.ts",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user