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
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <module-id> | pnpm modules:disable -- --module <module-id>";
|
||||
|
||||
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<string> {
|
||||
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<void> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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 DiscoveredModule[]>;
|
||||
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
|
||||
/** 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<ModuleLifecycleConfiguration>;
|
||||
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<ModuleLifecycleConfiguration> {
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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<typeof moduleLifecycleSchema>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
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<Record<ModuleLifecycleStoreErrorCode, string>> = 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<ModuleLifecycleConfiguration>;
|
||||
/** 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<ModuleEnablementRecord>;
|
||||
}
|
||||
|
||||
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<ModuleLifecycleConfiguration> {
|
||||
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<ModuleEnablementRecord> {
|
||||
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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<string, MutableEligibility>();
|
||||
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<string, string>();
|
||||
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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user