feat: add the module configuration and secrets contract #28
@@ -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
|
||||
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
|
||||
completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) has
|
||||
landed.** The operational
|
||||
completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) and
|
||||
pass 2 (the module configuration and secrets contract) have landed.** The operational
|
||||
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
|
||||
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
|
||||
[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
|
||||
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
|
||||
not exist.
|
||||
|
||||
@@ -509,20 +567,23 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
4 permissions registered
|
||||
5 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
DATABASE_URL is not configured.
|
||||
|
||||
Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
5 migrations · checksums valid
|
||||
6 migrations · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
core:0002_authorization_integrity · 634294731180
|
||||
core:0003_dashboard_navigation_tables · 2cde27807aa4
|
||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||
core:0005_module_configuration · c8d57f24f84b
|
||||
|
||||
PostgreSQL
|
||||
✓ Database TLS verified
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleSecretSource,
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -11,7 +12,11 @@ import type {
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
ModuleConfigurationSettings,
|
||||
ModuleConfigurationStore,
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleLifecycleStore,
|
||||
ModuleUiPublicationRegistry,
|
||||
} 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 { createGrantsRoutes } from "./grants-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 { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||
@@ -68,6 +74,25 @@ export interface CreateApiAppOptions {
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
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
|
||||
* 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
|
||||
@@ -244,13 +269,34 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
configuration: {
|
||||
secrets: options.moduleConfiguration.secrets,
|
||||
store: options.moduleConfiguration.store,
|
||||
},
|
||||
snapshot: options.moduleLifecycle.snapshot,
|
||||
store: options.moduleLifecycle.store,
|
||||
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, {
|
||||
configurations: options.moduleConfiguration.snapshots,
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleConfigurationView,
|
||||
createModuleDatabaseView,
|
||||
createModuleLifecycleView,
|
||||
type ModuleConfigurationDrizzleDatabase,
|
||||
type ModuleLifecycleDrizzleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
@@ -20,6 +22,7 @@ export interface ApiDatabaseHandle {
|
||||
readonly dashboard: DashboardDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
readonly module: ModuleDatabase;
|
||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||
/** 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. */
|
||||
@@ -31,7 +34,8 @@ export interface ApiDatabaseHandle {
|
||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
||||
* 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()`
|
||||
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||
@@ -49,6 +53,7 @@ export function createApiDatabase(
|
||||
dashboard: createDashboardDrizzleView(pool),
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
module: createModuleDatabaseView(pool),
|
||||
moduleConfiguration: createModuleConfigurationView(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 {
|
||||
ModuleId,
|
||||
ModuleRouteHandlerContext,
|
||||
ModuleRuntimeServices,
|
||||
RegisteredModuleHttpRoute,
|
||||
import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
type ModuleId,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { ModuleConfigurationLookup } from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
import {
|
||||
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
|
||||
* after authorization succeeds. */
|
||||
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
|
||||
@@ -212,6 +224,7 @@ export function mountModuleHttpRoutes(
|
||||
const path = moduleRoutePath(route.moduleId, route.path);
|
||||
const finalHandler = async (c: Context): Promise<Response> => {
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
||||
moduleId: route.moduleId,
|
||||
params: Object.freeze({ ...c.req.param() }),
|
||||
principal: getAuthorizedPrincipal(c),
|
||||
|
||||
@@ -2,9 +2,11 @@ import type {
|
||||
ModuleBlockedReason,
|
||||
ModuleBlockingDependency,
|
||||
ModuleRuntimeState,
|
||||
ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
|
||||
import {
|
||||
type ModuleConfigurationStore,
|
||||
type ModuleLifecycleSnapshot,
|
||||
type ModuleLifecycleStore,
|
||||
ModuleLifecycleStoreError,
|
||||
@@ -58,7 +60,11 @@ export interface ModuleLifecycleModuleView {
|
||||
readonly runtime: {
|
||||
readonly state: ModuleRuntimeState;
|
||||
readonly reason?: ModuleBlockedReason;
|
||||
/** Present for a dependency reason. */
|
||||
readonly dependency?: ModuleBlockingDependency;
|
||||
/** Present for a configuration reason (ADR 0029): the offending key, never its value. A key is
|
||||
* declared in a public manifest; a value may be a credential or arbitrary operator text. */
|
||||
readonly configurationKey?: string;
|
||||
};
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
@@ -74,6 +80,16 @@ export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlew
|
||||
* if it were restarted right now. */
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -137,6 +153,9 @@ function present(view: ModuleLifecycleView): ModuleLifecycleModuleView {
|
||||
state: view.runtime.state,
|
||||
...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }),
|
||||
...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }),
|
||||
...(view.runtime.configurationKey === undefined
|
||||
? {}
|
||||
: { configurationKey: view.runtime.configurationKey }),
|
||||
},
|
||||
version: view.version,
|
||||
};
|
||||
@@ -197,8 +216,14 @@ export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutes
|
||||
// every other authorized Core read does.
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
try {
|
||||
const configuration = await options.store.readConfiguration();
|
||||
const modules = resolveModuleLifecycleView(options.snapshot, configuration).map(present);
|
||||
const [configuration, settings] = await Promise.all([
|
||||
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);
|
||||
} catch (error) {
|
||||
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
|
||||
// stored now, resolved against the same boot snapshot the collection read uses, so the two
|
||||
// endpoints can never disagree about `restartRequired`.
|
||||
const configuration = await options.store.readConfiguration();
|
||||
const view = resolveModuleLifecycleView(options.snapshot, configuration).find(
|
||||
(entry) => entry.moduleId === moduleId,
|
||||
);
|
||||
const [configuration, settings] = await Promise.all([
|
||||
options.store.readConfiguration(),
|
||||
options.configuration.store.readSettings(),
|
||||
]);
|
||||
const view = resolveModuleLifecycleView(options.snapshot, configuration, {
|
||||
secrets: options.configuration.secrets,
|
||||
settings,
|
||||
}).find((entry) => entry.moduleId === moduleId);
|
||||
if (!view) {
|
||||
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
|
||||
}
|
||||
|
||||
+56
-7
@@ -1,7 +1,11 @@
|
||||
import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import {
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
@@ -9,9 +13,12 @@ import {
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createEnvironmentModuleSecretSource,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleEligibilityResolution,
|
||||
resolveModuleEligibility,
|
||||
type StartedModuleRuntime,
|
||||
@@ -142,6 +149,17 @@ export interface StartApiOptions {
|
||||
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
|
||||
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
|
||||
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> {
|
||||
@@ -405,12 +423,28 @@ export async function startApi(
|
||||
// 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.
|
||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||
let eligibility: ModuleEligibilityResolution;
|
||||
try {
|
||||
eligibility = resolveModuleEligibility(
|
||||
inventory.modules,
|
||||
await moduleLifecycleStore.readConfiguration(),
|
||||
const moduleConfigurationStore = createModuleConfigurationStore(
|
||||
databaseHandle.moduleConfiguration,
|
||||
);
|
||||
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) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
@@ -427,6 +461,7 @@ export async function startApi(
|
||||
let moduleRuntime: StartedModuleRuntime;
|
||||
try {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||
configurations: eligibility.configurations,
|
||||
...(options.moduleHookTimeoutMsOverride === undefined
|
||||
? {}
|
||||
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||
@@ -463,6 +498,15 @@ export async function startApi(
|
||||
}),
|
||||
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.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
runtime,
|
||||
@@ -615,7 +659,12 @@ function readConfigFromEnvironment(): {
|
||||
export async function main(): Promise<void> {
|
||||
const { environment, input } = readConfigFromEnvironment();
|
||||
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 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:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -301,7 +302,7 @@ async function createGatedRouteModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${id} module`,
|
||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||
version: "0.1.0",
|
||||
@@ -342,7 +343,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -434,7 +435,7 @@ async function createUiModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
}),
|
||||
@@ -1102,12 +1103,14 @@ describe.sequential("live API — startup and migration guarantees", () => {
|
||||
{ environment: "development" },
|
||||
);
|
||||
|
||||
// Startup reads the stored module enablement configuration before importing any module
|
||||
// entrypoint (ADR 0028). Against an unmigrated database that read cannot succeed, and the
|
||||
// platform fails closed rather than starting every discovered module: it cannot know what an
|
||||
// administrator decided, and starting a module they disabled is worse than not starting.
|
||||
// The failure is Core's own sanitized store error — no connection string, host, SQL, driver
|
||||
// message, or stack.
|
||||
// Startup reads the stored module enablement configuration and then the stored module
|
||||
// configuration, both before importing any module entrypoint (ADR 0028, ADR 0029). Against an
|
||||
// unmigrated database neither read can succeed, and the platform fails closed rather than
|
||||
// starting every discovered module: it cannot know what an administrator decided, and starting
|
||||
// a module they disabled — or one whose required configuration is absent — is worse than not
|
||||
// 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;
|
||||
try {
|
||||
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?.details).toEqual(["calendar 0.1.0"]);
|
||||
expect(permissionsCheck).toMatchObject({
|
||||
// Core's own four (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`) plus the fixture
|
||||
// module's one.
|
||||
details: ["5 permissions registered"],
|
||||
// Core's own five (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
||||
// `core.modules.configuration.manage`) plus the fixture module's one.
|
||||
details: ["6 permissions registered"],
|
||||
status: "pass",
|
||||
});
|
||||
// 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 { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.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 untrustedOrigin = "https://evil.example.test";
|
||||
@@ -90,6 +91,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -110,6 +112,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -131,6 +134,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -154,6 +158,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -179,6 +184,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -204,6 +210,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -226,6 +233,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -250,6 +258,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -271,6 +280,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -294,6 +304,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -324,6 +335,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -355,6 +367,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -391,6 +404,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -409,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -429,6 +444,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -448,6 +464,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -465,6 +482,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -489,6 +507,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -516,6 +535,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
@@ -546,6 +566,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
@@ -567,6 +588,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.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" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -145,6 +146,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -215,7 +215,7 @@ function fakeDiscoveredModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${id} module`,
|
||||
permissions,
|
||||
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:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -249,6 +250,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
@@ -517,7 +519,13 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
const readHandlerEvent = [...events]
|
||||
.reverse()
|
||||
.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?.database).toBe(startEvent?.services?.database);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ModuleRouteConfigurationError,
|
||||
} from "../src/module-http-routes.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 untrustedOrigin = "https://evil.example.test";
|
||||
@@ -111,6 +112,7 @@ function buildModuleApp(options: {
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
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(capturedRequest).toBeInstanceOf(Request);
|
||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
||||
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
|
||||
// or configuration reaches a module handler.
|
||||
// Exactly config, moduleId, params, principal, services — and nothing else. `config` is this
|
||||
// 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([
|
||||
"config",
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
@@ -356,6 +362,10 @@ describe("module route handler adaptation", () => {
|
||||
expect(Object.isFrozen(capturedContext)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.params)).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 () => {
|
||||
|
||||
@@ -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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 untrustedOrigin = "https://evil.example.test";
|
||||
@@ -74,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -168,6 +169,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: {
|
||||
discoveredModules: modules,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} 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:
|
||||
@@ -102,7 +103,7 @@ async function createFixtureModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name,
|
||||
permissions: [
|
||||
{
|
||||
@@ -221,6 +222,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
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" } : {}),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||
...permission,
|
||||
@@ -177,6 +178,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
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" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
version,
|
||||
}),
|
||||
@@ -177,6 +178,7 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
Binary file not shown.
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
"./src/authorization/schema.ts",
|
||||
"./src/dashboard/schema.ts",
|
||||
"./src/modules/lifecycle-schema.ts",
|
||||
"./src/modules/configuration-schema.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "ekklesia_core_module_configuration" (
|
||||
"module_id" text NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_module_configuration_module_id_key_pk" PRIMARY KEY("module_id","key"),
|
||||
CONSTRAINT "ekklesia_core_module_configuration_module_check" CHECK (length("ekklesia_core_module_configuration"."module_id") > 0),
|
||||
CONSTRAINT "ekklesia_core_module_configuration_key_check" CHECK (length("ekklesia_core_module_configuration"."key") > 0),
|
||||
CONSTRAINT "ekklesia_core_module_configuration_value_check" CHECK (length("ekklesia_core_module_configuration"."value") <= 2048)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_module_configuration_module_idx" ON "ekklesia_core_module_configuration" USING btree ("module_id");
|
||||
@@ -0,0 +1,993 @@
|
||||
{
|
||||
"id": "d41e490c-dbe1-4d73-9e11-6e79baa91908",
|
||||
"prevId": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ekklesia_core_account": {
|
||||
"name": "ekklesia_core_account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_account_user_id_idx": {
|
||||
"name": "ekklesia_core_account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_account",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_session": {
|
||||
"name": "ekklesia_core_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_session_user_id_idx": {
|
||||
"name": "ekklesia_core_session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_session",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_session_token_unique": {
|
||||
"name": "ekklesia_core_session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_user": {
|
||||
"name": "ekklesia_core_user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_user_email_unique": {
|
||||
"name": "ekklesia_core_user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_verification": {
|
||||
"name": "ekklesia_core_verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_verification_identifier_idx": {
|
||||
"name": "ekklesia_core_verification_identifier_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_authorization_audit": {
|
||||
"name": "ekklesia_core_authorization_audit",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"subject_user_id": {
|
||||
"name": "subject_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_authorization_audit_subject_idx": {
|
||||
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "subject_user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_authorization_audit_action_check": {
|
||||
"name": "ekklesia_core_authorization_audit_action_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_scope_check": {
|
||||
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_grant": {
|
||||
"name": "ekklesia_core_grant",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_kind": {
|
||||
"name": "source_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_ref": {
|
||||
"name": "source_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"created_by_actor_type": {
|
||||
"name": "created_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by_actor_id": {
|
||||
"name": "created_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_grant_user_permission_idx": {
|
||||
"name": "ekklesia_core_grant_user_permission_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "permission_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_grant",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_grant_source_kind_check": {
|
||||
"name": "ekklesia_core_grant_source_kind_check",
|
||||
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||
},
|
||||
"ekklesia_core_grant_created_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner": {
|
||||
"name": "ekklesia_core_platform_owner",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_at": {
|
||||
"name": "granted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"granted_by_actor_type": {
|
||||
"name": "granted_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_by_actor_id": {
|
||||
"name": "granted_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_platform_owner",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"consumed_for_user_id": {
|
||||
"name": "consumed_for_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_group": {
|
||||
"name": "ekklesia_core_dashboard_group",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_group_order_idx": {
|
||||
"name": "ekklesia_core_dashboard_group_order_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_group_id_check": {
|
||||
"name": "ekklesia_core_dashboard_group_id_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
|
||||
},
|
||||
"ekklesia_core_dashboard_group_label_check": {
|
||||
"name": "ekklesia_core_dashboard_group_label_check",
|
||||
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_placement": {
|
||||
"name": "ekklesia_core_dashboard_placement",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"group_id": {
|
||||
"name": "group_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_placement_group_idx": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "group_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
|
||||
"tableFrom": "ekklesia_core_dashboard_placement",
|
||||
"tableTo": "ekklesia_core_dashboard_group",
|
||||
"columnsFrom": ["group_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_placement_module_check": {
|
||||
"name": "ekklesia_core_dashboard_placement_module_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_module_state": {
|
||||
"name": "ekklesia_core_module_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_module_state_module_check": {
|
||||
"name": "ekklesia_core_module_state_module_check",
|
||||
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_module_configuration": {
|
||||
"name": "ekklesia_core_module_configuration",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_module_configuration_module_idx": {
|
||||
"name": "ekklesia_core_module_configuration_module_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "module_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"ekklesia_core_module_configuration_module_id_key_pk": {
|
||||
"name": "ekklesia_core_module_configuration_module_id_key_pk",
|
||||
"columns": ["module_id", "key"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_module_configuration_module_check": {
|
||||
"name": "ekklesia_core_module_configuration_module_check",
|
||||
"value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0"
|
||||
},
|
||||
"ekklesia_core_module_configuration_key_check": {
|
||||
"name": "ekklesia_core_module_configuration_key_check",
|
||||
"value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0"
|
||||
},
|
||||
"ekklesia_core_module_configuration_value_check": {
|
||||
"name": "ekklesia_core_module_configuration_value_check",
|
||||
"value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0005_module_configuration",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1787423121678,
|
||||
"tag": "0004_module_lifecycle_state",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1787451496425,
|
||||
"tag": "0005_module_configuration",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -129,6 +129,29 @@ export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.modules.lifecycle.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* Restricted, and deliberately **separate from `core.modules.lifecycle.manage`**. The two authorize
|
||||
* genuinely different actions: lifecycle decides *whether a module runs at all*, while this decides
|
||||
* *how a running module behaves*. Someone trusted to tune a slideshow's timing need not be trusted
|
||||
* to take modules offline, and the reverse is equally true, so folding them into one permission
|
||||
* would grant authority nobody asked for in whichever direction it was used (ADR 0029).
|
||||
*
|
||||
* It gates reading as well as writing, because the read returns the module's declared schema
|
||||
* together with every stored value — endpoints, bucket names, and whatever else an operator has
|
||||
* configured. That is deployment detail, not something every signed-in user needs.
|
||||
*
|
||||
* **It never exposes secret material.** A `secret` key's value is deployment-supplied, is never
|
||||
* persisted, and is never returned by any route; the API reports only whether one is configured.
|
||||
* Holding this permission therefore confers no ability to read a credential — there is no code path
|
||||
* that would serve one.
|
||||
*
|
||||
* Like every other Core permission it is a **platform** permission a module can neither declare,
|
||||
* gate, nor observe, and holding it confers no authority inside any module.
|
||||
*/
|
||||
export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.modules.configuration.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||
@@ -157,6 +180,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
||||
description: "Read and change which installed modules are enabled.",
|
||||
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
}),
|
||||
Object.freeze({
|
||||
baseline: "restricted" as const,
|
||||
description: "Read and change an installed module's own configuration values.",
|
||||
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,11 +127,15 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`,
|
||||
);
|
||||
const blocked = resolution.entries.filter((entry) => entry.blocked !== undefined);
|
||||
for (const entry of blocked) {
|
||||
console.log(
|
||||
`- "${entry.moduleId}" will not start: ${entry.blocked?.reason} (${entry.blocked?.dependency.moduleId}).`,
|
||||
);
|
||||
for (const entry of resolution.entries) {
|
||||
const blocked = entry.blocked;
|
||||
if (!blocked) {
|
||||
continue;
|
||||
}
|
||||
// Names the dependency or the configuration key, never a configuration value.
|
||||
const cause =
|
||||
"dependency" in blocked ? blocked.dependency.moduleId : blocked.configurationKey;
|
||||
console.log(`- "${entry.moduleId}" will not start: ${blocked.reason} (${cause}).`);
|
||||
}
|
||||
} finally {
|
||||
await close().catch(() => undefined);
|
||||
|
||||
@@ -16,6 +16,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
|
||||
"modules.dependencies",
|
||||
"modules.permissions",
|
||||
"modules.lifecycle",
|
||||
"modules.configuration",
|
||||
]),
|
||||
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
isVersionCompatible,
|
||||
moduleConfigurationKeySchema,
|
||||
moduleIdSchema,
|
||||
type ModuleSecretSource,
|
||||
permissionIdSchema,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -41,6 +44,8 @@ import {
|
||||
} from "../migrations/index.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleConfigurationDatabase,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleDatabase,
|
||||
createModuleLifecycleStore,
|
||||
type DiscoveredModule,
|
||||
@@ -49,6 +54,7 @@ import {
|
||||
type ModuleEligibility,
|
||||
ModulePermissionCompositionError,
|
||||
type ModuleDiscoveryOptions,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleLifecycleConfiguration,
|
||||
type ModuleMigrationAggregate,
|
||||
type ModuleSchemaAggregate,
|
||||
@@ -62,6 +68,7 @@ export type DoctorCheckId =
|
||||
| "modules.dependencies"
|
||||
| "modules.permissions"
|
||||
| "modules.lifecycle"
|
||||
| "modules.configuration"
|
||||
| "schemas.discovery"
|
||||
| "migrations.discovery"
|
||||
| "postgresql.connection"
|
||||
@@ -102,6 +109,10 @@ export interface DoctorOptions {
|
||||
readonly databaseUrl?: string | undefined;
|
||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
|
||||
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required
|
||||
* secret as missing, which is the honest answer for a deployment that has not set it. */
|
||||
readonly moduleSecrets?: ModuleSecretSource;
|
||||
readonly runtimeRange: string;
|
||||
readonly runtimeVersion: string;
|
||||
}
|
||||
@@ -128,6 +139,12 @@ export interface DoctorDependencies {
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
) => Promise<ModuleLifecycleConfiguration>;
|
||||
/** Reads the stored module configuration read-only, behind the same dependency boundary as every
|
||||
* other I/O Doctor performs. */
|
||||
readonly readModuleConfiguration: (
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
) => Promise<ModuleConfigurationSettings>;
|
||||
readonly planMigrations: (options: {
|
||||
readonly aggregate: ModuleMigrationAggregate;
|
||||
readonly database: MigrationDatabase;
|
||||
@@ -163,6 +180,20 @@ async function readModuleLifecycleForRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens its own short-lived connection, reads, and always closes it — the same discipline
|
||||
* `readModuleLifecycleForRuntime` follows. */
|
||||
async function readModuleConfigurationForRuntime(
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
): Promise<ModuleConfigurationSettings> {
|
||||
const { close, db } = createModuleConfigurationDatabase(connectionString, options);
|
||||
try {
|
||||
return await createModuleConfigurationStore(db).readSettings();
|
||||
} finally {
|
||||
await close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
composePermissions: composePlatformPermissionRegistry,
|
||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||
@@ -170,6 +201,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
readModuleConfiguration: readModuleConfigurationForRuntime,
|
||||
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||
runtimeSatisfies: isVersionCompatible,
|
||||
});
|
||||
@@ -301,6 +333,16 @@ function safeLifecycleDetail(entry: ModuleEligibility): string {
|
||||
if (!entry.blocked) {
|
||||
return `${identity} · enabled`;
|
||||
}
|
||||
if (!("dependency" in entry.blocked)) {
|
||||
// A configuration block names the offending **key** and never its value: a key is declared in a
|
||||
// public manifest, while a value may be a credential or arbitrary operator-entered text. The key
|
||||
// is re-validated against the same grammar the manifest accepted it under, for the same
|
||||
// defense-in-depth reason every other interpolation here is.
|
||||
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
|
||||
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||
}
|
||||
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
|
||||
}
|
||||
const dependency = entry.blocked.dependency;
|
||||
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
|
||||
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||
@@ -343,6 +385,79 @@ function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorChec
|
||||
return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether each installed module's configuration would actually let it run — and
|
||||
* deliberately reports **nothing about the values themselves**.
|
||||
*
|
||||
* Not one configuration value is printed, secret or otherwise. A value is operator-entered text that
|
||||
* may be an endpoint, a bucket name, or a credential that ended up in the wrong field; a *key* is
|
||||
* declared in a public manifest and is safe. So a healthy module renders as a count, and an unhealthy
|
||||
* one names the reason and the offending key. That is everything an operator needs to act, and
|
||||
* nothing that turns `pnpm doctor` output — which people paste into chat — into a disclosure channel.
|
||||
*/
|
||||
function safeConfigurationDetail(entry: ModuleEligibility, declaredCount: number): string {
|
||||
if (
|
||||
!moduleIdSchema.safeParse(entry.moduleId).success ||
|
||||
!semanticVersionSchema.safeParse(entry.version).success
|
||||
) {
|
||||
throw new TypeError("Unsafe module configuration diagnostic value.");
|
||||
}
|
||||
const identity = `${entry.moduleId} ${entry.version}`;
|
||||
if (declaredCount === 0) {
|
||||
return `${identity} · no configuration declared`;
|
||||
}
|
||||
const declared = `${declaredCount} ${plural(declaredCount, "key")} declared`;
|
||||
if (entry.blocked && !("dependency" in entry.blocked)) {
|
||||
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
|
||||
throw new TypeError("Unsafe module configuration diagnostic value.");
|
||||
}
|
||||
return `${identity} · ${declared} · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
|
||||
}
|
||||
if (!entry.configured.enabled) {
|
||||
// Not evaluated: a disabled module's configuration is not resolved at all, and claiming it is
|
||||
// valid would be asserting something nothing checked.
|
||||
return `${identity} · ${declared} · not evaluated (disabled)`;
|
||||
}
|
||||
return `${identity} · ${declared} · valid`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration counterpart to `modules.lifecycle`: which installed modules could not run
|
||||
* because of their own configuration, and which key is at fault.
|
||||
*
|
||||
* A module whose configuration is fine, or which declares none, is a `pass` line. A module blocked
|
||||
* by configuration is a `warning`, for the same reason a dependency-blocked module is: an enabled
|
||||
* module that cannot run is an anomaly an operator should see without reading logs.
|
||||
*/
|
||||
function moduleConfigurationCheck(
|
||||
entries: readonly ModuleEligibility[],
|
||||
declaredCounts: ReadonlyMap<string, number>,
|
||||
): DoctorCheck {
|
||||
const blockedByConfiguration = entries.filter(
|
||||
(entry) => entry.blocked !== undefined && !("dependency" in entry.blocked),
|
||||
).length;
|
||||
const declaringModules = entries.filter(
|
||||
(entry) => (declaredCounts.get(entry.moduleId) ?? 0) > 0,
|
||||
).length;
|
||||
const details = [
|
||||
`${declaringModules} ${plural(declaringModules, "module")} declaring configuration · ${blockedByConfiguration} blocked`,
|
||||
...entries.map((entry) =>
|
||||
safeConfigurationDetail(entry, declaredCounts.get(entry.moduleId) ?? 0),
|
||||
),
|
||||
];
|
||||
if (blockedByConfiguration > 0) {
|
||||
return freezeCheck(
|
||||
"modules.configuration",
|
||||
"warning",
|
||||
blockedByConfiguration === 1
|
||||
? "1 module cannot run with its current configuration"
|
||||
: `${blockedByConfiguration} modules cannot run with their current configuration`,
|
||||
details,
|
||||
);
|
||||
}
|
||||
return freezeCheck("modules.configuration", "pass", "Module configuration valid", details);
|
||||
}
|
||||
|
||||
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||
const details =
|
||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
||||
@@ -672,31 +787,61 @@ export async function diagnoseEkklesia(
|
||||
// operator asking "is this module running?" must still get an answer when an unrelated check
|
||||
// failed. It needs only a successful discovery and a configured database.
|
||||
let lifecycleCheck: DoctorCheck;
|
||||
let configurationCheck: DoctorCheck;
|
||||
const skippedConfiguration = (detail: string): DoctorCheck =>
|
||||
freezeCheck("modules.configuration", "skipped", "Module configuration skipped", [detail]);
|
||||
if (!discoveredModules) {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
configurationCheck = skippedConfiguration("Complete module discovery is required.");
|
||||
} else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||
"DATABASE_URL is not configured.",
|
||||
]);
|
||||
configurationCheck = skippedConfiguration("DATABASE_URL is not configured.");
|
||||
} else {
|
||||
try {
|
||||
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
||||
environment: options.databaseEnvironment ?? "production",
|
||||
});
|
||||
const configuration = await dependencies.readModuleLifecycle(options.databaseUrl, {
|
||||
const poolOptions = {
|
||||
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||
transport: lifecycleTransport,
|
||||
};
|
||||
// Both axes read and resolved in the one pass the platform itself uses, so Doctor can never
|
||||
// report a module as runnable that startup would block, or the reverse.
|
||||
// Sequential for the same reason `startApi` reads them sequentially: a deterministic failure
|
||||
// beats two tiny queries racing.
|
||||
const configuration = await dependencies.readModuleLifecycle(
|
||||
options.databaseUrl,
|
||||
poolOptions,
|
||||
);
|
||||
const settings = await dependencies.readModuleConfiguration(options.databaseUrl, poolOptions);
|
||||
const resolution = resolveModuleEligibility(discoveredModules, configuration, {
|
||||
secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE,
|
||||
settings,
|
||||
});
|
||||
lifecycleCheck = moduleLifecycleCheck(
|
||||
resolveModuleEligibility(discoveredModules, configuration).entries,
|
||||
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
|
||||
configurationCheck = moduleConfigurationCheck(
|
||||
resolution.entries,
|
||||
new Map(
|
||||
discoveredModules.map(
|
||||
(module) => [module.manifest.id, module.manifest.configuration.length] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
|
||||
"The stored module enablement configuration could not be read safely.",
|
||||
]);
|
||||
configurationCheck = freezeCheck(
|
||||
"modules.configuration",
|
||||
"failure",
|
||||
"Module configuration unavailable",
|
||||
["The stored module configuration could not be read safely."],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,6 +940,7 @@ export async function diagnoseEkklesia(
|
||||
dependencyCheck,
|
||||
permissionsCheck,
|
||||
lifecycleCheck,
|
||||
configurationCheck,
|
||||
schemaCheck,
|
||||
migrationCheck,
|
||||
transportCheck,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, index, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* Core-owned storage for the ordinary, non-secret configuration an operator has set for an
|
||||
* installed module.
|
||||
*
|
||||
* **Secret material is never stored here, and there is nowhere to put it.** Ekklesia has no
|
||||
* encryption at rest and no key management, so a `secret` key's value is supplied by the deployment
|
||||
* environment and resolved into a module's snapshot without ever touching PostgreSQL (ADR 0029).
|
||||
* The store refuses to write a key a module declared as `secret`; this table has no column, flag, or
|
||||
* convention that would make a plaintext credential look acceptable.
|
||||
*
|
||||
* **One row per (module, key), not one JSON document per module.** A `jsonb` blob would put every
|
||||
* invariant in application code, make a single key impossible to address, and invite exactly the
|
||||
* arbitrary-settings-bag this pass exists to avoid — the same reasoning ADR 0027 used to reject a
|
||||
* generic settings framework in favour of purpose-built tables.
|
||||
*
|
||||
* Platform-global, like the Dashboard and lifecycle tables, because Ekklesia has no organization or
|
||||
* tenant model to scope it to. When one exists, this table gains a scope column.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One stored configuration value.
|
||||
*
|
||||
* `key` is **data, never an SQL identifier**. It is a value in a parameterized column, so a module
|
||||
* declaring an outlandish key can produce a strange row and nothing else — no column, no table, and
|
||||
* no dynamic SQL anywhere in this domain.
|
||||
*
|
||||
* `value` is `text` holding the unparsed literal. Every declared type has one canonical text form,
|
||||
* and keeping storage untyped is what makes "a persisted value is never silently trusted" a
|
||||
* structural property: nothing can read this as a number until `resolveModuleConfigurationFor` has
|
||||
* validated it against the module's own current declaration.
|
||||
*
|
||||
* `module_id` carries **no foreign key**, for the same reason the lifecycle and Dashboard tables do
|
||||
* not: modules are packages discovered on disk, not rows. A value for a module that is not currently
|
||||
* deployed is therefore normal and is retained, so redeploying restores an operator's settings
|
||||
* rather than resetting them.
|
||||
*/
|
||||
export const ekklesiaCoreModuleConfiguration = pgTable(
|
||||
"ekklesia_core_module_configuration",
|
||||
{
|
||||
moduleId: text("module_id").notNull(),
|
||||
key: text("key").notNull(),
|
||||
value: text("value").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.moduleId, table.key] }),
|
||||
index("ekklesia_core_module_configuration_module_idx").on(table.moduleId),
|
||||
// Defense in depth beneath the application-layer identifier contracts, which never produce
|
||||
// either: an empty module id or key would be an unaddressable, permanently inert row.
|
||||
check("ekklesia_core_module_configuration_module_check", sql`length(${table.moduleId}) > 0`),
|
||||
check("ekklesia_core_module_configuration_key_check", sql`length(${table.key}) > 0`),
|
||||
// The same 2048-character ceiling the contract and the store enforce, restated where the data
|
||||
// actually lives, so no caller — including a future one — can store a value the resolver would
|
||||
// then reject as invalid on every boot.
|
||||
check("ekklesia_core_module_configuration_value_check", sql`length(${table.value}) <= 2048`),
|
||||
],
|
||||
);
|
||||
|
||||
/** Explicit map passed to Drizzle, mirroring `authorizationSchema`, `dashboardSchema`, and
|
||||
* `moduleLifecycleSchema`. */
|
||||
export const moduleConfigurationSchema = {
|
||||
moduleConfiguration: ekklesiaCoreModuleConfiguration,
|
||||
} as const;
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
|
||||
moduleConfigurationKeySchema,
|
||||
moduleIdSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "../database/index.js";
|
||||
import type { ModuleConfigurationRecord, ModuleConfigurationSettings } from "./configuration.js";
|
||||
import {
|
||||
ekklesiaCoreModuleConfiguration,
|
||||
moduleConfigurationSchema,
|
||||
} from "./configuration-schema.js";
|
||||
|
||||
export type ModuleConfigurationDrizzleDatabase = NodePgDatabase<typeof moduleConfigurationSchema>;
|
||||
|
||||
/** A typed Drizzle view over an externally owned pool, exactly like `createModuleLifecycleView` and
|
||||
* `createDashboardDrizzleView`: it never constructs, registers an error listener on, or closes a
|
||||
* pool. */
|
||||
export function createModuleConfigurationView(pool: Pool): ModuleConfigurationDrizzleDatabase {
|
||||
return drizzle(pool, { schema: moduleConfigurationSchema });
|
||||
}
|
||||
|
||||
export interface ModuleConfigurationDatabaseHandle {
|
||||
readonly close: () => Promise<void>;
|
||||
readonly db: ModuleConfigurationDrizzleDatabase;
|
||||
}
|
||||
|
||||
/** A self-contained handle for a caller that needs only this domain for the length of one command —
|
||||
* Doctor's read-only configuration check. Composes the shared validated pool factory rather than
|
||||
* repeating connection-string and timeout validation. `apps/api` shares one pool and uses
|
||||
* `createModuleConfigurationView` instead. */
|
||||
export function createModuleConfigurationDatabase(
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions = {},
|
||||
): ModuleConfigurationDatabaseHandle {
|
||||
const { close, pool } = createValidatedPostgresPool(connectionString, options);
|
||||
return Object.freeze({ close, db: createModuleConfigurationView(pool) });
|
||||
}
|
||||
|
||||
export type ModuleConfigurationStoreErrorCode =
|
||||
| "MODULE_CONFIGURATION_KEY_INVALID"
|
||||
| "MODULE_CONFIGURATION_MODULE_ID_INVALID"
|
||||
| "MODULE_CONFIGURATION_READ_FAILED"
|
||||
| "MODULE_CONFIGURATION_VALUE_INVALID"
|
||||
| "MODULE_CONFIGURATION_WRITE_FAILED";
|
||||
|
||||
const storeErrorMessages: Readonly<Record<ModuleConfigurationStoreErrorCode, string>> =
|
||||
Object.freeze({
|
||||
MODULE_CONFIGURATION_KEY_INVALID: "A module configuration key is invalid.",
|
||||
MODULE_CONFIGURATION_MODULE_ID_INVALID: "A module identifier is invalid.",
|
||||
MODULE_CONFIGURATION_READ_FAILED: "The module configuration could not be read.",
|
||||
MODULE_CONFIGURATION_VALUE_INVALID: "A module configuration value is invalid.",
|
||||
MODULE_CONFIGURATION_WRITE_FAILED: "The module configuration could not be saved.",
|
||||
});
|
||||
|
||||
/**
|
||||
* Never carries the underlying database error, SQL text, host, connection string, stack — **or the
|
||||
* offending value**.
|
||||
*
|
||||
* That last exclusion is specific to this domain and deliberate: a configuration value can be
|
||||
* operator-entered text of any kind, and an error message is one of the easiest ways for a value to
|
||||
* end up in a log. The key is safe to name (it is declared in a public manifest); the value never
|
||||
* is.
|
||||
*/
|
||||
export class ModuleConfigurationStoreError extends Error {
|
||||
readonly code: ModuleConfigurationStoreErrorCode;
|
||||
|
||||
constructor(code: ModuleConfigurationStoreErrorCode) {
|
||||
super(storeErrorMessages[code]);
|
||||
this.name = "ModuleConfigurationStoreError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReplaceModuleConfigurationInput {
|
||||
readonly moduleId: string;
|
||||
/** The complete set of ordinary values for this module. Replaces, rather than merges: a key
|
||||
* absent here is deleted, so an operator clearing a value does not have to discover a separate
|
||||
* delete verb, and the stored document always matches what was last submitted. */
|
||||
readonly values: readonly { readonly key: string; readonly value: string }[];
|
||||
}
|
||||
|
||||
export interface ModuleConfigurationStore {
|
||||
/** Every stored value, in deterministic (module, key) order. Small by nature — a handful of rows
|
||||
* per configured module — so it is read whole rather than paginated. */
|
||||
readSettings(): Promise<ModuleConfigurationSettings>;
|
||||
/**
|
||||
* Replaces one module's stored values atomically.
|
||||
*
|
||||
* Scoped to a single module id by signature: there is no call shape that writes across modules,
|
||||
* and no verb that takes a raw table, column, or predicate. Writing configuration never starts,
|
||||
* stops, or reconfigures anything in the running process — the change is applied by the next
|
||||
* restart.
|
||||
*/
|
||||
replace(input: ReplaceModuleConfigurationInput): Promise<readonly ModuleConfigurationRecord[]>;
|
||||
}
|
||||
|
||||
function assertValidModuleId(value: string): void {
|
||||
if (!moduleIdSchema.safeParse(value).success) {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_MODULE_ID_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidKey(value: string): void {
|
||||
if (!moduleConfigurationKeySchema.safeParse(value).success) {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidValue(value: string): void {
|
||||
if (typeof value !== "string" || value.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_VALUE_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function rethrowAsStoreError(error: unknown, code: ModuleConfigurationStoreErrorCode): never {
|
||||
if (error instanceof ModuleConfigurationStoreError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ModuleConfigurationStoreError(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's persistence for ordinary module configuration.
|
||||
*
|
||||
* Validation lives here rather than only at the HTTP boundary, so the invariants hold for every
|
||||
* caller: identifiers are checked against the platform's one module-id grammar and one
|
||||
* configuration-key grammar rather than second copies invented here, and values are bounded exactly
|
||||
* as the contract and the database bound them.
|
||||
*
|
||||
* Nothing in this file reads a manifest, resolves a declaration, applies a default, or decides
|
||||
* whether a value is *meaningful* — it stores text under a validated key.
|
||||
* `resolveModuleConfigurationFor` is the one place a value is interpreted, which is what keeps the
|
||||
* store from acquiring a second, drifting copy of the type rules. It equally never sees a secret:
|
||||
* the HTTP boundary rejects a `secret` key before a write reaches here, and secret material has no
|
||||
* storage path at all.
|
||||
*/
|
||||
export function createModuleConfigurationStore(
|
||||
db: ModuleConfigurationDrizzleDatabase,
|
||||
): ModuleConfigurationStore {
|
||||
return Object.freeze({
|
||||
async readSettings(): Promise<ModuleConfigurationSettings> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
key: ekklesiaCoreModuleConfiguration.key,
|
||||
moduleId: ekklesiaCoreModuleConfiguration.moduleId,
|
||||
value: ekklesiaCoreModuleConfiguration.value,
|
||||
})
|
||||
.from(ekklesiaCoreModuleConfiguration)
|
||||
.orderBy(
|
||||
asc(ekklesiaCoreModuleConfiguration.moduleId),
|
||||
asc(ekklesiaCoreModuleConfiguration.key),
|
||||
);
|
||||
return Object.freeze({
|
||||
records: Object.freeze(rows.map((row) => Object.freeze({ ...row }))),
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error, "MODULE_CONFIGURATION_READ_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
async replace(
|
||||
input: ReplaceModuleConfigurationInput,
|
||||
): Promise<readonly ModuleConfigurationRecord[]> {
|
||||
assertValidModuleId(input.moduleId);
|
||||
const seen = new Set<string>();
|
||||
for (const entry of input.values) {
|
||||
assertValidKey(entry.key);
|
||||
assertValidValue(entry.value);
|
||||
if (seen.has(entry.key)) {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID");
|
||||
}
|
||||
seen.add(entry.key);
|
||||
}
|
||||
try {
|
||||
// One transaction, so a replace is never observed half-applied: a concurrent boot either
|
||||
// sees the previous complete document or the new one, never a mixture that would validate
|
||||
// differently from both.
|
||||
return await db.transaction(async (tx) => {
|
||||
// Delete this module's rows, then insert the submitted ones. Expressed as "delete all,
|
||||
// insert submitted" rather than a key-wise diff: the whole document is being replaced, so
|
||||
// the outcome is identical and there is one predicate to audit instead of two. The
|
||||
// predicate is scoped to `input.moduleId`, so another module's rows are unreachable from
|
||||
// here by construction, not by care.
|
||||
await tx
|
||||
.delete(ekklesiaCoreModuleConfiguration)
|
||||
.where(eq(ekklesiaCoreModuleConfiguration.moduleId, input.moduleId));
|
||||
if (input.values.length === 0) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(ekklesiaCoreModuleConfiguration)
|
||||
.values(
|
||||
input.values.map((entry) => ({
|
||||
key: entry.key,
|
||||
moduleId: input.moduleId,
|
||||
value: entry.value,
|
||||
})),
|
||||
)
|
||||
.returning({
|
||||
key: ekklesiaCoreModuleConfiguration.key,
|
||||
moduleId: ekklesiaCoreModuleConfiguration.moduleId,
|
||||
value: ekklesiaCoreModuleConfiguration.value,
|
||||
});
|
||||
return Object.freeze(
|
||||
[...inserted]
|
||||
.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0))
|
||||
.map((row) => Object.freeze({ ...row })),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error, "MODULE_CONFIGURATION_WRITE_FAILED");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
isSupportedConfigurationUrl,
|
||||
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
|
||||
type ModuleConfiguration,
|
||||
type ModuleConfigurationBlockedReason,
|
||||
type ModuleConfigurationValue,
|
||||
type ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { ReadonlyModuleManifest } from "./discovery.js";
|
||||
|
||||
/**
|
||||
* One declaration exactly as discovery hands it over: deeply frozen, so an `enum`'s `values` arrive
|
||||
* as a `readonly string[]`. Derived from the manifest type rather than restated, so a change to the
|
||||
* declaration contract becomes a compile error here instead of a silently divergent second copy.
|
||||
*/
|
||||
type DiscoveredConfigurationDeclaration = ReadonlyModuleManifest["configuration"][number];
|
||||
|
||||
/**
|
||||
* One persisted configuration value, exactly as it is stored: an owning module, a key, and an
|
||||
* **unparsed text literal**.
|
||||
*
|
||||
* The value is text rather than a typed column or a JSON document on purpose. Every declared type
|
||||
* has one canonical text form, values are small and bounded, and — most importantly — keeping the
|
||||
* stored form untyped makes "a persisted value is never silently trusted" structural rather than
|
||||
* aspirational: nothing can read this as a number until it has been validated against the module's
|
||||
* own current declaration.
|
||||
*/
|
||||
export interface ModuleConfigurationRecord {
|
||||
readonly moduleId: string;
|
||||
readonly key: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
/** The complete stored configuration for every module anyone has ever configured. */
|
||||
export interface ModuleConfigurationSettings {
|
||||
readonly records: readonly ModuleConfigurationRecord[];
|
||||
}
|
||||
|
||||
/** Everything resolution needs that Core cannot derive from a manifest: what an operator stored,
|
||||
* and what the deployment supplies for secrets. */
|
||||
export interface ModuleConfigurationInput {
|
||||
readonly settings: ModuleConfigurationSettings;
|
||||
/** Defaults to supplying nothing, so a caller that provides no secrets says so by omission and a
|
||||
* module requiring one is reported `secret_missing` rather than silently starting without it. */
|
||||
readonly secrets?: ModuleSecretSource;
|
||||
}
|
||||
|
||||
/** Why one module's configuration could not be resolved, naming the offending key. Never the value:
|
||||
* a key is public (it is declared in an installed manifest), a value may be a credential. */
|
||||
export interface ModuleConfigurationIssue {
|
||||
readonly reason: ModuleConfigurationBlockedReason;
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
export interface ResolvedModuleConfiguration {
|
||||
/** The frozen snapshot the module receives, present only when every declared requirement was
|
||||
* satisfied. */
|
||||
readonly configuration?: ModuleConfiguration;
|
||||
/** The first unsatisfied requirement, in declaration order, or `undefined` when resolution
|
||||
* succeeded. Reporting one rather than all of them matches how a blocked dependency is reported:
|
||||
* an operator fixes the named problem and re-runs, and a list of cascading complaints from one
|
||||
* missing value would be noise. */
|
||||
readonly issue?: ModuleConfigurationIssue;
|
||||
/** Stored keys this module's *current* manifest no longer declares. Retained by the store and
|
||||
* ignored here — never an error, never passed to the module. Surfaced so an operator can see that
|
||||
* a downgrade or a renamed key left something behind. */
|
||||
readonly unknownKeys: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The module-facing snapshot, built over a `Map`.
|
||||
*
|
||||
* A `Map` rather than a plain object is deliberate and load-bearing: every lookup key here
|
||||
* originates in a module's manifest, and indexing a plain object with such a key is the exact shape
|
||||
* of a prototype-pollution bug. With a `Map` there is no prototype to reach, so `constructor` or
|
||||
* `__proto__` would be ordinary absent keys even if the contract's own reserved-name check were
|
||||
* ever removed.
|
||||
*/
|
||||
function createModuleConfiguration(
|
||||
values: ReadonlyMap<string, { readonly type: string; readonly value: ModuleConfigurationValue }>,
|
||||
): ModuleConfiguration {
|
||||
const sortedKeys = Object.freeze([...values.keys()].sort());
|
||||
const typed = (key: string, type: string): ModuleConfigurationValue | undefined => {
|
||||
const entry = values.get(key);
|
||||
// Fails closed identically for an undeclared key and a declared key of another type, in the
|
||||
// same style as `PermissionRegistry.get`: a caller can never distinguish "you did not declare
|
||||
// this" from "you declared it as something else", and neither one silently returns a value.
|
||||
return entry?.type === type ? entry.value : undefined;
|
||||
};
|
||||
return Object.freeze({
|
||||
get: (key: string): ModuleConfigurationValue | undefined => values.get(key)?.value,
|
||||
getBoolean: (key: string): boolean | undefined => {
|
||||
const value = typed(key, "boolean");
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
},
|
||||
getEnum: (key: string): string | undefined => {
|
||||
const value = typed(key, "enum");
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
getInteger: (key: string): number | undefined => {
|
||||
const value = typed(key, "integer");
|
||||
return typeof value === "number" ? value : undefined;
|
||||
},
|
||||
getSecret: (key: string): string | undefined => {
|
||||
const value = typed(key, "secret");
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
getString: (key: string): string | undefined => {
|
||||
const value = typed(key, "string");
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
getUrl: (key: string): string | undefined => {
|
||||
const value = typed(key, "url");
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
has: (key: string): boolean => values.has(key),
|
||||
keys: (): readonly string[] => sortedKeys,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one stored text literal against its declaration, or reports that it does not satisfy it.
|
||||
*
|
||||
* Never coerces, truncates, rounds, or falls back to the default: a stored value that does not
|
||||
* validate is an operator-visible error, not something to quietly repair. `"7 "`, `"07"`, `"yes"`,
|
||||
* and `"1"` are all rejected for the types where they are not the canonical form, so exactly one
|
||||
* spelling of any value is ever accepted and two deployments can never resolve the same stored row
|
||||
* differently.
|
||||
*/
|
||||
function parseStoredValue(
|
||||
declaration: DiscoveredConfigurationDeclaration,
|
||||
raw: string,
|
||||
): ModuleConfigurationValue | undefined {
|
||||
if (raw.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) {
|
||||
return undefined;
|
||||
}
|
||||
switch (declaration.type) {
|
||||
case "string": {
|
||||
if (declaration.minLength !== undefined && raw.length < declaration.minLength) {
|
||||
return undefined;
|
||||
}
|
||||
if (declaration.maxLength !== undefined && raw.length > declaration.maxLength) {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
case "integer": {
|
||||
if (!/^-?(?:0|[1-9]\d*)$/u.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
if (declaration.minimum !== undefined && parsed < declaration.minimum) {
|
||||
return undefined;
|
||||
}
|
||||
if (declaration.maximum !== undefined && parsed > declaration.maximum) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
case "boolean": {
|
||||
return raw === "true" ? true : raw === "false" ? false : undefined;
|
||||
}
|
||||
case "enum": {
|
||||
return declaration.values.includes(raw) ? raw : undefined;
|
||||
}
|
||||
case "url": {
|
||||
return isSupportedConfigurationUrl(raw) ? raw : undefined;
|
||||
}
|
||||
case "secret": {
|
||||
// Unreachable: a secret is never read from storage, and the store refuses to write one. The
|
||||
// branch exists so adding a type to the union is a compile error here rather than a silent
|
||||
// acceptance of persisted secret material.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one module's complete configuration from its own manifest declarations, the operator's
|
||||
* stored values, and the deployment's secrets.
|
||||
*
|
||||
* Pure and synchronous: no database, no filesystem, no `process.env`, no clock, and no module code.
|
||||
* It is handed everything it needs and returns either a frozen snapshot or the first unsatisfied
|
||||
* requirement, so every rule below is testable without PostgreSQL.
|
||||
*
|
||||
* ## Precedence, exactly
|
||||
*
|
||||
* 1. A stored value that validates against the current declaration wins.
|
||||
* 2. A stored value that does **not** validate is `configuration_invalid` — never replaced by the
|
||||
* default, because silently falling back would run the module on a value nobody chose while the
|
||||
* operator believes theirs took effect.
|
||||
* 3. Otherwise the declared `default` applies, if there is one.
|
||||
* 4. Otherwise a `required` key is `configuration_missing` (or `secret_missing`), and an optional
|
||||
* key is simply absent.
|
||||
*
|
||||
* Secrets never participate in steps 1–3: they are never stored and never defaulted, so a secret is
|
||||
* whatever the deployment supplies, or missing.
|
||||
*
|
||||
* ## Keys the manifest no longer declares
|
||||
*
|
||||
* Ignored, and retained by the store. A stored key the current manifest does not declare is exactly
|
||||
* the situation a module downgrade or a renamed key produces, and deleting the row would destroy an
|
||||
* operator's value on a temporary rollback — the same reasoning ADR 0027 applied to a Dashboard
|
||||
* placement for an uninstalled module.
|
||||
*/
|
||||
export function resolveModuleConfigurationFor(
|
||||
manifest: ReadonlyModuleManifest,
|
||||
input: ModuleConfigurationInput,
|
||||
): ResolvedModuleConfiguration {
|
||||
const secrets = input.secrets ?? EMPTY_MODULE_SECRET_SOURCE;
|
||||
const declarations = manifest.configuration;
|
||||
const declaredKeys = new Set(declarations.map((declaration) => declaration.key));
|
||||
const stored = new Map<string, string>();
|
||||
for (const record of input.settings.records) {
|
||||
if (record.moduleId === manifest.id) {
|
||||
stored.set(record.key, record.value);
|
||||
}
|
||||
}
|
||||
const unknownKeys = Object.freeze(
|
||||
[...stored.keys()].filter((key) => !declaredKeys.has(key)).sort(),
|
||||
);
|
||||
|
||||
const values = new Map<string, { type: string; value: ModuleConfigurationValue }>();
|
||||
for (const declaration of declarations) {
|
||||
if (declaration.type === "secret") {
|
||||
// Scoped by construction: the source is asked for this module's id and a key that module's
|
||||
// own manifest declares. There is no call shape that reaches another module's secret, and no
|
||||
// way to ask the host for an arbitrary environment variable.
|
||||
const supplied = secrets.get(manifest.id, declaration.key);
|
||||
if (typeof supplied === "string" && supplied.length > 0) {
|
||||
values.set(declaration.key, { type: "secret", value: supplied });
|
||||
continue;
|
||||
}
|
||||
if (declaration.required) {
|
||||
return Object.freeze({
|
||||
issue: Object.freeze({ key: declaration.key, reason: "secret_missing" as const }),
|
||||
unknownKeys,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = stored.get(declaration.key);
|
||||
if (raw !== undefined) {
|
||||
const parsed = parseStoredValue(declaration, raw);
|
||||
if (parsed === undefined) {
|
||||
return Object.freeze({
|
||||
issue: Object.freeze({ key: declaration.key, reason: "configuration_invalid" as const }),
|
||||
unknownKeys,
|
||||
});
|
||||
}
|
||||
values.set(declaration.key, { type: declaration.type, value: parsed });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (declaration.default !== undefined) {
|
||||
values.set(declaration.key, { type: declaration.type, value: declaration.default });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (declaration.required) {
|
||||
return Object.freeze({
|
||||
issue: Object.freeze({ key: declaration.key, reason: "configuration_missing" as const }),
|
||||
unknownKeys,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ configuration: createModuleConfiguration(values), unknownKeys });
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment variable one module's declared secret is read from, in an Ekklesia deployment.
|
||||
*
|
||||
* `EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`, with the module id's kebab-case and the key's
|
||||
* camelCase each folded to `UPPER_SNAKE`. A double underscore separates the three parts so a single
|
||||
* underscore inside a converted name can never make two different (module, key) pairs collide.
|
||||
*
|
||||
* **A module never sees this name.** It calls `config.getSecret("storageCredential")`; the mapping
|
||||
* is entirely the host's, which is exactly what lets a standalone host read the same declared secret
|
||||
* from a YAML file or a secret manager without the module's feature code changing (ADR 0029).
|
||||
*/
|
||||
export function moduleSecretEnvironmentVariableName(moduleId: string, key: string): string {
|
||||
const upperSnake = (value: string): string =>
|
||||
value
|
||||
.replace(/-/gu, "_")
|
||||
.replace(/([a-z0-9])([A-Z])/gu, "$1_$2")
|
||||
.toUpperCase();
|
||||
return `EKKLESIA_MODULE_SECRET__${upperSnake(moduleId)}__${upperSnake(key)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A secret source backed by a deployment's environment.
|
||||
*
|
||||
* Takes the environment **as an argument** rather than reading `process.env` itself, preserving the
|
||||
* repository rule that reusable Core code never touches the process environment — only executables
|
||||
* do (`core/src/cli/database-transport-environment.ts` states the same rule for database
|
||||
* transport). It is also what makes this trivially testable without mutating global state.
|
||||
*
|
||||
* There is deliberately no method that takes an arbitrary variable name. The only reachable values
|
||||
* are those a module declared as `secret` in its own manifest, looked up under a name this function
|
||||
* derives — a module cannot read `DATABASE_URL`, another module's secret, or anything else about
|
||||
* the host.
|
||||
*/
|
||||
export function createEnvironmentModuleSecretSource(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): ModuleSecretSource {
|
||||
return Object.freeze({
|
||||
get: (moduleId: string, key: string): string | undefined =>
|
||||
environment[moduleSecretEnvironmentVariableName(moduleId, key)],
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
export * from "./configuration-schema.js";
|
||||
export * from "./configuration-store.js";
|
||||
export * from "./configuration.js";
|
||||
export * from "./database.js";
|
||||
export * from "./discovery.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./lifecycle-schema.js";
|
||||
export * from "./lifecycle-store.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
+100
-12
@@ -1,9 +1,12 @@
|
||||
import type {
|
||||
ModuleBlockedReason,
|
||||
ModuleBlockingDependency,
|
||||
ModuleConfiguration,
|
||||
ModuleEnablementRecord,
|
||||
ModuleRuntimeState,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { ModuleConfigurationInput } from "./configuration.js";
|
||||
import { resolveModuleConfigurationFor } from "./configuration.js";
|
||||
import type { DiscoveredModule } from "./discovery.js";
|
||||
|
||||
/**
|
||||
@@ -35,14 +38,32 @@ export interface ModuleEligibility {
|
||||
readonly version: string;
|
||||
readonly configured: ModuleConfiguredState;
|
||||
readonly eligible: boolean;
|
||||
/** Present exactly when the module is configured enabled but not eligible. A disabled module is
|
||||
* not "blocked" — it is doing what it was told. */
|
||||
readonly blocked?: {
|
||||
readonly reason: ModuleBlockedReason;
|
||||
readonly dependency: ModuleBlockingDependency;
|
||||
};
|
||||
/**
|
||||
* Present exactly when the module is configured enabled but not eligible. A disabled module is
|
||||
* not "blocked" — it is doing what it was told.
|
||||
*
|
||||
* A discriminated pair rather than one shape with optional halves: a dependency reason always
|
||||
* names a dependency and a configuration reason always names a key, so neither can be
|
||||
* accidentally omitted and no consumer has to handle a `blocked` record that explains nothing.
|
||||
*/
|
||||
readonly blocked?: ModuleBlockedDetail;
|
||||
}
|
||||
|
||||
/** A block caused by the dependency graph: some module this one *requires* cannot run. */
|
||||
export interface ModuleDependencyBlockedDetail {
|
||||
readonly reason: "required_dependency_disabled" | "required_dependency_blocked";
|
||||
readonly dependency: ModuleBlockingDependency;
|
||||
}
|
||||
|
||||
/** A block caused by this module's own configuration (ADR 0029), naming the offending key — never
|
||||
* the value, which for a secret is a credential and for anything else is operator-entered text. */
|
||||
export interface ModuleConfigurationBlockedDetail {
|
||||
readonly reason: "configuration_missing" | "configuration_invalid" | "secret_missing";
|
||||
readonly configurationKey: string;
|
||||
}
|
||||
|
||||
export type ModuleBlockedDetail = ModuleDependencyBlockedDetail | ModuleConfigurationBlockedDetail;
|
||||
|
||||
export interface ModuleEligibilityResolution {
|
||||
/**
|
||||
* The modules the platform may start, **in the exact discovery order they were given**. Filtered,
|
||||
@@ -54,6 +75,15 @@ export interface ModuleEligibilityResolution {
|
||||
/** Every discovered module, in discovery order, eligible or not. This is the explanation surface;
|
||||
* `eligibleModules` is the executable one. */
|
||||
readonly entries: readonly ModuleEligibility[];
|
||||
/**
|
||||
* The frozen configuration snapshot for each **eligible** module, keyed by module id (ADR 0029).
|
||||
*
|
||||
* Produced by the same pass that decided eligibility, so a module's configuration is validated
|
||||
* exactly once and the value that made it eligible is the identical value it later receives —
|
||||
* never revalidated, and never re-read from the database, between those two moments. An
|
||||
* ineligible module has no entry: there is nothing to hand a module that is not going to start.
|
||||
*/
|
||||
readonly configurations: ReadonlyMap<string, ModuleConfiguration>;
|
||||
}
|
||||
|
||||
interface MutableEligibility {
|
||||
@@ -61,10 +91,7 @@ interface MutableEligibility {
|
||||
readonly version: string;
|
||||
readonly configured: ModuleConfiguredState;
|
||||
eligible: boolean;
|
||||
blocked?: {
|
||||
readonly reason: ModuleBlockedReason;
|
||||
readonly dependency: ModuleBlockingDependency;
|
||||
};
|
||||
blocked?: ModuleBlockedDetail;
|
||||
}
|
||||
|
||||
function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
||||
@@ -75,9 +102,15 @@ function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
||||
version: entry.version,
|
||||
...(entry.blocked
|
||||
? {
|
||||
blocked: Object.freeze({
|
||||
blocked:
|
||||
"dependency" in entry.blocked
|
||||
? Object.freeze({
|
||||
dependency: Object.freeze({ ...entry.blocked.dependency }),
|
||||
reason: entry.blocked.reason,
|
||||
})
|
||||
: Object.freeze({
|
||||
configurationKey: entry.blocked.configurationKey,
|
||||
reason: entry.blocked.reason,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
@@ -128,6 +161,7 @@ function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
||||
export function resolveModuleEligibility(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
configuration: ModuleLifecycleConfiguration,
|
||||
moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } },
|
||||
): ModuleEligibilityResolution {
|
||||
const configuredByModuleId = new Map(
|
||||
configuration.modules.map((record) => [record.moduleId, record.enabled] as const),
|
||||
@@ -146,6 +180,31 @@ export function resolveModuleEligibility(
|
||||
});
|
||||
}
|
||||
|
||||
// Validate each enabled module's own configuration before the dependency pass, so a module
|
||||
// blocked by its own configuration propagates to its required dependents through exactly the
|
||||
// existing mechanism rather than a second one. Runs only for modules that are still eligible: a
|
||||
// disabled module's configuration is not evaluated at all, because it is not going to start and
|
||||
// reporting a configuration problem for something nobody asked to run would be noise.
|
||||
const configurations = new Map<string, ModuleConfiguration>();
|
||||
for (const discovered of discoveredModules) {
|
||||
const entry = entries.get(discovered.manifest.id);
|
||||
if (!entry?.eligible) {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveModuleConfigurationFor(discovered.manifest, moduleConfiguration);
|
||||
if (resolved.issue) {
|
||||
entry.eligible = false;
|
||||
entry.blocked = {
|
||||
configurationKey: resolved.issue.key,
|
||||
reason: resolved.issue.reason,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (resolved.configuration) {
|
||||
configurations.set(discovered.manifest.id, resolved.configuration);
|
||||
}
|
||||
}
|
||||
|
||||
// The provider of each capability, taken from the same manifests discovery already validated.
|
||||
// A capability has exactly one provider in a valid inventory (discovery rejects a duplicate
|
||||
// module id, and a manifest may only declare capabilities inside its own namespace).
|
||||
@@ -214,7 +273,17 @@ export function resolveModuleEligibility(
|
||||
resolvedEntries.filter((entry) => entry.eligible).map((entry) => entry.moduleId),
|
||||
);
|
||||
|
||||
// A module whose own configuration resolved may still have lost eligibility in the dependency
|
||||
// pass above. Dropping its snapshot here keeps one invariant true: this map contains exactly the
|
||||
// modules that are going to start.
|
||||
for (const moduleId of [...configurations.keys()]) {
|
||||
if (!eligibleIds.has(moduleId)) {
|
||||
configurations.delete(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
configurations: configurations as ReadonlyMap<string, ModuleConfiguration>,
|
||||
eligibleModules: Object.freeze(
|
||||
discoveredModules.filter((discovered) => eligibleIds.has(discovered.manifest.id)),
|
||||
),
|
||||
@@ -233,7 +302,10 @@ export interface ModuleLifecycleView {
|
||||
readonly runtime: {
|
||||
readonly state: ModuleRuntimeState;
|
||||
readonly reason?: ModuleBlockedReason;
|
||||
/** Present for a dependency reason. */
|
||||
readonly dependency?: ModuleBlockingDependency;
|
||||
/** Present for a configuration reason (ADR 0029) — the offending key, never its value. */
|
||||
readonly configurationKey?: string;
|
||||
};
|
||||
/**
|
||||
* `true` when applying the *current* configuration would produce a different running set than
|
||||
@@ -274,12 +346,22 @@ export interface ModuleLifecycleSnapshot {
|
||||
export function resolveModuleLifecycleView(
|
||||
snapshot: ModuleLifecycleSnapshot,
|
||||
configuration: ModuleLifecycleConfiguration,
|
||||
moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } },
|
||||
): readonly ModuleLifecycleView[] {
|
||||
const started = new Set(snapshot.startedModuleIds);
|
||||
const bootEligibility = new Map(
|
||||
snapshot.eligibility.map((entry) => [entry.moduleId, entry] as const),
|
||||
);
|
||||
const pending = resolveModuleEligibility(snapshot.discoveredModules, configuration);
|
||||
// Re-resolved against *both* live axes: enablement and module configuration. Since ADR 0029 a
|
||||
// module's own configuration can decide eligibility, so correcting a missing required value must
|
||||
// show up as a pending restart exactly like re-enabling a module does. Passing only the
|
||||
// enablement half here would have quietly reported `restartRequired: false` for a module the next
|
||||
// boot really will start.
|
||||
const pending = resolveModuleEligibility(
|
||||
snapshot.discoveredModules,
|
||||
configuration,
|
||||
moduleConfiguration,
|
||||
);
|
||||
const wouldRun = new Set(pending.eligibleModules.map((module) => module.manifest.id));
|
||||
|
||||
return Object.freeze(
|
||||
@@ -291,11 +373,17 @@ export function resolveModuleLifecycleView(
|
||||
: boot === undefined || !boot.configured.enabled
|
||||
? { state: "disabled" as const }
|
||||
: boot.blocked
|
||||
? "dependency" in boot.blocked
|
||||
? {
|
||||
dependency: boot.blocked.dependency,
|
||||
reason: boot.blocked.reason,
|
||||
state: "blocked" as const,
|
||||
}
|
||||
: {
|
||||
configurationKey: boot.blocked.configurationKey,
|
||||
reason: boot.blocked.reason,
|
||||
state: "blocked" as const,
|
||||
}
|
||||
: // Enabled and unblocked at boot, yet not started: unreachable while startup stays
|
||||
// all-or-nothing, and reported as blocked-by-nothing rather than as `running`, so a
|
||||
// future change to startup atomicity can never make this silently claim success.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
type CapabilityId,
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
type ModuleConfiguration,
|
||||
type CapabilityRegistrar,
|
||||
type CapabilityResolver,
|
||||
capabilityIdSchema,
|
||||
@@ -76,6 +78,24 @@ export interface StartedModuleRuntime {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
|
||||
*
|
||||
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
|
||||
* shared object identity every module receives** — a property this runtime has a test for — while
|
||||
* configuration is per-module by definition. A module with no entry receives
|
||||
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
|
||||
* module code never branches on whether the platform supplied one.
|
||||
*/
|
||||
export type ModuleConfigurationLookup = ReadonlyMap<string, ModuleConfiguration>;
|
||||
|
||||
function configurationFor(
|
||||
configurations: ModuleConfigurationLookup,
|
||||
moduleId: string,
|
||||
): ModuleConfiguration {
|
||||
return configurations.get(moduleId) ?? EMPTY_MODULE_CONFIGURATION;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeTimeoutOptions {
|
||||
/** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS`, applied to both normal stop and startup
|
||||
* rollback, so a deadline can be proven in milliseconds instead of ten seconds. Production
|
||||
@@ -83,9 +103,23 @@ export interface ModuleRuntimeTimeoutOptions {
|
||||
readonly hookTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOptions {
|
||||
/**
|
||||
* The per-module configuration snapshots this runtime hands to each module's `register`, `start`,
|
||||
* `stop`, and HTTP handler contexts (ADR 0029). Production composition supplies exactly the map
|
||||
* `resolveModuleEligibility` produced while deciding which modules may run, so the configuration
|
||||
* that made a module eligible is the identical object it receives — never revalidated, and never
|
||||
* re-read, between those two moments.
|
||||
*
|
||||
* Omitted by a caller exercising no configuration; every module then receives
|
||||
* `EMPTY_MODULE_CONFIGURATION`.
|
||||
*/
|
||||
readonly configurations?: ModuleConfigurationLookup;
|
||||
}
|
||||
|
||||
export interface StartModuleRuntimeOptions
|
||||
extends ModuleDiscoveryOptions,
|
||||
ModuleRuntimeTimeoutOptions {
|
||||
ModuleRuntimeCompositionOptions {
|
||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
||||
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
||||
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
||||
@@ -222,6 +256,7 @@ class StagedCapabilityRegistry {
|
||||
module: DiscoveredModule,
|
||||
phase: "rollback" | "start" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
config: ModuleConfiguration,
|
||||
): ModuleRuntimeContext {
|
||||
const capabilities: CapabilityResolver = Object.freeze({
|
||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||
@@ -229,7 +264,7 @@ class StagedCapabilityRegistry {
|
||||
return this.#registered.get(capabilityId);
|
||||
},
|
||||
});
|
||||
return Object.freeze({ capabilities, moduleId: module.manifest.id, services });
|
||||
return Object.freeze({ capabilities, config, moduleId: module.manifest.id, services });
|
||||
}
|
||||
|
||||
publish(): CapabilityRegistry {
|
||||
@@ -464,12 +499,14 @@ async function registerModules(
|
||||
modules: readonly LoadedModule[],
|
||||
capabilityRegistry: StagedCapabilityRegistry,
|
||||
httpRoutes: StagedModuleHttpRoutes,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
): Promise<void> {
|
||||
for (const module of modules) {
|
||||
const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
|
||||
const routeStage = httpRoutes.createRegistrationStage(module.discovered);
|
||||
const context: ModuleRegistrationContext = Object.freeze({
|
||||
capabilities: capabilityStage.capabilities,
|
||||
config: configurationFor(configurations, module.discovered.manifest.id),
|
||||
http: routeStage.http,
|
||||
moduleId: module.discovered.manifest.id,
|
||||
});
|
||||
@@ -561,6 +598,7 @@ async function stopModules(
|
||||
registry: StagedCapabilityRegistry,
|
||||
phase: "rollback" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
options: StopModulesOptions = {},
|
||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
|
||||
@@ -574,7 +612,16 @@ async function stopModules(
|
||||
}
|
||||
try {
|
||||
const outcome = await awaitHookWithinDeadline(
|
||||
Promise.resolve(stop(registry.createRuntimeContext(module.discovered, phase, services))),
|
||||
Promise.resolve(
|
||||
stop(
|
||||
registry.createRuntimeContext(
|
||||
module.discovered,
|
||||
phase,
|
||||
services,
|
||||
configurationFor(configurations, moduleId),
|
||||
),
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
if (outcome === "timed-out") {
|
||||
@@ -609,6 +656,7 @@ async function startModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
services: ModuleRuntimeServices,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
options: StopModulesOptions = {},
|
||||
): Promise<void> {
|
||||
// A module joins the rollback set the moment its `start` hook is *invoked*, not when it
|
||||
@@ -622,7 +670,12 @@ async function startModules(
|
||||
rollbackCandidates.push(module);
|
||||
try {
|
||||
await module.definition.start?.(
|
||||
registry.createRuntimeContext(module.discovered, "start", services),
|
||||
registry.createRuntimeContext(
|
||||
module.discovered,
|
||||
"start",
|
||||
services,
|
||||
configurationFor(configurations, module.discovered.manifest.id),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const startIssue =
|
||||
@@ -639,6 +692,7 @@ async function startModules(
|
||||
registry,
|
||||
"rollback",
|
||||
services,
|
||||
configurations,
|
||||
options,
|
||||
);
|
||||
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||
@@ -657,13 +711,14 @@ async function startModules(
|
||||
export async function startDiscoveredModuleRuntime(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
services: ModuleRuntimeServices,
|
||||
timeouts: ModuleRuntimeTimeoutOptions = {},
|
||||
timeouts: ModuleRuntimeCompositionOptions = {},
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||
const httpRoutes = new StagedModuleHttpRoutes();
|
||||
await registerModules(modules, capabilityRegistry, httpRoutes);
|
||||
await startModules(modules, capabilityRegistry, services, timeouts);
|
||||
const configurations = timeouts.configurations ?? new Map<string, ModuleConfiguration>();
|
||||
await registerModules(modules, capabilityRegistry, httpRoutes, configurations);
|
||||
await startModules(modules, capabilityRegistry, services, configurations, timeouts);
|
||||
|
||||
const capabilities = capabilityRegistry.publish();
|
||||
const routes = httpRoutes.publish();
|
||||
@@ -683,7 +738,14 @@ export async function startDiscoveredModuleRuntime(
|
||||
routes,
|
||||
stop: (): Promise<void> => {
|
||||
stopPromise ??= (async () => {
|
||||
const issues = await stopModules(modules, capabilityRegistry, "stop", services, timeouts);
|
||||
const issues = await stopModules(
|
||||
modules,
|
||||
capabilityRegistry,
|
||||
"stop",
|
||||
services,
|
||||
configurations,
|
||||
timeouts,
|
||||
);
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleRuntimeError(issues);
|
||||
}
|
||||
@@ -698,6 +760,7 @@ export async function startModuleRuntime(
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const discovered = await discoverModules(options);
|
||||
return startDiscoveredModuleRuntime(discovered, options.services, {
|
||||
...(options.configurations === undefined ? {} : { configurations: options.configurations }),
|
||||
...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
@@ -121,12 +122,23 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
expect(declaration?.id).toBe("core.modules.lifecycle.manage");
|
||||
});
|
||||
|
||||
it("contains exactly these four permissions and no others", () => {
|
||||
it("registers core.modules.configuration.manage as restricted, separately from lifecycle", () => {
|
||||
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_MODULES_CONFIGURATION_MANAGE);
|
||||
expect(declaration).toBeDefined();
|
||||
expect(declaration?.baseline).toBe("restricted");
|
||||
expect(declaration?.id).toBe("core.modules.configuration.manage");
|
||||
// Deliberately a different permission from lifecycle management: tuning how a module behaves
|
||||
// and deciding whether it runs at all are different authorities (ADR 0029).
|
||||
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
||||
});
|
||||
|
||||
it("contains exactly these five permissions and no others", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -154,6 +166,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||
description: "Read and change which installed modules are enabled.",
|
||||
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
},
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "Read and change an installed module's own configuration values.",
|
||||
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
},
|
||||
]);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||
|
||||
@@ -175,6 +175,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -229,6 +230,7 @@ describe.sequential("live Core authorization store", () => {
|
||||
"ekklesia_core_dashboard_placement",
|
||||
"ekklesia_core_grant",
|
||||
"ekklesia_core_migrations",
|
||||
"ekklesia_core_module_configuration",
|
||||
"ekklesia_core_module_state",
|
||||
"ekklesia_core_platform_owner",
|
||||
"ekklesia_core_platform_owner_bootstrap",
|
||||
|
||||
@@ -91,7 +91,7 @@ async function createModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
: {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -174,6 +174,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
|
||||
planMigrations: async () => {
|
||||
throw new Error("database must not be planned");
|
||||
},
|
||||
readModuleConfiguration: async () => {
|
||||
throw new Error("module configuration must not be read");
|
||||
},
|
||||
readModuleLifecycle: async () => {
|
||||
throw new Error("module lifecycle configuration must not be read");
|
||||
},
|
||||
@@ -81,9 +84,11 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
4 permissions registered
|
||||
5 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
DATABASE_URL is not configured.
|
||||
|
||||
Data definitions
|
||||
✓ Schemas validated
|
||||
|
||||
+193
-1
@@ -35,13 +35,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
manifest: {
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
configuration: [],
|
||||
dependencies: {
|
||||
capabilities: { optional: {}, required: {} },
|
||||
modules: { optional: {}, required: {} },
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -137,6 +138,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||
planMigrations: vi.fn(async () => plan()),
|
||||
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
runtimeSatisfies: vi.fn(() => true),
|
||||
...overrides,
|
||||
@@ -206,6 +208,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"modules.dependencies",
|
||||
"modules.permissions",
|
||||
"modules.lifecycle",
|
||||
"modules.configuration",
|
||||
"schemas.discovery",
|
||||
"migrations.discovery",
|
||||
"database.transport",
|
||||
@@ -1016,3 +1019,192 @@ describe("Ekklesia Doctor — module lifecycle", () => {
|
||||
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia Doctor — module configuration", () => {
|
||||
function configuringModule(
|
||||
id: string,
|
||||
configuration: readonly Record<string, unknown>[],
|
||||
): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
manifest: { ...base.manifest, configuration } as unknown as ModuleManifest,
|
||||
};
|
||||
}
|
||||
|
||||
it("skips the check, rather than guessing, when DATABASE_URL is not configured", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("announcements")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(), services);
|
||||
|
||||
expect(check(report, "modules.configuration").status).toBe("skipped");
|
||||
expect(services.readModuleConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes and says so plainly when a module declares no configuration", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.configuration").status).toBe("pass");
|
||||
expect(check(report, "modules.configuration").details).toEqual([
|
||||
"0 modules declaring configuration · 0 blocked",
|
||||
"calendar 0.1.0 · no configuration declared",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes with a key count when configuration is satisfied, and prints no value", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [
|
||||
{ key: "slideSeconds", type: "integer", default: 7, required: false },
|
||||
{ key: "bucket", type: "string", required: true },
|
||||
]),
|
||||
]),
|
||||
),
|
||||
readModuleConfiguration: vi.fn(async () =>
|
||||
Object.freeze({
|
||||
records: Object.freeze([
|
||||
{ key: "bucket", moduleId: "announcements", value: "very-specific-bucket-name" },
|
||||
]),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
expect(configuration.status).toBe("pass");
|
||||
expect(configuration.details).toEqual([
|
||||
"1 module declaring configuration · 0 blocked",
|
||||
"announcements 0.1.0 · 2 keys declared · valid",
|
||||
]);
|
||||
// Counts and keys, never values: `pnpm doctor` output gets pasted into chat.
|
||||
expect(JSON.stringify(configuration)).not.toContain("very-specific-bucket-name");
|
||||
});
|
||||
|
||||
it("warns and names the offending key when required configuration is missing", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
expect(configuration.status).toBe("warning");
|
||||
expect(configuration.summary).toBe("1 module cannot run with its current configuration");
|
||||
expect(configuration.details).toEqual([
|
||||
"1 module declaring configuration · 1 blocked",
|
||||
"announcements 0.1.0 · 1 key declared · configuration_missing · bucket",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names a missing secret's key and never its value", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [
|
||||
{ key: "storageCredential", type: "secret", required: true },
|
||||
]),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.configuration").details).toContain(
|
||||
"announcements 0.1.0 · 1 key declared · secret_missing · storageCredential",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a supplied secret as satisfied, without printing it", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [
|
||||
{ key: "storageCredential", type: "secret", required: true },
|
||||
]),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(databaseUrl),
|
||||
moduleSecrets: { get: () => "super-secret-value" },
|
||||
},
|
||||
services,
|
||||
);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
expect(configuration.status).toBe("pass");
|
||||
expect(JSON.stringify(configuration)).not.toContain("super-secret-value");
|
||||
});
|
||||
|
||||
it("does not evaluate a disabled module's configuration, and says so", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]),
|
||||
]),
|
||||
),
|
||||
readModuleLifecycle: vi.fn(async () =>
|
||||
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "announcements" }]) }),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
// Claiming it is valid would assert something nothing checked; claiming it is broken would be
|
||||
// noise about a module nobody asked to run.
|
||||
expect(configuration.status).toBe("pass");
|
||||
expect(configuration.details).toContain(
|
||||
"announcements 0.1.0 · 1 key declared · not evaluated (disabled)",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails safely, without a driver detail, when the configuration cannot be read", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("announcements")])),
|
||||
readModuleConfiguration: vi.fn(async () => {
|
||||
throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
expect(configuration.status).toBe("failure");
|
||||
expect(JSON.stringify(configuration)).not.toContain("hunter2");
|
||||
expect(JSON.stringify(configuration)).not.toContain("10.40.0.2");
|
||||
});
|
||||
|
||||
it("agrees with the lifecycle check, because both come from one resolution", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([
|
||||
configuringModule("announcements", [{ key: "bucket", type: "string", required: true }]),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
// Doctor can never report a module as runnable that startup would block, or the reverse.
|
||||
expect(check(report, "modules.lifecycle").status).toBe("warning");
|
||||
expect(check(report, "modules.configuration").status).toBe("warning");
|
||||
expect(check(report, "modules.lifecycle").details).toContain(
|
||||
"announcements 0.1.0 · blocked · configuration_missing · bucket",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 4,
|
||||
"manifestVersion": 5,
|
||||
"id": "db-facade",
|
||||
"name": "Database Facade Integration Test Module",
|
||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
type ModuleConfigurationDeclarationInput,
|
||||
type ModuleSecretSource,
|
||||
parseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
||||
import { resolveModuleEligibility, resolveModuleLifecycleView } from "../src/modules/lifecycle.js";
|
||||
|
||||
/** A minimal in-memory `DiscoveredModule`, built through `parseModuleManifest` so a fixture real
|
||||
* discovery would reject can never stand in for one it would accept. */
|
||||
function fakeModule(
|
||||
id: string,
|
||||
options: {
|
||||
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: options.configuration ?? [],
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
return {
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function enablement(...records: readonly (readonly [string, boolean])[]) {
|
||||
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
|
||||
}
|
||||
|
||||
function settings(...records: readonly (readonly [string, string, string])[]) {
|
||||
return {
|
||||
records: records.map(([moduleId, key, value]) => ({ key, moduleId, value })),
|
||||
};
|
||||
}
|
||||
|
||||
function secretsFor(
|
||||
map: Readonly<Record<string, Readonly<Record<string, string>>>>,
|
||||
): ModuleSecretSource {
|
||||
return { get: (moduleId, key) => map[moduleId]?.[key] };
|
||||
}
|
||||
|
||||
function ids(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||
return modules.map((module) => module.manifest.id);
|
||||
}
|
||||
|
||||
function entry(
|
||||
resolution: ReturnType<typeof resolveModuleEligibility>,
|
||||
moduleId: string,
|
||||
): ReturnType<typeof resolveModuleEligibility>["entries"][number] {
|
||||
const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId);
|
||||
if (!found) {
|
||||
throw new Error(`No eligibility entry for ${moduleId}.`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("configuration decides eligibility, through the existing lifecycle resolver", () => {
|
||||
it("keeps a module with satisfied configuration eligible, and hands it a snapshot", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "bucket", type: "string", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "bucket", "announcements"]) },
|
||||
);
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
||||
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
||||
// The same pass that decided eligibility produced the snapshot, so the configuration that made
|
||||
// the module eligible is the identical object it will receive.
|
||||
expect(resolution.configurations.get("announcements")?.getString("bucket")).toBe(
|
||||
"announcements",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks an enabled module whose required configuration is missing, naming the key", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "bucket", type: "string", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
{ settings: settings() },
|
||||
);
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
configurationKey: "bucket",
|
||||
reason: "configuration_missing",
|
||||
});
|
||||
// No snapshot for a module that is not going to start.
|
||||
expect(resolution.configurations.has("announcements")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks on an invalid stored value rather than starting on a default nobody chose", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "slideSeconds", type: "integer", default: 7, maximum: 600 }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "slideSeconds", "100000"]) },
|
||||
);
|
||||
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
configurationKey: "slideSeconds",
|
||||
reason: "configuration_invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks on a missing required secret, distinctly from a missing ordinary value", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "storageCredential", type: "secret", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
{ settings: settings() },
|
||||
);
|
||||
|
||||
// A different reason because it has a different fix: set an environment variable on the
|
||||
// deployment, not a value in the database.
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
configurationKey: "storageCredential",
|
||||
reason: "secret_missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("becomes eligible once the deployment supplies the secret, with no stored value involved", () => {
|
||||
const module = fakeModule("announcements", {
|
||||
configuration: [{ key: "storageCredential", type: "secret", required: true }],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveModuleEligibility([module], enablement(), { settings: settings() }).eligibleModules,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
ids(
|
||||
resolveModuleEligibility([module], enablement(), {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(),
|
||||
}).eligibleModules,
|
||||
),
|
||||
).toEqual(["announcements"]);
|
||||
});
|
||||
|
||||
it("does not evaluate a disabled module's configuration at all", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "bucket", type: "string", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(["announcements", false]),
|
||||
{ settings: settings() },
|
||||
);
|
||||
|
||||
// Disabled is not blocked: it is doing what it was told, and reporting a configuration problem
|
||||
// for something nobody asked to run would be noise.
|
||||
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
||||
expect(entry(resolution, "announcements").configured.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a module declaring no configuration exactly as it was", () => {
|
||||
const resolution = resolveModuleEligibility([fakeModule("calendar")], enablement(), {
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
||||
expect(resolution.configurations.get("calendar")?.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves identically when no configuration input is supplied at all", () => {
|
||||
// The third argument is optional, so every pre-existing caller keeps its exact behaviour.
|
||||
const withoutInput = resolveModuleEligibility([fakeModule("calendar")], enablement());
|
||||
const withEmptyInput = resolveModuleEligibility([fakeModule("calendar")], enablement(), {
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(withoutInput.entries).toEqual(withEmptyInput.entries);
|
||||
expect(ids(withoutInput.eligibleModules)).toEqual(["calendar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration blocking propagates through the dependency graph", () => {
|
||||
const graph = [
|
||||
fakeModule("storage", {
|
||||
configuration: [{ key: "bucket", type: "string", required: true }],
|
||||
}),
|
||||
fakeModule("media", { dependencies: { modules: { required: { storage: "^1.0.0" } } } }),
|
||||
fakeModule("announcements", { dependencies: { modules: { required: { media: "^1.0.0" } } } }),
|
||||
];
|
||||
|
||||
it("blocks required dependents of a configuration-blocked module, transitively", () => {
|
||||
const resolution = resolveModuleEligibility(graph, enablement(), { settings: settings() });
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
// The chain terminates at the module whose own configuration is at fault, exactly as it
|
||||
// terminates at a disabled module — one mechanism, not two.
|
||||
expect(entry(resolution, "storage").blocked).toEqual({
|
||||
configurationKey: "bucket",
|
||||
reason: "configuration_missing",
|
||||
});
|
||||
expect(entry(resolution, "media").blocked).toEqual({
|
||||
dependency: { kind: "module", moduleId: "storage" },
|
||||
reason: "required_dependency_blocked",
|
||||
});
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
dependency: { kind: "module", moduleId: "media" },
|
||||
reason: "required_dependency_blocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("unblocks the whole chain once the configuration is supplied", () => {
|
||||
const resolution = resolveModuleEligibility(graph, enablement(), {
|
||||
settings: settings(["storage", "bucket", "media-objects"]),
|
||||
});
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]);
|
||||
});
|
||||
|
||||
it("never blocks an unrelated module because of another module's configuration", () => {
|
||||
const resolution = resolveModuleEligibility([...graph, fakeModule("calendar")], enablement(), {
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
||||
expect(entry(resolution, "calendar").blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the snapshot of a module that resolved its own configuration but lost eligibility", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("storage"),
|
||||
fakeModule("announcements", {
|
||||
configuration: [{ key: "bucket", type: "string", default: "b" }],
|
||||
dependencies: { modules: { required: { storage: "^1.0.0" } } },
|
||||
}),
|
||||
],
|
||||
enablement(["storage", false]),
|
||||
{ settings: settings() },
|
||||
);
|
||||
|
||||
// Its own configuration was fine; it is blocked by its dependency. The map must still contain
|
||||
// exactly the modules that are going to start.
|
||||
expect(entry(resolution, "announcements").blocked?.reason).toBe("required_dependency_disabled");
|
||||
expect(resolution.configurations.has("announcements")).toBe(false);
|
||||
expect([...resolution.configurations.keys()]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration changes are restart-required, and reported honestly", () => {
|
||||
const module = fakeModule("announcements", {
|
||||
configuration: [{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 }],
|
||||
});
|
||||
const discoveredModules = [module];
|
||||
|
||||
function bootSnapshot(bootSettings: ReturnType<typeof settings>) {
|
||||
const resolution = resolveModuleEligibility(discoveredModules, enablement(), {
|
||||
settings: bootSettings,
|
||||
});
|
||||
return {
|
||||
discoveredModules,
|
||||
eligibility: resolution.entries,
|
||||
startedModuleIds: resolution.eligibleModules.map((entry) => entry.manifest.id),
|
||||
};
|
||||
}
|
||||
|
||||
it("does not claim a running module changed when only stored configuration did", () => {
|
||||
const view = resolveModuleLifecycleView(bootSnapshot(settings()), enablement(), {
|
||||
settings: settings(["announcements", "slideSeconds", "12"]),
|
||||
});
|
||||
|
||||
// The module really is still running with 7; a configuration change is applied by the next
|
||||
// restart, and this reports exactly that rather than pretending the value took effect.
|
||||
expect(view[0]?.runtime).toEqual({ state: "running" });
|
||||
expect(view[0]?.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a pending restart when new configuration would change whether the module runs", () => {
|
||||
// Boot with a value that blocked the module, then correct it.
|
||||
const blocked = resolveModuleEligibility(discoveredModules, enablement(), {
|
||||
settings: settings(["announcements", "slideSeconds", "0"]),
|
||||
});
|
||||
expect(blocked.eligibleModules).toEqual([]);
|
||||
|
||||
const view = resolveModuleLifecycleView(
|
||||
{
|
||||
discoveredModules,
|
||||
eligibility: blocked.entries,
|
||||
startedModuleIds: [],
|
||||
},
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "slideSeconds", "12"]) },
|
||||
);
|
||||
|
||||
// Correcting the value must show as restart-pending: the next boot really will start it.
|
||||
expect(view[0]?.runtime).toEqual({
|
||||
configurationKey: "slideSeconds",
|
||||
reason: "configuration_invalid",
|
||||
state: "blocked",
|
||||
});
|
||||
expect(view[0]?.restartRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the boot-time configuration reason, not a recomputed one", () => {
|
||||
const blocked = resolveModuleEligibility(discoveredModules, enablement(), {
|
||||
settings: settings(["announcements", "slideSeconds", "0"]),
|
||||
});
|
||||
const view = resolveModuleLifecycleView(
|
||||
{ discoveredModules, eligibility: blocked.entries, startedModuleIds: [] },
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "slideSeconds", "0"]) },
|
||||
);
|
||||
|
||||
expect(view[0]?.runtime).toEqual({
|
||||
configurationKey: "slideSeconds",
|
||||
reason: "configuration_invalid",
|
||||
state: "blocked",
|
||||
});
|
||||
expect(view[0]?.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("never puts a configuration value into the lifecycle view, only the key", () => {
|
||||
const blocked = resolveModuleEligibility(discoveredModules, enablement(), {
|
||||
settings: settings(["announcements", "slideSeconds", "0"]),
|
||||
});
|
||||
const view = resolveModuleLifecycleView(
|
||||
{ discoveredModules, eligibility: blocked.entries, startedModuleIds: [] },
|
||||
enablement(),
|
||||
{ settings: settings(["announcements", "slideSeconds", "0"]) },
|
||||
);
|
||||
|
||||
expect(JSON.stringify(view)).toContain("slideSeconds");
|
||||
expect(JSON.stringify(view)).not.toContain('"0"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createModuleConfigurationStore,
|
||||
createModuleConfigurationView,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
resolveModuleConfigurationFor,
|
||||
} from "../src/modules/index.js";
|
||||
import { parseModuleManifest } from "@ekklesia/contracts";
|
||||
import { createValidatedPostgresPool } from "../src/database/index.js";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "../src/migrations/index.js";
|
||||
|
||||
/**
|
||||
* The module configuration store against real PostgreSQL.
|
||||
*
|
||||
* `core/tests/module-configuration.test.ts` proves the validation and defaulting rules with no
|
||||
* database at all; this proves the things only a database can: that migration `0005` really creates
|
||||
* the table with the composite primary key and value ceiling the schema declares, that a replace is
|
||||
* transactional and scoped to one module, that stored values survive a completely new connection,
|
||||
* and — the security-relevant one — that a module id or key is only ever *data* in a parameterized
|
||||
* column, never an SQL identifier. The in-memory double the API route tests use reproduces none of
|
||||
* that, which is why it lives here.
|
||||
*/
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const coreMigrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
const trackedDatabases = new Set<string>();
|
||||
let databaseCounter = 0;
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw harnessError("The module configuration integration database is not configured.");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new TypeError("Unsupported protocol.");
|
||||
}
|
||||
} catch {
|
||||
throw harnessError("The module configuration integration database configuration is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function quoteDatabaseName(databaseName: string): string {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||
throw harnessError("The generated integration database name is invalid.");
|
||||
}
|
||||
return `"${databaseName}"`;
|
||||
}
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
const safeLabel = label
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||
.replaceAll(/^_+|_+$/gu, "")
|
||||
.slice(0, 10);
|
||||
databaseCounter += 1;
|
||||
return `ekk_modcfg_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
function databaseUrl(databaseName: string): string {
|
||||
const parsed = new URL(baseDatabaseUrl());
|
||||
parsed.pathname = `/${databaseName}`;
|
||||
parsed.searchParams.delete("application_name");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||
const client = new Client({ connectionString });
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The public harness failure stays static when connection cleanup also fails.
|
||||
}
|
||||
throw harnessError("The module configuration integration database connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeQuery<Row extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: unknown[] = [],
|
||||
): Promise<readonly Row[]> {
|
||||
try {
|
||||
const result = await client.query<Row>(text, values);
|
||||
return result.rows;
|
||||
} catch {
|
||||
throw harnessError("An integration database administration operation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeEnd(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
throw harnessError("An integration database connection could not be closed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(
|
||||
admin,
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||
[databaseName],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||
);
|
||||
trackedDatabases.delete(databaseName);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
trackedDatabases.add(name);
|
||||
return { name, url: databaseUrl(name) };
|
||||
}
|
||||
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-modcfg-no-modules-"));
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const databaseName of [...trackedDatabases]) {
|
||||
await dropDatabase(databaseName).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration,
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
}
|
||||
|
||||
describe.sequential("live Core module configuration store", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let store: ModuleConfigurationStore;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
it("creates exactly the one configuration table, with its composite key and declared constraints", async () => {
|
||||
database = await createDisposableDatabase("fresh");
|
||||
await applyCoreMigrations(database.url);
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
|
||||
const columns = await safeQuery<{ column_name: string; is_nullable: string }>(
|
||||
admin,
|
||||
`select column_name, is_nullable
|
||||
from information_schema.columns
|
||||
where table_name = 'ekklesia_core_module_configuration'
|
||||
order by column_name`,
|
||||
);
|
||||
const primaryKey = await safeQuery<{ column_name: string }>(
|
||||
admin,
|
||||
`select kcu.column_name
|
||||
from information_schema.table_constraints tc
|
||||
join information_schema.key_column_usage kcu
|
||||
on kcu.constraint_name = tc.constraint_name
|
||||
where tc.table_name = 'ekklesia_core_module_configuration'
|
||||
and tc.constraint_type = 'PRIMARY KEY'
|
||||
order by kcu.ordinal_position`,
|
||||
);
|
||||
// Only the explicitly declared CHECKs: PostgreSQL 17 also materializes a system `..._not_null`
|
||||
// CHECK per NOT NULL column, which is not something this schema declares.
|
||||
const checks = await safeQuery<{ constraint_name: string }>(
|
||||
admin,
|
||||
`select tc.constraint_name
|
||||
from information_schema.table_constraints tc
|
||||
where tc.table_name = 'ekklesia_core_module_configuration'
|
||||
and tc.constraint_type = 'CHECK'
|
||||
and tc.constraint_name not like '%_not_null'
|
||||
order by tc.constraint_name`,
|
||||
);
|
||||
|
||||
expect(columns.map((column) => column.column_name)).toEqual([
|
||||
"created_at",
|
||||
"key",
|
||||
"module_id",
|
||||
"updated_at",
|
||||
"value",
|
||||
]);
|
||||
// One row per (module, key): a module can never have two values for one key, enforced by the
|
||||
// database rather than by application care.
|
||||
expect(primaryKey.map((column) => column.column_name)).toEqual(["module_id", "key"]);
|
||||
expect(checks.map((check) => check.constraint_name)).toEqual([
|
||||
"ekklesia_core_module_configuration_key_check",
|
||||
"ekklesia_core_module_configuration_module_check",
|
||||
"ekklesia_core_module_configuration_value_check",
|
||||
]);
|
||||
// Deliberately absent: there is no column that could hold secret material, and none that
|
||||
// records where a value came from. Secrets are deployment-supplied and never persisted.
|
||||
expect(columns.map((column) => column.column_name)).not.toContain("secret");
|
||||
expect(columns.map((column) => column.column_name)).not.toContain("encrypted");
|
||||
});
|
||||
|
||||
it("reads an empty configuration from a freshly migrated database", async () => {
|
||||
const view = createValidatedPostgresPool(database.url, {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
});
|
||||
close = view.close;
|
||||
store = createModuleConfigurationStore(createModuleConfigurationView(view.pool));
|
||||
|
||||
expect(await store.readSettings()).toEqual({ records: [] });
|
||||
});
|
||||
|
||||
it("persists values and reads them back over a completely new connection", async () => {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [
|
||||
{ key: "bucket", value: "slides" },
|
||||
{ key: "slideSeconds", value: "12" },
|
||||
],
|
||||
});
|
||||
|
||||
const separate = createValidatedPostgresPool(database.url, {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
});
|
||||
try {
|
||||
const reread = await createModuleConfigurationStore(
|
||||
createModuleConfigurationView(separate.pool),
|
||||
).readSettings();
|
||||
expect(reread.records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
]);
|
||||
} finally {
|
||||
await separate.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces the whole document for one module, clearing keys the submission omits", async () => {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "slides" }],
|
||||
});
|
||||
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never touches another module's rows during a replace", async () => {
|
||||
await store.replace({ moduleId: "calendar", values: [{ key: "bucket", value: "events" }] });
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "renamed" }],
|
||||
});
|
||||
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "renamed" },
|
||||
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears a module's document entirely when given no values", async () => {
|
||||
await store.replace({ moduleId: "calendar", values: [] });
|
||||
|
||||
expect(
|
||||
(await store.readSettings()).records.filter((record) => record.moduleId === "calendar"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats a module id and a key as data, never as an SQL identifier", async () => {
|
||||
// The store validates identifiers before they reach PostgreSQL, so this is rejected by the
|
||||
// application. The row count afterwards proves nothing was executed either way.
|
||||
await expect(
|
||||
store.replace({
|
||||
moduleId: 'announcements"; drop table ekklesia_core_module_configuration; --',
|
||||
values: [{ key: "bucket", value: "x" }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_MODULE_ID_INVALID" });
|
||||
await expect(
|
||||
store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: 'bucket"; drop table ekklesia_core_module_configuration; --', value: "x" }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_KEY_INVALID" });
|
||||
|
||||
const tables = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
`select count(*)::text as count from information_schema.tables
|
||||
where table_name = 'ekklesia_core_module_configuration'`,
|
||||
);
|
||||
expect(tables[0]?.count).toBe("1");
|
||||
});
|
||||
|
||||
it("rejects a duplicate key in one submission before reaching the database", async () => {
|
||||
await expect(
|
||||
store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [
|
||||
{ key: "bucket", value: "a" },
|
||||
{ key: "bucket", value: "b" },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ModuleConfigurationStoreError);
|
||||
});
|
||||
|
||||
it("rejects an oversized value at the application boundary and at the database", async () => {
|
||||
await expect(
|
||||
store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "x".repeat(2049) }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_VALUE_INVALID" });
|
||||
|
||||
// Defense in depth: the same ceiling restated where the data lives, so no future caller can
|
||||
// store a value the resolver would then reject as invalid on every boot.
|
||||
await expect(
|
||||
safeQuery(
|
||||
admin,
|
||||
`insert into ekklesia_core_module_configuration (module_id, key, value)
|
||||
values ('announcements', 'oversized', repeat('x', 2049))`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("never carries a configuration value in a store error", async () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "a-value-nobody-should-see".repeat(200) }],
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
const rendered = `${String(captured)} ${JSON.stringify(captured)}`;
|
||||
expect(rendered).not.toContain("a-value-nobody-should-see");
|
||||
});
|
||||
|
||||
it("retains a value for a module that is no longer deployed, and resolution ignores it", async () => {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [{ key: "bucket", value: "slides" }],
|
||||
});
|
||||
await store.replace({ moduleId: "retired", values: [{ key: "bucket", value: "old" }] });
|
||||
const settings = await store.readSettings();
|
||||
|
||||
const resolved = resolveModuleConfigurationFor(
|
||||
manifestWith([{ key: "bucket", type: "string", required: true }]),
|
||||
{ settings },
|
||||
);
|
||||
|
||||
expect(resolved.configuration?.getString("bucket")).toBe("slides");
|
||||
// Retained rather than deleted: redeploying `retired` restores its value with no new write.
|
||||
expect(settings.records.some((record) => record.moduleId === "retired")).toBe(true);
|
||||
});
|
||||
|
||||
it("drives real validation and defaulting from real stored rows", async () => {
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [
|
||||
{ key: "bucket", value: "slides" },
|
||||
{ key: "slideSeconds", value: "900" },
|
||||
],
|
||||
});
|
||||
|
||||
const manifest = manifestWith([
|
||||
{ key: "bucket", type: "string", required: true },
|
||||
{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 },
|
||||
{ key: "pollIntervalSeconds", type: "integer", default: 30 },
|
||||
]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: await store.readSettings(),
|
||||
});
|
||||
|
||||
// 900 exceeds the module's own declared maximum: reported, never clamped and never silently
|
||||
// replaced by the default.
|
||||
expect(resolved.configuration).toBeUndefined();
|
||||
expect(resolved.issue).toEqual({ key: "slideSeconds", reason: "configuration_invalid" });
|
||||
|
||||
await store.replace({
|
||||
moduleId: "announcements",
|
||||
values: [
|
||||
{ key: "bucket", value: "slides" },
|
||||
{ key: "slideSeconds", value: "12" },
|
||||
],
|
||||
});
|
||||
const fixed = resolveModuleConfigurationFor(manifest, { settings: await store.readSettings() });
|
||||
expect(fixed.configuration?.getInteger("slideSeconds")).toBe(12);
|
||||
expect(fixed.configuration?.getInteger("pollIntervalSeconds")).toBe(30);
|
||||
|
||||
await close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,667 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
type ModuleConfigurationDeclarationInput,
|
||||
type ModuleSecretSource,
|
||||
parseModuleManifest,
|
||||
safeParseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createEnvironmentModuleSecretSource,
|
||||
moduleSecretEnvironmentVariableName,
|
||||
resolveModuleConfigurationFor,
|
||||
} from "../src/modules/configuration.js";
|
||||
import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js";
|
||||
|
||||
function manifestWith(
|
||||
configuration: readonly ModuleConfigurationDeclarationInput[],
|
||||
id = "announcements",
|
||||
) {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration,
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
}
|
||||
|
||||
/** The stored document, in the shape the store produces. */
|
||||
function settings(...records: readonly (readonly [string, string, string])[]) {
|
||||
return {
|
||||
records: records.map(([moduleId, key, value]) => ({ key, moduleId, value })),
|
||||
};
|
||||
}
|
||||
|
||||
function secretsFor(
|
||||
map: Readonly<Record<string, Readonly<Record<string, string>>>>,
|
||||
): ModuleSecretSource {
|
||||
return { get: (moduleId, key) => map[moduleId]?.[key] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration set modelled on what the real Announcements product would need as an Ekklesia
|
||||
* module — a slide duration with a default, a poll interval with a default, a bucket name, and a
|
||||
* deployment-supplied storage credential.
|
||||
*
|
||||
* Modelled, never imported: the Announcements repository is a reference consumer for this design and
|
||||
* is not a dependency of Ekklesia's tests. Nothing Announcements-specific exists in Core; this
|
||||
* fixture only proves the *shape* of settings a real product needs can be declared, validated,
|
||||
* defaulted, and delivered without the module reaching any platform infrastructure.
|
||||
*/
|
||||
const announcementsShapedDeclarations: readonly ModuleConfigurationDeclarationInput[] = [
|
||||
{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 },
|
||||
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
|
||||
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
|
||||
{ key: "storageCredential", type: "secret", required: true },
|
||||
];
|
||||
|
||||
describe("module configuration declarations", () => {
|
||||
it("accepts a module that declares no configuration at all", () => {
|
||||
const manifest = manifestWith([]);
|
||||
|
||||
expect(manifest.configuration).toEqual([]);
|
||||
// A backend-only module stays exactly as valid as it was before configuration existed.
|
||||
expect(resolveModuleConfigurationFor(manifest, { settings: settings() }).issue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts every declared type with its own constraints", () => {
|
||||
const manifest = manifestWith([
|
||||
{ key: "bucket", type: "string", minLength: 1, maxLength: 63 },
|
||||
{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 },
|
||||
{ key: "shuffle", type: "boolean", default: false },
|
||||
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
|
||||
{ key: "webhook", type: "url" },
|
||||
{ key: "apiToken", type: "secret" },
|
||||
]);
|
||||
|
||||
expect(manifest.configuration.map((entry) => entry.type)).toEqual([
|
||||
"string",
|
||||
"integer",
|
||||
"boolean",
|
||||
"enum",
|
||||
"url",
|
||||
"secret",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [
|
||||
{ key: "apiToken", type: "string", default: "not-a-secret" },
|
||||
{ key: "apiToken", type: "secret", required: true },
|
||||
],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"configuration.1.key",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [{ key: "apiToken", type: "secret", default: "hunter2" }],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
// `.strict()` on the secret shape means there is no `default` field to populate at all — the
|
||||
// rejection is structural rather than a rule someone has to remember to apply.
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [{ key: "slideSeconds", type: "integer", required: true, default: 7 }],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an integer default below its own minimum",
|
||||
{ key: "n", type: "integer", default: 0, minimum: 1 },
|
||||
],
|
||||
[
|
||||
"an integer default above its own maximum",
|
||||
{ key: "n", type: "integer", default: 9, maximum: 5 },
|
||||
],
|
||||
[
|
||||
"a string default shorter than its own minLength",
|
||||
{ key: "s", type: "string", default: "", minLength: 1 },
|
||||
],
|
||||
[
|
||||
"an enum default outside its own values",
|
||||
{ key: "e", type: "enum", values: ["a"], default: "b" },
|
||||
],
|
||||
[
|
||||
"a url default that is not an absolute http(s) URL",
|
||||
{ key: "u", type: "url", default: "/relative" },
|
||||
],
|
||||
[
|
||||
"a url default with a dangerous protocol",
|
||||
{ key: "u", type: "url", default: "file:///etc/passwd" },
|
||||
],
|
||||
])("rejects %s", (_label, declaration) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [declaration],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
"toString",
|
||||
"valueOf",
|
||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [{ key, type: "string" }],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Slide",
|
||||
"slide_seconds",
|
||||
"slide.seconds",
|
||||
"slide-seconds",
|
||||
"9lives",
|
||||
"",
|
||||
])("rejects the malformed configuration key %s", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration: [{ key, type: "string" }],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleConfigurationFor — defaults and precedence", () => {
|
||||
it("applies declared defaults when nothing is stored", () => {
|
||||
const manifest = manifestWith(announcementsShapedDeclarations);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(["announcements", "bucket", "announcements"]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(7);
|
||||
expect(resolved.configuration?.getInteger("pollIntervalSeconds")).toBe(30);
|
||||
});
|
||||
|
||||
it("lets a stored value override a default", () => {
|
||||
const manifest = manifestWith(announcementsShapedDeclarations);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(
|
||||
["announcements", "bucket", "announcements"],
|
||||
["announcements", "slideSeconds", "12"],
|
||||
),
|
||||
});
|
||||
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(12);
|
||||
});
|
||||
|
||||
it("never falls back to the default when a stored value is invalid", () => {
|
||||
const manifest = manifestWith(announcementsShapedDeclarations);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(
|
||||
["announcements", "bucket", "announcements"],
|
||||
["announcements", "slideSeconds", "99999"],
|
||||
),
|
||||
});
|
||||
|
||||
// Silently substituting 7 here would run the slideshow at a duration nobody chose while the
|
||||
// operator believes theirs took effect.
|
||||
expect(resolved.configuration).toBeUndefined();
|
||||
expect(resolved.issue).toEqual({ key: "slideSeconds", reason: "configuration_invalid" });
|
||||
});
|
||||
|
||||
it("reports a required key with no value and no default as missing", () => {
|
||||
const manifest = manifestWith(announcementsShapedDeclarations);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "bucket", reason: "configuration_missing" });
|
||||
});
|
||||
|
||||
it("leaves an optional key with no value and no default simply absent", () => {
|
||||
const manifest = manifestWith([{ key: "webhook", type: "url" }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() });
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.has("webhook")).toBe(false);
|
||||
expect(resolved.configuration?.getUrl("webhook")).toBeUndefined();
|
||||
expect(resolved.configuration?.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-canonical integer", "integer", "07"],
|
||||
["an integer with whitespace", "integer", " 7 "],
|
||||
["a non-numeric integer", "integer", "seven"],
|
||||
["a float for an integer", "integer", "7.5"],
|
||||
["a non-canonical boolean", "boolean", "yes"],
|
||||
["a numeric boolean", "boolean", "1"],
|
||||
["a capitalized boolean", "boolean", "True"],
|
||||
])("rejects %s rather than coercing it", (_label, type, stored) => {
|
||||
const manifest = manifestWith([{ key: "value", type } as ModuleConfigurationDeclarationInput]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(["announcements", "value", stored]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "value", reason: "configuration_invalid" });
|
||||
});
|
||||
|
||||
it("validates a stored value against every declared constraint", () => {
|
||||
const manifest = manifestWith([
|
||||
{ key: "fit", type: "enum", values: ["contain", "cover"] },
|
||||
{ key: "webhook", type: "url" },
|
||||
{ key: "bucket", type: "string", maxLength: 4 },
|
||||
]);
|
||||
|
||||
for (const [key, bad] of [
|
||||
["fit", "stretch"],
|
||||
["webhook", "javascript:alert(1)"],
|
||||
["bucket", "far-too-long"],
|
||||
] as const) {
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(["announcements", key, bad]),
|
||||
});
|
||||
expect(resolved.issue).toEqual({ key, reason: "configuration_invalid" });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a valid stored value for every declared type", () => {
|
||||
const manifest = manifestWith([
|
||||
{ key: "bucket", type: "string" },
|
||||
{ key: "slideSeconds", type: "integer" },
|
||||
{ key: "shuffle", type: "boolean" },
|
||||
{ key: "fit", type: "enum", values: ["contain", "cover"] },
|
||||
{ key: "webhook", type: "url" },
|
||||
]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(
|
||||
["announcements", "bucket", "announcements"],
|
||||
["announcements", "slideSeconds", "12"],
|
||||
["announcements", "shuffle", "true"],
|
||||
["announcements", "fit", "cover"],
|
||||
["announcements", "webhook", "https://hooks.example.test/notify"],
|
||||
),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.getString("bucket")).toBe("announcements");
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(12);
|
||||
expect(resolved.configuration?.getBoolean("shuffle")).toBe(true);
|
||||
expect(resolved.configuration?.getEnum("fit")).toBe("cover");
|
||||
expect(resolved.configuration?.getUrl("webhook")).toBe("https://hooks.example.test/notify");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleConfigurationFor — keys the manifest no longer declares", () => {
|
||||
it("ignores an undeclared stored key rather than failing, and reports it", () => {
|
||||
const manifest = manifestWith([{ key: "slideSeconds", type: "integer", default: 7 }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(
|
||||
["announcements", "slideSeconds", "9"],
|
||||
["announcements", "transitionMilliseconds", "1100"],
|
||||
),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.keys()).toEqual(["slideSeconds"]);
|
||||
// Retained by the store and surfaced here, so a downgrade or a rename does not destroy an
|
||||
// operator's value and is visible rather than merely tolerated.
|
||||
expect(resolved.unknownKeys).toEqual(["transitionMilliseconds"]);
|
||||
expect(resolved.configuration?.get("transitionMilliseconds")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the default for a key a newer version added, without any migration step", () => {
|
||||
// v1 stored only `slideSeconds`; v2 adds `transitionMilliseconds` with a default.
|
||||
const v2 = manifestWith([
|
||||
{ key: "slideSeconds", type: "integer", default: 7 },
|
||||
{ key: "transitionMilliseconds", type: "integer", default: 1100 },
|
||||
]);
|
||||
const resolved = resolveModuleConfigurationFor(v2, {
|
||||
settings: settings(["announcements", "slideSeconds", "9"]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(9);
|
||||
expect(resolved.configuration?.getInteger("transitionMilliseconds")).toBe(1100);
|
||||
});
|
||||
|
||||
it("blocks rather than guesses when a newer version adds a required key", () => {
|
||||
const v2 = manifestWith([
|
||||
{ key: "slideSeconds", type: "integer", default: 7 },
|
||||
{ key: "bucket", type: "string", required: true },
|
||||
]);
|
||||
const resolved = resolveModuleConfigurationFor(v2, {
|
||||
settings: settings(["announcements", "slideSeconds", "9"]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "bucket", reason: "configuration_missing" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleConfigurationFor — isolation", () => {
|
||||
it("reads only its own module's records, never another module's", () => {
|
||||
const manifest = manifestWith([{ key: "bucket", type: "string" }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(["announcements", "bucket", "mine"], ["calendar", "bucket", "not-mine"]),
|
||||
});
|
||||
|
||||
expect(resolved.configuration?.getString("bucket")).toBe("mine");
|
||||
expect(resolved.configuration?.keys()).toEqual(["bucket"]);
|
||||
});
|
||||
|
||||
it("asks the secret source only for its own module id", () => {
|
||||
const asked: string[] = [];
|
||||
const manifest = manifestWith([{ key: "apiToken", type: "secret", required: true }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: {
|
||||
get: (moduleId, key) => {
|
||||
asked.push(`${moduleId}:${key}`);
|
||||
return moduleId === "announcements" ? "s3cret" : "another-module-secret";
|
||||
},
|
||||
},
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(asked).toEqual(["announcements:apiToken"]);
|
||||
expect(resolved.configuration?.getSecret("apiToken")).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("gives a module no way to reach a key it never declared", () => {
|
||||
const manifest = manifestWith([{ key: "bucket", type: "string", default: "b" }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
settings: settings(["announcements", "somebodyElsesKey", "value"]),
|
||||
});
|
||||
|
||||
for (const probe of ["somebodyElsesKey", "__proto__", "constructor", "toString"]) {
|
||||
expect(resolved.configuration?.get(probe)).toBeUndefined();
|
||||
expect(resolved.configuration?.has(probe)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("module configuration accessors", () => {
|
||||
it("fails closed when the accessor does not match the declared type", () => {
|
||||
const manifest = manifestWith([{ key: "slideSeconds", type: "integer", default: 7 }]);
|
||||
const config = resolveModuleConfigurationFor(manifest, { settings: settings() }).configuration;
|
||||
|
||||
expect(config?.getInteger("slideSeconds")).toBe(7);
|
||||
// An undeclared key and a declared key of another type are deliberately indistinguishable, in
|
||||
// the same style as `PermissionRegistry.get`.
|
||||
expect(config?.getString("slideSeconds")).toBeUndefined();
|
||||
expect(config?.getBoolean("slideSeconds")).toBeUndefined();
|
||||
expect(config?.getSecret("slideSeconds")).toBeUndefined();
|
||||
expect(config?.getString("neverDeclared")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not expose a secret through any non-secret accessor", () => {
|
||||
const manifest = manifestWith([{ key: "apiToken", type: "secret", required: true }]);
|
||||
const config = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { apiToken: "s3cret" } }),
|
||||
settings: settings(),
|
||||
}).configuration;
|
||||
|
||||
expect(config?.getSecret("apiToken")).toBe("s3cret");
|
||||
expect(config?.getString("apiToken")).toBeUndefined();
|
||||
expect(config?.getUrl("apiToken")).toBeUndefined();
|
||||
expect(config?.getEnum("apiToken")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is frozen, sorted, and has no other surface", () => {
|
||||
const manifest = manifestWith([
|
||||
{ key: "zulu", type: "string", default: "z" },
|
||||
{ key: "alpha", type: "string", default: "a" },
|
||||
]);
|
||||
const config = resolveModuleConfigurationFor(manifest, { settings: settings() }).configuration;
|
||||
|
||||
expect(Object.isFrozen(config)).toBe(true);
|
||||
expect(config?.keys()).toEqual(["alpha", "zulu"]);
|
||||
expect(Object.keys(config ?? {}).sort()).toEqual([
|
||||
"get",
|
||||
"getBoolean",
|
||||
"getEnum",
|
||||
"getInteger",
|
||||
"getSecret",
|
||||
"getString",
|
||||
"getUrl",
|
||||
"has",
|
||||
"keys",
|
||||
]);
|
||||
});
|
||||
|
||||
it("supplies an empty configuration that behaves like any other, for a module declaring none", () => {
|
||||
expect(EMPTY_MODULE_CONFIGURATION.keys()).toEqual([]);
|
||||
expect(EMPTY_MODULE_CONFIGURATION.has("anything")).toBe(false);
|
||||
expect(EMPTY_MODULE_CONFIGURATION.get("anything")).toBeUndefined();
|
||||
expect(EMPTY_MODULE_CONFIGURATION.getSecret("anything")).toBeUndefined();
|
||||
expect(Object.isFrozen(EMPTY_MODULE_CONFIGURATION)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deployment-supplied secrets", () => {
|
||||
it("reports a required secret the deployment does not supply", () => {
|
||||
const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() });
|
||||
|
||||
expect(resolved.configuration).toBeUndefined();
|
||||
expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" });
|
||||
});
|
||||
|
||||
it("treats an empty-string secret as not supplied", () => {
|
||||
const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "" } }),
|
||||
settings: settings(),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" });
|
||||
});
|
||||
|
||||
it("leaves an optional secret absent rather than blocking", () => {
|
||||
const manifest = manifestWith([{ key: "storageCredential", type: "secret" }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, { settings: settings() });
|
||||
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.has("storageCredential")).toBe(false);
|
||||
});
|
||||
|
||||
it("never reads a secret from stored configuration, even if a row exists for that key", () => {
|
||||
const manifest = manifestWith([{ key: "storageCredential", type: "secret", required: true }]);
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
// A row that should be unreachable — the HTTP boundary refuses to write one, and this proves
|
||||
// the resolver would ignore it even if one arrived by another route.
|
||||
settings: settings(["announcements", "storageCredential", "planted-in-the-database"]),
|
||||
});
|
||||
|
||||
expect(resolved.issue).toEqual({ key: "storageCredential", reason: "secret_missing" });
|
||||
expect(resolved.configuration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives a deterministic, collision-free environment variable name", () => {
|
||||
expect(moduleSecretEnvironmentVariableName("announcements", "storageCredential")).toBe(
|
||||
"EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL",
|
||||
);
|
||||
expect(moduleSecretEnvironmentVariableName("media-storage", "accessKeyId")).toBe(
|
||||
"EKKLESIA_MODULE_SECRET__MEDIA_STORAGE__ACCESS_KEY_ID",
|
||||
);
|
||||
// The double underscore is what keeps two different (module, key) pairs from folding onto one
|
||||
// name once kebab-case and camelCase are both flattened to underscores.
|
||||
expect(moduleSecretEnvironmentVariableName("media", "storageAccessKey")).not.toBe(
|
||||
moduleSecretEnvironmentVariableName("media-storage", "accessKey"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads only the derived name from the supplied environment, never an arbitrary variable", () => {
|
||||
const environment = {
|
||||
DATABASE_URL: "postgresql://secret@host/db",
|
||||
EKKLESIA_IDENTITY_SECRET: "platform-secret",
|
||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "s3cret",
|
||||
};
|
||||
const source = createEnvironmentModuleSecretSource(environment);
|
||||
|
||||
expect(source.get("announcements", "storageCredential")).toBe("s3cret");
|
||||
// There is no accessor that takes a raw variable name, so a module can never reach these.
|
||||
expect(source.get("announcements", "databaseUrl")).toBeUndefined();
|
||||
expect(source.get("calendar", "storageCredential")).toBeUndefined();
|
||||
expect(Object.keys(source)).toEqual(["get"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration over a real discovered inventory", () => {
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function writeModule(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
configuration: readonly ModuleConfigurationDeclarationInput[],
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: `@test/module-${id}`,
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
configuration,
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
'throw new Error("configuration discovery must never import a module server entrypoint");',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
it("discovers declarations without importing a single line of module code", async () => {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-config-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await writeModule(modulesDirectory, "announcements", announcementsShapedDeclarations);
|
||||
|
||||
const discovered: readonly DiscoveredModule[] = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
// Every fixture entrypoint throws on import; reaching these assertions is direct evidence that
|
||||
// discovery stayed inert, exactly as it was before configuration existed.
|
||||
expect(discovered).toHaveLength(1);
|
||||
expect(discovered[0]?.manifest.configuration.map((entry) => entry.key)).toEqual([
|
||||
"slideSeconds",
|
||||
"pollIntervalSeconds",
|
||||
"bucket",
|
||||
"storageCredential",
|
||||
]);
|
||||
|
||||
const manifest = discovered[0]?.manifest;
|
||||
if (!manifest) {
|
||||
throw new Error("expected a discovered module");
|
||||
}
|
||||
const resolved = resolveModuleConfigurationFor(manifest, {
|
||||
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
||||
settings: settings(["announcements", "bucket", "announcements"]),
|
||||
});
|
||||
|
||||
// The whole point, end to end: a real product's settings arrive validated and defaulted, and the
|
||||
// module reached no database, no environment, and no platform internal to get them.
|
||||
expect(resolved.issue).toBeUndefined();
|
||||
expect(resolved.configuration?.getInteger("slideSeconds")).toBe(7);
|
||||
expect(resolved.configuration?.getInteger("pollIntervalSeconds")).toBe(30);
|
||||
expect(resolved.configuration?.getString("bucket")).toBe("announcements");
|
||||
expect(resolved.configuration?.getSecret("storageCredential")).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("rejects a manifest whose configuration declaration is malformed, at discovery", async () => {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-config-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await writeModule(modulesDirectory, "announcements", [
|
||||
{ key: "apiToken", type: "secret", required: true },
|
||||
// A duplicate key: the manifest is invalid, so the whole inventory is rejected before any
|
||||
// configuration is ever resolved.
|
||||
{ key: "apiToken", type: "string" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "ModuleDiscoveryError" });
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -197,7 +198,7 @@ afterAll(async () => {
|
||||
* by eligibility resolution. */
|
||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -228,7 +229,7 @@ describe.sequential("live Core module lifecycle store", () => {
|
||||
const tables = await safeQuery<{ table_name: string }>(
|
||||
admin,
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = 'public' and table_name like 'ekklesia_core_module%'
|
||||
where table_schema = 'public' and table_name = 'ekklesia_core_module_state'
|
||||
order by table_name`,
|
||||
);
|
||||
const columns = await safeQuery<{
|
||||
|
||||
@@ -22,7 +22,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -343,7 +343,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -65,7 +65,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
@@ -26,7 +27,7 @@ interface PermissionInput {
|
||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -71,8 +72,9 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
expect(registry.has("people.directory.read")).toBe(true);
|
||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||
// Core's own four declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(6);
|
||||
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
||||
// Core's own five declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||
@@ -91,6 +93,7 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
"calendar.events.read",
|
||||
"calendar.events.write",
|
||||
"people.directory.read",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import type {
|
||||
ModuleConfiguration,
|
||||
ModuleDatabase,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { discoverModules } from "../src/modules/discovery.js";
|
||||
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||
import {
|
||||
ModuleDiscoveryError,
|
||||
ModuleRuntimeError,
|
||||
@@ -52,7 +57,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -1351,3 +1356,148 @@ describe("module startup rollback and bounded stop hooks", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("module configuration delivery (ADR 0029)", () => {
|
||||
it("hands each module its own frozen configuration in register, start, and stop", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "announcements", {
|
||||
manifest: {
|
||||
...manifestFor("announcements"),
|
||||
configuration: [
|
||||
{ key: "slideSeconds", type: "integer", default: 7 },
|
||||
{ key: "bucket", type: "string", required: true },
|
||||
],
|
||||
},
|
||||
serverSource: `export default {
|
||||
id: "announcements", version: "0.1.0",
|
||||
register(context) { ${recorder.expression}.push(context.config); },
|
||||
start(context) { ${recorder.expression}.push(context.config); },
|
||||
stop(context) { ${recorder.expression}.push(context.config); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
const resolution = resolveModuleEligibility(
|
||||
discovered,
|
||||
{ modules: [] },
|
||||
{ settings: { records: [{ key: "bucket", moduleId: "announcements", value: "slides" }] } },
|
||||
);
|
||||
const runtime = await startDiscoveredModuleRuntime(
|
||||
resolution.eligibleModules,
|
||||
fakeModuleRuntimeServices(),
|
||||
{ configurations: resolution.configurations },
|
||||
);
|
||||
await runtime.stop();
|
||||
|
||||
expect(recorder.values).toHaveLength(3);
|
||||
const [registerConfig, startConfig, stopConfig] = recorder.values as ModuleConfiguration[];
|
||||
// Configuration is available during `register` too — it is an already-resolved in-memory
|
||||
// snapshot, not a database-backed getter, so it reintroduces no I/O into a hook that must stay
|
||||
// declarative.
|
||||
expect(registerConfig?.getInteger("slideSeconds")).toBe(7);
|
||||
expect(registerConfig?.getString("bucket")).toBe("slides");
|
||||
// The exact same object across every hook, so two hooks can never disagree about a value.
|
||||
expect(startConfig).toBe(registerConfig);
|
||||
expect(stopConfig).toBe(registerConfig);
|
||||
expect(Object.isFrozen(registerConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it("gives each module only its own configuration, never another module's", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createReferenceRecorder();
|
||||
for (const id of ["alpha", "bravo"]) {
|
||||
await createRuntimeModule(modulesDirectory, id, {
|
||||
manifest: {
|
||||
...manifestFor(id),
|
||||
configuration: [{ key: "label", type: "string", required: true }],
|
||||
},
|
||||
serverSource: `export default {
|
||||
id: ${JSON.stringify(id)}, version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push([context.moduleId, context.config.getString("label"), context.config.keys()]); }
|
||||
};`,
|
||||
});
|
||||
}
|
||||
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
const resolution = resolveModuleEligibility(
|
||||
discovered,
|
||||
{ modules: [] },
|
||||
{
|
||||
settings: {
|
||||
records: [
|
||||
{ key: "label", moduleId: "alpha", value: "alpha-value" },
|
||||
{ key: "label", moduleId: "bravo", value: "bravo-value" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
const runtime = await startDiscoveredModuleRuntime(
|
||||
resolution.eligibleModules,
|
||||
fakeModuleRuntimeServices(),
|
||||
{ configurations: resolution.configurations },
|
||||
);
|
||||
await runtime.stop();
|
||||
|
||||
expect(recorder.values).toEqual([
|
||||
["alpha", "alpha-value", ["label"]],
|
||||
["bravo", "bravo-value", ["label"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("supplies an empty configuration, never undefined, to a module that declares none", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "plain", {
|
||||
serverSource: `export default {
|
||||
id: "plain", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push([typeof context.config, context.config.keys()]); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||
await runtime.stop();
|
||||
|
||||
// Module code never has to branch on whether the platform supplied a configuration.
|
||||
expect(recorder.values).toEqual([["object", []]]);
|
||||
});
|
||||
|
||||
it("keeps the services object one shared identity even though configuration is per-module", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
for (const id of ["alpha", "bravo"]) {
|
||||
await createRuntimeModule(modulesDirectory, id, {
|
||||
manifest: {
|
||||
...manifestFor(id),
|
||||
configuration: [{ key: "label", type: "string", default: id }],
|
||||
},
|
||||
serverSource: `export default {
|
||||
id: ${JSON.stringify(id)}, version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); ${recorder.expression}.push(context.config); }
|
||||
};`,
|
||||
});
|
||||
}
|
||||
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory, services));
|
||||
const resolution = resolveModuleEligibility(
|
||||
discovered,
|
||||
{ modules: [] },
|
||||
{ settings: { records: [] } },
|
||||
);
|
||||
const runtime = await startDiscoveredModuleRuntime(resolution.eligibleModules, services, {
|
||||
configurations: resolution.configurations,
|
||||
});
|
||||
await runtime.stop();
|
||||
|
||||
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
|
||||
// This is the reason configuration is a sibling of `services` rather than a field inside it:
|
||||
// `services` is deliberately one shared object for the whole runtime, while configuration must
|
||||
// differ per module. Both properties hold simultaneously.
|
||||
expect(alphaServices).toBe(services);
|
||||
expect(bravoServices).toBe(services);
|
||||
expect(alphaConfig).not.toBe(bravoConfig);
|
||||
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
|
||||
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ function manifestFor(
|
||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -51,7 +51,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
version,
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
- [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
|
||||
- [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
|
||||
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.
|
||||
|
||||
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-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). |
|
||||
| `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
|
||||
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||
@@ -387,9 +389,12 @@ account.
|
||||
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||||
pool (see [Module database access](#module-database-access)).
|
||||
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
5. Read the stored module enablement configuration and resolve, over that exact same discovered
|
||||
inventory, which modules are **eligible** to run (`resolveModuleEligibility`,
|
||||
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle)).
|
||||
5. Read the stored module enablement configuration and then the stored module configuration, and
|
||||
resolve, over that exact same discovered inventory, which modules are **eligible** to run and
|
||||
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
|
||||
never rediscovered for this step — passing it the services object every module's `start`/`stop`
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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.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.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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
[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
|
||||
|
||||
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
|
||||
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,
|
||||
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
|
||||
@@ -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`,
|
||||
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
|
||||
|
||||
Between discovery and startup, the platform consults its own **enablement configuration** and
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./authorization.js";
|
||||
export * from "./identifiers.js";
|
||||
export * from "./identity.js";
|
||||
export * from "./module-configuration.js";
|
||||
export * from "./module-database.js";
|
||||
export * from "./module-http.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The complete set of value shapes a module may declare as configuration.
|
||||
*
|
||||
* Deliberately six, each earning its place against a real module need rather than anticipating one:
|
||||
* a bucket name (`string`), a poll interval or slide duration (`integer`), a feature toggle
|
||||
* (`boolean`), a fixed choice (`enum`), an endpoint (`url`), and a deployment-supplied credential
|
||||
* (`secret`). There is no `object`, no `array`, no nesting, and no recursion — this is a validation
|
||||
* vocabulary, not a form-builder DSL, and a module that genuinely needs structured data owns a table
|
||||
* for it (ADR 0029).
|
||||
*
|
||||
* `secret` is a *type* rather than a flag on `string` because it is not a string with an extra
|
||||
* property: it is never persisted by Ekklesia, never defaulted, never returned by any API, and
|
||||
* never printed by diagnostics. Making that a separate declaration is what lets every one of those
|
||||
* rules be enforced structurally instead of remembered.
|
||||
*/
|
||||
export const MODULE_CONFIGURATION_TYPES = [
|
||||
"string",
|
||||
"integer",
|
||||
"boolean",
|
||||
"enum",
|
||||
"url",
|
||||
"secret",
|
||||
] as const;
|
||||
|
||||
export const moduleConfigurationTypeSchema = z.enum(MODULE_CONFIGURATION_TYPES);
|
||||
|
||||
export type ModuleConfigurationType = z.infer<typeof moduleConfigurationTypeSchema>;
|
||||
|
||||
/**
|
||||
* Names JavaScript can carry safely and JSON can round-trip without surprise: lower camelCase,
|
||||
* letters and digits only, bounded.
|
||||
*
|
||||
* No dots, so a configuration key can never be mistaken for a permission or capability identifier,
|
||||
* which are dot-namespaced. No underscores or leading capitals, so there is exactly one spelling of
|
||||
* any key. And an explicit reserved-name rejection below, because `constructor`, `prototype`, and
|
||||
* friends satisfy this pattern while being exactly the property names that turn an ordinary object
|
||||
* write into prototype pollution. Core additionally never indexes a plain object with a
|
||||
* module-supplied key — every lookup goes through a `Map` — so this check is defense in depth over a
|
||||
* structural guarantee rather than the only thing standing between a manifest and `Object.prototype`.
|
||||
*/
|
||||
const RESERVED_CONFIGURATION_KEYS: ReadonlySet<string> = new Set([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"hasOwnProperty",
|
||||
"isPrototypeOf",
|
||||
"propertyIsEnumerable",
|
||||
"prototype",
|
||||
"toLocaleString",
|
||||
"toString",
|
||||
"valueOf",
|
||||
]);
|
||||
|
||||
export const moduleConfigurationKeySchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z][a-zA-Z0-9]*$/u, "Must be a lower camelCase configuration key.")
|
||||
.refine(
|
||||
(key) => !RESERVED_CONFIGURATION_KEYS.has(key),
|
||||
"Must not be a reserved JavaScript object property name.",
|
||||
);
|
||||
|
||||
export type ModuleConfigurationKey = z.infer<typeof moduleConfigurationKeySchema>;
|
||||
|
||||
/** The one bound on any stored configuration literal. Generous for a bucket name, an endpoint, or a
|
||||
* short list, and far below anything that would make the per-module document expensive to read at
|
||||
* startup. Enforced by the contract, by the store, and by the database. */
|
||||
export const MODULE_CONFIGURATION_VALUE_MAX_LENGTH = 2048;
|
||||
|
||||
/** Operator-facing text only. Never parsed, never interpreted, and never used to decide anything —
|
||||
* changing it can never change what a value means, exactly like a permission's `description`. */
|
||||
const descriptionSchema = z.string().trim().min(1).max(240).optional();
|
||||
|
||||
const commonFields = {
|
||||
key: moduleConfigurationKeySchema,
|
||||
description: descriptionSchema,
|
||||
/** When true and no value resolves, the module is not eligible to start and the platform reports
|
||||
* a structured reason naming this key. Mutually exclusive with `default`: a key that always has a
|
||||
* value can never be missing, so declaring both states an intent the platform cannot honour. */
|
||||
required: z.boolean().default(false),
|
||||
};
|
||||
|
||||
const stringDeclarationSchema = z
|
||||
.object({
|
||||
...commonFields,
|
||||
type: z.literal("string"),
|
||||
default: z.string().max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
|
||||
minLength: z.number().int().min(0).max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
|
||||
maxLength: z.number().int().min(1).max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const integerDeclarationSchema = z
|
||||
.object({
|
||||
...commonFields,
|
||||
type: z.literal("integer"),
|
||||
default: z.number().int().safe().optional(),
|
||||
minimum: z.number().int().safe().optional(),
|
||||
maximum: z.number().int().safe().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const booleanDeclarationSchema = z
|
||||
.object({
|
||||
...commonFields,
|
||||
type: z.literal("boolean"),
|
||||
default: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const enumDeclarationSchema = z
|
||||
.object({
|
||||
...commonFields,
|
||||
type: z.literal("enum"),
|
||||
/** Bounded and non-empty: an enum with no members can never be satisfied, and one with hundreds
|
||||
* is a lookup table that belongs in the module's own storage. */
|
||||
values: z.array(z.string().min(1).max(120)).min(1).max(32),
|
||||
default: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const urlDeclarationSchema = z
|
||||
.object({
|
||||
...commonFields,
|
||||
type: z.literal("url"),
|
||||
default: z.string().max(MODULE_CONFIGURATION_VALUE_MAX_LENGTH).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* A deployment-supplied credential. Note what this shape does **not** have: no `default`, because a
|
||||
* checked-in default credential is a leaked credential, and no constraints, because Ekklesia never
|
||||
* inspects the value.
|
||||
*/
|
||||
const secretDeclarationSchema = z
|
||||
.object({
|
||||
...commonFields,
|
||||
type: z.literal("secret"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const moduleConfigurationDeclarationSchema = z
|
||||
.discriminatedUnion("type", [
|
||||
stringDeclarationSchema,
|
||||
integerDeclarationSchema,
|
||||
booleanDeclarationSchema,
|
||||
enumDeclarationSchema,
|
||||
urlDeclarationSchema,
|
||||
secretDeclarationSchema,
|
||||
])
|
||||
.superRefine((declaration, context) => {
|
||||
if (
|
||||
declaration.type !== "secret" &&
|
||||
declaration.required &&
|
||||
declaration.default !== undefined
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A key with a default can never be missing, so it must not also be required.",
|
||||
path: ["required"],
|
||||
});
|
||||
}
|
||||
if (declaration.type === "string") {
|
||||
if (
|
||||
declaration.minLength !== undefined &&
|
||||
declaration.maxLength !== undefined &&
|
||||
declaration.minLength > declaration.maxLength
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "minLength must not exceed maxLength.",
|
||||
path: ["minLength"],
|
||||
});
|
||||
}
|
||||
if (declaration.default !== undefined) {
|
||||
if (
|
||||
(declaration.minLength !== undefined &&
|
||||
declaration.default.length < declaration.minLength) ||
|
||||
(declaration.maxLength !== undefined &&
|
||||
declaration.default.length > declaration.maxLength)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A default value must satisfy its own declared constraints.",
|
||||
path: ["default"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (declaration.type === "integer") {
|
||||
if (
|
||||
declaration.minimum !== undefined &&
|
||||
declaration.maximum !== undefined &&
|
||||
declaration.minimum > declaration.maximum
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "minimum must not exceed maximum.",
|
||||
path: ["minimum"],
|
||||
});
|
||||
}
|
||||
if (declaration.default !== undefined) {
|
||||
if (
|
||||
(declaration.minimum !== undefined && declaration.default < declaration.minimum) ||
|
||||
(declaration.maximum !== undefined && declaration.default > declaration.maximum)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A default value must satisfy its own declared constraints.",
|
||||
path: ["default"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (declaration.type === "enum") {
|
||||
if (new Set(declaration.values).size !== declaration.values.length) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Enum values must be unique.",
|
||||
path: ["values"],
|
||||
});
|
||||
}
|
||||
if (declaration.default !== undefined && !declaration.values.includes(declaration.default)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A default value must be one of the declared enum values.",
|
||||
path: ["default"],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (declaration.type === "url" && declaration.default !== undefined) {
|
||||
if (!isSupportedConfigurationUrl(declaration.default)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A default URL must be an absolute http: or https: URL.",
|
||||
path: ["default"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type ModuleConfigurationDeclarationInput = z.input<
|
||||
typeof moduleConfigurationDeclarationSchema
|
||||
>;
|
||||
export type ModuleConfigurationDeclaration = z.output<typeof moduleConfigurationDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* The one URL rule for `type: "url"`, shared by declaration validation and value validation so the
|
||||
* two can never disagree.
|
||||
*
|
||||
* Only absolute `http:`/`https:` URLs. Everything else — `file:`, `javascript:`, `data:`, a
|
||||
* protocol-relative `//host`, a bare hostname — is rejected outright rather than normalized,
|
||||
* because a configuration value is frequently handed straight to `fetch`, and the difference
|
||||
* between "an endpoint an operator meant" and "a local file the server can read" must not be a
|
||||
* parsing accident.
|
||||
*/
|
||||
export function isSupportedConfigurationUrl(value: string): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
}
|
||||
|
||||
/** Every value a resolved configuration can hold. `secret` resolves to a `string` like the others —
|
||||
* the difference is entirely in where it comes from and where it may go, never in its shape. */
|
||||
export type ModuleConfigurationValue = string | number | boolean;
|
||||
|
||||
/**
|
||||
* A module's own validated configuration, frozen at startup.
|
||||
*
|
||||
* **This is the whole module-facing contract.** It has no reference to PostgreSQL, no environment
|
||||
* access, no module id but its own, and no way to reach another module's values: the object simply
|
||||
* does not contain them, and no accessor takes a namespace. A future standalone host implements
|
||||
* this exact interface from a YAML file, its own database, or anything else, and the module's
|
||||
* feature code cannot tell the difference — which is the point (ADR 0029).
|
||||
*
|
||||
* Every accessor fails closed: an undeclared key, a key whose declared type does not match the
|
||||
* accessor, and an absent optional value are all `undefined`, deliberately indistinguishable, in the
|
||||
* same style as `PermissionRegistry.get`. A key the manifest declares `required`, or gives a
|
||||
* `default`, is guaranteed present by the time any module code runs — the platform refuses to start
|
||||
* a module whose required configuration is missing — so those accessors return `undefined` only for
|
||||
* a key the module never declared.
|
||||
*/
|
||||
export interface ModuleConfiguration {
|
||||
/** Every key this module declared *and* that resolved to a value, sorted. Never another module's,
|
||||
* and never a key that only exists in storage. */
|
||||
keys(): readonly string[];
|
||||
has(key: string): boolean;
|
||||
/** The validated value under its declared type, or `undefined`. Secrets are reachable here too —
|
||||
* a module reading its own secret is the entire point — which is exactly why this object must
|
||||
* never be serialized into a response. Core never does; see `module-configuration-routes.ts`. */
|
||||
get(key: string): ModuleConfigurationValue | undefined;
|
||||
getString(key: string): string | undefined;
|
||||
getInteger(key: string): number | undefined;
|
||||
getBoolean(key: string): boolean | undefined;
|
||||
/** The resolved member of a `type: "enum"` key. Separate from `getString` so a module cannot
|
||||
* silently start reading a free-text key as though it were a closed choice. */
|
||||
getEnum(key: string): string | undefined;
|
||||
/** A validated absolute `http:`/`https:` URL string. Returned as a string rather than a `URL` so
|
||||
* the contract stays JSON-shaped and identical across hosts. */
|
||||
getUrl(key: string): string | undefined;
|
||||
/** A deployment-supplied credential. Never persisted by Ekklesia, never returned by any HTTP
|
||||
* route, and never printed by diagnostics. */
|
||||
getSecret(key: string): string | undefined;
|
||||
}
|
||||
|
||||
/** The empty configuration every module without declarations receives. `context.config` is therefore
|
||||
* always present and never `undefined`, so module code never branches on whether the platform
|
||||
* supplied one. */
|
||||
export const EMPTY_MODULE_CONFIGURATION: ModuleConfiguration = Object.freeze({
|
||||
get: (): undefined => undefined,
|
||||
getBoolean: (): undefined => undefined,
|
||||
getEnum: (): undefined => undefined,
|
||||
getInteger: (): undefined => undefined,
|
||||
getSecret: (): undefined => undefined,
|
||||
getString: (): undefined => undefined,
|
||||
getUrl: (): undefined => undefined,
|
||||
has: (): boolean => false,
|
||||
keys: (): readonly string[] => Object.freeze([]),
|
||||
});
|
||||
|
||||
/**
|
||||
* How a host supplies deployment-owned secret material for one module's declared `secret` keys.
|
||||
*
|
||||
* Scoped by construction: a lookup takes a module id **and** a key the platform already validated
|
||||
* against that module's own manifest, so there is no call shape that could read another module's
|
||||
* secret and no way to ask for an arbitrary environment variable. A module never sees this
|
||||
* interface at all — the host resolves secrets into the module's frozen snapshot before any module
|
||||
* code runs.
|
||||
*/
|
||||
export interface ModuleSecretSource {
|
||||
get(moduleId: string, key: string): string | undefined;
|
||||
}
|
||||
|
||||
/** A secret source that supplies nothing. The explicit "this deployment provides no module secrets"
|
||||
* value, so a caller states that rather than passing `undefined` and hoping. */
|
||||
export const EMPTY_MODULE_SECRET_SOURCE: ModuleSecretSource = Object.freeze({
|
||||
get: (): undefined => undefined,
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import type { EkklesiaPrincipal } from "./identity.js";
|
||||
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
|
||||
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||
|
||||
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
|
||||
@@ -220,6 +221,12 @@ export interface ModuleRouteHandlerContext {
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
readonly services: ModuleRuntimeServices;
|
||||
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
|
||||
* same object, not a re-read — so a handler and a lifecycle hook can never disagree about a value
|
||||
* (ADR 0029). Never serialized into a response by Core; a module that chooses to expose part of
|
||||
* its own configuration does so through its own route, deliberately, and must never expose a
|
||||
* secret. */
|
||||
readonly config: ModuleConfiguration;
|
||||
}
|
||||
|
||||
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
||||
|
||||
@@ -43,24 +43,62 @@ export const moduleRuntimeStateSchema = z.enum(MODULE_RUNTIME_STATES);
|
||||
export type ModuleRuntimeState = z.infer<typeof moduleRuntimeStateSchema>;
|
||||
|
||||
/**
|
||||
* Why an enabled module is not running. A structured reason plus the exact dependency it names,
|
||||
* rather than one state name per case — four states would encode the same two facts twice.
|
||||
* Why an enabled module is not running. A structured reason plus the exact thing it names — a
|
||||
* dependency, or a configuration key — rather than one state name per case, which would encode the
|
||||
* same two facts twice.
|
||||
*
|
||||
* `required_dependency_disabled` means the dependency an administrator turned off is the direct
|
||||
* cause. `required_dependency_blocked` means the named dependency is itself blocked, so the cause
|
||||
* is transitive; following the chain always terminates at a `..._disabled` entry, because a
|
||||
* dependency that is merely absent or incompatible is rejected by discovery long before
|
||||
* eligibility is resolved.
|
||||
* is transitive; following the chain always terminates at a reason that is not
|
||||
* `required_dependency_blocked`, because a dependency that is merely absent or incompatible is
|
||||
* rejected by discovery long before eligibility is resolved. Since ADR 0029 that terminal reason
|
||||
* may also be one of the three configuration reasons below — a module blocked by its own
|
||||
* configuration blocks its required dependents exactly like a disabled one does.
|
||||
*/
|
||||
export const MODULE_BLOCKED_REASONS = [
|
||||
"required_dependency_disabled",
|
||||
"required_dependency_blocked",
|
||||
"configuration_missing",
|
||||
"configuration_invalid",
|
||||
"secret_missing",
|
||||
] as const;
|
||||
|
||||
export const moduleBlockedReasonSchema = z.enum(MODULE_BLOCKED_REASONS);
|
||||
|
||||
export type ModuleBlockedReason = z.infer<typeof moduleBlockedReasonSchema>;
|
||||
|
||||
/**
|
||||
* The three configuration reasons an enabled module may fail to become eligible (ADR 0029). They
|
||||
* are separate because each has a **different operational fix**, which is the only justification
|
||||
* for a distinct reason code:
|
||||
*
|
||||
* - `configuration_missing` — a required key has no stored value and no default. Fix: set a value
|
||||
* through the configuration API.
|
||||
* - `configuration_invalid` — a stored value exists but does not satisfy the module's own declared
|
||||
* type or constraints. Fix: correct the value. A persisted value is never silently coerced,
|
||||
* truncated, or replaced by a default.
|
||||
* - `secret_missing` — a required `secret` key has no deployment-supplied value. Fix: set it in the
|
||||
* deployment environment, not in the database — Ekklesia never stores secret material
|
||||
* (ADR 0029).
|
||||
*
|
||||
* Each names the offending **key**, never the value. A key is public — it is declared in an
|
||||
* installed module's manifest — while a value may be a credential.
|
||||
*/
|
||||
export const MODULE_CONFIGURATION_BLOCKED_REASONS = [
|
||||
"configuration_missing",
|
||||
"configuration_invalid",
|
||||
"secret_missing",
|
||||
] as const;
|
||||
|
||||
export type ModuleConfigurationBlockedReason =
|
||||
(typeof MODULE_CONFIGURATION_BLOCKED_REASONS)[number];
|
||||
|
||||
export function isModuleConfigurationBlockedReason(
|
||||
reason: ModuleBlockedReason,
|
||||
): reason is ModuleConfigurationBlockedReason {
|
||||
return (MODULE_CONFIGURATION_BLOCKED_REASONS as readonly string[]).includes(reason);
|
||||
}
|
||||
|
||||
/** Both kinds of edge a module's manifest can declare as required: another module, or a capability
|
||||
* some other module provides. Both block identically — a required capability is exactly as
|
||||
* required as a required module — and both name the *providing module* so an operator has
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
||||
import {
|
||||
type ModuleConfigurationDeclaration,
|
||||
moduleConfigurationDeclarationSchema,
|
||||
} from "./module-configuration.js";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
logicalBucketIdSchema,
|
||||
@@ -102,17 +106,25 @@ function reportDuplicates(
|
||||
}
|
||||
|
||||
/**
|
||||
* The one manifest shape Core accepts. Adding `entrypoints.ui` is an incompatible shape change —
|
||||
* version 3's `entrypoints` object is `.strict()` and rejects `ui` outright — so the canonical
|
||||
* manifest moves to `manifestVersion: 4` rather than reinterpreting version 3 (ADR 0021).
|
||||
* The one manifest shape Core accepts. Adding a top-level `configuration` array is an incompatible
|
||||
* shape change — version 4's manifest object is `.strict()` and rejects `configuration` outright —
|
||||
* so the canonical manifest moves to `manifestVersion: 5` rather than reinterpreting version 4
|
||||
* (ADR 0029).
|
||||
*
|
||||
* Version 3, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||
* *before* deciding whether it may start, so an enabled module whose required configuration is
|
||||
* missing is reported as `blocked` with a structured reason instead of taking API startup down from
|
||||
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||
* importing a single line of module code, exactly as they always have.
|
||||
*
|
||||
* Version 4, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||
* (ADR 0006) and version 2 by version 3 (ADR 0014). One manifest document can therefore never be
|
||||
* read as two different versions. Upgrading a backend-only version 3 manifest is a one-character
|
||||
* edit: no field added by version 4 is required.
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
|
||||
* manifest document can therefore never be read as two different versions. Upgrading a version 4
|
||||
* manifest is a one-character edit: no field added by version 5 is required.
|
||||
*/
|
||||
export const MODULE_MANIFEST_VERSION = 4;
|
||||
export const MODULE_MANIFEST_VERSION = 5;
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
@@ -130,6 +142,10 @@ export const moduleManifestSchema = z
|
||||
dependencies: dependenciesSchema,
|
||||
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||
permissions: z.array(permissionDeclarationSchema).default(() => []),
|
||||
/** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert
|
||||
* data: declaring configuration never causes Core to read a database, contact a host, or run
|
||||
* module code during discovery. A module that needs nothing configured omits the field. */
|
||||
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
entrypoints: entrypointsSchema,
|
||||
})
|
||||
@@ -153,6 +169,15 @@ export const moduleManifestSchema = z
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
// One namespace for every configuration key a module owns, so an ordinary key and a secret key
|
||||
// can never collide: a module cannot declare `apiToken` as both a defaultable string and a
|
||||
// deployment-supplied secret and leave the platform to guess which one a stored row means.
|
||||
reportDuplicates(
|
||||
manifest.configuration.map((declaration) => declaration.key),
|
||||
["configuration"],
|
||||
context,
|
||||
"key",
|
||||
);
|
||||
|
||||
manifest.capabilities.forEach((capability, index) => {
|
||||
if (!capability.id.startsWith(`${manifest.id}.`)) {
|
||||
@@ -228,6 +253,10 @@ export function safeParseModuleManifest(input: unknown) {
|
||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
||||
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
||||
* here for discoverability alongside the manifest's other per-field declaration types. */
|
||||
export type ModuleManifestConfigurationDeclaration = ModuleConfigurationDeclaration;
|
||||
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
||||
/** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's
|
||||
* authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||
@@ -28,6 +29,15 @@ export interface ModuleRegistrationContext {
|
||||
* afterward from `start`, `stop`, or any other post-registration code path. */
|
||||
readonly http: ModuleHttpRouteRegistrar;
|
||||
readonly moduleId: ModuleId;
|
||||
/**
|
||||
* This module's own validated configuration, available here as well as at runtime (ADR 0029).
|
||||
*
|
||||
* Present during `register` deliberately, and without reopening the rule that registration stays
|
||||
* declarative and I/O-free: this is an already-resolved frozen snapshot held in memory, not a
|
||||
* database-backed getter, so reading it performs no query and cannot fail. That is exactly why
|
||||
* `services` — which *is* a live database facade — remains absent from this context.
|
||||
*/
|
||||
readonly config: ModuleConfiguration;
|
||||
}
|
||||
|
||||
/** The same context shape for both `start` and `stop`: whichever hook runs, it receives the exact
|
||||
@@ -38,6 +48,19 @@ export interface ModuleRuntimeContext {
|
||||
readonly capabilities: CapabilityResolver;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly services: ModuleRuntimeServices;
|
||||
/**
|
||||
* This module's own validated configuration, frozen for the lifetime of the process.
|
||||
*
|
||||
* Deliberately **not** a field on `ModuleRuntimeServices`: that object is one shared instance
|
||||
* every module receives, and configuration is per-module by definition. It sits beside `moduleId`
|
||||
* — the context's other per-module fact — so the scoping is visible in the shape rather than
|
||||
* asserted in a comment.
|
||||
*
|
||||
* Immutable for the process lifetime: a configuration change is applied by the next restart, so a
|
||||
* value read during `start` and the same value read in a request handler an hour later are the
|
||||
* same value (ADR 0029).
|
||||
*/
|
||||
readonly config: ModuleConfiguration;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeDefinition {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
isModuleConfigurationBlockedReason,
|
||||
MODULE_BLOCKED_REASONS,
|
||||
MODULE_CONFIGURATION_BLOCKED_REASONS,
|
||||
MODULE_DEPENDENCY_KINDS,
|
||||
MODULE_MANIFEST_VERSION,
|
||||
MODULE_RUNTIME_STATES,
|
||||
@@ -13,7 +15,7 @@ import {
|
||||
|
||||
function validManifest(): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
version: "0.1.0",
|
||||
@@ -209,17 +211,23 @@ describe("module manifest contract", () => {
|
||||
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a version 3 manifest rather than silently interpreting it as version 4", () => {
|
||||
// A backend-only version 3 manifest is otherwise byte-identical to a valid version 4 one:
|
||||
// only the version literal separates them, and it is not reinterpreted.
|
||||
it("rejects a version 3 manifest rather than silently interpreting it as a later version", () => {
|
||||
const version3Manifest = { ...validManifest(), manifestVersion: 3 };
|
||||
|
||||
expect(safeParseModuleManifest(version3Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a version 4 manifest rather than silently interpreting it as version 5", () => {
|
||||
// A manifest declaring no configuration is otherwise byte-identical between version 4 and
|
||||
// version 5: only the version literal separates them, and it is not reinterpreted (ADR 0029).
|
||||
const version4Manifest = { ...validManifest(), manifestVersion: 4 };
|
||||
|
||||
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(4);
|
||||
for (const manifestVersion of [1, 2, 3, 5, "4", 4.5, null]) {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(5);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||
}
|
||||
expect(
|
||||
@@ -353,7 +361,21 @@ describe("module manifest contract", () => {
|
||||
expect(MODULE_BLOCKED_REASONS).toEqual([
|
||||
"required_dependency_disabled",
|
||||
"required_dependency_blocked",
|
||||
"configuration_missing",
|
||||
"configuration_invalid",
|
||||
"secret_missing",
|
||||
]);
|
||||
// The three configuration reasons are a subset, and each corresponds to a real implemented
|
||||
// check with a distinct operator fix (ADR 0029) — the only justification for a separate code.
|
||||
expect(MODULE_CONFIGURATION_BLOCKED_REASONS).toEqual([
|
||||
"configuration_missing",
|
||||
"configuration_invalid",
|
||||
"secret_missing",
|
||||
]);
|
||||
for (const reason of MODULE_CONFIGURATION_BLOCKED_REASONS) {
|
||||
expect(isModuleConfigurationBlockedReason(reason)).toBe(true);
|
||||
}
|
||||
expect(isModuleConfigurationBlockedReason("required_dependency_disabled")).toBe(false);
|
||||
expect(MODULE_DEPENDENCY_KINDS).toEqual(["module", "capability"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
defineModuleRuntime,
|
||||
isModuleHttpMethod,
|
||||
MODULE_HTTP_METHODS,
|
||||
@@ -59,6 +60,9 @@ describe("defineModuleRuntime", () => {
|
||||
provide: () => undefined,
|
||||
resolve: () => undefined,
|
||||
},
|
||||
// A module declaring no configuration still receives an empty snapshot rather than
|
||||
// `undefined`, so module code never branches on whether the platform supplied one.
|
||||
config: EMPTY_MODULE_CONFIGURATION,
|
||||
http,
|
||||
moduleId: "example",
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
|
||||
const manifest =
|
||||
options.manifest ??
|
||||
JSON.stringify({
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id: "example",
|
||||
name: "Example",
|
||||
version: "0.1.0",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 4,
|
||||
"manifestVersion": 5,
|
||||
"id": "example",
|
||||
"name": "Example",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 4,
|
||||
"manifestVersion": 5,
|
||||
"id": "example",
|
||||
"name": "Example Module",
|
||||
"description": "A minimal Ekklesia module template with backend behavior and optional UI.",
|
||||
@@ -21,6 +21,15 @@
|
||||
"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": {
|
||||
"buckets": [
|
||||
{
|
||||
|
||||
@@ -31,7 +31,17 @@ export default defineModuleRuntime({
|
||||
const rows = await context.services.database
|
||||
.select({ id: records.id, value: records.value })
|
||||
.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 {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type CapabilityId,
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
type ModuleConfiguration,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteDefinitionInput,
|
||||
type ModuleRouteHandlerContext,
|
||||
@@ -31,6 +33,9 @@ async function registerAndCaptureRoutes(): Promise<{
|
||||
const provided = new Map<CapabilityId, unknown>();
|
||||
const routes: ModuleRouteDefinitionInput[] = [];
|
||||
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: {
|
||||
provide(capabilityId, implementation) {
|
||||
provided.set(capabilityId, implementation);
|
||||
@@ -124,6 +129,7 @@ describe("module template", () => {
|
||||
database: fakeModuleDatabase(fakeRows),
|
||||
});
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
config: EMPTY_MODULE_CONFIGURATION,
|
||||
moduleId: "example",
|
||||
params: Object.freeze({}),
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
@@ -133,7 +139,69 @@ describe("module template", () => {
|
||||
const response = await route.handler(new Request("http://localhost/records"), context);
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
"core/tests/authorization-bootstrap.integration.ts",
|
||||
"core/tests/dashboard-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/module-database.integration.ts",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user