diff --git a/README.md b/README.md index 2361657..2615c68 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ Phase 4C (Core authorization), Phase 5A (module authorization integration), Phas transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening 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.** The operational +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 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 @@ -386,9 +388,70 @@ restores its position; deleting a group ungroups its modules through the databas application cleanup. See [ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md). +Phase 7A pass 1 gives the platform an **operational module lifecycle** — the smallest one that is +actually useful, and nothing beyond it. Until now the accepted module set was exactly "whatever was +found on disk at startup", and `ModuleLifecycleStage` named nine states that nothing produced or +consumed. That vocabulary is replaced by two axes that real code produces: + +```text +configuration enabled / disabled persistent, administrator-owned +runtime running / disabled / blocked observed per boot +``` + +**Enablement is host configuration, and a module can never see it.** One Core table (migration +`0004`) holds `module_id` and `enabled` — no install source, download URL, update channel, signature, +or JSON blob, because those are the columns an installer would need and their absence is what stops +one being built by accident. A module never declares or reads its own enabled state, and is never +told it is enabled: its host loads and starts it, or does not. That is what keeps the same module +implementation runnable under a future standalone host. + +**A newly discovered module is enabled, and the default is the absence of a row.** Deploying a module +is still sufficient to run it — no initialization write, no database edit — mirroring the rule that a +module with no Dashboard placement is still visible. An explicit record always wins, in both +directions, and a record for a module that is no longer deployed is retained and ignored, so +redeploying restores the decision. + +**Required dependencies block; optional ones never do.** Disabling `storage` does not silently +re-enable it because something needs it — it makes every module that *requires* it ineligible, +transitively, with a structured reason (`required_dependency_disabled` → +`required_dependency_blocked`) naming the module an administrator must act on. A required *capability* +dependency blocks identically, because its only provider is a module. An optional dependency being +disabled is the same situation as it never being installed: the dependent starts, and +`capabilities.resolve(...)` returns `undefined`. + +**Exposure follows eligibility, structurally.** `startApi` hands the same eligible list to the module +runtime and to the UI publication registry, so a disabled module is never imported at all: no hooks, +no routes (an unmounted path gets the ordinary `404` — there is no per-request enablement check +anywhere), no capabilities, no published artifact, and no entry in the browser inventory or Dashboard +navigation. Its **migrations are still discovered and applied**, because deployed schema ownership is +not runtime state, and its **permissions stay registered**, so grants survive an enable/disable cycle. + +**Startup stays all-or-nothing, deliberately, and changes need a restart.** One module that fails to +start still takes API startup down; that guarantee is what ADR 0025/0026/0027 rest on, and inverting +it is a separate design. There is consequently no `failed` runtime state — no live process could +report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable -- +--module ` as the out-of-band path, since the HTTP API is served by the process a bad module +takes down. `PUT /api/v1/module-lifecycle//enabled` writes a decision and reports honestly that +the process has not changed: + +```json +{ "configured": { "enabled": false, "explicit": true }, + "runtime": { "state": "running" }, + "restartRequired": true } +``` + +Nothing is hot-unloaded to make a button feel immediate. `running` means a module's `start` hook +completed and **nothing more** — a module that starts and then cannot reach its object storage has a +health problem, not a lifecycle problem, and this pass adds no health-probe contract. `pnpm doctor` +gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading +logs. Reading and changing both require the new restricted platform permission +`core.modules.lifecycle.manage`. Disabling a module is an **operational** control, not a sandbox: its +code is still on disk and still trusted. There is still **no installer**. See +[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.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, and no client-side -routing. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond +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 the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do not exist. @@ -446,16 +509,20 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 1 permission registered + 4 permissions registered + ○ Module lifecycle skipped + DATABASE_URL is not configured. Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 3 migrations · checksums valid + 5 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 PostgreSQL ✓ Database TLS verified @@ -469,6 +536,8 @@ Identity ○ Identity configuration skipped Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification). Identity runtime configuration (signing key, base URL, trusted origins) is not set. + ✓ Direct client-address mode + Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted. Object storage ○ Check unavailable @@ -496,6 +565,13 @@ CLI into a temporary location and structurally compares it against the checked-i and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade workflow. +`pnpm modules:enable -- --module ` and `pnpm modules:disable -- --module ` record which +installed modules this deployment runs. They read `DATABASE_URL`, run the same manifest-only +discovery every other command does (so a module this deployment cannot see is refused), write one +row, and start nothing — the change takes effect at the next `pnpm api:start`. They exist so a module +that breaks startup can be turned off without the API, since the HTTP lifecycle endpoint is served by +the process such a module takes down. + `pnpm api:start` builds the workspace, then starts the identity HTTP transport (`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated `DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e2e1baf..8a5e375 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,7 +10,11 @@ import type { } from "@ekklesia/core/authorization"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import type { DashboardStore } from "@ekklesia/core/dashboard"; -import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules"; +import type { + ModuleLifecycleSnapshot, + ModuleLifecycleStore, + ModuleUiPublicationRegistry, +} from "@ekklesia/core/modules"; import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import { cors } from "hono/cors"; @@ -18,6 +22,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 { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js"; import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js"; import { createModuleUiRoutes } from "./module-ui-routes.js"; @@ -49,6 +54,20 @@ export interface CreateApiAppOptions { * is: a caller that publishes no module UI must say so explicitly with an empty registry, never * fall through to an implicit one. See `./module-ui-routes.js`. */ readonly moduleUi: ModuleUiPublicationRegistry; + /** + * The platform's operational module lifecycle: the store holding which modules are enabled, and the + * boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028). + * Required rather than defaulted, like every other module option here — a caller exercising no + * modules says so with an empty snapshot, never falls through to an implicit one. + * + * The snapshot must come from the same boot as `moduleRoutes` and `moduleUi`; that is what makes + * "running" an observation of this process rather than a prediction. See + * `./module-lifecycle-routes.js`. + */ + readonly moduleLifecycle: { + readonly snapshot: ModuleLifecycleSnapshot; + readonly store: ModuleLifecycleStore; + }; /** 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 @@ -216,6 +235,21 @@ export function createApiApp(options: CreateApiAppOptions): Hono { }), ); + // Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/` + // belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it + // rather than inside it. See `./module-lifecycle-routes.js`. + app.route( + "/", + createModuleLifecycleRoutes({ + evaluator: options.authorization.evaluator, + registry: options.authorization.registry, + runtime, + snapshot: options.moduleLifecycle.snapshot, + store: options.moduleLifecycle.store, + trustedOrigins: allowedOrigins, + }), + ); + mountModuleHttpRoutes(app, options.moduleRoutes, { evaluator: options.authorization.evaluator, registry: options.authorization.registry, diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index 3074a49..3eb41d6 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -9,13 +9,18 @@ import { } from "@ekklesia/core/dashboard"; import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database"; import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity"; -import { createModuleDatabaseView } from "@ekklesia/core/modules"; +import { + createModuleDatabaseView, + createModuleLifecycleView, + type ModuleLifecycleDrizzleDatabase, +} from "@ekklesia/core/modules"; export interface ApiDatabaseHandle { readonly authorization: AuthorizationDrizzleDatabase; readonly dashboard: DashboardDrizzleDatabase; readonly identity: IdentityDrizzleDatabase; readonly module: ModuleDatabase; + 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. */ readonly close: () => Promise; @@ -24,9 +29,10 @@ export interface ApiDatabaseHandle { /** * Composes the one physical PostgreSQL pool this process uses for Core's identity and * authorization schemas plus the module database facade: one validated, timeout-bounded - * `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by three - * independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`, - * `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()` + * `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent + * typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`, + * `createDashboardDrizzleView`, `createModuleLifecycleView`, `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 * for callers that only need one domain. @@ -43,5 +49,6 @@ export function createApiDatabase( dashboard: createDashboardDrizzleView(pool), identity: createIdentityDrizzleView(pool), module: createModuleDatabaseView(pool), + moduleLifecycle: createModuleLifecycleView(pool), }); } diff --git a/apps/api/src/module-lifecycle-routes.ts b/apps/api/src/module-lifecycle-routes.ts new file mode 100644 index 0000000..606376c --- /dev/null +++ b/apps/api/src/module-lifecycle-routes.ts @@ -0,0 +1,243 @@ +import type { + ModuleBlockedReason, + ModuleBlockingDependency, + ModuleRuntimeState, +} from "@ekklesia/contracts"; +import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization"; +import { + type ModuleLifecycleSnapshot, + type ModuleLifecycleStore, + ModuleLifecycleStoreError, + type ModuleLifecycleView, + resolveModuleLifecycleView, +} 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 of it — the same rule ADR 0025 established for + * `/api/v1/module-ui`, and for the same concrete reason. Every module HTTP route is mounted under + * `/api/v1/modules//`, and `moduleRoutePathSchema` accepts `enabled` as an + * ordinary static segment, so a module named `announcements` registering `PUT /enabled` would land + * on exactly `/api/v1/modules/announcements/enabled`. Publishing the platform's own lifecycle + * 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 whether modules run. A sibling prefix + * cannot collide. + */ +export const MODULE_LIFECYCLE_PATH = "/api/v1/module-lifecycle"; +const MODULE_LIFECYCLE_ENABLED_PATH = `${MODULE_LIFECYCLE_PATH}/:moduleId/enabled`; + +/** + * One module as an operator sees it. Two clearly separated axes, deliberately never merged into a + * single status word. + * + * `configured` is the decision that survives restarts. `runtime` is what *this process* did. They + * can legitimately disagree — Ekklesia does not hot-load or hot-unload modules — and + * `restartRequired` names that disagreement instead of hiding it behind a status that would be a + * lie in one direction or the other. + * + * Nothing here carries a filesystem path, a package name, a manifest path, a directory, an + * entrypoint, a caught error, or a stack. The only identifiers are module ids, capability ids, and + * a fixed reason code from a closed set — all of them values the platform itself produced and + * validated. + */ +export interface ModuleLifecycleModuleView { + readonly id: string; + readonly version: string; + readonly configured: { + readonly enabled: boolean; + /** `false` means nobody has decided and the platform default (enabled) applies. */ + readonly explicit: boolean; + }; + readonly runtime: { + readonly state: ModuleRuntimeState; + readonly reason?: ModuleBlockedReason; + readonly dependency?: ModuleBlockingDependency; + }; + readonly restartRequired: boolean; +} + +export interface ModuleLifecycleResponse { + readonly modules: readonly ModuleLifecycleModuleView[]; +} + +export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlewareOptions { + /** The boot snapshot: the discovered inventory this process started from, the eligibility it + * resolved, and the modules the runtime reports it actually started. Captured once at startup and + * never recomputed — this endpoint describes the platform that is running, not one that would run + * if it were restarted right now. */ + readonly snapshot: ModuleLifecycleSnapshot; + readonly store: ModuleLifecycleStore; + readonly trustedOrigins: ReadonlySet; +} + +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, exactly as + * `dashboard-routes.ts` does. */ +function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response { + if (error instanceof ModuleLifecycleStoreError) { + if ( + error.code === "MODULE_LIFECYCLE_READ_FAILED" || + error.code === "MODULE_LIFECYCLE_WRITE_FAILED" + ) { + return sanitizedFailure(c, 503, error.code); + } + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + return sanitizedFailure( + c, + 503, + fallback === "read" ? "MODULE_LIFECYCLE_READ_FAILED" : "MODULE_LIFECYCLE_WRITE_FAILED", + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const ENABLED_BODY_KEYS = new Set(["enabled"]); + +/** + * Structured, closed validation — never a configuration blob. Any key beyond `enabled` makes the + * whole body invalid rather than being silently dropped, so a caller cannot smuggle a field a + * future version might interpret, and a typo fails loudly instead of quietly doing nothing. Mirrors + * `parseGroupBody`/`parsePlacementBody` (`./dashboard-routes.js`) exactly. + */ +function parseEnabledBody(raw: unknown): { enabled: boolean } | undefined { + if (!isRecord(raw) || Object.keys(raw).some((key) => !ENABLED_BODY_KEYS.has(key))) { + return undefined; + } + return typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : undefined; +} + +async function readJsonBody(c: Context): Promise { + try { + return (await c.req.json()) as unknown; + } catch { + return undefined; + } +} + +function present(view: ModuleLifecycleView): ModuleLifecycleModuleView { + return { + configured: { enabled: view.configured.enabled, explicit: view.configured.explicit }, + id: view.moduleId, + restartRequired: view.restartRequired, + runtime: { + state: view.runtime.state, + ...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }), + ...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }), + }, + version: view.version, + }; +} + +/** + * The platform's operational lifecycle resource: which installed modules are enabled, what this + * process actually did with each of them, and the narrow, structured API for changing that. + * + * ## Enablement is host configuration, and structurally cannot be anything else + * + * A module neither declares nor reads its own enabled state. Nothing in a manifest, a capability, a + * permission declaration, or a lifecycle hook can influence a value stored here, and a module is + * never told it is enabled — its host simply loads and starts it, or does not. That is what keeps + * the same module implementation reusable under a future standalone host, whose loading decisions + * are its own (ADR 0028). + * + * ## Changing configuration does not change the running process + * + * `PUT` writes a decision. It does not start, stop, load, unload, or reconfigure anything: there is + * no hot module installation or removal in this platform (ADR 0015, unchanged). The response + * therefore reports the new configuration *and* the unchanged runtime observation *and* + * `restartRequired`, rather than pretending the module stopped. Hot-unloading a module to make a + * button feel immediate would trade a real guarantee — that a module's ESM evaluation happens + * exactly once, and can never be rolled back — for a cosmetic one. + * + * ## Only a module this process can see may be configured + * + * `PUT` against an id that is not in the boot inventory is a `404`. Unlike an inert Dashboard + * placement, an enablement record is operational configuration, and accepting one for an arbitrary + * identifier would make this an unbounded write surface for a delegable permission. *Existing* + * records for modules that are no longer deployed are still retained and simply ignored, so + * redeploying a module restores the decision (see `resolveModuleEligibility`). + * + * ## Reading is administrative too + * + * Both verbs require `core.modules.lifecycle.manage` (restricted). This resource is the only place + * a disabled module, a blocked module, and the required-dependency edges between them are visible; + * `core.modules.ui.read` deliberately covers only what is mountable. Mutations additionally require + * an exact trusted `Origin` and a bounded body before authorization work begins, exactly like every + * other Core mutation. + */ +export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutesOptions): Hono { + const routes = new Hono(); + const requireManage = requirePermission( + { evaluator: options.evaluator, registry: options.registry, runtime: options.runtime }, + CORE_MODULES_LIFECYCLE_MANAGE, + ); + const requireOrigin = requireTrustedOrigin(options.trustedOrigins); + const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES); + const installedModuleIds = new Set( + options.snapshot.discoveredModules.map((module) => module.manifest.id), + ); + + routes.get(MODULE_LIFECYCLE_PATH, requireManage, async (c) => { + c.header("Cache-Control", "no-store"); + // Not per-principal in content, but it is cookie-authorized, so it varies for the same reason + // 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); + return c.json({ modules } satisfies ModuleLifecycleResponse, 200); + } catch (error) { + return storeFailure(c, error, "read"); + } + }); + + routes.put( + MODULE_LIFECYCLE_ENABLED_PATH, + requireOrigin, + mutationBodyLimit, + requireManage, + async (c) => { + c.header("Cache-Control", "no-store"); + const moduleId = c.req.param("moduleId"); + const body = parseEnabledBody(await readJsonBody(c)); + if (!body) { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + if (!installedModuleIds.has(moduleId)) { + return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED"); + } + try { + await options.store.setEnabled({ enabled: body.enabled, moduleId }); + // 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, + ); + if (!view) { + return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED"); + } + return c.json(present(view), 200); + } catch (error) { + return storeFailure(c, error, "write"); + } + }, + ); + + return routes; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 90b6dcf..2b6b14e 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -9,8 +9,11 @@ import { import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { + createModuleLifecycleStore, createModuleUiPublicationRegistry, type DiscoveredModule, + type ModuleEligibilityResolution, + resolveModuleEligibility, type StartedModuleRuntime, startDiscoveredModuleRuntime, } from "@ekklesia/core/modules"; @@ -321,12 +324,15 @@ function bindServer( * facade each get a typed Drizzle view over it — see `./database.js`). * 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool, * then the Core identity runtime, authorization store, and evaluator. - * 4. Start the module runtime from that exact same discovered inventory - * (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the - * same services object every module's `start`/`stop` hook receives. - * 5. Create the Hono application, mounting the started module runtime's published routes and - * passing the exact same services object to every module HTTP handler. - * 6. Bind the Node HTTP server. + * 4. Read the stored module enablement configuration and resolve, from that exact same discovered + * inventory, which modules are eligible to run (`resolveModuleEligibility`). + * 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never + * rediscovering modules a second time — passing it the same services object every module's + * `start`/`stop` hook receives. + * 6. Create the Hono application, mounting the started module runtime's published routes, publishing + * UI for that same eligible set, and passing the exact same services object to every module HTTP + * handler. + * 7. Bind the Node HTTP server. * * Discovery and permission composition happen before any pool, Better Auth instance, module * entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid @@ -336,10 +342,20 @@ function bindServer( * module database facade, identity, authorization) exists before any executable module lifecycle * code runs, and every module route is published only after the entire module runtime has * registered and started successfully, so no request can ever reach a module before every module - * has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point - * and tests can stop the server, the module runtime, and the shared pool deterministically. Never - * applies migrations, generates schema, or issues a module database query merely because modules - * were discovered — the database must already be migrated through `pnpm migrations:apply`. + * has started. A module that is disabled, or blocked by a disabled required dependency, is never + * imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no + * published UI artifact — there is no per-request "is this module enabled?" check anywhere, because + * there is nothing mounted to check. + * + * Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration + * cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` 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 at all. + * + * Returns a handle with an idempotent `shutdown()` so both the executable entry point and tests can + * stop the server, the module runtime, and the shared pool deterministically. Never applies + * migrations, generates schema, or issues a module database query merely because modules were + * discovered — the database must already be migrated through `pnpm migrations:apply`. */ export async function startApi( config: ApiRuntimeConfig, @@ -384,12 +400,33 @@ export async function startApi( platformOwners: authorizationStore, }); + // Read the administrator's enablement decisions and resolve which discovered modules may actually + // start, before a single module entrypoint is imported. A failure here fails startup rather than + // 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(), + ); + } catch (error) { + await safeCloseDatabase(databaseHandle); + throw error; + } + // Module load/register/start failures use Core's own existing rollback (already-started // modules are stopped, in reverse order, before this rejects) — there is no separate runtime // handle to stop here, only the pool this failure leaves open. + // + // Only eligible modules are started, and startup remains all-or-nothing across *that* set: an + // ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no + // routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves + // discovery's topological order, so a started module's required dependencies still precede it. let moduleRuntime: StartedModuleRuntime; try { - moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, { + moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, { ...(options.moduleHookTimeoutMsOverride === undefined ? {} : { hookTimeoutMs: options.moduleHookTimeoutMsOverride }), @@ -409,11 +446,23 @@ export async function startApi( }, moduleRoutes: moduleRuntime.routes, moduleServices: services, - // Built from the exact same discovered inventory, and only once the module runtime has - // started — module startup is all-or-nothing, so every module in this list is a running - // module, and a module that failed to load, register, or start never reaches this line at - // all. Modules declaring no `entrypoints.ui` are simply absent from the registry. - moduleUi: createModuleUiPublicationRegistry(inventory.modules), + // Built from the exact **eligible** set the runtime just started, never the raw discovered + // inventory, and only once startup has succeeded — so the published set is the running set. A + // disabled or dependency-blocked module therefore publishes no artifact, appears in no browser + // inventory, and appears in no resolved Dashboard navigation, by construction rather than by a + // filter somewhere downstream. Modules declaring no `entrypoints.ui` are simply absent. + moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules), + moduleLifecycle: { + // The boot snapshot: what this process discovered, what it resolved, and what the runtime + // reports it actually started — read from the runtime rather than assumed equal to the + // eligible set. + snapshot: Object.freeze({ + discoveredModules: inventory.modules, + eligibility: eligibility.entries, + startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)), + }), + store: moduleLifecycleStore, + }, // A typed view over the same single pool as identity, authorization, and the module facade. dashboard: createDashboardStore(databaseHandle.dashboard), runtime, diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index aad51e8..99a807e 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -195,6 +195,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0001_authorization_core_tables", "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", + "core:0004_module_lifecycle_state", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", @@ -612,6 +613,32 @@ async function signIn( return { cookiePair: setCookie ? firstCookiePair(setCookie) : undefined, response }; } +/** + * Signs up an account over real HTTP, grants it platform-owner authority through Core's own real + * bootstrap operation (exactly what `pnpm authorization:bootstrap-owner` performs), and signs it + * in — so a scenario needing an administrator exercises the genuine evaluator path rather than a + * stubbed decision. + */ +async function signUpPlatformOwner( + origin: string, + url: string, +): Promise<{ readonly cookie: string; readonly userId: string }> { + const email = `owner-${randomBytes(6).toString("hex")}@example.test`; + const password = "correct horse battery staple"; + const created = await signUp(origin, email, password); + const { close, db } = createAuthorizationDrizzleDatabase(url); + try { + await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(created.body.user.id) }); + } finally { + await close(); + } + const session = await signIn(origin, email, password); + if (!session.cookiePair) { + throw harnessError("The platform-owner session cookie was not issued."); + } + return { cookie: session.cookiePair, userId: created.body.user.id }; +} + async function fetchPrincipal( origin: string, headers: Record = {}, @@ -1055,7 +1082,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => { }); describe.sequential("live API — startup and migration guarantees", () => { - it("does not create any tables or migration history when started against an unmigrated database", async () => { + it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => { const database = await createDisposableDatabase("unmigrated"); try { const config = createApiRuntimeConfig( @@ -1071,33 +1098,36 @@ describe.sequential("live API — startup and migration guarantees", () => { port: 0, }, // Development validation, for the same reason `startConfiguredApi` documents: this scenario - // drives a real sign-up request, which production configuration refuses to enable at all. + // uses the disposable plaintext loopback database, which production configuration refuses. { environment: "development" }, ); - const running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); - const origin = apiOrigin(running); + // 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. + let captured: unknown; try { - // A request that touches the (nonexistent) identity tables fails, but must not repair - // or create anything. The exact failure body is not asserted here (Better Auth's - // internal-error shape for this case is not a stable public contract); only that it is - // not a successful sign-up. - const response = await fetch(`${origin}/api/auth/sign-up/email`, { - body: JSON.stringify({ - email: "dana@example.test", - name: "Integration Test User", - password: "correct horse battery staple", - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - expect(response.status).not.toBe(200); - } finally { - await running.shutdown(); + await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); + } catch (error) { + captured = error; } + expect(captured).toMatchObject({ + code: "MODULE_LIFECYCLE_READ_FAILED", + name: "ModuleLifecycleStoreError", + }); + const rendered = `${String(captured)} ${JSON.stringify(captured)}`; + expect(rendered).not.toContain(database.name); + expect(rendered).not.toContain("127.0.0.1"); + expect(rendered).not.toContain("select"); const admin = await openAdministrativeClient(database.url); try { + // The invariant this scenario has always existed to protect, unchanged and now proven on a + // path that never even reached Better Auth: a failed start repairs nothing, creates + // nothing, and applies no migration. const rows = await safeQuery<{ count: string }>( admin, "select count(*)::text as count from information_schema.tables where table_schema = 'public'", @@ -2159,9 +2189,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 three (`core.authorization.grants.manage`, `core.modules.ui.read`, - // `core.dashboard.navigation.manage`) plus the fixture module's one. - details: ["4 permissions registered"], + // 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"], status: "pass", }); // Doctor and permission composition read only the manifest — the fixture's register/start @@ -2979,6 +3010,283 @@ describe.sequential("live API — Node header/request timeouts and client errors * disposable database is closed in `finally`, and each scenario carries its own outer deadline so * a regression fails rather than hangs. */ +describe.sequential("live API — module lifecycle governs what actually runs", () => { + /** Writes one explicit enablement decision through the real table, before the API starts — + * exactly what `pnpm modules:disable` or a prior `PUT` would have left behind. Nothing in this + * suite reaches into the store through a double. */ + async function storeEnablement(url: string, moduleId: string, enabled: boolean): Promise { + const client = await openAdministrativeClient(url); + try { + await safeQuery( + client, + `insert into ekklesia_core_module_state (module_id, enabled) values ($1, $2) + on conflict (module_id) do update set enabled = excluded.enabled`, + [moduleId, enabled], + ); + } finally { + await safeEnd(client); + } + } + + it("never imports, registers, starts, or mounts a disabled module, and still runs its neighbours", async () => { + const modulesDirectory = await createFixtureModulesDirectory(); + const recorder = createEventRecorder(); + await createFixtureModule(modulesDirectory, "calendar", recorder, { + permissions: [ + { baseline: "public", description: "Read calendar events.", id: "calendar.events.read" }, + ], + }); + await createFixtureModule(modulesDirectory, "announcements", recorder, { + permissions: [ + { + baseline: "public", + description: "Read announcements.", + id: "announcements.events.read", + }, + ], + }); + + const database = await createDisposableDatabase("lifecycle_off"); + await applyCoreMigrations(database.url); + await storeEnablement(database.url, "announcements", false); + + const running = await startConfiguredApi(database.url, { + modulesDirectoryOverride: modulesDirectory, + }); + try { + const origin = apiOrigin(running); + + // The enabled module really did run: its markers are present, and its route answers. + expect(recorder.events).toContain("calendar:register"); + expect(recorder.events).toContain("calendar:start"); + const calendarRoute = await fetch(`${origin}/api/v1/modules/calendar/events/abc`); + expect(calendarRoute.status).toBe(200); + + // The disabled module ran nothing at all. Not "registered but skipped", not "started and + // gated per request" — its compiled entrypoint was never imported, so no hook could run. + expect(recorder.events).not.toContain("announcements:register"); + expect(recorder.events).not.toContain("announcements:start"); + expect(recorder.events.filter((event) => event.startsWith("announcements:"))).toEqual([]); + + // No route was ever mounted, so the platform's ordinary 404 answers — there is no per-request + // "is this module enabled?" check anywhere, because there is nothing mounted to check. + const disabledRoute = await fetch(`${origin}/api/v1/modules/announcements/events/abc`); + expect(disabledRoute.status).toBe(404); + expect(await disabledRoute.json()).toEqual({ code: "NOT_FOUND" }); + + // Shutdown never invokes a stop hook for a module that never started. + await running.shutdown(); + expect(recorder.events).toContain("calendar:stop"); + expect(recorder.events).not.toContain("announcements:stop"); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("blocks a dependent of a disabled required dependency, and explains why over HTTP", async () => { + const modulesDirectory = await createFixtureModulesDirectory(); + const recorder = createEventRecorder(); + await createFixtureModule(modulesDirectory, "storage", recorder); + await createFixtureModule(modulesDirectory, "announcements", recorder, { + dependencies: { modules: { required: { storage: "^0.1.0" } } }, + }); + + const database = await createDisposableDatabase("lifecycle_block"); + await applyCoreMigrations(database.url); + await storeEnablement(database.url, "storage", false); + + const running = await startConfiguredApi(database.url, { + modulesDirectoryOverride: modulesDirectory, + }); + try { + const origin = apiOrigin(running); + // Neither ran: the disabled dependency by decision, the dependent because starting it would + // violate its own required-dependency contract. + expect(recorder.events).toEqual([]); + + const owner = await signUpPlatformOwner(origin, database.url); + const lifecycle = await fetch(`${origin}/api/v1/module-lifecycle`, { + headers: { cookie: owner.cookie, origin: dashboardOrigin }, + }); + expect(lifecycle.status).toBe(200); + const body = (await lifecycle.json()) as { + readonly modules: readonly { + readonly id: string; + readonly configured: { readonly enabled: boolean; readonly explicit: boolean }; + readonly runtime: { + readonly state: string; + readonly reason?: string; + readonly dependency?: { readonly kind: string; readonly moduleId: string }; + }; + readonly restartRequired: boolean; + }[]; + }; + + expect(body.modules).toEqual([ + { + configured: { enabled: false, explicit: true }, + id: "storage", + restartRequired: false, + runtime: { state: "disabled" }, + version: "0.1.0", + }, + { + configured: { enabled: true, explicit: false }, + id: "announcements", + restartRequired: false, + runtime: { + dependency: { kind: "module", moduleId: "storage" }, + reason: "required_dependency_disabled", + state: "blocked", + }, + version: "0.1.0", + }, + ]); + // The operator's question — "why is this module unavailable?" — answered without a log line, + // and without a filesystem path anywhere in the payload. + const raw = JSON.stringify(body); + expect(raw).not.toContain(modulesDirectory); + expect(raw).not.toContain("dist/"); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("applies a change made over HTTP only at the next start, and says so in the meantime", async () => { + const modulesDirectory = await createFixtureModulesDirectory(); + const recorder = createEventRecorder(); + await createFixtureModule(modulesDirectory, "calendar", recorder, { + permissions: [ + { baseline: "public", description: "Read calendar events.", id: "calendar.events.read" }, + ], + }); + + const database = await createDisposableDatabase("lifecycle_restart"); + await applyCoreMigrations(database.url); + + const first = await startConfiguredApi(database.url, { + modulesDirectoryOverride: modulesDirectory, + }); + try { + const origin = apiOrigin(first); + const owner = await signUpPlatformOwner(origin, database.url); + + const mutation = await fetch(`${origin}/api/v1/module-lifecycle/calendar/enabled`, { + body: JSON.stringify({ enabled: false }), + headers: { + "content-type": "application/json", + cookie: owner.cookie, + origin: dashboardOrigin, + }, + method: "PUT", + }); + expect(mutation.status).toBe(200); + expect(await mutation.json()).toEqual({ + configured: { enabled: false, explicit: true }, + id: "calendar", + restartRequired: true, + // Honest: this process really is still serving that module. + runtime: { state: "running" }, + version: "0.1.0", + }); + + // And it demonstrably still is — nothing was hot-unloaded to make the change feel immediate. + expect((await fetch(`${origin}/api/v1/modules/calendar/events/abc`)).status).toBe(200); + } finally { + await first.shutdown().catch(() => undefined); + } + + recorder.events.length = 0; + const second = await startConfiguredApi(database.url, { + modulesDirectoryOverride: modulesDirectory, + }); + try { + const origin = apiOrigin(second); + // The next start applies the stored decision: nothing imported, nothing mounted. + expect(recorder.events).toEqual([]); + expect((await fetch(`${origin}/api/v1/modules/calendar/events/abc`)).status).toBe(404); + } finally { + await second.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("keeps a disabled module's UI out of the inventory and out of Dashboard navigation", async () => { + const modulesDirectory = await createFixtureModulesDirectory(); + await createUiModule( + modulesDirectory, + "shown", + "1.0.0", + 'export default { id: "shown", version: "1.0.0", mount() {} };\n', + ); + await createUiModule( + modulesDirectory, + "hidden", + "1.0.0", + 'export default { id: "hidden", version: "1.0.0", mount() {} };\n', + ); + + const database = await createDisposableDatabase("lifecycle_ui"); + await applyCoreMigrations(database.url); + await storeEnablement(database.url, "hidden", false); + + const running = await startConfiguredApi(database.url, { + modulesDirectoryOverride: modulesDirectory, + }); + try { + const origin = apiOrigin(running); + const owner = await signUpPlatformOwner(origin, database.url); + const headers = { cookie: owner.cookie, origin: dashboardOrigin }; + + const inventory = (await (await fetch(`${origin}/api/v1/module-ui`, { headers })).json()) as { + readonly modules: readonly { readonly id: string }[]; + }; + const navigation = (await ( + await fetch(`${origin}/api/v1/dashboard/navigation`, { headers }) + ).json()) as { + readonly ungrouped: readonly { readonly module: { readonly id: string } }[]; + }; + + expect(inventory.modules.map((entry) => entry.id)).toEqual(["shown"]); + expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["shown"]); + + // The artifact itself is unreachable too, even to a caller that knows both halves of its + // identity — publication is derived from the eligible set, not from the file's existence. + const artifact = await fetch(`${origin}/api/v1/module-ui/hidden/1.0.0`); + expect(artifact.status).toBe(404); + expect((await fetch(`${origin}/api/v1/module-ui/shown/1.0.0`)).status).toBe(200); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("still discovers a disabled module's migrations: deployed schema ownership is not runtime state", async () => { + const modulesDirectory = await createFixtureModulesDirectory(); + const recorder = createEventRecorder(); + await createFixtureModule(modulesDirectory, "calendar", recorder); + + const database = await createDisposableDatabase("lifecycle_migrations"); + await applyCoreMigrations(database.url); + await storeEnablement(database.url, "calendar", false); + + // Migration discovery is offline and reads the deployed inventory, never the enablement table. + // A disabled module's tables must still be migrated, or enabling it later would need a schema + // change in the middle of a restart. + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }, + }); + expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]); + }); +}); + describe.sequential("live API — bounded HTTP drain and shutdown", () => { async function withDeadline(operation: Promise, timeoutMs: number): Promise { let timer: ReturnType | undefined; diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index a3d3e4e..ef12420 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -20,6 +20,7 @@ import { describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; 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"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; @@ -88,6 +89,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -107,6 +109,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -127,6 +130,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -149,6 +153,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -173,6 +178,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -197,6 +203,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -218,6 +225,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -241,6 +249,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -261,6 +270,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -283,6 +293,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -312,6 +323,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -342,6 +354,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -377,6 +390,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -394,6 +408,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -413,6 +428,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -431,6 +447,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -447,6 +464,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -470,6 +488,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -496,6 +515,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), @@ -525,6 +545,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ @@ -545,6 +566,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index f96d947..7bafaa1 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -26,6 +26,10 @@ import { createApiApp } from "../src/app.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { + createInMemoryModuleLifecycleStore, + moduleLifecycleSnapshotFor, +} from "./support/in-memory-module-lifecycle.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; @@ -137,6 +141,10 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> { database: Object.freeze({}) as unknown as ModuleDatabase, }) as ModuleRuntimeServices, moduleUi: createModuleUiPublicationRegistry(modules), + moduleLifecycle: { + snapshot: moduleLifecycleSnapshotFor(modules), + store: createInMemoryModuleLifecycleStore(), + }, runtime, trustedOrigins: [trustedOrigin], }); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index bafee49..cafccec 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -48,6 +48,21 @@ function validConfig() { ); } +/** + * The one query behavior every fake pool in this file needs: startup reads the stored module + * enablement configuration (`ModuleLifecycleStore.readConfiguration`) before it imports any module + * entrypoint, and an installation that has never disabled anything genuinely has zero rows. Returning + * an empty result set is therefore the accurate stand-in for a real, migrated, unconfigured database + * — not a way to skip the read. The tests that care about a *specific* configuration drive the real + * resolver directly instead (`core/tests/module-lifecycle.test.ts`) or supply real rows through the + * real store (`core/tests/module-lifecycle-store.integration.ts`). + */ +function emptyQuery() { + return vi + .fn() + .mockResolvedValue({ command: "SELECT", fields: [], oid: 0, rowCount: 0, rows: [] }); +} + const temporaryDirectories: string[] = []; async function createTemporaryModulesDirectory(): Promise { @@ -359,7 +374,7 @@ describe("Ekklesia API shutdown lifecycle", () => { it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); const { server } = mockSuccessfulServe(); @@ -375,7 +390,7 @@ describe("Ekklesia API shutdown lifecycle", () => { it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); mockSuccessfulServe(); @@ -395,7 +410,7 @@ describe("Ekklesia API shutdown lifecycle", () => { it("keeps shutdown idempotent for calls made after the first has already resolved", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); const { server } = mockSuccessfulServe(); @@ -411,7 +426,7 @@ describe("Ekklesia API shutdown lifecycle", () => { it("closes an already-created database pool when identity construction fails during startup", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockImplementation(() => { throw new Error("postgresql://admin:secret@db.internal.example/x"); @@ -429,7 +444,7 @@ describe("Ekklesia API shutdown lifecycle", () => { it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); mockSuccessfulServe(); @@ -467,7 +482,7 @@ describe("Ekklesia API shutdown lifecycle", () => { it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); mockSuccessfulServe(); @@ -520,7 +535,7 @@ describe("Ekklesia API shutdown lifecycle", () => { it("closes an already-created database pool when the server fails to bind its listen address", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); mockFailingServe(new Error("EADDRINUSE")); @@ -584,7 +599,7 @@ describe("Ekklesia API Node server timeouts", () => { it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); mockSuccessfulServe(); @@ -621,7 +636,7 @@ describe("Ekklesia API Node server timeouts", () => { it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); mockSuccessfulServe(); @@ -673,7 +688,7 @@ describe("Ekklesia API Node server timeouts", () => { async function startAndCaptureClientErrorHandler() { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); const { clientErrorHandler } = mockSuccessfulServe(); @@ -891,7 +906,7 @@ describe("Ekklesia API module permission composition at startup", () => { const modulesDirectory = await createTemporaryModulesDirectory(); const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); mockSuccessfulServe(); @@ -933,7 +948,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => { function mockPoolAndAuth(): { poolEnd: ReturnType } { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { - return { end: poolEnd, on: vi.fn() }; + return { end: poolEnd, on: vi.fn(), query: emptyQuery() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); return { poolEnd }; @@ -1131,6 +1146,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => { recorder.events.push("pool:end"); }), on: vi.fn(), + query: emptyQuery(), }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index 093cce0..14271d0 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -221,6 +221,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0001_authorization_core_tables", "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", + "core:0004_module_lifecycle_state", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -247,6 +248,7 @@ async function applyMigrationsWithModule(url: string): Promise { "core:0001_authorization_core_tables", "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", + "core:0004_module_lifecycle_state", "db-facade:0000_initial", ]); expect(aggregate.migrations[0]?.checksum).toBe( @@ -444,6 +446,7 @@ describe.sequential("live API — module database explicit migration ownership", expect( await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"), ).toBe(1); + expect(await migrationHistoryCount(database.url, "core:0004_module_lifecycle_state")).toBe(1); } finally { await dropDatabase(database.name); } diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index 9b0a927..1ed81ec 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -26,6 +26,7 @@ import { mergedVaryHeader, ModuleRouteConfigurationError, } from "../src/module-http-routes.js"; +import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; @@ -109,6 +110,7 @@ function buildModuleApp(options: { moduleRoutes: options.routes, moduleServices: services, moduleUi: createModuleUiPublicationRegistry([]), + moduleLifecycle: emptyModuleLifecycle(), dashboard: createInMemoryDashboardStore(), runtime, trustedOrigins: options.trustedOrigins ?? [trustedOrigin], diff --git a/apps/api/tests/module-lifecycle-routes.test.ts b/apps/api/tests/module-lifecycle-routes.test.ts new file mode 100644 index 0000000..7d138bc --- /dev/null +++ b/apps/api/tests/module-lifecycle-routes.test.ts @@ -0,0 +1,572 @@ +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 ModuleDatabase, + type ModuleRuntimeServices, + parseEkklesiaUserId, +} from "@ekklesia/contracts"; +import { + type AuthorizationStore, + CORE_MODULES_LIFECYCLE_MANAGE, + createAuthorizationEvaluator, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, + type DiscoveredModule, + discoverModules, + type ModuleLifecycleStore, + ModuleLifecycleStoreError, + 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 { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; +import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; +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"; + +const trustedOrigin = "https://dashboard.example.test"; +const untrustedOrigin = "https://evil.example.test"; +const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444"); + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +interface ModuleFixture { + readonly id: string; + readonly dependencies?: Record; +} + +async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { + const moduleDirectory = path.join(modulesDirectory, fixture.id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await mkdir(path.join(moduleDirectory, "dist/ui"), { 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" }, + ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), + entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, + id: fixture.id, + manifestVersion: 4, + 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" };`, + ), + writeFile( + path.join(moduleDirectory, "dist/ui/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`, + ), + ]); +} + +const defaultFixtures: readonly ModuleFixture[] = [ + { id: "storage" }, + { id: "announcements", dependencies: { modules: { required: { storage: "^1.0.0" } } } }, +]; + +interface InstallOptions { + readonly fixtures?: readonly ModuleFixture[]; + readonly principal?: EkklesiaPrincipal; + readonly platformOwner?: boolean; + readonly bootDisabled?: readonly string[]; + readonly store?: ModuleLifecycleStore; + /** Permissions this principal actually holds through a grant, evaluated by Core's real evaluator + * — never a bypass, so a delegated holder is exercised the same way a real one would be. */ + readonly grants?: readonly string[]; +} + +interface Installation { + readonly app: Hono; + readonly modules: readonly DiscoveredModule[]; + readonly store: ModuleLifecycleStore; +} + +/** + * Composes the real application over a real discovered inventory, reproducing `startApi`'s own + * composition: resolve eligibility from the stored configuration, then build the UI publication + * registry and the boot snapshot from the **eligible** list rather than the discovered one. Doing it + * the same way here is what makes these assertions evidence about production composition rather than + * about a bespoke test wiring. + */ +async function install(options: InstallOptions = {}): Promise { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-lifecycle-")); + temporaryDirectories.push(modulesDirectory); + for (const fixture of options.fixtures ?? defaultFixtures) { + await createModule(modulesDirectory, fixture); + } + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + + const store = + options.store ?? + createInMemoryModuleLifecycleStore( + (options.bootDisabled ?? []).map((moduleId) => ({ enabled: false, moduleId })), + ); + const bootConfiguration = await store.readConfiguration(); + const eligibility = resolveModuleEligibility(modules, bootConfiguration); + + 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, + // Permission composition still runs over the whole discovered inventory, unchanged: a + // disabled module's permissions stay registered and grantable, and nothing enforces them + // because it mounts no routes. + registry: composePlatformPermissionRegistry(modules), + store: authorizationStore, + }, + dashboard: createInMemoryDashboardStore(), + moduleLifecycle: { + snapshot: { + discoveredModules: modules, + eligibility: eligibility.entries, + startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id), + }, + store, + }, + 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 LifecycleEntry { + readonly id: string; + readonly version: string; + readonly configured: { readonly enabled: boolean; readonly explicit: boolean }; + readonly runtime: { + readonly state: string; + readonly reason?: string; + readonly dependency?: { readonly kind: string; readonly moduleId: string }; + }; + readonly restartRequired: boolean; +} + +async function readLifecycle(app: Hono): Promise { + const response = await app.request(MODULE_LIFECYCLE_PATH, { + headers: { origin: trustedOrigin }, + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { readonly modules: readonly LifecycleEntry[] }; + return body.modules; +} + +function setEnabled(app: Hono, moduleId: string, enabled: boolean, origin = trustedOrigin) { + return app.request(`${MODULE_LIFECYCLE_PATH}/${moduleId}/enabled`, { + body: JSON.stringify({ enabled }), + headers: { "content-type": "application/json", origin }, + method: "PUT", + }); +} + +describe("GET /api/v1/module-lifecycle", () => { + it("reports every discovered module with both axes separated", async () => { + const { app } = await install(owner()); + + expect(await readLifecycle(app)).toEqual([ + { + configured: { enabled: true, explicit: false }, + id: "storage", + restartRequired: false, + runtime: { state: "running" }, + version: "1.0.0", + }, + { + configured: { enabled: true, explicit: false }, + id: "announcements", + restartRequired: false, + runtime: { state: "running" }, + version: "1.0.0", + }, + ]); + }); + + it("explains a disabled module and the dependent it blocks, with a structured reason", async () => { + const { app } = await install({ ...owner(), bootDisabled: ["storage"] }); + + expect(await readLifecycle(app)).toEqual([ + { + configured: { enabled: false, explicit: true }, + id: "storage", + restartRequired: false, + runtime: { state: "disabled" }, + version: "1.0.0", + }, + { + configured: { enabled: true, explicit: false }, + id: "announcements", + restartRequired: false, + runtime: { + dependency: { kind: "module", moduleId: "storage" }, + reason: "required_dependency_disabled", + state: "blocked", + }, + version: "1.0.0", + }, + ]); + }); + + it("is not cached and varies on origin and cookie", async () => { + const { app } = await install(owner()); + const response = await app.request(MODULE_LIFECYCLE_PATH, { + headers: { origin: trustedOrigin }, + }); + + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.get("Vary")).toContain("Cookie"); + }); + + it("refuses an anonymous caller", async () => { + const { app } = await install(); + const response = await app.request(MODULE_LIFECYCLE_PATH); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + }); + + it("refuses an ordinary authenticated user who holds no lifecycle permission", async () => { + // The same principal that `core.modules.ui.read` (baseline `authenticated`) would admit: this + // resource is deliberately restricted, because it is the only place a disabled module is + // visible at all. + const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) }); + const inventory = await app.request(MODULE_UI_INVENTORY_PATH); + const lifecycle = await app.request(MODULE_LIFECYCLE_PATH); + + expect(inventory.status).toBe(200); + expect(lifecycle.status).toBe(403); + }); + + it("admits a non-owner holding exactly core.modules.lifecycle.manage through a real grant", async () => { + const { app } = await install({ + grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)], + principal: createAuthenticatedPrincipal(viewerId), + }); + + expect((await app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200); + // Holding it confers no module authority: the Dashboard's own manage permission is untouched by + // this grant, so a lifecycle administrator cannot reorganize navigation. + const reorganize = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, { + body: JSON.stringify({ label: "Media", order: 10 }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "PUT", + }); + expect(reorganize.status).toBe(403); + }); + + it("reports a sanitized 503 when the configuration cannot be read, never a driver detail", async () => { + const failing: ModuleLifecycleStore = { + readConfiguration: async () => { + throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_READ_FAILED"); + }, + setEnabled: async () => { + throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED"); + }, + }; + // Boot with a working store, then swap in the failing one for the request path only. + const { app } = await install({ ...owner(), store: failingAfterBoot(failing) }); + const response = await app.request(MODULE_LIFECYCLE_PATH, { + headers: { origin: trustedOrigin }, + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "MODULE_LIFECYCLE_READ_FAILED" }); + }); +}); + +/** Answers the one boot-time read with an empty configuration, then fails every later call — so a + * request-path failure can be exercised without making startup composition itself impossible. */ +function failingAfterBoot(failing: ModuleLifecycleStore): ModuleLifecycleStore { + let bootRead = false; + return { + async readConfiguration() { + if (!bootRead) { + bootRead = true; + return { modules: [] }; + } + return failing.readConfiguration(); + }, + setEnabled: (input) => failing.setEnabled(input), + }; +} + +describe("PUT /api/v1/module-lifecycle/:moduleId/enabled", () => { + it("persists a decision and reports honestly that the running process has not changed", async () => { + const { app } = await install(owner()); + + const response = await setEnabled(app, "announcements", false); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + configured: { enabled: false, explicit: true }, + id: "announcements", + // Not `disabled`: this process is still serving that module. Claiming otherwise would be a + // lie about a module that is still running. + restartRequired: true, + runtime: { state: "running" }, + version: "1.0.0", + }); + }); + + it("marks the dependents of a newly disabled module as restart-pending too", async () => { + const { app } = await install(owner()); + + await setEnabled(app, "storage", false); + + expect((await readLifecycle(app)).map((entry) => [entry.id, entry.restartRequired])).toEqual([ + ["storage", true], + ["announcements", true], + ]); + }); + + it("is idempotent, and re-enabling clears the pending restart", async () => { + const { app } = await install(owner()); + + await setEnabled(app, "announcements", false); + await setEnabled(app, "announcements", false); + const reEnabled = await setEnabled(app, "announcements", true); + + expect(await reEnabled.json()).toEqual({ + configured: { enabled: true, explicit: true }, + id: "announcements", + restartRequired: false, + runtime: { state: "running" }, + version: "1.0.0", + }); + }); + + it("refuses a module this process cannot see, rather than storing an unverifiable record", async () => { + const { app, store } = await install(owner()); + + const response = await setEnabled(app, "not-installed", false); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" }); + expect((await store.readConfiguration()).modules).toEqual([]); + }); + + it("refuses a malformed module identifier without reaching the store", async () => { + const { app, store } = await install(owner()); + + const response = await setEnabled(app, "Not%20A%20Module", false); + + expect(response.status).toBe(404); + expect((await store.readConfiguration()).modules).toEqual([]); + }); + + it("rejects an untrusted origin before authorization or the store runs", async () => { + const { app, store } = await install(owner()); + + const response = await setEnabled(app, "announcements", false, untrustedOrigin); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + expect((await store.readConfiguration()).modules).toEqual([]); + }); + + it("rejects a missing origin", async () => { + const { app } = await install(owner()); + const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, { + body: JSON.stringify({ enabled: false }), + headers: { "content-type": "application/json" }, + method: "PUT", + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + }); + + it("refuses an authenticated caller without the restricted permission", async () => { + const { app, store } = await install({ principal: createAuthenticatedPrincipal(viewerId) }); + + const response = await setEnabled(app, "announcements", false); + + expect(response.status).toBe(403); + expect((await store.readConfiguration()).modules).toEqual([]); + }); + + it.each([ + ["a non-boolean value", { enabled: "false" }], + ["a missing field", {}], + ["an unexpected key alongside a valid one", { enabled: false, force: true }], + ["an array", []], + ["a bare string", "disabled"], + ])("rejects %s with a closed, structured validation failure", async (_label, body) => { + const { app, store } = await install(owner()); + const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, { + 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" }); + expect((await store.readConfiguration()).modules).toEqual([]); + }); + + it("enforces the platform body limit with the one sanitized 413 contract", async () => { + const { app } = await install(owner()); + const oversized = JSON.stringify({ enabled: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) }); + const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, { + 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" }); + }); + + it("never leaks a filesystem path, package name, or manifest path through either verb", async () => { + const { app } = await install(owner()); + const listed = await ( + await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } }) + ).text(); + const mutated = await (await setEnabled(app, "announcements", false)).text(); + + for (const payload of [listed, mutated]) { + expect(payload).not.toContain("/tmp"); + expect(payload).not.toContain("@test/module-"); + expect(payload).not.toContain("ekklesia.module.json"); + expect(payload).not.toContain("dist/"); + expect(payload).not.toContain("entrypoint"); + } + }); +}); + +describe("lifecycle state governs module UI exposure", () => { + it("keeps a disabled module out of the mountable inventory and out of Dashboard navigation", async () => { + const { app } = await install({ + bootDisabled: ["announcements"], + platformOwner: true, + principal: createAuthenticatedPrincipal(viewerId), + }); + + const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { + readonly modules: readonly { readonly id: string }[]; + }; + const navigation = (await (await app.request(DASHBOARD_NAVIGATION_PATH)).json()) as { + readonly groups: readonly { readonly modules: readonly { readonly name: string }[] }[]; + readonly ungrouped: readonly { readonly module: { readonly id: string } }[]; + }; + + // Discovered, validated, and declaring a real compiled UI artifact — and still absent, because + // publication is built from the eligible set rather than the discovered one. + expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage"]); + expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["storage"]); + expect(navigation.groups).toEqual([]); + }); + + it("keeps a dependency-blocked module out of both, exactly like an explicitly disabled one", async () => { + const { app } = await install({ + bootDisabled: ["storage"], + platformOwner: true, + principal: createAuthenticatedPrincipal(viewerId), + }); + + const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { + readonly modules: readonly { readonly id: string }[]; + }; + + expect(inventory.modules).toEqual([]); + }); + + it("does not remove a module from navigation merely because a restart is pending", async () => { + const { app } = await install({ + platformOwner: true, + principal: createAuthenticatedPrincipal(viewerId), + }); + + await setEnabled(app, "announcements", false); + const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { + readonly modules: readonly { readonly id: string }[]; + }; + + // The module really is still mounted and still serving; hiding its UI now would break a viewer + // mid-session for a change that has not taken effect. `restartRequired` is where that pending + // change is reported. + expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage", "announcements"]); + }); +}); + +describe("the lifecycle 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 () => { + // `moduleRoutePathSchema` accepts `enabled` as an ordinary static segment, so a module named + // `announcements` may legitimately register `PUT /enabled` — mounted at exactly + // `/api/v1/modules/announcements/enabled`. If the platform published its lifecycle mutation + // there, registration order would decide which one answers. + expect(MODULE_LIFECYCLE_PATH.startsWith("/api/v1/modules/")).toBe(false); + expect(MODULE_LIFECYCLE_PATH).toBe("/api/v1/module-lifecycle"); + + const { app } = await install(owner()); + const collision = await app.request("/api/v1/modules/announcements/enabled", { + body: JSON.stringify({ enabled: false }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "PUT", + }); + + expect(collision.status).toBe(404); + expect(await collision.json()).toEqual({ code: "NOT_FOUND" }); + }); +}); diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 90f0731..caec7f5 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -30,6 +30,10 @@ import { import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp } from "../src/app.js"; +import { + createInMemoryModuleLifecycleStore, + moduleLifecycleSnapshotFor, +} from "./support/in-memory-module-lifecycle.js"; /** * The whole chain, with nothing between the halves faked: @@ -213,6 +217,10 @@ async function startHarness(options: HarnessOptions = {}): Promise { moduleRoutes: moduleRuntime.routes, moduleServices: services, moduleUi: createModuleUiPublicationRegistry(modules), + moduleLifecycle: { + snapshot: moduleLifecycleSnapshotFor(modules), + store: createInMemoryModuleLifecycleStore(), + }, dashboard: createInMemoryDashboardStore(), runtime, trustedOrigins: ["https://dashboard.example.test"], diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index ce3f98c..92ca157 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -28,6 +28,10 @@ import { createApiApp } from "../src/app.js"; import { moduleApiBaseUrl } from "../src/module-http-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { moduleUiArtifactUrl } from "../src/module-ui-routes.js"; +import { + createInMemoryModuleLifecycleStore, + moduleLifecycleSnapshotFor, +} from "./support/in-memory-module-lifecycle.js"; const trustedOrigin = "https://dashboard.example.test"; const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111"); @@ -169,6 +173,10 @@ async function install(options: InstallOptions): Promise { moduleRoutes: Object.freeze([]), moduleServices: services, moduleUi: createModuleUiPublicationRegistry(modules), + moduleLifecycle: { + snapshot: moduleLifecycleSnapshotFor(modules), + store: createInMemoryModuleLifecycleStore(), + }, dashboard: createInMemoryDashboardStore(), runtime, trustedOrigins: [trustedOrigin], diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index cabc44f..de486a3 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -28,6 +28,10 @@ import { MODULE_UI_CONTENT_TYPE, moduleUiArtifactUrl, } from "../src/module-ui-routes.js"; +import { + createInMemoryModuleLifecycleStore, + moduleLifecycleSnapshotFor, +} from "./support/in-memory-module-lifecycle.js"; const trustedOrigin = "https://dashboard.example.test"; @@ -169,6 +173,10 @@ async function install(fixtures: readonly ModuleFixture[]): Promise( + seed.map((record) => [record.moduleId, record.enabled] as const), + ); + return Object.freeze({ + async readConfiguration(): Promise { + return Object.freeze({ + modules: Object.freeze( + [...records.entries()] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([moduleId, enabled]) => Object.freeze({ enabled, moduleId })), + ), + }); + }, + async setEnabled(input: SetModuleEnabledInput): Promise { + if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(input.moduleId)) { + throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID"); + } + records.set(input.moduleId, input.enabled); + return Object.freeze({ enabled: input.enabled, moduleId: input.moduleId }); + }, + }); +} + +/** + * Builds the boot snapshot the same way `startApi` does — resolve eligibility from the discovered + * inventory and the stored configuration, then record what would have started — rather than + * hand-writing one, so a test can never assert against a snapshot the real composition would never + * produce. + */ +export function moduleLifecycleSnapshotFor( + discoveredModules: readonly DiscoveredModule[], + configuration: ModuleLifecycleConfiguration = { modules: [] }, +): ModuleLifecycleSnapshot { + const resolution = resolveModuleEligibility(discoveredModules, configuration); + return Object.freeze({ + discoveredModules, + eligibility: resolution.entries, + startedModuleIds: Object.freeze(resolution.eligibleModules.map((module) => module.manifest.id)), + }); +} + +/** The lifecycle option for a `createApiApp` caller that exercises no modules at all — the explicit + * "no modules" statement the required option asks for, never an implicit default. */ +export function emptyModuleLifecycle(): { + readonly snapshot: ModuleLifecycleSnapshot; + readonly store: ModuleLifecycleStore; +} { + return { + snapshot: moduleLifecycleSnapshotFor([]), + store: createInMemoryModuleLifecycleStore(), + }; +} diff --git a/core/drizzle.config.ts b/core/drizzle.config.ts index 07c262f..607f695 100644 --- a/core/drizzle.config.ts +++ b/core/drizzle.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ "./src/identity/schema.ts", "./src/authorization/schema.ts", "./src/dashboard/schema.ts", + "./src/modules/lifecycle-schema.ts", ], out: "./drizzle", migrations: { diff --git a/core/drizzle/0004_module_lifecycle_state.sql b/core/drizzle/0004_module_lifecycle_state.sql new file mode 100644 index 0000000..cc927c1 --- /dev/null +++ b/core/drizzle/0004_module_lifecycle_state.sql @@ -0,0 +1,7 @@ +CREATE TABLE "ekklesia_core_module_state" ( + "module_id" text PRIMARY KEY NOT NULL, + "enabled" boolean 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_state_module_check" CHECK (length("ekklesia_core_module_state"."module_id") > 0) +); diff --git a/core/drizzle/meta/0004_snapshot.json b/core/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..1c900f0 --- /dev/null +++ b/core/drizzle/meta/0004_snapshot.json @@ -0,0 +1,914 @@ +{ + "id": "b153e7a4-3100-4610-b296-a1a0b3cbfff6", + "prevId": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a", + "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 + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index d098641..f5612b5 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -25,6 +25,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0004_module_lifecycle_state", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index 8124569..20a2465 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1787408382271, "tag": "0003_dashboard_navigation_tables", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1787423121678, + "tag": "0004_module_lifecycle_state", + "breakpoints": true } ] } diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts index d637e6e..1fe0fe2 100644 --- a/core/src/authorization/permissions.ts +++ b/core/src/authorization/permissions.ts @@ -111,6 +111,24 @@ export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId( "core.dashboard.navigation.manage", ); +/** + * Restricted, and it gates **reading as well as changing**. Deciding which installed modules run is + * an administrative operation, and the read is not a milder version of it: the lifecycle view is the + * only place a *disabled* module, a *blocked* module, and the required-dependency edges between them + * are visible at all. `core.modules.ui.read` deliberately exposes the mountable set to every signed-in + * user; this exposes the parts of the installation that are deliberately not running, which is not + * the same audience. A second, weaker read permission over the same resource would be ceremony + * (ADR 0027's reasoning, applied in the other direction). + * + * It is a **platform** permission and never a module's. A module can neither declare it, gate it, nor + * observe it: whether a module runs is a decision its host makes about it, not a decision it takes + * part in (ADR 0028). Holding it confers no authority inside any module — every module route stays + * enforced against that module's own declared permission. + */ +export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId( + "core.modules.lifecycle.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 @@ -134,6 +152,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob description: "Organize the Dashboard: create, rename, order, and delete navigation groups.", id: CORE_DASHBOARD_NAVIGATION_MANAGE, }), + Object.freeze({ + baseline: "restricted" as const, + description: "Read and change which installed modules are enabled.", + id: CORE_MODULES_LIFECYCLE_MANAGE, + }), ]); /** diff --git a/core/src/cli/set-module-enabled.ts b/core/src/cli/set-module-enabled.ts new file mode 100644 index 0000000..b03861d --- /dev/null +++ b/core/src/cli/set-module-enabled.ts @@ -0,0 +1,173 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { + createModuleLifecycleDatabase, + createModuleLifecycleStore, + discoverModules, + ModuleDiscoveryError, + ModuleLifecycleStoreError, + resolveModuleEligibility, +} from "../modules/index.js"; +import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js"; + +/** + * Enables or disables one installed module, out of band from the running API. + * + * This exists because module startup is deliberately all-or-nothing (ADR 0015, ADR 0020, preserved + * by ADR 0028): a module whose `start` hook throws takes API startup down with it, and the HTTP + * lifecycle API that could otherwise turn it off is served by that same process. Without an + * out-of-band path, the only recovery from one bad module would be editing the database by hand. + * This command is that path — the same shape as `pnpm authorization:bootstrap-owner`: explicit, + * operator-invoked, never HTTP-exposed, and never inferred. + * + * It writes configuration and nothing else. It starts nothing, stops nothing, imports no module + * server entrypoint, runs no lifecycle hook, and applies no migration. The change takes effect on + * the next API start. + */ +class ModuleLifecycleCliError extends Error { + constructor(message: string) { + super(message); + this.name = "ModuleLifecycleCliError"; + this.stack = `${this.name}: ${this.message}`; + } +} + +const usage = + "Usage: pnpm modules:enable -- --module | pnpm modules:disable -- --module "; + +interface ParsedArgs { + readonly enabled: boolean; + readonly moduleId: string; +} + +/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts`: this command + * takes one named flag plus one mode flag, so a general-purpose argument dependency is not + * warranted. `--enable` and `--disable` are mutually exclusive and one is required, so the intent + * is always stated rather than defaulted. */ +function parseArgs(argv: readonly string[]): ParsedArgs { + let moduleId: string | undefined; + let enabled: boolean | undefined; + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + if (flag === "--module") { + moduleId = argv[index + 1]; + index += 1; + } else if (flag === "--enable") { + if (enabled === false) { + throw new ModuleLifecycleCliError(usage); + } + enabled = true; + } else if (flag === "--disable") { + if (enabled === true) { + throw new ModuleLifecycleCliError(usage); + } + enabled = false; + } + } + if (moduleId === undefined || enabled === undefined) { + throw new ModuleLifecycleCliError(usage); + } + return { enabled, moduleId }; +} + +interface PackageVersion { + readonly version?: unknown; +} + +async function readPackageVersion(packagePath: string): Promise { + const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion; + if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) { + throw new ModuleLifecycleCliError("The repository package metadata could not be read."); + } + return packageData.version; +} + +async function main(): Promise { + const { enabled, moduleId } = parseArgs(process.argv.slice(2)); + const repositoryRoot = process.cwd(); + const [platformVersion, moduleSdkVersion] = await Promise.all([ + readPackageVersion(path.join(repositoryRoot, "package.json")), + readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")), + ]); + + // The same rule the HTTP API enforces: a module this deployment cannot currently see may not be + // configured. Manifest-only, exactly like `pnpm modules:validate` — no module server entrypoint + // is imported and no lifecycle hook runs, so a module that crashes during `start` is still fully + // configurable here, which is the case this command exists for. + const discovered = await discoverModules({ + moduleSdkVersion, + modulesDirectory: path.join(repositoryRoot, "modules"), + platformVersion, + }); + if (!discovered.some((module) => module.manifest.id === moduleId)) { + throw new ModuleLifecycleCliError( + `No installed module declares the id "${moduleId}". No changes were made.`, + ); + } + + // Read only when this command actually executes — never merely by importing this module. + const databaseUrl = process.env.DATABASE_URL; + if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) { + throw new ModuleLifecycleCliError("DATABASE_URL is required."); + } + + // Asserted before the connection is constructed, so an unsafe production transport never opens a + // client at all. + const transport = resolveExecutableDatabaseTransport(databaseUrl); + const { close, db } = createModuleLifecycleDatabase(databaseUrl, { + connectionTimeoutMs: 10_000, + operationTimeoutMs: 10_000, + transport, + }); + try { + const store = createModuleLifecycleStore(db); + await store.setEnabled({ enabled, moduleId }); + const resolution = resolveModuleEligibility(discovered, await store.readConfiguration()); + 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}).`, + ); + } + } finally { + await close().catch(() => undefined); + } +} + +function reportCliError(error: unknown): void { + if (error instanceof ModuleLifecycleCliError) { + console.error(error.message); + return; + } + if (error instanceof ModuleLifecycleStoreError) { + console.error(`[${error.code}] ${error.message}`); + return; + } + if (error instanceof ModuleDiscoveryError) { + console.error(error.message); + for (const issue of error.issues) { + const issuePath = issue.path ? ` (${issue.path})` : ""; + console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`); + } + return; + } + console.error("The module enablement change failed."); +} + +function isDirectExecution(): boolean { + const entrypoint = process.argv[1]; + return ( + entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href + ); +} + +if (isDirectExecution()) { + main().catch((error: unknown) => { + reportCliError(error); + process.exitCode = 1; + }); +} diff --git a/core/src/diagnostics/doctor-report.ts b/core/src/diagnostics/doctor-report.ts index 6b1e1aa..e634769 100644 --- a/core/src/diagnostics/doctor-report.ts +++ b/core/src/diagnostics/doctor-report.ts @@ -11,7 +11,12 @@ function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSecti const sections: readonly DoctorSection[] = Object.freeze([ section("Platform", ["platform.runtime"]), - section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]), + section("Modules", [ + "modules.discovery", + "modules.dependencies", + "modules.permissions", + "modules.lifecycle", + ]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]), section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]), section("Identity", ["identity.configuration", "identity.client-address"]), diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 40f7981..eef2766 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { + capabilityIdSchema, isVersionCompatible, moduleIdSchema, permissionIdSchema, @@ -27,6 +28,7 @@ import { } from "../identity/config.js"; import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js"; import { identityAdapterSchema } from "../identity/schema.js"; +import type { PostgresPoolOptions } from "../database/pool.js"; import { createPostgresMigrationDatabase, discoverAllMigrations, @@ -39,13 +41,18 @@ import { } from "../migrations/index.js"; import { composePlatformPermissionRegistry, + createModuleLifecycleDatabase, + createModuleLifecycleStore, type DiscoveredModule, discoverModuleSchemas, discoverModules, + type ModuleEligibility, ModulePermissionCompositionError, type ModuleDiscoveryOptions, + type ModuleLifecycleConfiguration, type ModuleMigrationAggregate, type ModuleSchemaAggregate, + resolveModuleEligibility, } from "../modules/index.js"; export type DoctorCheckId = @@ -54,6 +61,7 @@ export type DoctorCheckId = | "modules.discovery" | "modules.dependencies" | "modules.permissions" + | "modules.lifecycle" | "schemas.discovery" | "migrations.discovery" | "postgresql.connection" @@ -113,6 +121,13 @@ export interface DoctorDependencies { options: ModuleDiscoveryOptions, ) => Promise; readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise; + /** Reads the stored module enablement configuration read-only. Separated behind the dependency + * boundary like every other I/O Doctor performs, so the whole report stays exercisable without a + * database. */ + readonly readModuleLifecycle: ( + connectionString: string, + options: PostgresPoolOptions, + ) => Promise; readonly planMigrations: (options: { readonly aggregate: ModuleMigrationAggregate; readonly database: MigrationDatabase; @@ -134,6 +149,20 @@ async function discoverAllMigrationsForRuntime( }); } +/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool + * behind, and never shares one with the migration planner's, whose lifetime it does not control. */ +async function readModuleLifecycleForRuntime( + connectionString: string, + options: PostgresPoolOptions, +): Promise { + const { close, db } = createModuleLifecycleDatabase(connectionString, options); + try { + return await createModuleLifecycleStore(db).readConfiguration(); + } finally { + await close().catch(() => undefined); + } +} + const productionDependencies: DoctorDependencies = Object.freeze({ composePermissions: composePlatformPermissionRegistry, createMigrationDatabase: createPostgresMigrationDatabase, @@ -141,6 +170,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({ discoverModules, discoverSchemas: discoverModuleSchemas, planMigrations: planModuleMigrations, + readModuleLifecycle: readModuleLifecycleForRuntime, runtimeSatisfies: isVersionCompatible, }); @@ -249,6 +279,70 @@ function permissionCompositionFailure(error: unknown): DoctorCheck { ]); } +/** + * Renders one module's operational state in a form that is safe by construction: only a module id, a + * semantic version, a fixed state word, a fixed reason code from a closed set, and — for a + * capability edge — a capability id. Identity values are re-validated before interpolation, exactly + * like `safeModuleDetails` and `safeMigrationReference` do, so a future drift bug cannot turn this + * into a disclosure channel. No filesystem path, package name, manifest path, or caught error can + * reach this line, because none of them is on the record it reads. + */ +function safeLifecycleDetail(entry: ModuleEligibility): string { + if ( + !moduleIdSchema.safeParse(entry.moduleId).success || + !semanticVersionSchema.safeParse(entry.version).success + ) { + throw new TypeError("Unsafe module lifecycle diagnostic value."); + } + const identity = `${entry.moduleId} ${entry.version}`; + if (!entry.configured.enabled) { + return `${identity} · disabled${entry.configured.explicit ? "" : " (default)"}`; + } + if (!entry.blocked) { + return `${identity} · enabled`; + } + const dependency = entry.blocked.dependency; + if (!moduleIdSchema.safeParse(dependency.moduleId).success) { + throw new TypeError("Unsafe module lifecycle diagnostic value."); + } + const target = + dependency.kind === "capability" && + dependency.capabilityId !== undefined && + capabilityIdSchema.safeParse(dependency.capabilityId).success + ? `${dependency.capabilityId} (${dependency.moduleId})` + : dependency.moduleId; + return `${identity} · blocked · ${entry.blocked.reason} · ${target}`; +} + +/** + * Reports what an operator most wants to know before anything else: which installed modules this + * deployment would actually run, and why any of them would not. + * + * Configuration lives in PostgreSQL, so this check is honestly skipped when `DATABASE_URL` is not + * configured rather than reporting a default that might not be what is stored. A **disabled** module + * is a `pass` with a detail line: it is a decision somebody made, not a problem. A **blocked** module + * is a `warning`: an enabled module that cannot run is an anomaly worth surfacing, and it is exactly + * the question "why is this module unavailable?" that would otherwise send an operator to the logs. + */ +function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorCheck { + const disabled = entries.filter((entry) => !entry.configured.enabled).length; + const blocked = entries.filter((entry) => entry.blocked !== undefined).length; + const eligible = entries.filter((entry) => entry.eligible).length; + const details = [ + `${eligible} eligible · ${disabled} disabled · ${blocked} blocked`, + ...entries.map(safeLifecycleDetail), + ]; + if (blocked > 0) { + return freezeCheck( + "modules.lifecycle", + "warning", + blocked === 1 ? "1 enabled module cannot run" : `${blocked} enabled modules cannot run`, + details, + ); + } + return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details); +} + function moduleDiscoveryFailure(error: unknown): DoctorCheck { const details = error instanceof Error && error.name === "ModuleDiscoveryError" @@ -574,6 +668,38 @@ export async function diagnoseEkklesia( } } + // Deliberately independent of permission composition, schema, and migration discovery: an + // 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; + if (!discoveredModules) { + lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [ + "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.", + ]); + } else { + try { + const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, { + environment: options.databaseEnvironment ?? "production", + }); + const configuration = await dependencies.readModuleLifecycle(options.databaseUrl, { + connectionTimeoutMs: doctorPostgresTimeoutMs, + operationTimeoutMs: doctorPostgresTimeoutMs, + transport: lifecycleTransport, + }); + lifecycleCheck = moduleLifecycleCheck( + resolveModuleEligibility(discoveredModules, configuration).entries, + ); + } catch { + lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [ + "The stored module enablement configuration could not be read safely.", + ]); + } + } + let schemaCheck: DoctorCheck; let migrationCheck: DoctorCheck; let migrations: ModuleMigrationAggregate | undefined; @@ -668,6 +794,7 @@ export async function diagnoseEkklesia( moduleCheck, dependencyCheck, permissionsCheck, + lifecycleCheck, schemaCheck, migrationCheck, transportCheck, diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index 7a010e7..d643553 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -1,5 +1,8 @@ export * from "./database.js"; export * from "./discovery.js"; +export * from "./lifecycle.js"; +export * from "./lifecycle-schema.js"; +export * from "./lifecycle-store.js"; export * from "./migration-discovery.js"; export * from "./permissions.js"; export * from "./runtime.js"; diff --git a/core/src/modules/lifecycle-schema.ts b/core/src/modules/lifecycle-schema.ts new file mode 100644 index 0000000..47ccbac --- /dev/null +++ b/core/src/modules/lifecycle-schema.ts @@ -0,0 +1,60 @@ +import { sql } from "drizzle-orm"; +import { boolean, check, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +/** + * Core-owned storage for the one operational decision an administrator makes about an installed + * module: should it run? + * + * This is **configuration, not runtime state**. Nothing about what happened during a boot is stored + * here — not a start failure, not a last-seen timestamp, not a health result. A restart naturally + * retries every enabled module, which is the behavior an operator expects and which persisting a + * transient failure would quietly break (ADR 0028). + * + * It is also **not an installation registry**. A module is "installed" by being a valid module + * package on disk that discovery accepts; this table records nothing about where it came from, how + * it got there, or what version it is. There is deliberately no install source, download URL, + * update channel, signature, marketplace metadata, or free-form JSON column: none of those concepts + * exist in this platform, and a column for one is how an installer gets built by accident. + * + * Platform-global, for the same reason the Dashboard tables are (ADR 0027): Ekklesia has no + * organization or tenant model to scope it to. When one exists, this table gains a scope column. + * + * Table and constraint names are explicit and namespaced exactly like + * `core/src/authorization/schema.ts` and `core/src/dashboard/schema.ts`, so ownership is + * unambiguous inside the shared public PostgreSQL schema. + */ + +/** + * One explicit enablement decision. + * + * **A row exists only because somebody decided.** `enabled` has no column default on purpose: the + * platform's default for a newly discovered module is expressed by the *absence* of a row and + * resolved in `resolveModuleEligibility`, not by a default that would silently materialize a + * decision nobody made. Storing `enabled = true` explicitly is still meaningful — it records that + * an administrator looked at this module and left it on. + * + * `module_id` carries **no foreign key to any module**, because modules are packages discovered on + * disk, not rows — the same reasoning `ekklesia_core_dashboard_placement.module_id` already + * follows. A record for a module that is not currently deployed is therefore normal and is + * retained, so redeploying a module restores the decision instead of resetting it. + * `resolveModuleEligibility` ignores such a record entirely. + */ +export const ekklesiaCoreModuleState = pgTable( + "ekklesia_core_module_state", + { + moduleId: text("module_id").primaryKey(), + enabled: boolean("enabled").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + // Defense in depth beneath the application-layer `moduleIdSchema` contract, which never + // produces this: an empty module id would be an unaddressable, permanently inert record. + check("ekklesia_core_module_state_module_check", sql`length(${table.moduleId}) > 0`), + ], +); + +/** Explicit map passed to Drizzle, mirroring `authorizationSchema` and `dashboardSchema`. */ +export const moduleLifecycleSchema = { + moduleState: ekklesiaCoreModuleState, +} as const; diff --git a/core/src/modules/lifecycle-store.ts b/core/src/modules/lifecycle-store.ts new file mode 100644 index 0000000..9a29832 --- /dev/null +++ b/core/src/modules/lifecycle-store.ts @@ -0,0 +1,150 @@ +import { asc } from "drizzle-orm"; +import { type ModuleEnablementRecord, moduleIdSchema } from "@ekklesia/contracts"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { createValidatedPostgresPool, type PostgresPoolOptions } from "../database/index.js"; +import type { ModuleLifecycleConfiguration } from "./lifecycle.js"; +import { ekklesiaCoreModuleState, moduleLifecycleSchema } from "./lifecycle-schema.js"; + +export type ModuleLifecycleDrizzleDatabase = NodePgDatabase; + +/** + * A typed Drizzle view over an externally owned pool, exactly like `createDashboardDrizzleView` and + * `createAuthorizationDrizzleView`: it never constructs, registers an error listener on, or closes + * a pool. The caller owns the pool once, however many typed views it creates over it. + */ +export function createModuleLifecycleView(pool: Pool): ModuleLifecycleDrizzleDatabase { + return drizzle(pool, { schema: moduleLifecycleSchema }); +} + +export interface ModuleLifecycleDatabaseHandle { + readonly close: () => Promise; + readonly db: ModuleLifecycleDrizzleDatabase; +} + +/** + * A self-contained handle for a caller that needs *only* this domain and owns the connection for + * the length of one command — Doctor's read-only lifecycle check and the enable/disable CLI. It + * composes the shared, already-validated pool factory rather than repeating a fourth copy of + * connection-string and timeout validation. `apps/api`, which shares one physical pool across every + * domain, uses `createModuleLifecycleView` instead. + */ +export function createModuleLifecycleDatabase( + connectionString: string, + options: PostgresPoolOptions = {}, +): ModuleLifecycleDatabaseHandle { + const { close, pool } = createValidatedPostgresPool(connectionString, options); + return Object.freeze({ close, db: createModuleLifecycleView(pool) }); +} + +export type ModuleLifecycleStoreErrorCode = + | "MODULE_LIFECYCLE_MODULE_ID_INVALID" + | "MODULE_LIFECYCLE_READ_FAILED" + | "MODULE_LIFECYCLE_WRITE_FAILED"; + +const storeErrorMessages: Readonly> = Object.freeze({ + MODULE_LIFECYCLE_MODULE_ID_INVALID: "A module identifier is invalid.", + MODULE_LIFECYCLE_READ_FAILED: "The module lifecycle configuration could not be read.", + MODULE_LIFECYCLE_WRITE_FAILED: "The module lifecycle configuration could not be saved.", +}); + +/** Never carries the underlying database error, SQL text, host, connection string, or stack — + * matching `AuthorizationStoreError` and `DashboardStoreError` exactly. */ +export class ModuleLifecycleStoreError extends Error { + readonly code: ModuleLifecycleStoreErrorCode; + + constructor(code: ModuleLifecycleStoreErrorCode) { + super(storeErrorMessages[code]); + this.name = "ModuleLifecycleStoreError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +export interface SetModuleEnabledInput { + readonly moduleId: string; + readonly enabled: boolean; +} + +export interface ModuleLifecycleStore { + /** The whole stored configuration, in deterministic module-id order. Small by nature — at most one + * row per module ever decided about — so it is read whole rather than paginated. */ + readConfiguration(): Promise; + /** Records one explicit decision, creating or replacing it. Idempotent: the same input twice + * leaves the same single row. Writing configuration never starts, stops, loads, or unloads + * anything — the running process is untouched until it is restarted. */ + setEnabled(input: SetModuleEnabledInput): Promise; +} + +function assertValidModuleId(value: string): void { + if (!moduleIdSchema.safeParse(value).success) { + throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID"); + } +} + +function rethrowAsStoreError(error: unknown, code: ModuleLifecycleStoreErrorCode): never { + if (error instanceof ModuleLifecycleStoreError) { + throw error; + } + throw new ModuleLifecycleStoreError(code); +} + +/** + * Core's persistence for which installed modules are enabled. + * + * Validation lives here rather than only at an HTTP boundary, so the invariant holds for every + * caller — the API, the CLI, and any future one: an identifier is checked against + * `moduleIdSchema`, the platform's one module-identifier grammar, rather than a second grammar + * invented here. + * + * Nothing in this file reads a manifest, imports module code, resolves a dependency, or decides + * whether a module may start. It stores a decision; `resolveModuleEligibility` interprets it. That + * separation is what keeps enablement host-owned configuration instead of something a module could + * ever influence. + */ +export function createModuleLifecycleStore( + db: ModuleLifecycleDrizzleDatabase, +): ModuleLifecycleStore { + return Object.freeze({ + async readConfiguration(): Promise { + try { + const rows = await db + .select({ + enabled: ekklesiaCoreModuleState.enabled, + moduleId: ekklesiaCoreModuleState.moduleId, + }) + .from(ekklesiaCoreModuleState) + .orderBy(asc(ekklesiaCoreModuleState.moduleId)); + return Object.freeze({ + modules: Object.freeze(rows.map((row) => Object.freeze({ ...row }))), + }); + } catch (error) { + rethrowAsStoreError(error, "MODULE_LIFECYCLE_READ_FAILED"); + } + }, + + async setEnabled(input: SetModuleEnabledInput): Promise { + assertValidModuleId(input.moduleId); + try { + const [row] = await db + .insert(ekklesiaCoreModuleState) + .values({ enabled: input.enabled, moduleId: input.moduleId }) + .onConflictDoUpdate({ + set: { enabled: input.enabled, updatedAt: new Date() }, + target: ekklesiaCoreModuleState.moduleId, + }) + .returning({ + enabled: ekklesiaCoreModuleState.enabled, + moduleId: ekklesiaCoreModuleState.moduleId, + }); + if (!row) { + throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED"); + } + return Object.freeze(row); + } catch (error) { + rethrowAsStoreError(error, "MODULE_LIFECYCLE_WRITE_FAILED"); + } + }, + }); +} diff --git a/core/src/modules/lifecycle.ts b/core/src/modules/lifecycle.ts new file mode 100644 index 0000000..739dbe7 --- /dev/null +++ b/core/src/modules/lifecycle.ts @@ -0,0 +1,312 @@ +import type { + ModuleBlockedReason, + ModuleBlockingDependency, + ModuleEnablementRecord, + ModuleRuntimeState, +} from "@ekklesia/contracts"; +import type { DiscoveredModule } from "./discovery.js"; + +/** + * The complete stored enablement configuration: only the modules somebody explicitly decided + * about. A module with no record here is not "missing" — it is at its default, and the default is + * enabled (see `resolveModuleEligibility`). + */ +export interface ModuleLifecycleConfiguration { + readonly modules: readonly ModuleEnablementRecord[]; +} + +/** How a module's configured state was arrived at. `explicit` distinguishes "an administrator chose + * this" from "nobody has decided, so the platform default applies", which is exactly the difference + * an operator needs when they are wondering whether a module is deliberately off. */ +export interface ModuleConfiguredState { + readonly enabled: boolean; + readonly explicit: boolean; +} + +/** + * One module's resolved eligibility: what configuration says, and whether the dependency graph + * permits it to start. + * + * `eligible` is the single fact runtime composition consumes. Everything else on this record exists + * to explain it. + */ +export interface ModuleEligibility { + readonly moduleId: string; + 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; + }; +} + +export interface ModuleEligibilityResolution { + /** + * The modules the platform may start, **in the exact discovery order they were given**. Filtered, + * never re-sorted: discovery already produced a topological order, and blocking propagates along + * the same edges, so removing an ineligible module can never leave a module ahead of a required + * dependency it still has. + */ + readonly eligibleModules: readonly DiscoveredModule[]; + /** Every discovered module, in discovery order, eligible or not. This is the explanation surface; + * `eligibleModules` is the executable one. */ + readonly entries: readonly ModuleEligibility[]; +} + +interface MutableEligibility { + readonly moduleId: string; + readonly version: string; + readonly configured: ModuleConfiguredState; + eligible: boolean; + blocked?: { + readonly reason: ModuleBlockedReason; + readonly dependency: ModuleBlockingDependency; + }; +} + +function frozenEligibility(entry: MutableEligibility): ModuleEligibility { + return Object.freeze({ + configured: Object.freeze({ ...entry.configured }), + eligible: entry.eligible, + moduleId: entry.moduleId, + version: entry.version, + ...(entry.blocked + ? { + blocked: Object.freeze({ + dependency: Object.freeze({ ...entry.blocked.dependency }), + reason: entry.blocked.reason, + }), + } + : {}), + }); +} + +/** + * Resolves which of the already-discovered modules the platform may actually start. + * + * Pure and synchronous, exactly like `resolveDashboardNavigation`: no database, no filesystem, no + * clock, no module code. It is handed the discovered inventory and the stored configuration and + * returns what to start plus why anything else is not starting, so every rule below is testable + * without PostgreSQL. + * + * ## The rules, exactly + * + * - **A module with no stored record is enabled.** This is the platform's default policy, chosen + * so that deploying a module is sufficient to run it — the behavior every earlier phase already + * had — and so that discovering a module never requires an initialization write. It mirrors + * ADR 0027's rule that a module with no Dashboard placement is still visible. + * - **An explicit record always wins over the default,** in both directions. A stored + * `enabled: true` is indistinguishable in effect from no record at all, and is retained anyway + * so an administrator can see that somebody decided. + * - **A stored record for a module that is not installed is ignored, not an error.** It is + * retained by the store for the same reason a Dashboard placement is: redeploying the module + * restores the decision instead of silently resetting it. + * - **A disabled module blocks every module that *requires* it,** transitively, and the block + * names the dependency rather than silently re-enabling it. Administrator intent is never + * overridden. + * - **An optional dependency never blocks anything.** A module that declares an optional + * dependency has already accepted that it may be absent — discovery permits an optional + * dependency to not be installed at all — and "disabled" is the same situation arriving a + * different way. The dependent starts, and `context.capabilities.resolve(...)` simply returns + * `undefined`, exactly as it does when the provider was never installed. + * - **A required *capability* dependency blocks exactly like a required module dependency**, + * because it is one: the capability's only provider is a module, and if that module does not + * run, nothing registers the capability. + * - **A missing, incompatible, duplicated, or cyclic dependency is not represented here at all.** + * Discovery rejects all four before this function is ever reached, so there is no eligibility + * state for them and no second place where the dependency graph is validated. + * + * ## What this function structurally cannot do + * + * It never reads a manifest field beyond identity, version, and the declared dependency maps; + * never imports or evaluates module code; never writes; and never enables anything. Disabling a + * dependency can only ever *remove* modules from the eligible set. + */ +export function resolveModuleEligibility( + discoveredModules: readonly DiscoveredModule[], + configuration: ModuleLifecycleConfiguration, +): ModuleEligibilityResolution { + const configuredByModuleId = new Map( + configuration.modules.map((record) => [record.moduleId, record.enabled] as const), + ); + + const entries = new Map(); + for (const discovered of discoveredModules) { + const moduleId = discovered.manifest.id; + const stored = configuredByModuleId.get(moduleId); + const enabled = stored ?? true; + entries.set(moduleId, { + configured: { enabled, explicit: stored !== undefined }, + eligible: enabled, + moduleId, + version: discovered.manifest.version, + }); + } + + // 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). + const capabilityProviders = new Map(); + for (const discovered of discoveredModules) { + for (const declaration of discovered.manifest.capabilities) { + capabilityProviders.set(declaration.id, discovered.manifest.id); + } + } + + // One pass in discovery order is sufficient and is what makes transitive blocking deterministic: + // discovery hands back a topological order, so every required dependency of a module has already + // been resolved by the time that module is examined. + for (const discovered of discoveredModules) { + const entry = entries.get(discovered.manifest.id); + if (!entry?.eligible) { + continue; + } + + const requiredEdges: readonly ModuleBlockingDependency[] = Object.freeze([ + ...Object.keys(discovered.manifest.dependencies.modules.required).map( + (dependencyId): ModuleBlockingDependency => ({ + kind: "module", + moduleId: dependencyId, + }), + ), + ...Object.keys(discovered.manifest.dependencies.capabilities.required).flatMap( + (capabilityId): readonly ModuleBlockingDependency[] => { + const providerId = capabilityProviders.get(capabilityId); + // Unreachable through real discovery, which rejects a required capability nothing + // provides. Skipping rather than inventing a state keeps this function from having an + // opinion discovery already owns. + return providerId === undefined + ? [] + : [{ capabilityId, kind: "capability", moduleId: providerId }]; + }, + ), + ]); + + for (const dependency of requiredEdges) { + const provider = entries.get(dependency.moduleId); + if (!provider || provider.eligible) { + continue; + } + entry.eligible = false; + entry.blocked = { + dependency, + reason: provider.configured.enabled + ? "required_dependency_blocked" + : "required_dependency_disabled", + }; + break; + } + } + + const resolvedEntries = Object.freeze( + discoveredModules.map((discovered) => { + const entry = entries.get(discovered.manifest.id); + if (!entry) { + throw new Error("Internal lifecycle error: a discovered module was not indexed."); + } + return frozenEligibility(entry); + }), + ); + const eligibleIds = new Set( + resolvedEntries.filter((entry) => entry.eligible).map((entry) => entry.moduleId), + ); + + return Object.freeze({ + eligibleModules: Object.freeze( + discoveredModules.filter((discovered) => eligibleIds.has(discovered.manifest.id)), + ), + entries: resolvedEntries, + }); +} + +/** + * What one module looks like to an operator: the decision, what this process actually did, and + * whether the two currently disagree. + */ +export interface ModuleLifecycleView { + readonly moduleId: string; + readonly version: string; + readonly configured: ModuleConfiguredState; + readonly runtime: { + readonly state: ModuleRuntimeState; + readonly reason?: ModuleBlockedReason; + readonly dependency?: ModuleBlockingDependency; + }; + /** + * `true` when applying the *current* configuration would produce a different running set than + * this process actually has. Computed by re-resolving eligibility against live configuration and + * comparing with what the runtime really started — never by assuming the two agree. + * + * It is deliberately symmetric and transitive: disabling a module that others require marks those + * dependents too, because they really will stop on the next restart. + */ + readonly restartRequired: boolean; +} + +export interface ModuleLifecycleSnapshot { + /** The exact discovered inventory this process booted from. Never re-discovered afterwards: this + * process runs the modules it found at startup, and reporting anything else would describe a + * platform that is not running. */ + readonly discoveredModules: readonly DiscoveredModule[]; + /** The eligibility this process resolved at boot, which is what explains a module that is not + * running right now. */ + readonly eligibility: readonly ModuleEligibility[]; + /** The module ids the runtime reports it actually started (`StartedModuleRuntime.modules`), not + * the ids this process predicted it would start. Under the preserved all-or-nothing startup model + * these are the same set; reading the runtime's own answer is what keeps that an observation + * rather than an assumption. */ + readonly startedModuleIds: readonly string[]; +} + +/** + * Composes the operator-facing read model from the boot snapshot plus the configuration as it + * stands *now*. + * + * The two inputs are deliberately from different moments, because the honest answer is: "here is + * what this process is doing, here is what you have since asked for, and here is whether those + * differ." Collapsing them would either hide a pending change or claim a change already took + * effect. Ekklesia does not hot-load or hot-unload a module (ADR 0015, unchanged), so a + * configuration change is only ever applied by the next restart. + */ +export function resolveModuleLifecycleView( + snapshot: ModuleLifecycleSnapshot, + configuration: ModuleLifecycleConfiguration, +): 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); + const wouldRun = new Set(pending.eligibleModules.map((module) => module.manifest.id)); + + return Object.freeze( + pending.entries.map((current) => { + const boot = bootEligibility.get(current.moduleId); + const isRunning = started.has(current.moduleId); + const runtime = isRunning + ? { state: "running" as const } + : boot === undefined || !boot.configured.enabled + ? { state: "disabled" as const } + : boot.blocked + ? { + dependency: boot.blocked.dependency, + 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. + { state: "blocked" as const }; + return Object.freeze({ + configured: current.configured, + moduleId: current.moduleId, + restartRequired: wouldRun.has(current.moduleId) !== isRunning, + runtime: Object.freeze(runtime), + version: current.version, + }); + }), + ); +} diff --git a/core/src/modules/ui-publication.ts b/core/src/modules/ui-publication.ts index 6137965..8965706 100644 --- a/core/src/modules/ui-publication.ts +++ b/core/src/modules/ui-publication.ts @@ -65,26 +65,28 @@ export interface ModuleUiPublicationRegistry { /** * Builds the platform's module UI publication policy from an already-discovered module list. * - * The policy is deliberately small, because the repository's module state is deliberately small: - * Ekklesia has one accepted module set — the modules `discoverModules` validated at startup — and - * no installer, no enable/disable state, and no trust store. `ModuleLifecycleStage` - * (`@ekklesia/contracts`) names stages as a vocabulary but no runtime state machine implements - * them. So "publishable" means exactly: + * The policy is deliberately small. "Publishable" means exactly: * - * 1. the module is in the discovered, fully validated set the platform accepted at startup, and + * 1. the module is in the **eligible** set the platform started — it survived discovery *and* + * the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) — + * and * 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file * inside the module package. * * A module directory sitting on disk satisfies neither on its own — it has to have survived * discovery — which is the property `docs/development/security.md` requires: *the existence of an - * artifact on disk must not be what makes it servable*. + * artifact on disk must not be what makes it servable*. Since ADR 0028 the same is true of a module + * an administrator disabled: it is discovered and validated, and it is still not publishable, + * because this registry is built from the eligible list rather than the discovered one. That is why + * the filtering lives at the *call site's choice of input* rather than as a `disabled` check + * somewhere downstream — there is exactly one authoritative eligible set, and everything that + * exposes a module consumes it. * * Pure and synchronous: no filesystem, environment, database, or network access, and it never - * imports, evaluates, or otherwise executes a module's UI artifact. The caller supplies a complete - * discovered list; production composition (`apps/api/src/server.ts`) builds this only after the - * module runtime has started, so the published set is also the running set — module startup is - * all-or-nothing (`startDiscoveredModuleRuntime`, `./runtime.js`), so a module that failed to load, - * register, or start takes the whole API startup down with it rather than lingering as a + * imports, evaluates, or otherwise executes a module's UI artifact. Production composition + * (`apps/api/src/server.ts`) passes the exact list it just started, and only after + * `startDiscoveredModuleRuntime` succeeded — startup is all-or-nothing across that set, so a module + * that failed to load, register, or start takes API startup down with it rather than lingering as a * publishable-but-not-running module. */ export function createModuleUiPublicationRegistry( diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts index 6074a75..82aa1d7 100644 --- a/core/tests/authorization-permissions.test.ts +++ b/core/tests/authorization-permissions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_UI_READ, CORE_PERMISSION_DECLARATIONS, CORE_PERMISSION_REGISTRY, @@ -110,11 +111,22 @@ describe("CORE_PERMISSION_REGISTRY", () => { expect(declaration?.id).toBe("core.dashboard.navigation.manage"); }); - it("contains exactly these three permissions and no others", () => { + it("registers core.modules.lifecycle.manage as restricted", () => { + const declaration = CORE_PERMISSION_REGISTRY.get(CORE_MODULES_LIFECYCLE_MANAGE); + expect(declaration).toBeDefined(); + // Deciding which installed modules run is administrative, and so is *reading* that: the + // lifecycle view is the only place a disabled or blocked module is visible at all, unlike the + // merely-authenticated mountable inventory (ADR 0028). + expect(declaration?.baseline).toBe("restricted"); + expect(declaration?.id).toBe("core.modules.lifecycle.manage"); + }); + + it("contains exactly these four 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, ]); }); }); @@ -137,11 +149,16 @@ describe("CORE_PERMISSION_DECLARATIONS", () => { description: "Organize the Dashboard: create, rename, order, and delete navigation groups.", id: CORE_DASHBOARD_NAVIGATION_MANAGE, }, + { + baseline: "restricted", + description: "Read and change which installed modules are enabled.", + id: CORE_MODULES_LIFECYCLE_MANAGE, + }, ]); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true); - expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true); - expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true); - expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[2])).toBe(true); + for (const declaration of CORE_PERMISSION_DECLARATIONS) { + expect(Object.isFrozen(declaration)).toBe(true); + } }); it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => { diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts index b57f7a5..938eba7 100644 --- a/core/tests/authorization-store.integration.ts +++ b/core/tests/authorization-store.integration.ts @@ -174,6 +174,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0001_authorization_core_tables", "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", + "core:0004_module_lifecycle_state", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -228,6 +229,7 @@ describe.sequential("live Core authorization store", () => { "ekklesia_core_dashboard_placement", "ekklesia_core_grant", "ekklesia_core_migrations", + "ekklesia_core_module_state", "ekklesia_core_platform_owner", "ekklesia_core_platform_owner_bootstrap", "ekklesia_core_session", diff --git a/core/tests/dashboard-store.integration.ts b/core/tests/dashboard-store.integration.ts index a26558d..1a633ec 100644 --- a/core/tests/dashboard-store.integration.ts +++ b/core/tests/dashboard-store.integration.ts @@ -173,6 +173,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0001_authorization_core_tables", "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", + "core:0004_module_lifecycle_state", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index c5c1dc3..0716851 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies { planMigrations: async () => { throw new Error("database must not be planned"); }, + readModuleLifecycle: async () => { + throw new Error("module lifecycle configuration must not be read"); + }, runtimeSatisfies: () => true, }; } @@ -78,7 +81,9 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 3 permissions registered + 4 permissions registered + ○ Module lifecycle skipped + DATABASE_URL is not configured. Data definitions ✓ Schemas validated diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 8c3e972..b27d443 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -137,6 +137,7 @@ function dependencies(overrides: Partial = {}): DoctorDepend discoverModules: vi.fn(async () => Object.freeze([])), discoverSchemas: vi.fn(async () => schemaAggregate()), planMigrations: vi.fn(async () => plan()), + readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })), runtimeSatisfies: vi.fn(() => true), ...overrides, }; @@ -204,6 +205,7 @@ describe("Ekklesia Doctor diagnostics", () => { "modules.discovery", "modules.dependencies", "modules.permissions", + "modules.lifecycle", "schemas.discovery", "migrations.discovery", "database.transport", @@ -844,3 +846,173 @@ describe("Ekklesia Doctor diagnostics", () => { expect(services.createMigrationDatabase).not.toHaveBeenCalled(); }); }); + +const databaseUrl = "postgresql://not-rendered.invalid/ekklesia"; + +describe("Ekklesia Doctor — module lifecycle", () => { + /** A discovered module double with a real required-dependency edge, so blocking is exercised + * through the same resolver production uses rather than a hand-written eligibility record. */ + function dependentModule(id: string, requires: string): DiscoveredModule { + const base = discoveredModule(id); + return { + ...base, + manifest: { + ...base.manifest, + dependencies: { + capabilities: { optional: {}, required: {} }, + modules: { optional: {}, required: { [requires]: "^0.1.0" } }, + }, + } as unknown as ModuleManifest, + }; + } + + it("skips the check, rather than guessing a default, when DATABASE_URL is not configured", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])), + }); + + const report = await diagnoseEkklesia(options(), services); + + expect(check(report, "modules.lifecycle").status).toBe("skipped"); + expect(check(report, "modules.lifecycle").details).toEqual(["DATABASE_URL is not configured."]); + // Configuration lives in PostgreSQL; reporting "everything is enabled" without reading it would + // be a guess an operator could act on. + expect(services.readModuleLifecycle).not.toHaveBeenCalled(); + }); + + it("skips the check when discovery itself failed", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => { + throw new Error("broken inventory"); + }), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + + expect(check(report, "modules.lifecycle").status).toBe("skipped"); + expect(check(report, "modules.lifecycle").details).toEqual([ + "Complete module discovery is required.", + ]); + }); + + it("passes and lists each module when everything enabled can run", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => + Object.freeze([discoveredModule("storage"), discoveredModule("calendar", "0.2.0")]), + ), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + + expect(check(report, "modules.lifecycle").status).toBe("pass"); + expect(check(report, "modules.lifecycle").details).toEqual([ + "2 eligible · 0 disabled · 0 blocked", + "storage 0.1.0 · enabled", + "calendar 0.2.0 · enabled", + ]); + }); + + it("still passes when a module is deliberately disabled — a decision is not a problem", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])), + readModuleLifecycle: vi.fn(async () => + Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "calendar" }]) }), + ), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + + expect(check(report, "modules.lifecycle").status).toBe("pass"); + expect(check(report, "modules.lifecycle").details).toEqual([ + "0 eligible · 1 disabled · 0 blocked", + "calendar 0.1.0 · disabled", + ]); + }); + + it("distinguishes the platform default from a decision somebody made", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])), + readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + + // Nothing stored: the module is enabled by the platform default, and the detail says so. + expect(check(report, "modules.lifecycle").details).toContain("calendar 0.1.0 · enabled"); + }); + + it("warns when an enabled module cannot run, naming the dependency an operator must act on", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => + Object.freeze([discoveredModule("storage"), dependentModule("announcements", "storage")]), + ), + readModuleLifecycle: vi.fn(async () => + Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "storage" }]) }), + ), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + const lifecycle = check(report, "modules.lifecycle"); + + // An enabled module that cannot run is the anomaly worth surfacing — and it is exactly the + // question an operator would otherwise take to the logs. + expect(lifecycle.status).toBe("warning"); + expect(lifecycle.summary).toBe("1 enabled module cannot run"); + expect(lifecycle.details).toEqual([ + "0 eligible · 1 disabled · 1 blocked", + "storage 0.1.0 · disabled", + "announcements 0.1.0 · blocked · required_dependency_disabled · storage", + ]); + expect(report.result).toBe("ready-with-warnings"); + }); + + it("fails safely, without a driver detail, when the configuration cannot be read", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])), + readModuleLifecycle: vi.fn(async () => { + throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2"); + }), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + const lifecycle = check(report, "modules.lifecycle"); + + expect(lifecycle.status).toBe("failure"); + expect(lifecycle.details).toEqual([ + "The stored module enablement configuration could not be read safely.", + ]); + expect(JSON.stringify(lifecycle)).not.toContain("hunter2"); + expect(JSON.stringify(lifecycle)).not.toContain("10.40.0.2"); + }); + + it("reads with finite timeouts and the same validated transport policy every executable uses", async () => { + const services = dependencies({ + discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])), + }); + + await diagnoseEkklesia(options(databaseUrl), services); + + expect(services.readModuleLifecycle).toHaveBeenCalledWith( + databaseUrl, + expect.objectContaining({ + connectionTimeoutMs: 10_000, + operationTimeoutMs: 10_000, + transport: expect.objectContaining({ mode: "verify-full" }), + }), + ); + }); + + it("still reports lifecycle when an unrelated check fails, and never blocks on one", async () => { + const services = dependencies({ + composePermissions: vi.fn(() => { + throw new Error("composition exploded"); + }), + discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])), + }); + + const report = await diagnoseEkklesia(options(databaseUrl), services); + + expect(check(report, "modules.permissions").status).toBe("failure"); + expect(check(report, "modules.lifecycle").status).toBe("pass"); + }); +}); diff --git a/core/tests/module-lifecycle-store.integration.ts b/core/tests/module-lifecycle-store.integration.ts new file mode 100644 index 0000000..e6feead --- /dev/null +++ b/core/tests/module-lifecycle-store.integration.ts @@ -0,0 +1,417 @@ +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 { + createModuleLifecycleStore, + createModuleLifecycleView, + type ModuleLifecycleStore, + ModuleLifecycleStoreError, + resolveModuleEligibility, +} from "../src/modules/index.js"; +import { parseModuleManifest } from "@ekklesia/contracts"; +import type { DiscoveredModule } from "../src/modules/discovery.js"; +import { createValidatedPostgresPool } from "../src/database/index.js"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "../src/migrations/index.js"; + +/** + * The module lifecycle configuration store against real PostgreSQL. + * + * `core/tests/module-lifecycle.test.ts` proves the resolution rules with no database at all; this + * proves the things only a database can: that migration `0004` really creates the table with the + * constraints the schema declares, that a decision survives a completely new connection, that the + * upsert really is idempotent on `module_id`, that `enabled` has **no** column default (so the + * platform's default for a new module is genuinely the absence of a row, not a materialized one), + * and that a record for a module that is no longer deployed is retained and simply ignored. 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(); +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 lifecycle 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 lifecycle 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_lifecycle_${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 { + 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 lifecycle integration database connection failed."); + } +} + +async function safeQuery( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +async function dropDatabase(databaseName: string): Promise { + 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 { + const emptyModulesDirectory = await mkdtemp( + path.join(tmpdir(), "ekklesia-lifecycle-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", + ]); + 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); + } +}); + +/** A minimal, in-memory `DiscoveredModule` double, built through `parseModuleManifest` so a fixture + * real discovery would reject can never stand in for one it would accept. Only `manifest` is read + * by eligibility resolution. */ +function fakeModule(id: string, dependencies?: Record): DiscoveredModule { + const manifest = parseModuleManifest({ + manifestVersion: 4, + id, + name: `${id} module`, + version: "1.0.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + ...(dependencies ? { 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}`, + }; +} + +describe.sequential("live Core module lifecycle store", () => { + let database: { name: string; url: string }; + let admin: Client; + let store: ModuleLifecycleStore; + let close: () => Promise; + + it("creates exactly the one lifecycle table, with its declared constraints and no enabled default", async () => { + database = await createDisposableDatabase("fresh"); + await applyCoreMigrations(database.url); + admin = await openAdministrativeClient(database.url); + + 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%' + order by table_name`, + ); + const columns = await safeQuery<{ + column_name: string; + is_nullable: string; + column_default: string | null; + }>( + admin, + `select column_name, is_nullable, column_default + from information_schema.columns + where table_name = 'ekklesia_core_module_state' + order by column_name`, + ); + // Only the explicitly declared CHECK constraint: 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_state' and tc.constraint_type = 'CHECK' + and tc.constraint_name not like '%_not_null' + order by tc.constraint_name`, + ); + + expect(tables.map((table) => table.table_name)).toEqual(["ekklesia_core_module_state"]); + // Deliberately minimal: an identity, a decision, and two audit timestamps. No install source, + // download URL, update channel, signature, version, or JSON blob — the columns an installer + // would need are absent, which is what keeps one from being built here by accident. + expect(columns.map((column) => column.column_name)).toEqual([ + "created_at", + "enabled", + "module_id", + "updated_at", + ]); + const enabled = columns.find((column) => column.column_name === "enabled"); + expect(enabled?.is_nullable).toBe("NO"); + // The load-bearing assertion: a row exists only because somebody decided. The platform's + // default for a newly discovered module is the *absence* of a row, resolved in + // `resolveModuleEligibility` — never a column default that would materialize a decision + // nobody made. + expect(enabled?.column_default).toBeNull(); + expect(checks.map((check) => check.constraint_name)).toEqual([ + "ekklesia_core_module_state_module_check", + ]); + }); + + 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 = createModuleLifecycleStore(createModuleLifecycleView(view.pool)); + + expect(await store.readConfiguration()).toEqual({ modules: [] }); + }); + + it("persists an explicit decision and reads it back over a completely new connection", async () => { + await store.setEnabled({ enabled: false, moduleId: "announcements" }); + + const separate = createValidatedPostgresPool(database.url, { + connectionTimeoutMs: 10_000, + operationTimeoutMs: 10_000, + }); + try { + const reread = await createModuleLifecycleStore( + createModuleLifecycleView(separate.pool), + ).readConfiguration(); + expect(reread.modules).toEqual([{ enabled: false, moduleId: "announcements" }]); + } finally { + await separate.close(); + } + }); + + it("is idempotent on module_id: the same decision twice leaves exactly one row", async () => { + await store.setEnabled({ enabled: false, moduleId: "announcements" }); + await store.setEnabled({ enabled: false, moduleId: "announcements" }); + + const rows = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from ekklesia_core_module_state where module_id = 'announcements'`, + ); + expect(rows[0]?.count).toBe("1"); + }); + + it("replaces a decision in place, and bumps updated_at rather than inserting a second row", async () => { + const before = await safeQuery<{ created_at: Date; updated_at: Date }>( + admin, + `select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`, + ); + await store.setEnabled({ enabled: true, moduleId: "announcements" }); + const after = await safeQuery<{ created_at: Date; updated_at: Date }>( + admin, + `select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`, + ); + + expect(after).toHaveLength(1); + expect(after[0]?.created_at).toEqual(before[0]?.created_at); + expect(after[0]?.updated_at.getTime()).toBeGreaterThanOrEqual( + before[0]?.updated_at.getTime() ?? 0, + ); + expect((await store.readConfiguration()).modules).toEqual([ + { enabled: true, moduleId: "announcements" }, + ]); + }); + + it("returns records in deterministic module-id order", async () => { + await store.setEnabled({ enabled: false, moduleId: "video" }); + await store.setEnabled({ enabled: true, moduleId: "calendar" }); + + expect((await store.readConfiguration()).modules.map((record) => record.moduleId)).toEqual([ + "announcements", + "calendar", + "video", + ]); + }); + + it("rejects a malformed module identifier before it ever reaches PostgreSQL", async () => { + await expect(store.setEnabled({ enabled: false, moduleId: "Not A Module" })).rejects.toThrow( + ModuleLifecycleStoreError, + ); + await expect(store.setEnabled({ enabled: false, moduleId: "" })).rejects.toMatchObject({ + code: "MODULE_LIFECYCLE_MODULE_ID_INVALID", + }); + + const rows = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from ekklesia_core_module_state`, + ); + expect(rows[0]?.count).toBe("3"); + }); + + it("rejects an empty module id at the database level too, beneath the application contract", async () => { + await expect( + safeQuery( + admin, + `insert into ekklesia_core_module_state (module_id, enabled) values ('', true)`, + ), + ).rejects.toThrow(); + }); + + it("retains a record for a module that is no longer deployed, and resolution ignores it", async () => { + // The whole configuration, including the row for `video`, which is not in this inventory. + const configuration = await store.readConfiguration(); + const deployed = [fakeModule("announcements"), fakeModule("calendar")]; + + const resolution = resolveModuleEligibility(deployed, configuration); + + expect(resolution.entries.map((entry) => entry.moduleId)).toEqual([ + "announcements", + "calendar", + ]); + expect(resolution.eligibleModules.map((module) => module.manifest.id)).toEqual([ + "announcements", + "calendar", + ]); + // Retained, not deleted: redeploying `video` restores the decision with no new write. + expect(configuration.modules.some((record) => record.moduleId === "video")).toBe(true); + const redeployed = resolveModuleEligibility([...deployed, fakeModule("video")], configuration); + expect(redeployed.eligibleModules.map((module) => module.manifest.id)).toEqual([ + "announcements", + "calendar", + ]); + }); + + it("drives real dependency blocking from real stored rows", async () => { + await store.setEnabled({ enabled: false, moduleId: "storage" }); + const resolution = resolveModuleEligibility( + [ + fakeModule("storage"), + fakeModule("announcements", { modules: { required: { storage: "^1.0.0" } } }), + ], + await store.readConfiguration(), + ); + + expect(resolution.eligibleModules).toEqual([]); + expect(resolution.entries.find((entry) => entry.moduleId === "announcements")?.blocked).toEqual( + { + dependency: { kind: "module", moduleId: "storage" }, + reason: "required_dependency_disabled", + }, + ); + + await close(); + }); +}); diff --git a/core/tests/module-lifecycle.test.ts b/core/tests/module-lifecycle.test.ts new file mode 100644 index 0000000..da40e43 --- /dev/null +++ b/core/tests/module-lifecycle.test.ts @@ -0,0 +1,529 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { parseModuleManifest } from "@ekklesia/contracts"; +import { afterEach, describe, expect, it } from "vitest"; +import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js"; +import { + type ModuleLifecycleConfiguration, + resolveModuleEligibility, + resolveModuleLifecycleView, +} from "../src/modules/lifecycle.js"; + +/** A minimal, in-memory `DiscoveredModule` double, built the same way `module-permissions.test.ts` + * builds one: the manifest still goes through `parseModuleManifest`, so a fixture real discovery + * would reject can never silently stand in for one it would accept. Only `manifest` is read by + * eligibility resolution; the rest exist to satisfy the type. */ +function fakeModule( + id: string, + options: { + readonly capabilities?: readonly { readonly id: string; readonly version: string }[]; + readonly dependencies?: Record; + } = {}, +): DiscoveredModule { + const manifest = parseModuleManifest({ + manifestVersion: 4, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + capabilities: options.capabilities ?? [], + ...(options.dependencies ? { dependencies: options.dependencies } : {}), + entrypoints: { + // Deliberately a path nothing will ever resolve: eligibility resolution must never import, + // evaluate, or otherwise touch a module's server entrypoint. + 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 configuration( + ...records: readonly (readonly [string, boolean])[] +): ModuleLifecycleConfiguration { + return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) }; +} + +function ids(modules: readonly DiscoveredModule[]): readonly string[] { + return modules.map((module) => module.manifest.id); +} + +function entry( + resolution: ReturnType, + moduleId: string, +): ReturnType["entries"][number] { + const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId); + if (!found) { + throw new Error(`No eligibility entry for ${moduleId}.`); + } + return found; +} + +describe("resolveModuleEligibility — default policy for a newly discovered module", () => { + it("treats a module with no stored record as enabled, so deploying a module is enough to run it", () => { + const resolution = resolveModuleEligibility([fakeModule("announcements")], configuration()); + + expect(ids(resolution.eligibleModules)).toEqual(["announcements"]); + expect(entry(resolution, "announcements").configured).toEqual({ + enabled: true, + explicit: false, + }); + }); + + it("distinguishes the default from a decision somebody actually made", () => { + const resolution = resolveModuleEligibility( + [fakeModule("announcements")], + configuration(["announcements", true]), + ); + + // Same effect, different provenance — which is exactly what an operator needs to know when + // they are wondering whether a module is deliberately on. + expect(entry(resolution, "announcements").configured).toEqual({ + enabled: true, + explicit: true, + }); + expect(ids(resolution.eligibleModules)).toEqual(["announcements"]); + }); + + it("never writes, and never enables anything: an explicit disable always wins over the default", () => { + const resolution = resolveModuleEligibility( + [fakeModule("announcements")], + configuration(["announcements", false]), + ); + + expect(resolution.eligibleModules).toEqual([]); + expect(entry(resolution, "announcements")).toEqual({ + configured: { enabled: false, explicit: true }, + eligible: false, + moduleId: "announcements", + version: "0.1.0", + }); + // A disabled module is not "blocked" — it is doing exactly what it was told. + expect(entry(resolution, "announcements").blocked).toBeUndefined(); + }); + + it("ignores a stored record for a module that is not deployed, rather than failing startup", () => { + const resolution = resolveModuleEligibility( + [fakeModule("announcements")], + configuration(["announcements", true], ["removed-long-ago", false]), + ); + + expect(resolution.entries.map((candidate) => candidate.moduleId)).toEqual(["announcements"]); + expect(ids(resolution.eligibleModules)).toEqual(["announcements"]); + }); + + it("restores a retained decision when the module is deployed again", () => { + const stale = configuration(["announcements", false]); + + // Absent: the record is inert. + expect(resolveModuleEligibility([], stale).entries).toEqual([]); + // Redeployed: the same record decides again, with no new write. + expect(resolveModuleEligibility([fakeModule("announcements")], stale).eligibleModules).toEqual( + [], + ); + }); +}); + +describe("resolveModuleEligibility — required dependencies", () => { + it("keeps a dependent eligible while its required dependency is enabled", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("storage"), + fakeModule("announcements", { + dependencies: { modules: { required: { storage: "^0.1.0" } } }, + }), + ], + configuration(), + ); + + expect(ids(resolution.eligibleModules)).toEqual(["storage", "announcements"]); + }); + + it("blocks a dependent when its required module dependency is disabled, and names the dependency", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("storage"), + fakeModule("announcements", { + dependencies: { modules: { required: { storage: "^0.1.0" } } }, + }), + ], + configuration(["storage", false]), + ); + + expect(resolution.eligibleModules).toEqual([]); + expect(entry(resolution, "announcements").blocked).toEqual({ + dependency: { kind: "module", moduleId: "storage" }, + reason: "required_dependency_disabled", + }); + // Administrator intent is never overridden: disabling `storage` does not silently re-enable it + // because something needs it. + expect(entry(resolution, "storage").configured.enabled).toBe(false); + }); + + it("blocks a dependent when the required capability's only provider is disabled", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("storage", { capabilities: [{ id: "storage.objects", version: "1.0.0" }] }), + fakeModule("announcements", { + dependencies: { capabilities: { required: { "storage.objects": "^1.0.0" } } }, + }), + ], + configuration(["storage", false]), + ); + + expect(resolution.eligibleModules).toEqual([]); + // A required capability is exactly as required as a required module, and the reason names the + // module an administrator would have to enable — not just the capability id. + expect(entry(resolution, "announcements").blocked).toEqual({ + dependency: { capabilityId: "storage.objects", kind: "capability", moduleId: "storage" }, + reason: "required_dependency_disabled", + }); + }); + + it("propagates blocking transitively, and distinguishes the direct cause from the inherited one", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("storage"), + fakeModule("media", { + dependencies: { modules: { required: { storage: "^0.1.0" } } }, + }), + fakeModule("announcements", { + dependencies: { modules: { required: { media: "^0.1.0" } } }, + }), + ], + configuration(["storage", false]), + ); + + expect(resolution.eligibleModules).toEqual([]); + expect(entry(resolution, "media").blocked?.reason).toBe("required_dependency_disabled"); + // Following the chain always terminates at a `..._disabled` entry, so an operator can walk from + // any blocked module to the one decision that caused it. + expect(entry(resolution, "announcements").blocked).toEqual({ + dependency: { kind: "module", moduleId: "media" }, + reason: "required_dependency_blocked", + }); + }); + + it("blocks only the dependents of the disabled module, never an unrelated one", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("storage"), + fakeModule("announcements", { + dependencies: { modules: { required: { storage: "^0.1.0" } } }, + }), + fakeModule("calendar"), + ], + configuration(["storage", false]), + ); + + expect(ids(resolution.eligibleModules)).toEqual(["calendar"]); + expect(entry(resolution, "calendar").blocked).toBeUndefined(); + }); + + it("preserves discovery order in the eligible list rather than re-sorting it", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("storage"), + fakeModule("media", { dependencies: { modules: { required: { storage: "^0.1.0" } } } }), + fakeModule("announcements", { + dependencies: { modules: { required: { media: "^0.1.0" } } }, + }), + ], + configuration(), + ); + + // Filtering, never re-ordering: a started module's required dependencies still precede it. + expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]); + }); +}); + +describe("resolveModuleEligibility — optional dependencies", () => { + it("leaves a dependent eligible when its optional module dependency is disabled", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("analytics"), + fakeModule("announcements", { + dependencies: { modules: { optional: { analytics: "^0.1.0" } } }, + }), + ], + configuration(["analytics", false]), + ); + + // A module declaring an optional dependency already accepted that it may be absent — discovery + // permits an optional dependency to not be installed at all — and "disabled" is that same + // situation arriving a different way. + expect(ids(resolution.eligibleModules)).toEqual(["announcements"]); + expect(entry(resolution, "announcements").blocked).toBeUndefined(); + }); + + it("leaves a dependent eligible when its optional capability's provider is disabled", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("analytics", { capabilities: [{ id: "analytics.events", version: "1.0.0" }] }), + fakeModule("announcements", { + dependencies: { capabilities: { optional: { "analytics.events": "^1.0.0" } } }, + }), + ], + configuration(["analytics", false]), + ); + + expect(ids(resolution.eligibleModules)).toEqual(["announcements"]); + }); + + it("leaves a dependent eligible when its optional dependency is itself blocked", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("storage"), + fakeModule("analytics", { + dependencies: { modules: { required: { storage: "^0.1.0" } } }, + }), + fakeModule("announcements", { + dependencies: { modules: { optional: { analytics: "^0.1.0" } } }, + }), + ], + configuration(["storage", false]), + ); + + expect(ids(resolution.eligibleModules)).toEqual(["announcements"]); + }); + + it("blocks on the required edge only, when a module declares both kinds", () => { + const resolution = resolveModuleEligibility( + [ + fakeModule("analytics"), + fakeModule("storage"), + fakeModule("announcements", { + dependencies: { + modules: { optional: { analytics: "^0.1.0" }, required: { storage: "^0.1.0" } }, + }, + }), + ], + configuration(["analytics", false], ["storage", false]), + ); + + expect(entry(resolution, "announcements").blocked).toEqual({ + dependency: { kind: "module", moduleId: "storage" }, + reason: "required_dependency_disabled", + }); + }); +}); + +describe("resolveModuleEligibility — 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, + dependencies?: Record, + ): Promise { + 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: "0.1.0", + }), + ), + writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + manifestVersion: 4, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + ...(dependencies ? { dependencies } : {}), + entrypoints: { server: "./dist/server/index.js" }, + }), + ), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + 'throw new Error("eligibility resolution must never import a module server entrypoint");', + ), + ]); + } + + it("resolves against the exact list real discovery produces, without importing any entrypoint", async () => { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-")); + temporaryDirectories.push(modulesDirectory); + await writeModule(modulesDirectory, "storage"); + await writeModule(modulesDirectory, "announcements", { + modules: { required: { storage: "^0.1.0" } }, + }); + + const discovered = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + + // Discovery's own topological order, which eligibility resolution relies on and never redoes. + expect(ids(discovered)).toEqual(["storage", "announcements"]); + + const blocked = resolveModuleEligibility(discovered, configuration(["storage", false])); + expect(blocked.eligibleModules).toEqual([]); + // Every fixture entrypoint throws on import; reaching this assertion is direct evidence that + // nothing here imported one. + expect(entry(blocked, "announcements").blocked?.reason).toBe("required_dependency_disabled"); + }); + + it("leaves a missing required dependency to discovery, rather than modelling it as a lifecycle state", async () => { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-")); + temporaryDirectories.push(modulesDirectory); + await writeModule(modulesDirectory, "announcements", { + modules: { required: { storage: "^0.1.0" } }, + }); + + // There is deliberately no eligibility state for "required dependency not installed": discovery + // rejects the whole inventory long before enablement is consulted, so there is exactly one place + // the dependency graph is validated. + await expect( + discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }), + ).rejects.toMatchObject({ name: "ModuleDiscoveryError" }); + }); +}); + +describe("resolveModuleLifecycleView — configuration versus what this process actually did", () => { + const storage = fakeModule("storage"); + const announcements = fakeModule("announcements", { + dependencies: { modules: { required: { storage: "^0.1.0" } } }, + }); + const discoveredModules = [storage, announcements]; + + function snapshotFor(bootConfiguration: ModuleLifecycleConfiguration) { + const resolution = resolveModuleEligibility(discoveredModules, bootConfiguration); + return { + discoveredModules, + eligibility: resolution.entries, + startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id), + }; + } + + it("reports a started module as running, with no restart pending", () => { + const view = resolveModuleLifecycleView(snapshotFor(configuration()), configuration()); + + expect(view).toEqual([ + { + configured: { enabled: true, explicit: false }, + moduleId: "storage", + restartRequired: false, + runtime: { state: "running" }, + version: "0.1.0", + }, + { + configured: { enabled: true, explicit: false }, + moduleId: "announcements", + restartRequired: false, + runtime: { state: "running" }, + version: "0.1.0", + }, + ]); + }); + + it("does not claim a module stopped when configuration changed but the process did not", () => { + const view = resolveModuleLifecycleView( + snapshotFor(configuration()), + configuration(["announcements", false]), + ); + + const changed = view.find((entry) => entry.moduleId === "announcements"); + // The honest triple: configured off, still running, restart pending. Reporting `disabled` here + // would be a lie about a process that is still serving that module's routes. + expect(changed?.configured).toEqual({ enabled: false, explicit: true }); + expect(changed?.runtime).toEqual({ state: "running" }); + expect(changed?.restartRequired).toBe(true); + // An unrelated module is untouched. + expect(view.find((entry) => entry.moduleId === "storage")?.restartRequired).toBe(false); + }); + + it("marks restart pending for a dependent too, because it really will stop on the next boot", () => { + const view = resolveModuleLifecycleView( + snapshotFor(configuration()), + configuration(["storage", false]), + ); + + expect(view.map((entry) => [entry.moduleId, entry.restartRequired])).toEqual([ + ["storage", true], + ["announcements", true], + ]); + expect(view.every((entry) => entry.runtime.state === "running")).toBe(true); + }); + + it("marks restart pending in the other direction, without claiming the module started", () => { + const view = resolveModuleLifecycleView( + snapshotFor(configuration(["announcements", false])), + configuration(["announcements", true]), + ); + + const changed = view.find((entry) => entry.moduleId === "announcements"); + expect(changed?.configured).toEqual({ enabled: true, explicit: true }); + expect(changed?.runtime).toEqual({ state: "disabled" }); + expect(changed?.restartRequired).toBe(true); + }); + + it("explains a blocked module with the boot-time reason, not a recomputed one", () => { + const view = resolveModuleLifecycleView( + snapshotFor(configuration(["storage", false])), + configuration(["storage", false]), + ); + + expect(view.find((entry) => entry.moduleId === "storage")?.runtime).toEqual({ + state: "disabled", + }); + expect(view.find((entry) => entry.moduleId === "announcements")?.runtime).toEqual({ + dependency: { kind: "module", moduleId: "storage" }, + reason: "required_dependency_disabled", + state: "blocked", + }); + expect(view.every((entry) => entry.restartRequired === false)).toBe(true); + }); + + it("reports what the runtime actually started, not what eligibility predicted", () => { + // A snapshot whose started set disagrees with its own eligibility is not reachable while + // startup stays all-or-nothing. It is constructed here deliberately: the read model must be an + // observation of the runtime, so if that guarantee ever changes this can never silently report + // a module as `running` because it was merely eligible. + const eligible = resolveModuleEligibility(discoveredModules, configuration()); + const view = resolveModuleLifecycleView( + { + discoveredModules, + eligibility: eligible.entries, + startedModuleIds: ["storage"], + }, + configuration(), + ); + + expect(view.find((entry) => entry.moduleId === "storage")?.runtime.state).toBe("running"); + expect(view.find((entry) => entry.moduleId === "announcements")?.runtime.state).toBe("blocked"); + expect(view.find((entry) => entry.moduleId === "announcements")?.restartRequired).toBe(true); + }); + + it("ignores a stored record for an undeployed module rather than inventing a row for it", () => { + const view = resolveModuleLifecycleView( + snapshotFor(configuration()), + configuration(["removed-long-ago", false]), + ); + + expect(view.map((entry) => entry.moduleId)).toEqual(["storage", "announcements"]); + }); +}); diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts index 1c41f17..760236e 100644 --- a/core/tests/module-permissions.test.ts +++ b/core/tests/module-permissions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_UI_READ, CORE_PERMISSION_DECLARATIONS, PermissionRegistryError, @@ -69,8 +70,9 @@ describe("composePlatformPermissionRegistry", () => { expect(registry.has("calendar.events.read")).toBe(true); expect(registry.has("people.directory.read")).toBe(true); expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true); - // Core's own three declarations plus one from each module. - expect(registry.declarations).toHaveLength(5); + expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true); + // Core's own four declarations plus one from each module. + expect(registry.declarations).toHaveLength(6); }); it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => { @@ -88,6 +90,7 @@ describe("composePlatformPermissionRegistry", () => { CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_MODULES_UI_READ, CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_MODULES_LIFECYCLE_MANAGE, "calendar.events.read", "calendar.events.write", "people.directory.read", diff --git a/docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md b/docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md index ffc69b2..35718ae 100644 --- a/docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md +++ b/docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md @@ -30,6 +30,15 @@ names nine stages, but nothing implements a state machine over them: there is no enable/disable, no trust store, and no per-module status record. What exists is one accepted set — the modules `discoverModules` validated at startup and `startDiscoveredModuleRuntime` then started. +> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).** +> The paragraph above described the platform accurately at the time. There is now a persistent +> enable/disable state and a resolved eligibility model, and `ModuleLifecycleStage` has been replaced +> by the two axes ADR 0028 records. "Publishable" consequently derives from the **eligible** module +> set rather than the discovered one, so a disabled or dependency-blocked module publishes no +> artifact. Everything else in this ADR — lookup by validated identity, the sibling prefix, exact +> version matching, the anonymous artifact route, and the caching contract — is unchanged and still +> accepted. There is still no installer. + ## Decision ### Publication is derived from validated module identity, never from a request path diff --git a/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md index 57e2569..926cc9b 100644 --- a/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md +++ b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md @@ -79,6 +79,14 @@ configure, and no module that declares no UI. Ekklesia has no enable/disable sta `ModuleLifecycleStage` remains vocabulary with no runtime state machine behind it — and reporting one would mean inventing it. +> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).** +> Ekklesia now does have enable/disable state, reported by its own resource +> (`GET /api/v1/module-lifecycle`) rather than by this one. This endpoint is unchanged and remains +> exactly what it was: the host-neutral list of what may be mounted, with no management verbs. What +> changed is upstream of it — the inventory now derives from the **eligible** module set, so a +> disabled or dependency-blocked module is simply absent, and the lifecycle vocabulary this +> paragraph names has been replaced by the two axes ADR 0028 records. + ### Both URLs are server-produced, and that is the point `ui.url` comes from `moduleUiArtifactUrl` (ADR 0025) and `api.baseUrl` from `moduleApiBaseUrl`, a new diff --git a/docs/architecture/decisions/0028-module-operational-lifecycle.md b/docs/architecture/decisions/0028-module-operational-lifecycle.md new file mode 100644 index 0000000..0143942 --- /dev/null +++ b/docs/architecture/decisions/0028-module-operational-lifecycle.md @@ -0,0 +1,426 @@ +# 0028: Module operational lifecycle — enablement, eligibility, and what "running" means + +- 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), + [0025](0025-trusted-module-ui-asset-publication.md), + [0026](0026-minimal-ekklesia-module-ui-host.md), + [0027](0027-dashboard-navigation-and-flat-modules.md) + +## Context + +Everything upstream of this decision exists. Core discovers modules, validates manifests and +dependencies, orders them topologically, composes permissions, starts a runtime, mounts module HTTP +routes, publishes module-owned UI, and resolves a Dashboard arrangement over it. + +What did not exist was any notion of a module being **on or off**. The accepted module set was +exactly "the modules found on disk at startup", and three separate places in the repository said so +in almost the same words: + +> `ModuleLifecycleStage` (`@ekklesia/contracts`) names nine stages, but nothing implements a state +> machine over them: there is no installer, no enable/disable, no trust store, and no per-module +> status record. +> — ADR 0025 + +That vocabulary — `discovered`, `validated`, `configured`, `registered`, `migrated`, `initialized`, +`started`, `stopped`, `failed` — was aspiration written down as a contract. Nothing produced any of +those values, and nothing consumed them. + +Before migrating a real product into the module system, the platform needs an answer to a small set +of operational questions that a church running this software will genuinely ask: *is this module +on? should it start? what happens to the things that depend on it? why is it not available?* This +ADR records the smallest model that answers them honestly. + +Two constraints came from the repository rather than from principle. + +**Startup is all-or-nothing, deliberately.** ADR 0015 and ADR 0020 built that guarantee carefully: +a module joins the rollback set the moment its `start` hook is invoked, capabilities and routes are +published only after every module registers and starts, and a failed start unwinds in reverse order +under bounded deadlines. ADR 0025 then *depends* on it — "the published set is also the running +set" — as does ADR 0026's inventory and ADR 0027's navigation. + +**There is exactly one persistence precedent, and it is recent.** ADR 0027 added two Core-owned, +platform-global tables for Dashboard configuration, with the explicit rule that a module with no +stored row is still visible. That precedent shaped both the schema conventions and the default +policy below. + +## Decision + +### Two axes, never one status word + +```text +configuration enabled / disabled persistent, administrator-owned +runtime running / disabled / blocked ephemeral, observed per boot +``` + +`MODULE_LIFECYCLE_STAGES` is **replaced** by these two, because a contract should name states that +something actually produces. Both are in `@ekklesia/contracts`; both are observable through +`GET /api/v1/module-lifecycle`. + +They are kept apart because they answer different questions and change at different times. +Collapsing them into one word would force a lie in one direction or the other the moment an +administrator changes a setting on a process that is still running. + +### `installed` means "a valid module package on disk that discovery accepted" — and nothing else + +There is no installer, no installation registry, no package database, and no install source. This +pass deliberately did not create one. The honest chain today is: + +```text +present on disk + ↓ discovery validates manifest, package, entrypoints, dependencies, cycles +discovered + ↓ platform-owned enablement configuration +enabled + ↓ required-dependency resolution +eligible + ↓ module runtime import, register, start +running +``` + +A future installer's job is to put a validated package on disk and record an enablement decision, +then reconcile on restart. Nothing in this contract assumes a source-control checkout, an image, or +a registry, so that installer remains possible without changing anything here. + +### Enablement is host configuration, and a module can never see it + +One Core table, `ekklesia_core_module_state` (migration `0004_module_lifecycle_state`), with +exactly `module_id`, `enabled`, and two audit timestamps. No install source, download URL, update +channel, signature, marketplace metadata, or JSON blob — those are the columns an installer would +need, and their absence is what stops one being built here by accident. + +`module_id` carries **no foreign key**, for the same reason `ekklesia_core_dashboard_placement` +does not: modules are packages, not rows. A record for a module that is no longer deployed is +retained and ignored, so redeploying restores the decision rather than resetting it. + +A module never declares, reads, or influences its enablement. Nothing in a manifest, a capability, +a permission, or a lifecycle hook can reach it, and a module is never told it is enabled — its host +either loads and starts it, or does not. **That is what keeps the same module implementation +reusable under a future standalone host**, whose loading decisions are its own. A module asking +"am I enabled in Ekklesia?" would be a module that only runs inside Ekklesia. + +### A newly discovered module is enabled, and the default is the absence of a row + +`enabled` has no column default. The platform's default is expressed by there being no record at +all, and resolved in `resolveModuleEligibility`. + +This preserves the behavior every earlier phase had — deploying a module is sufficient to run it — +requires no initialization write when a module first appears, and mirrors ADR 0027's rule that a +module with no placement is still visible. Default-disabled was rejected: with no management UI, it +would mean every deployment ships a platform where nothing works until someone runs a command, and +it would make "install a module" a two-step operation for no safety gain, since the code is already +trusted the moment it is on disk (see **Trust is unchanged**). + +An explicit `enabled: true` record is still meaningful and is retained: it records that somebody +looked and left it on. The read model reports `explicit` alongside `enabled` for exactly that +reason. + +### Required dependencies block; optional dependencies never do + +Disabling `storage` does not silently re-enable it because something needs it. It makes every +module that **requires** it ineligible, transitively: + +```text +storage disabled + ↓ +media blocked · required_dependency_disabled · storage + ↓ +announcements blocked · required_dependency_blocked · media +``` + +Following the chain always terminates at a `..._disabled` entry, so an operator can walk from any +blocked module to the one decision that caused it. + +A required **capability** dependency blocks identically, because it is one: a capability's only +provider is a module, and if that module does not run, nothing registers the capability. The reason +names the *providing module* — the thing an administrator can act on — with the capability id +alongside it. + +An optional dependency never blocks. A module declaring one has already accepted that it may be +absent (discovery permits an optional dependency to not be installed at all), and "disabled" is the +same situation arriving a different way: `context.capabilities.resolve(...)` returns `undefined`, +exactly as it does when the provider was never installed. Required and optional behavior are tested +separately and deliberately. + +A dependency that is **missing, version-incompatible, duplicated, or cyclic** has no eligibility +state at all. Discovery rejects the whole inventory before enablement is ever consulted, so the +dependency graph is validated in exactly one place. + +Eligibility is a **filter over discovery's order, never a re-sort**. Blocking propagates along the +same edges discovery used to produce a topological order, so removing an ineligible module can +never leave a module ahead of a required dependency it still has. + +### Startup atomicity is preserved, deliberately + +Startup remains all-or-nothing **across the eligible set**. A module that fails to load, register, +or start still aborts API startup and unwinds through Core's existing reverse-order rollback. + +This was the sharpest question in the pass, and changing it would have been a much larger decision +than this ADR should make by accident. ADR 0015 and ADR 0020 built that guarantee on purpose, and +ADR 0025, ADR 0026, and ADR 0027 all rest on "the published set is the running set". Moving to +per-module failure isolation means the permission registry, the route snapshot, the publication +registry, and the navigation view each have to be derived from a set that is only known *after* +partial failure — a real design, and a separate one. + +The cost is stated plainly rather than hidden: **one broken module still takes down the whole +platform.** Enablement is what makes that recoverable, and this pass closes the one path that would +otherwise be a deadlock — the HTTP lifecycle API is served by the process a bad module takes down, +so `pnpm modules:disable -- --module ` exists as an out-of-band operator command, in the same +shape as `pnpm authorization:bootstrap-owner`: explicit, never HTTP-exposed, never inferred. + +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. + +### Runtime failure is never persisted as configuration + +```text +enabled configuration → stored +failed to start during this boot runtime → not stored +``` + +A restart naturally retries every enabled module. There is no failure counter, no quarantine, no +backoff, and no "disabled because it crashed" write — a platform that disabled a module on its +behalf would be making an administrative decision nobody asked for, and the operator would have no +way to tell it apart from one they made. + +### `running` means its `start` hook completed, and nothing more + +It does **not** mean every external resource the module uses is reachable, now or ever. A module +that starts successfully and then cannot reach its object storage is a module with a health +problem, not a module with a lifecycle problem. + +This pass adds no runtime health-probe contract, no readiness hook, and no per-module health +endpoint. A module that wants to report its own health does so through its own API and its own UI, +like any other domain concern. The limitation is recorded here rather than left to be discovered. + +### Configuration changes take effect at the next start, and the API says so + +There is no hot module installation, removal, or reloading (ADR 0015, unchanged), so `PUT` writes a +decision and changes nothing about the running process. The response reports all three facts: + +```json +{ "id": "announcements", "version": "1.0.0", + "configured": { "enabled": false, "explicit": true }, + "runtime": { "state": "running" }, + "restartRequired": true } +``` + +Reporting `runtime: disabled` there would be a lie about a process still serving that module's +routes. `restartRequired` is computed by re-resolving eligibility against live configuration and +comparing with what the runtime **reports it actually started**, not with what eligibility +predicted — so it is symmetric (enabling a stopped module marks it too) and transitive (disabling a +dependency marks its dependents, because they really will stop). + +Hot-unloading to make a button feel immediate was rejected: it would trade a real guarantee — that +a trusted module's ESM evaluation happens exactly once and can never be rolled back — for a +cosmetic one. + +### Exposure follows eligibility, structurally rather than by a downstream check + +`apps/api/src/server.ts` passes the **same eligible list** to `startDiscoveredModuleRuntime` and to +`createModuleUiPublicationRegistry`. There is one authoritative eligible set, and everything that +exposes a module consumes it: + +- **HTTP routes.** A disabled module is never imported, so its `register` hook never runs and it + publishes no routes. There is no per-request "is this module enabled?" check anywhere, because + there is nothing mounted to check — an unmounted path gets the platform's ordinary `404`. +- **UI publication.** Its artifact URL returns the same `404` an uninstalled module does, even to a + caller who knows the exact id and version. ADR 0025's rule — *the existence of an artifact on disk + must not be what makes it servable* — now covers "discovered but disabled" as well. +- **Inventory and navigation.** Absent from `GET /api/v1/module-ui` and from + `GET /api/v1/dashboard/navigation`, by construction, since both derive from the publication + registry. Its Dashboard placement row is retained and simply renders nothing, exactly like an + uninstalled module's (ADR 0027). +- **Lifecycle hooks.** Never invoked, in either direction: shutdown runs no `stop` hook for a module + that never started. + +A module whose enablement changed but whose process has not restarted stays fully exposed. It really +is still mounted and still serving; hiding it now would break a viewer mid-session for a change that +has not taken effect, and `restartRequired` is where that pending change is reported instead. + +### Permission composition still spans the whole discovered inventory + +Unchanged, deliberately. ADR 0015 fixed the startup order so that discovery and permission +composition complete **before any pool, Better Auth instance, or module entrypoint exists** — a +composition failure therefore leaves nothing to close. Filtering permissions by enablement would +require reading the database first and would invert that order for no safety gain. + +A disabled module's permissions therefore stay registered and grantable. Nothing enforces them, +because it mounts no routes, and grants survive an enable/disable cycle intact — which is what an +administrator would expect, and lets a permission be granted ahead of enabling a module. + +### Migration participation is deployment, not runtime state + +```text +deployed schema/migration ownership ≠ runtime enabled state +``` + +Migration discovery, planning, and application continue to read the **deployed** inventory, +regardless of enablement. They are offline, Core-owned, explicitly operator-invoked commands and +must not depend on runtime configuration. A disabled module's tables must still be migrated, or +enabling it later would require a schema change in the middle of a restart. Nothing about the +planner, destructive-change approval, locking, checksums, or transactional guarantees changed. + +### The URL is a sibling of the module namespace, not a child + +```text +GET /api/v1/module-lifecycle +PUT /api/v1/module-lifecycle//enabled { "enabled": boolean } +``` + +The obvious `/api/v1/modules` and `/api/v1/modules/:id/enabled` are **wrong**, for the exact reason +ADR 0025 gave for `/api/v1/module-ui`. Core's route grammar accepts `enabled` as an ordinary static +segment, so a module named `announcements` may legitimately register `PUT /enabled` — mounted at +exactly `/api/v1/modules/announcements/enabled`. Publishing the platform's own lifecycle 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 whether modules run. A sibling prefix cannot collide. + +`PUT` against a module id not in the boot inventory is a `404`. Unlike an inert Dashboard placement, +an enablement record is operational configuration; accepting one for an arbitrary identifier would +make this an unbounded write surface for a delegable permission. The CLI enforces the identical rule +by running the same manifest-only discovery. *Existing* records for undeployed modules are still +retained and ignored. + +The body is closed and structured — any key beyond `enabled` invalidates the whole request rather +than being dropped — matching ADR 0027's mutation contract exactly. There is no configuration blob. + +### One restricted permission, gating read as well as write + +`core.modules.lifecycle.manage`, baseline `restricted`. The read is not a milder version of the +write: this resource is the only place a *disabled* module, a *blocked* module, and the +required-dependency edges between them are visible at all. `core.modules.ui.read` deliberately +exposes the mountable set to every signed-in user; this exposes the parts of the installation that +are deliberately not running, which is not the same audience. A second, weaker read permission over +the same resource would be ceremony. + +It is a **platform** permission, never a module's, and holding it confers no authority inside any +module. Mutations additionally require an exact trusted `Origin` and a bounded body before +authorization work begins, like every other Core mutation. + +Neither response carries a filesystem path, package name, manifest path, entrypoint, caught error, +or stack. The only identifiers are module ids, capability ids, and fixed reason codes from closed +sets — all values the platform itself produced and validated. + +### Startup fails closed when the configuration cannot be read + +`startApi` reads enablement before importing any module entrypoint. If that read fails — an +unmigrated or unreachable database — startup **refuses**, with Core's sanitized +`MODULE_LIFECYCLE_READ_FAILED`. It cannot know what an administrator decided, and starting a module +they disabled is worse than not starting at all. This is a behavior change: previously the API would +bind a listener against an unmigrated database and fail every request instead. Nothing is created or +repaired on that path. + +### Diagnostics answer "why is this module unavailable?" without the logs + +`pnpm doctor` gains one check, `modules.lifecycle`, in its existing Modules section — not a second +diagnostics framework. It is skipped when discovery failed or `DATABASE_URL` is unset (configuration +lives in PostgreSQL, and reporting a guessed default is worse than skipping). A **disabled** module +is a `pass` with a detail line: it is a decision, not a problem. A **blocked** module is a +`warning`: an enabled module that cannot run is the anomaly worth surfacing. Every rendered value is +re-validated before interpolation, matching the file's existing `safeModuleDetails` discipline. + +### Trust is unchanged + +Installed modules remain trusted compiled code running in the API process with its PostgreSQL +authority. Enablement is an **operational** control, not a security boundary: a module's code is on +disk and could be started by any operator with filesystem access. Nothing here sandboxes, isolates, +or restricts a module that does run, and nothing here should be described as if it did. + +## Validated against a real product + +The Announcements repository (`The_River/Announcements`, commit `9aab486`) was walked against this +model as a reference consumer. It was not modified, and nothing about it entered Core. + +It is a small Express + Socket.IO server that polls an S3-compatible bucket listing every 30 +seconds, advances a slide index on a 7-second timer, broadcasts state to a full-screen React +client, and toggles a blank-screen `mode` from an MQTT topic. It has no database and no +authentication. + +Three things it does made the two-axis model earn its keep. + +**Its storage is unreachable regularly, and that is not a lifecycle failure.** `loadSlides()` +already catches its own fetch failure, logs, and retries on the next tick. Under this model the +module stays `running`, because its `start` hook completed; "slides could not be loaded right now" +is the module's own health, surfaced through its own UI. Had `running` been defined as "everything +it needs is reachable", a router reboot would have looked like a lifecycle event. + +**Its `mode: on/off` toggle is emphatically not enablement.** It blanks the screen, is flipped +frequently by whoever is running a service, and must take effect instantly. Module enablement is an +administrative deployment decision that takes effect at the next restart. Mapping one onto the other +would have pushed a per-service operational toggle into Core's lifecycle and made it restart-based — +which is precisely the kind of conflation the separate configuration and runtime axes exist to +prevent. (Per the platform direction, that control belongs to a future Companion module speaking the +module API; Ekklesia Core will not use MQTT, and this ADR designs nothing around it.) + +**Its long-lived timers are what `stop` is for.** Neither `setInterval` is ever cleared today. As a +module it must clear both in a `stop` hook that is safe after a partially completed `start` (ADR +0020) — a migration task, not a Core gap, and comfortably inside the platform's 10-second hook +deadline. + +The rest of the walkthrough came out as designed: disabled means no routes, no UI, no navigation +entry, no timers, and no hooks; its migrations would still be discovered and applied; a future +required dependency being disabled would block it with a named reason; and nothing in its feature +code would ever ask whether it is enabled, so the same implementation stays deployable standalone. + +## Alternatives + +- **Implementing the nine `MODULE_LIFECYCLE_STAGES` names.** Rejected: most name a transition rather + than a state, several are unobservable, and shipping them would have repeated the original mistake + of publishing a vocabulary nothing produces. +- **A `failed` runtime state now.** Rejected while startup is all-or-nothing: no live process could + ever report it. +- **Per-module failure isolation in this pass.** Deferred deliberately. It is a genuine improvement + and a genuinely larger decision; four accepted ADRs depend on the current guarantee. +- **Default-disabled for a newly discovered module.** Rejected: it breaks every earlier phase's + behavior, requires an administrative step with no UI to perform it in, and buys no safety the + trusted-code model does not already concede. +- **A `disabled` column default.** Rejected: it would materialize a decision nobody made and destroy + the distinction between "at the default" and "explicitly set". +- **Persisting startup failures.** Rejected: it turns a transient runtime fact into administrative + configuration an operator did not write and cannot distinguish from their own. +- **Filtering permission composition by enablement.** Rejected: it inverts ADR 0015's deliberate + startup order for no gain, and would make grants vanish across an enable/disable cycle. +- **Filtering migration discovery by enablement.** Rejected: schema ownership is a deployment fact, + and a disabled module's tables must already exist when it is enabled. +- **Enable/disable at `/api/v1/modules/...`.** Rejected: it collides with the module route namespace, + where registration order would decide which handler answers. +- **A separate read permission.** Rejected: the read exposes what the write controls. +- **Hot enable/disable without a restart.** Rejected: ESM evaluation cannot be undone, and the + platform must not claim a module stopped when its code is still resident. +- **A generalized runtime health-probe SDK.** Rejected for this pass: "running" is deliberately a + narrow claim, and a health contract is a design of its own. +- **A module installer, package registry, or install-source columns.** Explicitly out of scope; the + schema deliberately has nowhere to put them. + +## Consequences + +Ekklesia has an operational lifecycle: an administrator can turn a module off, that decision +survives restarts, its dependents behave deterministically, and every surface that exposes a module +— routes, artifacts, inventory, navigation — follows the one eligible set rather than a filter +somewhere downstream. + +`createApiApp` takes a required `moduleLifecycle` option, matching the pattern `dashboard`, +`moduleUi`, and `moduleRoutes` already follow. Core gains a fourth permission, which moved several +existing assertions. `startApi` now reads the database before starting modules, so it refuses to +start against an unmigrated one — stricter than before, and stated as such. + +Two costs worth naming. Enablement is platform-global and will need a scope column when an +organization model exists, exactly like the Dashboard tables. And a broken module still takes the +whole platform down; the CLI makes that recoverable without a database edit, but it does not make it +pleasant, and closing it properly is the isolated-startup pass. + +## Deferred + +Not implemented or promised here: per-module startup failure isolation and a `failed` runtime state; +a module installer, marketplace, package registry, download/update/signature infrastructure, or +install-source metadata; hot loading, hot unloading, and filesystem watchers; a module-management +Dashboard UI or any administrator-facing affordance beyond the API and the CLI; automatic migration +application, and any coupling between enablement and migrations; a runtime health-probe or readiness +contract; module-owned configuration and secrets; per-module or per-organization scoping of +enablement; a "reset to default" operation that deletes an explicit record; and process isolation or +sandboxing of any kind. + +The permission-id typing gap carried since ADR 0022 is unchanged: `permissions.has` takes a plain +string, and the wire arrays are `string[]`. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index c9086ea..b381394 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -36,3 +36,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0025: Trusted module UI asset publication](0025-trusted-module-ui-asset-publication.md) — Accepted - [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted - [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted +- [0028: Module operational lifecycle — enablement, eligibility, and what "running" means](0028-module-operational-lifecycle.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index ae9ce22..20c7b44 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -308,3 +308,24 @@ Target architecture not yet implemented includes production approval-provider composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are scheduled by this document. + +Phase 7A pass 1 adds the platform's operational module lifecycle +([ADR 0028](decisions/0028-module-operational-lifecycle.md)). Core now has two clearly separated +axes: **configuration** (`enabled`/`disabled`, persistent, administrator-owned, stored in one Core +table) and **runtime** (`running`/`disabled`/`blocked`, observed per boot). `resolveModuleEligibility` +is a pure function of the discovered inventory plus that configuration: a module with no stored +record is enabled by default, an explicit decision always wins, a disabled module transitively blocks +every module that *requires* it (module and capability edges alike, with a structured reason naming +the provider), and an optional dependency never blocks anything. `startApi` starts only the eligible +set and builds the UI publication registry from that same list, so routes, artifacts, browser +inventory, and Dashboard navigation all follow one authoritative set rather than a filter downstream. + +Startup remains all-or-nothing across the eligible set — ADR 0015 and ADR 0020's rollback guarantee +is unchanged, and there is deliberately no `failed` runtime state, because no live process could +report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable` as the +out-of-band path. Changes take effect at the next start, and the read model reports configuration, +runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. +Migration discovery still spans the deployed inventory regardless of enablement, and permission +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. diff --git a/docs/development/api.md b/docs/development/api.md index 006f7eb..20b9e9f 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -80,6 +80,8 @@ No real values are shown here — configure per environment and never commit the | `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). | | `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). | | `GET /api/v1/module-ui//` | 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//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). | Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a deterministic `404 {"code":"NOT_FOUND"}`. @@ -385,12 +387,16 @@ 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. Start the module runtime from that exact same discovered inventory - (`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the - services object every module's `start`/`stop` hook receives. -6. Create the Hono application, mounting the started module runtime's published routes and passing - the identical services object to every module HTTP handler. -7. Bind the HTTP server. +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)). +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. +7. Create the Hono application, mounting the started module runtime's published routes, publishing + UI for that same eligible set, and passing the identical services object to every module HTTP + handler. +8. Bind the HTTP server. Discovery and permission composition happen before any pool, Better Auth instance, module entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid @@ -404,9 +410,143 @@ for the permission-composition detail, and [Module development](module-developme the manifest shape that produces these declarations. Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a -successful production startup does import each module's compiled server entrypoint and run its -`register`/`start` hooks — this is the one place in the platform that deliberately executes module -code, and only after every other startup step already listed has succeeded. +successful production startup does import each **eligible** module's compiled server entrypoint and +run its `register`/`start` hooks — this is the one place in the platform that deliberately executes +module code, and only after every other startup step already listed has succeeded. A module that is +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 +decided, and starting a module they disabled is worse than not starting. Nothing is created or +repaired on that path. + +## Module lifecycle + +```text +GET /api/v1/module-lifecycle +PUT /api/v1/module-lifecycle//enabled { "enabled": boolean } +``` + +The platform's operational view of its installed modules +([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): which are enabled, what +this process actually did with each of them, and whether the two currently disagree. + +It is a **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` is +(ADR 0025): `enabled` is a legal module route segment, so a module named `announcements` could +otherwise register `PUT /enabled` and collide with the platform at exactly +`/api/v1/modules/announcements/enabled`. + +```json +{ + "modules": [ + { + "id": "storage", + "version": "1.0.0", + "configured": { "enabled": false, "explicit": true }, + "runtime": { "state": "disabled" }, + "restartRequired": false + }, + { + "id": "announcements", + "version": "1.0.0", + "configured": { "enabled": true, "explicit": false }, + "runtime": { + "state": "blocked", + "reason": "required_dependency_disabled", + "dependency": { "kind": "module", "moduleId": "storage" } + }, + "restartRequired": false + } + ] +} +``` + +### Two axes, deliberately not one status word + +`configured` is the decision that survives restarts. `explicit: false` means nobody has decided and +the platform default (**enabled**) applies. `runtime` is what *this process* did: + +| state | meaning | +| ---------- | ----------------------------------------------------------------------------- | +| `running` | the module was eligible and its `start` hook completed in this process | +| `disabled` | configuration says it must not run, so it was never imported | +| `blocked` | it should run, but a **required** dependency cannot — see `reason`/`dependency` | + +`running` says the `start` hook completed. It says **nothing** about whether every external resource +that module uses is reachable right now; Ekklesia has no runtime health-probe contract, and this +state must not be read as one. There is deliberately no `failed` state: module startup is +all-or-nothing, so a module that fails to start aborts API startup and no live process could ever +report it. + +`reason` is one of `required_dependency_disabled` (the named dependency was turned off) or +`required_dependency_blocked` (the named dependency is itself blocked). Following the chain always +terminates at a `..._disabled` entry. An **optional** dependency never blocks anything. + +### Changing enablement requires a restart, and the response says so + +There is no hot module installation or removal, so `PUT` writes a decision and changes nothing about +the running process: + +```json +{ "id": "announcements", "version": "1.0.0", + "configured": { "enabled": false, "explicit": true }, + "runtime": { "state": "running" }, + "restartRequired": true } +``` + +Reporting `runtime: disabled` there would be a lie about a process still serving that module's +routes. `restartRequired` compares what the current configuration *would* start against what the +runtime reports it *did* start, so it is symmetric and transitive: disabling a module also marks its +dependents, because they really will stop on the next boot. + +A module whose enablement changed but whose process has not restarted stays fully exposed — routes, +artifact, inventory, and navigation. It really is still mounted; hiding it now would break a viewer +mid-session for a change that has not taken effect. + +### What a disabled module does not have + +Never imported ⇒ no `register`, `start`, or `stop` hook runs; no HTTP routes are mounted (an +unmounted path gets the platform's ordinary `404`, with no per-request enablement check anywhere); +no capabilities are registered; `GET /api/v1/module-ui//` returns the same `404` an +uninstalled module does; and it is absent from both `GET /api/v1/module-ui` and +`GET /api/v1/dashboard/navigation`. + +Its **migrations are still discovered and applied**. Deployed schema ownership is not runtime +enabled state: a disabled module's tables must already exist when it is enabled, or enabling it would +require a schema change in the middle of a restart. Its **permissions also stay registered**, so +grants survive an enable/disable cycle and can be issued ahead of enabling a module; nothing enforces +them, because it mounts no routes. + +### Authority + +Both verbs require `core.modules.lifecycle.manage` (baseline `restricted`). The read is gated too, +because this resource is the only place a disabled module, a blocked module, and the +required-dependency edges between them are visible at all — `core.modules.ui.read` deliberately +covers only what is mountable. Mutations additionally require an exact trusted `Origin` and a bounded +body (`CORE_MUTATION_MAX_BODY_BYTES`) before authorization work begins. + +The body is closed: any key beyond `enabled` invalidates the whole request rather than being dropped. +A `PUT` naming a module this process did not discover is `404 {"code":"MODULE_NOT_INSTALLED"}` — +enablement is operational configuration, not an unbounded key-value store. Existing records for +modules that are no longer deployed are retained and ignored, so redeploying restores the decision. + +No response carries a filesystem path, package name, manifest path, entrypoint, caught error, or +stack. + +### Recovering from a module that breaks startup + +Startup is all-or-nothing, so a module whose `start` hook throws takes the API down — including this +API. The out-of-band path is a Core CLI, in the same shape as `pnpm authorization:bootstrap-owner` +(explicit, never HTTP-exposed): + +```bash +pnpm modules:disable -- --module announcements +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 HTTP routes diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 297018d..601143b 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -100,14 +100,31 @@ grant lookup — there is no wildcard, prefix, or namespace-inheritance matching required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`, `calendar.events.read`) is purely a naming convention for readability and collision-avoidance. -`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and -remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the -same declarations exported separately in deeply immutable form, for the module composition helper -below to build on without reconstructing Core's own list: +`CORE_PERMISSION_REGISTRY` is Core's own registry and remains what Core-only code and existing tests +use directly. `CORE_PERMISSION_DECLARATIONS` is the same declarations exported separately in deeply +immutable form, for the module composition helper below to build on without reconstructing Core's own +list: | Permission | Baseline | Description | | --- | --- | --- | | `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. | +| `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. | + +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 +reading and changing module enablement +([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): the read is not a +milder version of the write, because the lifecycle view is the only place a *disabled* module, a +*blocked* module, and the required-dependency edges between them are visible at all, whereas +`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority +inside any module. + +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 +enforces those permissions while the module is disabled, because it mounts no routes at all. ## Module permission composition @@ -206,8 +223,8 @@ 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 `1 permission registered` with zero installed modules, since Core's -own declaration is always present); zero installed modules still composes successfully. If module +declaration count (for example `4 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 normally — they are independent diagnostics that do not depend on the permission registry. A diff --git a/docs/development/security.md b/docs/development/security.md index e3bde3b..53ce364 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -168,6 +168,33 @@ what a request may do. Reading navigation requires the same `core.modules.ui.rea inventory requires, so it discloses the same installed-module enumeration and nothing more; it carries no filesystem path, package root, or manifest internal. +### Disabling a module is an operational control, not a security boundary + +A module can be turned off, and that decision survives restarts +([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): a disabled module is +never imported, so its code never runs, it mounts no routes, registers no capabilities, and publishes +no UI. That is a real and useful operational property — it is how an operator recovers from a module +that misbehaves or breaks startup. + +**It is not containment.** The module's compiled code is still on disk, still trusted, and still +carries every authority listed above the moment anything starts it. Enablement is a row in +`ekklesia_core_module_state`; it constrains the platform's own composition, not the module. Anyone +who can put a module package on this filesystem, or who holds the database or operating-system access +to change that row, decides what runs. Nothing about disabling a module sandboxes, isolates, or +reduces the privileges of a module that *is* enabled. + +Changing enablement requires `core.modules.lifecycle.manage` (baseline `restricted`) and — for the +mutation — an exact trusted `Origin` and a bounded body. Reading is gated by the same permission +because the lifecycle view is the only place a disabled module, a blocked module, and the +required-dependency edges between them are visible; `core.modules.ui.read` deliberately exposes only +the mountable set. Neither response carries a filesystem path, package name, manifest path, +entrypoint, caught error, or stack — only module ids, capability ids, and fixed reason codes. + +Holding the lifecycle permission grants no authority *inside* any module: every module route stays +enforced against that module's own declared permission, and a disabled module's permissions remain +registered and grantable precisely so that an enable/disable cycle changes nothing about who holds +what. + ### Client-side permission checks are not a security boundary A host may tell a module UI what permissions it believes the viewer holds diff --git a/docs/development/testing.md b/docs/development/testing.md index e23f71b..bfa7907 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -152,6 +152,28 @@ key material is ever committed to this repository** — and the disposable conta `afterAll` even when an assertion fails. See [ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md). +A fifth dedicated suite (`core/tests/module-lifecycle-store.integration.ts`, included in the same +`pnpm test:migrations:integration` run) proves the module enablement store against real PostgreSQL: +that migration `0004` really creates `ekklesia_core_module_state` with exactly the four columns and +the one declared CHECK constraint the schema states — and, load-bearingly, that `enabled` has **no +column default**, so the platform's default for a newly discovered module is genuinely the absence of +a row rather than a materialized decision nobody made; that a decision survives a completely new +connection; that the upsert is idempotent on `module_id` and bumps `updated_at` in place rather than +inserting a second row; that a malformed identifier is rejected before it reaches PostgreSQL and the +empty-string case is refused by the database too; and that a record for a module which is no longer +deployed is retained and simply ignored by resolution, so redeploying restores the decision. + +The API suite additionally drives the whole lifecycle end to end against a real database and real +fixture modules: a disabled module's compiled entrypoint is never imported (proven by its +`register`/`start`/`stop` markers staying empty), its routes return the platform's ordinary `404`, +its UI artifact and inventory and navigation entries are all absent, and its neighbours run normally; +a module blocked by a disabled required dependency is explained over HTTP with a structured reason +naming that dependency and no filesystem path anywhere in the payload; a change made through +`PUT /api/v1/module-lifecycle//enabled` reports `restartRequired: true` while the module +demonstrably keeps serving, and is applied only by the next start; and a disabled module's migrations +are still discovered, because deployed schema ownership is not runtime enabled state. See +[ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md). + ### Which kind of timing proof lives where Pass 4 introduced several deadlines, and their tests deliberately differ in kind: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index d382c8e..2306bd7 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -962,6 +962,13 @@ PostgreSQL, including DDL rollback, advisory-lock contention, history behavior, sanitized failures. It remains outside the Docker-free `pnpm verify` gate; see the [testing guide](../development/testing.md). +`pnpm modules:enable -- --module ` and `pnpm modules:disable -- --module ` record the +platform's enablement decision for one installed module. They read `DATABASE_URL`, run the same +manifest-only discovery every other command does (so a module this deployment cannot see is refused), +write one row, and start nothing. The change takes effect at the next `pnpm api:start`. These exist +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` 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 @@ -969,13 +976,60 @@ discovery imports the compiled schema-definition entrypoint, so its no-I/O and n contract still applies. Doctor does not import or start module server entrypoints, apply migrations, or exercise runtime hooks; lifecycle behavior remains covered by automated tests. PostgreSQL planning is skipped when `DATABASE_URL` is absent, and object storage is skipped until Core has a -production storage integration. A skip or pending migration is visible as `READY WITH WARNINGS`, +production storage integration. Its `modules.lifecycle` check reports which installed modules are +enabled and which cannot run — also skipped without `DATABASE_URL`, since enablement is stored in +PostgreSQL. A deliberately disabled module is a pass with a detail line; an *enabled* module that +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. +### Whether your module runs at all is your host's decision + +Between discovery and startup, the platform consults its own **enablement configuration** and +resolves which discovered modules are eligible to run +([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): + +```text +present on disk → discovered → enabled → eligible → running +``` + +Three things matter to you as a module author, and only three. + +**You never see it.** Nothing in your manifest declares enablement, nothing in your `register`, +`start`, or `stop` context exposes it, and your module is never told it is enabled. Your host either +loads and starts you, or it does not. **Do not write feature code that asks whether it is enabled in +Ekklesia** — that is what keeps the same implementation runnable under a future standalone host, +whose loading decisions are its own. + +**Disabled means nothing of yours runs.** A disabled module's compiled server entrypoint is never +imported, so no hook fires, no route is mounted, no capability is registered, and its UI artifact is +not published or listed. There is no per-request "is this enabled?" check to code against, because +nothing is mounted. + +**A module with no stored decision is enabled.** Deploying your module is sufficient to run it; no +initialization step, database edit, or migration is needed to make it appear. + +Your **required** dependencies are real: if a module (or the provider of a capability) you declare +under `dependencies.modules.required` / `dependencies.capabilities.required` is disabled, you are +blocked and will not start, with a structured reason naming it. Your **optional** dependencies are +not: if an optional provider is disabled, you start normally and +`context.capabilities.resolve(...)` returns `undefined` — exactly as it does when that provider was +never installed. Declare optional if you can degrade; declare required if you genuinely cannot run. + +Your migrations are discovered and applied regardless of enablement — deployed schema ownership is a +deployment fact, not runtime state — so enabling your module later never needs a schema change in the +middle of a restart. + +Finally, "running" means your `start` hook completed. It does **not** mean every external resource +you use is reachable. If your object storage or an upstream service is down after you started, that +is your module's own health to surface through your own API and UI; the platform has no health-probe +contract and will keep reporting you as running. + +### Discovery and startup, step by step + Core completes discovery before importing any server entrypoint. It then loads and verifies every -runtime definition before running hooks, registers all modules dependency-first into an unpublished -staged capability registry and an unpublished staged HTTP route list side by side, and starts them in -the same order. Neither the capability registry nor the route list is published — visible on +eligible module's runtime definition before running hooks, registers all of them dependency-first +into an unpublished staged capability registry and an unpublished staged HTTP route list side by +side, and starts them in the same order. Neither the capability registry nor the route list is published — visible on `StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is reverse-order. If a later module fails to start, Core stops already-started modules in reverse order and reports any cleanup failures without leaking caught exceptions or machine paths. A failing diff --git a/package.json b/package.json index fc3a493..983db79 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "migrations:apply": "turbo run build && node --env-file-if-exists=.env core/dist/cli/apply-migrations.js", "migrations:plan": "turbo run build && node --env-file-if-exists=.env core/dist/cli/plan-migrations.js", "migrations:validate": "turbo run build && node core/dist/cli/validate-migrations.js", + "modules:disable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --disable", + "modules:enable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --enable", "modules:validate": "turbo run build && node core/dist/cli/validate-modules.js", "test": "turbo run test test:root", "test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh", diff --git a/packages/contracts/src/module-lifecycle.ts b/packages/contracts/src/module-lifecycle.ts index dc6ad76..7fb80a8 100644 --- a/packages/contracts/src/module-lifecycle.ts +++ b/packages/contracts/src/module-lifecycle.ts @@ -1,17 +1,97 @@ import { z } from "zod"; +import { moduleIdSchema } from "./identifiers.js"; -export const MODULE_LIFECYCLE_STAGES = [ - "discovered", - "validated", - "configured", - "registered", - "migrated", - "initialized", - "started", - "stopped", - "failed", +/** + * The two axes of a module's operational state, kept deliberately separate because they answer + * different questions, change at different times, and have different owners. + * + * **Configuration** is what an administrator decided: is this module supposed to run? It is + * persistent, survives restarts, and is platform-owned — a module never declares, reads, or + * influences it (ADR 0028). **Runtime** is what this process actually did with the module during + * *this* boot. Because Ekklesia has no hot loading or unloading, the two can legitimately + * disagree between a configuration change and the next restart, and the read model reports both + * rather than pretending a change took effect immediately. + * + * This replaces the nine-name `MODULE_LIFECYCLE_STAGES` vocabulary that shipped with ADR 0006 and + * which nothing ever implemented — ADR 0025 and ADR 0026 both recorded it as "vocabulary with no + * runtime state machine behind it". Every name below is produced by real code and observable + * through `GET /api/v1/module-lifecycle`; a stage that could never be observed would be a + * contract that lies. + */ +export const MODULE_RUNTIME_STATES = ["running", "disabled", "blocked"] as const; + +export const moduleRuntimeStateSchema = z.enum(MODULE_RUNTIME_STATES); + +/** + * What this process did with one module. + * + * - `running` — the module was enabled and eligible, and the module runtime loaded, registered, + * and started it. It says the module's `start` hook completed; it says **nothing** about whether + * every external resource that module uses is reachable now. Ekklesia has no runtime + * health-probe contract, and this state must never be read as one. + * - `disabled` — configuration says it must not run, so the platform never imported its server + * entrypoint, never invoked a lifecycle hook, never registered a route, and never published its + * UI. + * - `blocked` — configuration says it should run, but a *required* dependency cannot, so starting + * it would violate the dependency contract. See `ModuleBlockedReason`. + * + * There is deliberately no `failed`. Module startup remains all-or-nothing (ADR 0015, ADR 0020, + * preserved by ADR 0028): a module that fails to load, register, or start aborts API startup + * entirely, so no live process can ever report it. Adding the name before the behavior exists would + * publish a state nothing can produce. + */ +export type ModuleRuntimeState = z.infer; + +/** + * 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. + * + * `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. + */ +export const MODULE_BLOCKED_REASONS = [ + "required_dependency_disabled", + "required_dependency_blocked", ] as const; -export const moduleLifecycleStageSchema = z.enum(MODULE_LIFECYCLE_STAGES); +export const moduleBlockedReasonSchema = z.enum(MODULE_BLOCKED_REASONS); -export type ModuleLifecycleStage = z.infer; +export type ModuleBlockedReason = z.infer; + +/** 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 + * something they can act on. */ +export const MODULE_DEPENDENCY_KINDS = ["module", "capability"] as const; + +export const moduleDependencyKindSchema = z.enum(MODULE_DEPENDENCY_KINDS); + +export type ModuleDependencyKind = z.infer; + +/** + * The dependency that caused a block: which module is unavailable, and — for a capability edge + * — which capability was being depended on. `moduleId` is always the module an administrator + * would have to enable; `capabilityId` is present only when the edge came from + * `dependencies.capabilities.required`. + */ +export interface ModuleBlockingDependency { + readonly kind: ModuleDependencyKind; + readonly moduleId: string; + readonly capabilityId?: string; +} + +/** One persisted enablement decision. Deliberately the smallest possible record: an identity and a + * boolean. There is no install source, download URL, update channel, signature, marketplace + * metadata, or free-form JSON — none of those exist in this platform, and a column for one would + * invite inventing it (ADR 0028). */ +export const moduleEnablementRecordSchema = z + .object({ + moduleId: moduleIdSchema, + enabled: z.boolean(), + }) + .strict(); + +export type ModuleEnablementRecord = z.infer; diff --git a/packages/contracts/tests/module-manifest.test.ts b/packages/contracts/tests/module-manifest.test.ts index 41c13a4..a468fb1 100644 --- a/packages/contracts/tests/module-manifest.test.ts +++ b/packages/contracts/tests/module-manifest.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { isVersionCompatible, - MODULE_LIFECYCLE_STAGES, + MODULE_BLOCKED_REASONS, + MODULE_DEPENDENCY_KINDS, MODULE_MANIFEST_VERSION, + MODULE_RUNTIME_STATES, packageRelativePathSchema, parseModuleManifest, parsePermissionId, @@ -341,9 +343,18 @@ describe("module manifest contract", () => { expect(isVersionCompatible("not-a-version", "*")).toBe(false); }); - it("publishes the complete lifecycle vocabulary", () => { - expect(MODULE_LIFECYCLE_STAGES).toContain("discovered"); - expect(MODULE_LIFECYCLE_STAGES).toContain("failed"); + it("publishes exactly the operational lifecycle vocabulary the platform implements", () => { + // Every name here is produced by real code and observable through the lifecycle read model + // (ADR 0028). The nine-stage `MODULE_LIFECYCLE_STAGES` vocabulary this replaced named states + // nothing ever entered, including a `failed` state that startup atomicity makes unobservable — + // asserting the exact sets is what keeps this contract from drifting back into aspiration. + expect(MODULE_RUNTIME_STATES).toEqual(["running", "disabled", "blocked"]); + expect(MODULE_RUNTIME_STATES).not.toContain("failed"); + expect(MODULE_BLOCKED_REASONS).toEqual([ + "required_dependency_disabled", + "required_dependency_blocked", + ]); + expect(MODULE_DEPENDENCY_KINDS).toEqual(["module", "capability"]); }); describe("module-owned UI entrypoint (ADR 0021)", () => { diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 33ded6c..cc438b1 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "core/tests/authorization-store.integration.ts", "core/tests/authorization-bootstrap.integration.ts", "core/tests/dashboard-store.integration.ts", + "core/tests/module-lifecycle-store.integration.ts", "apps/api/tests/api-http.integration.ts", "apps/api/tests/module-database.integration.ts", ],