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
+35
-1
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user