feat: add the minimal operational module lifecycle
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s

Ekklesia had no notion of a module being on or off: the accepted module set
was exactly "whatever discovery found on disk", and `ModuleLifecycleStage`
named nine states nothing produced or consumed. This replaces that vocabulary
with two axes that real code produces — persistent, administrator-owned
configuration (`enabled`/`disabled`) and per-boot runtime observation
(`running`/`disabled`/`blocked`) — and wires them through startup.

- One Core table (`ekklesia_core_module_state`, migration 0004) holding only
  `module_id` and `enabled`. A module with no record is enabled by default, so
  deploying a module is still sufficient to run it and discovery never needs an
  initialization write. A record for an undeployed module is retained and
  ignored, so redeploying restores the decision.
- `resolveModuleEligibility` is pure: a disabled module transitively blocks
  every module that *requires* it (module and capability edges alike) with a
  structured reason naming the provider; optional dependencies never block; and
  a missing, incompatible, or cyclic dependency stays discovery's concern.
- `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. A disabled module is
  never imported, so there is no per-request enablement check anywhere.
- Startup stays all-or-nothing, deliberately; there is consequently no `failed`
  runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band
  recovery path for a module that breaks startup.
- 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 participation and permission composition still span the deployed
  and discovered inventories respectively, unchanged.
- New restricted platform permission `core.modules.lifecycle.manage` gates both
  reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`)
  that cannot collide with the module route namespace.
- `pnpm doctor` gains a `modules.lifecycle` check so "why is this module
  unavailable?" is answerable without reading logs.

See ADR 0028.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 14:45:34 -05:00
co-authored by Claude Opus 5
parent db67cf7700
commit 7729c99e24
52 changed files with 5311 additions and 116 deletions
+35 -1
View File
@@ -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,
+11 -4
View File
@@ -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<void>;
@@ -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),
});
}
+243
View File
@@ -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/<module-id>/<local path>`, 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<string>;
}
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary, 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<string, unknown> {
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<unknown> {
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;
}
+65 -16
View File
@@ -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,