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
@@ -195,6 +195,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -612,6 +613,32 @@ async function signIn(
|
||||
return { cookiePair: setCookie ? firstCookiePair(setCookie) : undefined, response };
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs up an account over real HTTP, grants it platform-owner authority through Core's own real
|
||||
* bootstrap operation (exactly what `pnpm authorization:bootstrap-owner` performs), and signs it
|
||||
* in — so a scenario needing an administrator exercises the genuine evaluator path rather than a
|
||||
* stubbed decision.
|
||||
*/
|
||||
async function signUpPlatformOwner(
|
||||
origin: string,
|
||||
url: string,
|
||||
): Promise<{ readonly cookie: string; readonly userId: string }> {
|
||||
const email = `owner-${randomBytes(6).toString("hex")}@example.test`;
|
||||
const password = "correct horse battery staple";
|
||||
const created = await signUp(origin, email, password);
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(url);
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(created.body.user.id) });
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
const session = await signIn(origin, email, password);
|
||||
if (!session.cookiePair) {
|
||||
throw harnessError("The platform-owner session cookie was not issued.");
|
||||
}
|
||||
return { cookie: session.cookiePair, userId: created.body.user.id };
|
||||
}
|
||||
|
||||
async function fetchPrincipal(
|
||||
origin: string,
|
||||
headers: Record<string, string> = {},
|
||||
@@ -1055,7 +1082,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
||||
});
|
||||
|
||||
describe.sequential("live API — startup and migration guarantees", () => {
|
||||
it("does not create any tables or migration history when started against an unmigrated database", async () => {
|
||||
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
|
||||
const database = await createDisposableDatabase("unmigrated");
|
||||
try {
|
||||
const config = createApiRuntimeConfig(
|
||||
@@ -1071,33 +1098,36 @@ describe.sequential("live API — startup and migration guarantees", () => {
|
||||
port: 0,
|
||||
},
|
||||
// Development validation, for the same reason `startConfiguredApi` documents: this scenario
|
||||
// drives a real sign-up request, which production configuration refuses to enable at all.
|
||||
// uses the disposable plaintext loopback database, which production configuration refuses.
|
||||
{ environment: "development" },
|
||||
);
|
||||
const running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||
const origin = apiOrigin(running);
|
||||
|
||||
// Startup reads the stored module enablement configuration before importing any module
|
||||
// entrypoint (ADR 0028). Against an unmigrated database that read cannot succeed, and the
|
||||
// platform fails closed rather than starting every discovered module: it cannot know what an
|
||||
// administrator decided, and starting a module they disabled is worse than not starting.
|
||||
// The failure is Core's own sanitized store error — no connection string, host, SQL, driver
|
||||
// message, or stack.
|
||||
let captured: unknown;
|
||||
try {
|
||||
// A request that touches the (nonexistent) identity tables fails, but must not repair
|
||||
// or create anything. The exact failure body is not asserted here (Better Auth's
|
||||
// internal-error shape for this case is not a stable public contract); only that it is
|
||||
// not a successful sign-up.
|
||||
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||
body: JSON.stringify({
|
||||
email: "[email protected]",
|
||||
name: "Integration Test User",
|
||||
password: "correct horse battery staple",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).not.toBe(200);
|
||||
} finally {
|
||||
await running.shutdown();
|
||||
await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({
|
||||
code: "MODULE_LIFECYCLE_READ_FAILED",
|
||||
name: "ModuleLifecycleStoreError",
|
||||
});
|
||||
const rendered = `${String(captured)} ${JSON.stringify(captured)}`;
|
||||
expect(rendered).not.toContain(database.name);
|
||||
expect(rendered).not.toContain("127.0.0.1");
|
||||
expect(rendered).not.toContain("select");
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
// The invariant this scenario has always existed to protect, unchanged and now proven on a
|
||||
// path that never even reached Better Auth: a failed start repairs nothing, creates
|
||||
// nothing, and applies no migration.
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
"select count(*)::text as count from information_schema.tables where table_schema = 'public'",
|
||||
@@ -2159,9 +2189,10 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||
expect(permissionsCheck).toMatchObject({
|
||||
// Core's own three (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`) plus the fixture module's one.
|
||||
details: ["4 permissions registered"],
|
||||
// Core's own four (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`) plus the fixture
|
||||
// module's one.
|
||||
details: ["5 permissions registered"],
|
||||
status: "pass",
|
||||
});
|
||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||
@@ -2979,6 +3010,283 @@ describe.sequential("live API — Node header/request timeouts and client errors
|
||||
* disposable database is closed in `finally`, and each scenario carries its own outer deadline so
|
||||
* a regression fails rather than hangs.
|
||||
*/
|
||||
describe.sequential("live API — module lifecycle governs what actually runs", () => {
|
||||
/** Writes one explicit enablement decision through the real table, before the API starts —
|
||||
* exactly what `pnpm modules:disable` or a prior `PUT` would have left behind. Nothing in this
|
||||
* suite reaches into the store through a double. */
|
||||
async function storeEnablement(url: string, moduleId: string, enabled: boolean): Promise<void> {
|
||||
const client = await openAdministrativeClient(url);
|
||||
try {
|
||||
await safeQuery(
|
||||
client,
|
||||
`insert into ekklesia_core_module_state (module_id, enabled) values ($1, $2)
|
||||
on conflict (module_id) do update set enabled = excluded.enabled`,
|
||||
[moduleId, enabled],
|
||||
);
|
||||
} finally {
|
||||
await safeEnd(client);
|
||||
}
|
||||
}
|
||||
|
||||
it("never imports, registers, starts, or mounts a disabled module, and still runs its neighbours", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
||||
permissions: [
|
||||
{ baseline: "public", description: "Read calendar events.", id: "calendar.events.read" },
|
||||
],
|
||||
});
|
||||
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||
permissions: [
|
||||
{
|
||||
baseline: "public",
|
||||
description: "Read announcements.",
|
||||
id: "announcements.events.read",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_off");
|
||||
await applyCoreMigrations(database.url);
|
||||
await storeEnablement(database.url, "announcements", false);
|
||||
|
||||
const running = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
|
||||
// The enabled module really did run: its markers are present, and its route answers.
|
||||
expect(recorder.events).toContain("calendar:register");
|
||||
expect(recorder.events).toContain("calendar:start");
|
||||
const calendarRoute = await fetch(`${origin}/api/v1/modules/calendar/events/abc`);
|
||||
expect(calendarRoute.status).toBe(200);
|
||||
|
||||
// The disabled module ran nothing at all. Not "registered but skipped", not "started and
|
||||
// gated per request" — its compiled entrypoint was never imported, so no hook could run.
|
||||
expect(recorder.events).not.toContain("announcements:register");
|
||||
expect(recorder.events).not.toContain("announcements:start");
|
||||
expect(recorder.events.filter((event) => event.startsWith("announcements:"))).toEqual([]);
|
||||
|
||||
// No route was ever mounted, so the platform's ordinary 404 answers — there is no per-request
|
||||
// "is this module enabled?" check anywhere, because there is nothing mounted to check.
|
||||
const disabledRoute = await fetch(`${origin}/api/v1/modules/announcements/events/abc`);
|
||||
expect(disabledRoute.status).toBe(404);
|
||||
expect(await disabledRoute.json()).toEqual({ code: "NOT_FOUND" });
|
||||
|
||||
// Shutdown never invokes a stop hook for a module that never started.
|
||||
await running.shutdown();
|
||||
expect(recorder.events).toContain("calendar:stop");
|
||||
expect(recorder.events).not.toContain("announcements:stop");
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks a dependent of a disabled required dependency, and explains why over HTTP", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createFixtureModule(modulesDirectory, "storage", recorder);
|
||||
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
});
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_block");
|
||||
await applyCoreMigrations(database.url);
|
||||
await storeEnablement(database.url, "storage", false);
|
||||
|
||||
const running = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
// Neither ran: the disabled dependency by decision, the dependent because starting it would
|
||||
// violate its own required-dependency contract.
|
||||
expect(recorder.events).toEqual([]);
|
||||
|
||||
const owner = await signUpPlatformOwner(origin, database.url);
|
||||
const lifecycle = await fetch(`${origin}/api/v1/module-lifecycle`, {
|
||||
headers: { cookie: owner.cookie, origin: dashboardOrigin },
|
||||
});
|
||||
expect(lifecycle.status).toBe(200);
|
||||
const body = (await lifecycle.json()) as {
|
||||
readonly modules: readonly {
|
||||
readonly id: string;
|
||||
readonly configured: { readonly enabled: boolean; readonly explicit: boolean };
|
||||
readonly runtime: {
|
||||
readonly state: string;
|
||||
readonly reason?: string;
|
||||
readonly dependency?: { readonly kind: string; readonly moduleId: string };
|
||||
};
|
||||
readonly restartRequired: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
expect(body.modules).toEqual([
|
||||
{
|
||||
configured: { enabled: false, explicit: true },
|
||||
id: "storage",
|
||||
restartRequired: false,
|
||||
runtime: { state: "disabled" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
id: "announcements",
|
||||
restartRequired: false,
|
||||
runtime: {
|
||||
dependency: { kind: "module", moduleId: "storage" },
|
||||
reason: "required_dependency_disabled",
|
||||
state: "blocked",
|
||||
},
|
||||
version: "0.1.0",
|
||||
},
|
||||
]);
|
||||
// The operator's question — "why is this module unavailable?" — answered without a log line,
|
||||
// and without a filesystem path anywhere in the payload.
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain(modulesDirectory);
|
||||
expect(raw).not.toContain("dist/");
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies a change made over HTTP only at the next start, and says so in the meantime", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
||||
permissions: [
|
||||
{ baseline: "public", description: "Read calendar events.", id: "calendar.events.read" },
|
||||
],
|
||||
});
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_restart");
|
||||
await applyCoreMigrations(database.url);
|
||||
|
||||
const first = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(first);
|
||||
const owner = await signUpPlatformOwner(origin, database.url);
|
||||
|
||||
const mutation = await fetch(`${origin}/api/v1/module-lifecycle/calendar/enabled`, {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: owner.cookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
expect(mutation.status).toBe(200);
|
||||
expect(await mutation.json()).toEqual({
|
||||
configured: { enabled: false, explicit: true },
|
||||
id: "calendar",
|
||||
restartRequired: true,
|
||||
// Honest: this process really is still serving that module.
|
||||
runtime: { state: "running" },
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
// And it demonstrably still is — nothing was hot-unloaded to make the change feel immediate.
|
||||
expect((await fetch(`${origin}/api/v1/modules/calendar/events/abc`)).status).toBe(200);
|
||||
} finally {
|
||||
await first.shutdown().catch(() => undefined);
|
||||
}
|
||||
|
||||
recorder.events.length = 0;
|
||||
const second = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(second);
|
||||
// The next start applies the stored decision: nothing imported, nothing mounted.
|
||||
expect(recorder.events).toEqual([]);
|
||||
expect((await fetch(`${origin}/api/v1/modules/calendar/events/abc`)).status).toBe(404);
|
||||
} finally {
|
||||
await second.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a disabled module's UI out of the inventory and out of Dashboard navigation", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
await createUiModule(
|
||||
modulesDirectory,
|
||||
"shown",
|
||||
"1.0.0",
|
||||
'export default { id: "shown", version: "1.0.0", mount() {} };\n',
|
||||
);
|
||||
await createUiModule(
|
||||
modulesDirectory,
|
||||
"hidden",
|
||||
"1.0.0",
|
||||
'export default { id: "hidden", version: "1.0.0", mount() {} };\n',
|
||||
);
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_ui");
|
||||
await applyCoreMigrations(database.url);
|
||||
await storeEnablement(database.url, "hidden", false);
|
||||
|
||||
const running = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
const owner = await signUpPlatformOwner(origin, database.url);
|
||||
const headers = { cookie: owner.cookie, origin: dashboardOrigin };
|
||||
|
||||
const inventory = (await (await fetch(`${origin}/api/v1/module-ui`, { headers })).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
const navigation = (await (
|
||||
await fetch(`${origin}/api/v1/dashboard/navigation`, { headers })
|
||||
).json()) as {
|
||||
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
|
||||
};
|
||||
|
||||
expect(inventory.modules.map((entry) => entry.id)).toEqual(["shown"]);
|
||||
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["shown"]);
|
||||
|
||||
// The artifact itself is unreachable too, even to a caller that knows both halves of its
|
||||
// identity — publication is derived from the eligible set, not from the file's existence.
|
||||
const artifact = await fetch(`${origin}/api/v1/module-ui/hidden/1.0.0`);
|
||||
expect(artifact.status).toBe(404);
|
||||
expect((await fetch(`${origin}/api/v1/module-ui/shown/1.0.0`)).status).toBe(200);
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("still discovers a disabled module's migrations: deployed schema ownership is not runtime state", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createFixtureModule(modulesDirectory, "calendar", recorder);
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_migrations");
|
||||
await applyCoreMigrations(database.url);
|
||||
await storeEnablement(database.url, "calendar", false);
|
||||
|
||||
// Migration discovery is offline and reads the deployed inventory, never the enablement table.
|
||||
// A disabled module's tables must still be migrated, or enabling it later would need a schema
|
||||
// change in the middle of a restart.
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — bounded HTTP drain and shutdown", () => {
|
||||
async function withDeadline<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
Reference in New Issue
Block a user