feat: add the minimal operational module lifecycle
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:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user