import type { ModuleBlockedReason, ModuleBlockingDependency, ModuleRuntimeState, ModuleSecretSource, } from "@ekklesia/contracts"; import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization"; import { type ModuleConfigurationStore, type ModuleLifecycleSnapshot, type ModuleStorageAvailability, 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; /** Present for a dependency reason. */ readonly dependency?: ModuleBlockingDependency; /** Present for a configuration reason (ADR 0029): the offending key, never its value. A key is * declared in a public manifest; a value may be a credential or arbitrary operator text. */ readonly configurationKey?: string; /** Present for a storage reason (ADR 0030): the logical bucket the module declared, never an * endpoint, a credential, or the physical bucket the host maps it to. */ readonly storageBucketId?: string; }; 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; /** * How to re-read the *other* axis that decides eligibility (ADR 0029). Since a module's own * configuration can block it, `restartRequired` must be computed against live configuration as * well as live enablement — otherwise correcting a missing required value would report no pending * restart for a module the next boot really will start. */ readonly configuration: { readonly store: ModuleConfigurationStore; readonly secrets: ModuleSecretSource; }; /** The other axis that decides eligibility (ADR 0030). A boolean, never a probe: whether storage * is *configured* decides eligibility, while whether it is *reachable right now* never does. */ readonly storage: ModuleStorageAvailability; 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 }), ...(view.runtime.configurationKey === undefined ? {} : { configurationKey: view.runtime.configurationKey }), ...(view.runtime.storageBucketId === undefined ? {} : { storageBucketId: view.runtime.storageBucketId }), }, 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, settings] = await Promise.all([ options.store.readConfiguration(), options.configuration.store.readSettings(), ]); const modules = resolveModuleLifecycleView( options.snapshot, configuration, { secrets: options.configuration.secrets, settings }, options.storage, ).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, settings] = await Promise.all([ options.store.readConfiguration(), options.configuration.store.readSettings(), ]); const view = resolveModuleLifecycleView( options.snapshot, configuration, { secrets: options.configuration.secrets, settings }, options.storage, ).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; }