feat: add the minimal operational module lifecycle #27

Merged
Zayin_Admin merged 1 commits from phase-7a/module-lifecycle into main 2026-08-23 02:02:14 +00:00
52 changed files with 5311 additions and 116 deletions
Showing only changes of commit 7729c99e24 - Show all commits
+81 -5
View File
@@ -11,7 +11,9 @@ Phase 4C (Core authorization), Phase 5A (module authorization integration), Phas
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
against an independent security audit) is complete: all five passes have landed and the final
independent whole-repository re-audit has closed, under the current trusted-contributor,
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** The operational
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) has
landed.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
pass 5 closure below and
@@ -386,9 +388,70 @@ restores its position; deleting a group ungroups its modules through the databas
application cleanup. See
[ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md).
Phase 7A pass 1 gives the platform an **operational module lifecycle** — the smallest one that is
actually useful, and nothing beyond it. Until now the accepted module set was exactly "whatever was
found on disk at startup", and `ModuleLifecycleStage` named nine states that nothing produced or
consumed. That vocabulary is replaced by two axes that real code produces:
```text
configuration enabled / disabled persistent, administrator-owned
runtime running / disabled / blocked observed per boot
```
**Enablement is host configuration, and a module can never see it.** One Core table (migration
`0004`) holds `module_id` and `enabled` — no install source, download URL, update channel, signature,
or JSON blob, because those are the columns an installer would need and their absence is what stops
one being built by accident. A module never declares or reads its own enabled state, and is never
told it is enabled: its host loads and starts it, or does not. That is what keeps the same module
implementation runnable under a future standalone host.
**A newly discovered module is enabled, and the default is the absence of a row.** Deploying a module
is still sufficient to run it — no initialization write, no database edit — mirroring the rule that a
module with no Dashboard placement is still visible. An explicit record always wins, in both
directions, and a record for a module that is no longer deployed is retained and ignored, so
redeploying restores the decision.
**Required dependencies block; optional ones never do.** Disabling `storage` does not silently
re-enable it because something needs it — it makes every module that *requires* it ineligible,
transitively, with a structured reason (`required_dependency_disabled` →
`required_dependency_blocked`) naming the module an administrator must act on. A required *capability*
dependency blocks identically, because its only provider is a module. An optional dependency being
disabled is the same situation as it never being installed: the dependent starts, and
`capabilities.resolve(...)` returns `undefined`.
**Exposure follows eligibility, structurally.** `startApi` hands the same eligible list to the module
runtime and to the UI publication registry, so a disabled module is never imported at all: no hooks,
no routes (an unmounted path gets the ordinary `404` — there is no per-request enablement check
anywhere), no capabilities, no published artifact, and no entry in the browser inventory or Dashboard
navigation. Its **migrations are still discovered and applied**, because deployed schema ownership is
not runtime state, and its **permissions stay registered**, so grants survive an enable/disable cycle.
**Startup stays all-or-nothing, deliberately, and changes need a restart.** One module that fails to
start still takes API startup down; that guarantee is what ADR 0025/0026/0027 rest on, and inverting
it is a separate design. There is consequently no `failed` runtime state — no live process could
report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable --
--module <id>` as the out-of-band path, since the HTTP API is served by the process a bad module
takes down. `PUT /api/v1/module-lifecycle/<id>/enabled` writes a decision and reports honestly that
the process has not changed:
```json
{ "configured": { "enabled": false, "explicit": true },
"runtime": { "state": "running" },
"restartRequired": true }
```
Nothing is hot-unloaded to make a button feel immediate. `running` means a module's `start` hook
completed and **nothing more** — a module that starts and then cannot reach its object storage has a
health problem, not a lifecycle problem, and this pass adds no health-probe contract. `pnpm doctor`
gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading
logs. Reading and changing both require the new restricted platform permission
`core.modules.lifecycle.manage`. Disabling a module is an **operational** control, not a sandbox: its
code is still on disk and still trusted. There is still **no installer**. See
[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md).
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, and no client-side
routing. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
and no module-management UI — module enablement is an API and a CLI, not a screen. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do
not exist.
@@ -446,16 +509,20 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
1 permission registered
4 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
3 migrations · checksums valid
5 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
core:0003_dashboard_navigation_tables · 2cde27807aa4
core:0004_module_lifecycle_state · c8b5d8b3d48b
PostgreSQL
✓ Database TLS verified
@@ -469,6 +536,8 @@ Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Object storage
○ Check unavailable
@@ -496,6 +565,13 @@ CLI into a temporary location and structurally compares it against the checked-i
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
workflow.
`pnpm modules:enable -- --module <id>` and `pnpm modules:disable -- --module <id>` record which
installed modules this deployment runs. They read `DATABASE_URL`, run the same manifest-only
discovery every other command does (so a module this deployment cannot see is refused), write one
row, and start nothing — the change takes effect at the next `pnpm api:start`. They exist so a module
that breaks startup can be turned off without the API, since the HTTP lifecycle endpoint is served by
the process such a module takes down.
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
+35 -1
View File
@@ -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,
+11 -4
View File
@@ -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),
});
}
+243
View File
@@ -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
View File
@@ -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,
+331 -23
View File
@@ -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;
+22
View File
@@ -20,6 +20,7 @@ import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
@@ -88,6 +89,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -107,6 +109,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -127,6 +130,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -149,6 +153,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -173,6 +178,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -197,6 +203,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -218,6 +225,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -241,6 +249,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -261,6 +270,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -283,6 +293,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -312,6 +323,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -342,6 +354,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -377,6 +390,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -394,6 +408,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -413,6 +428,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -431,6 +447,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -447,6 +464,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -470,6 +488,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
@@ -496,6 +515,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
@@ -525,6 +545,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({
@@ -545,6 +566,7 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
+8
View File
@@ -26,6 +26,10 @@ import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
@@ -137,6 +141,10 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
runtime,
trustedOrigins: [trustedOrigin],
});
+28 -12
View File
@@ -48,6 +48,21 @@ function validConfig() {
);
}
/**
* The one query behavior every fake pool in this file needs: startup reads the stored module
* enablement configuration (`ModuleLifecycleStore.readConfiguration`) before it imports any module
* entrypoint, and an installation that has never disabled anything genuinely has zero rows. Returning
* an empty result set is therefore the accurate stand-in for a real, migrated, unconfigured database
* — not a way to skip the read. The tests that care about a *specific* configuration drive the real
* resolver directly instead (`core/tests/module-lifecycle.test.ts`) or supply real rows through the
* real store (`core/tests/module-lifecycle-store.integration.ts`).
*/
function emptyQuery() {
return vi
.fn()
.mockResolvedValue({ command: "SELECT", fields: [], oid: 0, rowCount: 0, rows: [] });
}
const temporaryDirectories: string[] = [];
async function createTemporaryModulesDirectory(): Promise<string> {
@@ -359,7 +374,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
@@ -375,7 +390,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -395,7 +410,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
@@ -411,7 +426,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("closes an already-created database pool when identity construction fails during startup", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockImplementation(() => {
throw new Error("postgresql://admin:[email protected]/x");
@@ -429,7 +444,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -467,7 +482,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -520,7 +535,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockFailingServe(new Error("EADDRINUSE"));
@@ -584,7 +599,7 @@ describe("Ekklesia API Node server timeouts", () => {
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -621,7 +636,7 @@ describe("Ekklesia API Node server timeouts", () => {
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -673,7 +688,7 @@ describe("Ekklesia API Node server timeouts", () => {
async function startAndCaptureClientErrorHandler() {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { clientErrorHandler } = mockSuccessfulServe();
@@ -891,7 +906,7 @@ describe("Ekklesia API module permission composition at startup", () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -933,7 +948,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
return { poolEnd };
@@ -1131,6 +1146,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
recorder.events.push("pool:end");
}),
on: vi.fn(),
query: emptyQuery(),
};
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
@@ -221,6 +221,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",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -247,6 +248,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"db-facade:0000_initial",
]);
expect(aggregate.migrations[0]?.checksum).toBe(
@@ -444,6 +446,7 @@ describe.sequential("live API — module database explicit migration ownership",
expect(
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
).toBe(1);
expect(await migrationHistoryCount(database.url, "core:0004_module_lifecycle_state")).toBe(1);
} finally {
await dropDatabase(database.name);
}
@@ -26,6 +26,7 @@ import {
mergedVaryHeader,
ModuleRouteConfigurationError,
} from "../src/module-http-routes.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
@@ -109,6 +110,7 @@ function buildModuleApp(options: {
moduleRoutes: options.routes,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
@@ -0,0 +1,572 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
CORE_MODULES_LIFECYCLE_MANAGE,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
resolveModuleEligibility,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly dependencies?: Record<string, unknown>;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 4,
name: `${fixture.id} module`,
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
),
writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
),
]);
}
const defaultFixtures: readonly ModuleFixture[] = [
{ id: "storage" },
{ id: "announcements", dependencies: { modules: { required: { storage: "^1.0.0" } } } },
];
interface InstallOptions {
readonly fixtures?: readonly ModuleFixture[];
readonly principal?: EkklesiaPrincipal;
readonly platformOwner?: boolean;
readonly bootDisabled?: readonly string[];
readonly store?: ModuleLifecycleStore;
/** Permissions this principal actually holds through a grant, evaluated by Core's real evaluator
* — never a bypass, so a delegated holder is exercised the same way a real one would be. */
readonly grants?: readonly string[];
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly store: ModuleLifecycleStore;
}
/**
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
* composition: resolve eligibility from the stored configuration, then build the UI publication
* registry and the boot snapshot from the **eligible** list rather than the discovered one. Doing it
* the same way here is what makes these assertions evidence about production composition rather than
* about a bespoke test wiring.
*/
async function install(options: InstallOptions = {}): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-lifecycle-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures ?? defaultFixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const store =
options.store ??
createInMemoryModuleLifecycleStore(
(options.bootDisabled ?? []).map((moduleId) => ({ enabled: false, moduleId })),
);
const bootConfiguration = await store.readConfiguration();
const eligibility = resolveModuleEligibility(modules, bootConfiguration);
const held = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const authorizationStore = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: {
evaluator,
// Permission composition still runs over the whole discovered inventory, unchanged: a
// disabled module's permissions stay registered and grantable, and nothing enforces them
// because it mounts no routes.
registry: composePlatformPermissionRegistry(modules),
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
moduleLifecycle: {
snapshot: {
discoveredModules: modules,
eligibility: eligibility.entries,
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
},
store,
},
moduleRoutes: Object.freeze([]),
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, store };
}
function owner(): InstallOptions {
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
}
interface LifecycleEntry {
readonly id: string;
readonly version: 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;
}
async function readLifecycle(app: Hono): Promise<readonly LifecycleEntry[]> {
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(200);
const body = (await response.json()) as { readonly modules: readonly LifecycleEntry[] };
return body.modules;
}
function setEnabled(app: Hono, moduleId: string, enabled: boolean, origin = trustedOrigin) {
return app.request(`${MODULE_LIFECYCLE_PATH}/${moduleId}/enabled`, {
body: JSON.stringify({ enabled }),
headers: { "content-type": "application/json", origin },
method: "PUT",
});
}
describe("GET /api/v1/module-lifecycle", () => {
it("reports every discovered module with both axes separated", async () => {
const { app } = await install(owner());
expect(await readLifecycle(app)).toEqual([
{
configured: { enabled: true, explicit: false },
id: "storage",
restartRequired: false,
runtime: { state: "running" },
version: "1.0.0",
},
{
configured: { enabled: true, explicit: false },
id: "announcements",
restartRequired: false,
runtime: { state: "running" },
version: "1.0.0",
},
]);
});
it("explains a disabled module and the dependent it blocks, with a structured reason", async () => {
const { app } = await install({ ...owner(), bootDisabled: ["storage"] });
expect(await readLifecycle(app)).toEqual([
{
configured: { enabled: false, explicit: true },
id: "storage",
restartRequired: false,
runtime: { state: "disabled" },
version: "1.0.0",
},
{
configured: { enabled: true, explicit: false },
id: "announcements",
restartRequired: false,
runtime: {
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
state: "blocked",
},
version: "1.0.0",
},
]);
});
it("is not cached and varies on origin and cookie", async () => {
const { app } = await install(owner());
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(response.headers.get("Vary")).toContain("Cookie");
});
it("refuses an anonymous caller", async () => {
const { app } = await install();
const response = await app.request(MODULE_LIFECYCLE_PATH);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("refuses an ordinary authenticated user who holds no lifecycle permission", async () => {
// The same principal that `core.modules.ui.read` (baseline `authenticated`) would admit: this
// resource is deliberately restricted, because it is the only place a disabled module is
// visible at all.
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
const lifecycle = await app.request(MODULE_LIFECYCLE_PATH);
expect(inventory.status).toBe(200);
expect(lifecycle.status).toBe(403);
});
it("admits a non-owner holding exactly core.modules.lifecycle.manage through a real grant", async () => {
const { app } = await install({
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect((await app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
// Holding it confers no module authority: the Dashboard's own manage permission is untouched by
// this grant, so a lifecycle administrator cannot reorganize navigation.
const reorganize = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
body: JSON.stringify({ label: "Media", order: 10 }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(reorganize.status).toBe(403);
});
it("reports a sanitized 503 when the configuration cannot be read, never a driver detail", async () => {
const failing: ModuleLifecycleStore = {
readConfiguration: async () => {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_READ_FAILED");
},
setEnabled: async () => {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED");
},
};
// Boot with a working store, then swap in the failing one for the request path only.
const { app } = await install({ ...owner(), store: failingAfterBoot(failing) });
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_LIFECYCLE_READ_FAILED" });
});
});
/** Answers the one boot-time read with an empty configuration, then fails every later call — so a
* request-path failure can be exercised without making startup composition itself impossible. */
function failingAfterBoot(failing: ModuleLifecycleStore): ModuleLifecycleStore {
let bootRead = false;
return {
async readConfiguration() {
if (!bootRead) {
bootRead = true;
return { modules: [] };
}
return failing.readConfiguration();
},
setEnabled: (input) => failing.setEnabled(input),
};
}
describe("PUT /api/v1/module-lifecycle/:moduleId/enabled", () => {
it("persists a decision and reports honestly that the running process has not changed", async () => {
const { app } = await install(owner());
const response = await setEnabled(app, "announcements", false);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
configured: { enabled: false, explicit: true },
id: "announcements",
// Not `disabled`: this process is still serving that module. Claiming otherwise would be a
// lie about a module that is still running.
restartRequired: true,
runtime: { state: "running" },
version: "1.0.0",
});
});
it("marks the dependents of a newly disabled module as restart-pending too", async () => {
const { app } = await install(owner());
await setEnabled(app, "storage", false);
expect((await readLifecycle(app)).map((entry) => [entry.id, entry.restartRequired])).toEqual([
["storage", true],
["announcements", true],
]);
});
it("is idempotent, and re-enabling clears the pending restart", async () => {
const { app } = await install(owner());
await setEnabled(app, "announcements", false);
await setEnabled(app, "announcements", false);
const reEnabled = await setEnabled(app, "announcements", true);
expect(await reEnabled.json()).toEqual({
configured: { enabled: true, explicit: true },
id: "announcements",
restartRequired: false,
runtime: { state: "running" },
version: "1.0.0",
});
});
it("refuses a module this process cannot see, rather than storing an unverifiable record", async () => {
const { app, store } = await install(owner());
const response = await setEnabled(app, "not-installed", false);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("refuses a malformed module identifier without reaching the store", async () => {
const { app, store } = await install(owner());
const response = await setEnabled(app, "Not%20A%20Module", false);
expect(response.status).toBe(404);
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("rejects an untrusted origin before authorization or the store runs", async () => {
const { app, store } = await install(owner());
const response = await setEnabled(app, "announcements", false, untrustedOrigin);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("rejects a missing origin", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
body: JSON.stringify({ enabled: false }),
headers: { "content-type": "application/json" },
method: "PUT",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("refuses an authenticated caller without the restricted permission", async () => {
const { app, store } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
const response = await setEnabled(app, "announcements", false);
expect(response.status).toBe(403);
expect((await store.readConfiguration()).modules).toEqual([]);
});
it.each([
["a non-boolean value", { enabled: "false" }],
["a missing field", {}],
["an unexpected key alongside a valid one", { enabled: false, force: true }],
["an array", []],
["a bare string", "disabled"],
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
const { app, store } = await install(owner());
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
const { app } = await install(owner());
const oversized = JSON.stringify({ enabled: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) });
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
body: oversized,
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("never leaks a filesystem path, package name, or manifest path through either verb", async () => {
const { app } = await install(owner());
const listed = await (
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
).text();
const mutated = await (await setEnabled(app, "announcements", false)).text();
for (const payload of [listed, mutated]) {
expect(payload).not.toContain("/tmp");
expect(payload).not.toContain("@test/module-");
expect(payload).not.toContain("ekklesia.module.json");
expect(payload).not.toContain("dist/");
expect(payload).not.toContain("entrypoint");
}
});
});
describe("lifecycle state governs module UI exposure", () => {
it("keeps a disabled module out of the mountable inventory and out of Dashboard navigation", async () => {
const { app } = await install({
bootDisabled: ["announcements"],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
readonly modules: readonly { readonly id: string }[];
};
const navigation = (await (await app.request(DASHBOARD_NAVIGATION_PATH)).json()) as {
readonly groups: readonly { readonly modules: readonly { readonly name: string }[] }[];
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
};
// Discovered, validated, and declaring a real compiled UI artifact — and still absent, because
// publication is built from the eligible set rather than the discovered one.
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage"]);
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["storage"]);
expect(navigation.groups).toEqual([]);
});
it("keeps a dependency-blocked module out of both, exactly like an explicitly disabled one", async () => {
const { app } = await install({
bootDisabled: ["storage"],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
readonly modules: readonly { readonly id: string }[];
};
expect(inventory.modules).toEqual([]);
});
it("does not remove a module from navigation merely because a restart is pending", async () => {
const { app } = await install({
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
await setEnabled(app, "announcements", false);
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
readonly modules: readonly { readonly id: string }[];
};
// The module really is still mounted and still serving; hiding its UI now would break a viewer
// mid-session for a change that has not taken effect. `restartRequired` is where that pending
// change is reported.
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage", "announcements"]);
});
});
describe("the lifecycle prefix is a sibling of the module route namespace", () => {
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
// `moduleRoutePathSchema` accepts `enabled` as an ordinary static segment, so a module named
// `announcements` may legitimately register `PUT /enabled` — mounted at exactly
// `/api/v1/modules/announcements/enabled`. If the platform published its lifecycle mutation
// there, registration order would decide which one answers.
expect(MODULE_LIFECYCLE_PATH.startsWith("/api/v1/modules/")).toBe(false);
expect(MODULE_LIFECYCLE_PATH).toBe("/api/v1/module-lifecycle");
const { app } = await install(owner());
const collision = await app.request("/api/v1/modules/announcements/enabled", {
body: JSON.stringify({ enabled: false }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(collision.status).toBe(404);
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
});
});
@@ -30,6 +30,10 @@ import {
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp } from "../src/app.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
/**
* The whole chain, with nothing between the halves faked:
@@ -213,6 +217,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
moduleRoutes: moduleRuntime.routes,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: ["https://dashboard.example.test"],
@@ -28,6 +28,10 @@ import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
const trustedOrigin = "https://dashboard.example.test";
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
@@ -169,6 +173,10 @@ async function install(options: InstallOptions): Promise<Installation> {
moduleRoutes: Object.freeze([]),
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: [trustedOrigin],
+8
View File
@@ -28,6 +28,10 @@ import {
MODULE_UI_CONTENT_TYPE,
moduleUiArtifactUrl,
} from "../src/module-ui-routes.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
const trustedOrigin = "https://dashboard.example.test";
@@ -169,6 +173,10 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
moduleRoutes: Object.freeze([]),
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: [trustedOrigin],
@@ -0,0 +1,76 @@
import type { ModuleEnablementRecord } from "@ekklesia/contracts";
import type {
DiscoveredModule,
ModuleLifecycleConfiguration,
ModuleLifecycleSnapshot,
ModuleLifecycleStore,
SetModuleEnabledInput,
} from "@ekklesia/core/modules";
import { ModuleLifecycleStoreError, resolveModuleEligibility } from "@ekklesia/core/modules";
/**
* An in-memory `ModuleLifecycleStore` for tests that exercise **routing and resolution**, not
* persistence.
*
* It reproduces only what those tests depend on — records survive a write and read back in
* module-id order, and an invalid identifier is rejected — and deliberately reproduces nothing
* else. The real table's constraints, the upsert's conflict target, and the fact that a record for
* an undeployed module is retained are proven against real PostgreSQL in
* `core/tests/module-lifecycle-store.integration.ts`, so this double can never quietly stand in for
* a persistence guarantee nobody checked.
*/
export function createInMemoryModuleLifecycleStore(
seed: readonly ModuleEnablementRecord[] = [],
): ModuleLifecycleStore {
const records = new Map<string, boolean>(
seed.map((record) => [record.moduleId, record.enabled] as const),
);
return Object.freeze({
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
return Object.freeze({
modules: Object.freeze(
[...records.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([moduleId, enabled]) => Object.freeze({ enabled, moduleId })),
),
});
},
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(input.moduleId)) {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
}
records.set(input.moduleId, input.enabled);
return Object.freeze({ enabled: input.enabled, moduleId: input.moduleId });
},
});
}
/**
* Builds the boot snapshot the same way `startApi` does — resolve eligibility from the discovered
* inventory and the stored configuration, then record what would have started — rather than
* hand-writing one, so a test can never assert against a snapshot the real composition would never
* produce.
*/
export function moduleLifecycleSnapshotFor(
discoveredModules: readonly DiscoveredModule[],
configuration: ModuleLifecycleConfiguration = { modules: [] },
): ModuleLifecycleSnapshot {
const resolution = resolveModuleEligibility(discoveredModules, configuration);
return Object.freeze({
discoveredModules,
eligibility: resolution.entries,
startedModuleIds: Object.freeze(resolution.eligibleModules.map((module) => module.manifest.id)),
});
}
/** The lifecycle option for a `createApiApp` caller that exercises no modules at all — the explicit
* "no modules" statement the required option asks for, never an implicit default. */
export function emptyModuleLifecycle(): {
readonly snapshot: ModuleLifecycleSnapshot;
readonly store: ModuleLifecycleStore;
} {
return {
snapshot: moduleLifecycleSnapshotFor([]),
store: createInMemoryModuleLifecycleStore(),
};
}
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
"./src/identity/schema.ts",
"./src/authorization/schema.ts",
"./src/dashboard/schema.ts",
"./src/modules/lifecycle-schema.ts",
],
out: "./drizzle",
migrations: {
@@ -0,0 +1,7 @@
CREATE TABLE "ekklesia_core_module_state" (
"module_id" text PRIMARY KEY NOT NULL,
"enabled" boolean NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_module_state_module_check" CHECK (length("ekklesia_core_module_state"."module_id") > 0)
);
+914
View File
@@ -0,0 +1,914 @@
{
"id": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
"prevId": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_group": {
"name": "ekklesia_core_dashboard_group",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_group_order_idx": {
"name": "ekklesia_core_dashboard_group_order_idx",
"columns": [
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_group_id_check": {
"name": "ekklesia_core_dashboard_group_id_check",
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
},
"ekklesia_core_dashboard_group_label_check": {
"name": "ekklesia_core_dashboard_group_label_check",
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_placement": {
"name": "ekklesia_core_dashboard_placement",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_placement_group_idx": {
"name": "ekklesia_core_dashboard_placement_group_idx",
"columns": [
{
"expression": "group_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
"tableFrom": "ekklesia_core_dashboard_placement",
"tableTo": "ekklesia_core_dashboard_group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_placement_module_check": {
"name": "ekklesia_core_dashboard_placement_module_check",
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_module_state": {
"name": "ekklesia_core_module_state",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_module_state_module_check": {
"name": "ekklesia_core_module_state_module_check",
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+6
View File
@@ -25,6 +25,12 @@
"destructive": {
"mode": "none"
}
},
{
"tag": "0004_module_lifecycle_state",
"destructive": {
"mode": "none"
}
}
]
}
+7
View File
@@ -29,6 +29,13 @@
"when": 1787408382271,
"tag": "0003_dashboard_navigation_tables",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1787423121678,
"tag": "0004_module_lifecycle_state",
"breakpoints": true
}
]
}
+23
View File
@@ -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,
}),
]);
/**
+173
View File
@@ -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;
});
}
+6 -1
View File
@@ -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"]),
+127
View File
@@ -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,
+3
View File
@@ -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";
+60
View File
@@ -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;
+150
View File
@@ -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");
}
},
});
}
+312
View File
@@ -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,
});
}),
);
}
+14 -12
View File
@@ -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(
+21 -4
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_UI_READ,
CORE_PERMISSION_DECLARATIONS,
CORE_PERMISSION_REGISTRY,
@@ -110,11 +111,22 @@ describe("CORE_PERMISSION_REGISTRY", () => {
expect(declaration?.id).toBe("core.dashboard.navigation.manage");
});
it("contains exactly these three permissions and no others", () => {
it("registers core.modules.lifecycle.manage as restricted", () => {
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_MODULES_LIFECYCLE_MANAGE);
expect(declaration).toBeDefined();
// Deciding which installed modules run is administrative, and so is *reading* that: the
// lifecycle view is the only place a disabled or blocked module is visible at all, unlike the
// merely-authenticated mountable inventory (ADR 0028).
expect(declaration?.baseline).toBe("restricted");
expect(declaration?.id).toBe("core.modules.lifecycle.manage");
});
it("contains exactly these four permissions and no others", () => {
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
]);
});
});
@@ -137,11 +149,16 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
},
{
baseline: "restricted",
description: "Read and change which installed modules are enabled.",
id: CORE_MODULES_LIFECYCLE_MANAGE,
},
]);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[2])).toBe(true);
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
expect(Object.isFrozen(declaration)).toBe(true);
}
});
it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => {
@@ -174,6 +174,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",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -228,6 +229,7 @@ describe.sequential("live Core authorization store", () => {
"ekklesia_core_dashboard_placement",
"ekklesia_core_grant",
"ekklesia_core_migrations",
"ekklesia_core_module_state",
"ekklesia_core_platform_owner",
"ekklesia_core_platform_owner_bootstrap",
"ekklesia_core_session",
@@ -173,6 +173,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",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
+6 -1
View File
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
planMigrations: async () => {
throw new Error("database must not be planned");
},
readModuleLifecycle: async () => {
throw new Error("module lifecycle configuration must not be read");
},
runtimeSatisfies: () => true,
};
}
@@ -78,7 +81,9 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
3 permissions registered
4 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
Data definitions
✓ Schemas validated
+172
View File
@@ -137,6 +137,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
discoverModules: vi.fn(async () => Object.freeze([])),
discoverSchemas: vi.fn(async () => schemaAggregate()),
planMigrations: vi.fn(async () => plan()),
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
runtimeSatisfies: vi.fn(() => true),
...overrides,
};
@@ -204,6 +205,7 @@ describe("Ekklesia Doctor diagnostics", () => {
"modules.discovery",
"modules.dependencies",
"modules.permissions",
"modules.lifecycle",
"schemas.discovery",
"migrations.discovery",
"database.transport",
@@ -844,3 +846,173 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
});
});
const databaseUrl = "postgresql://not-rendered.invalid/ekklesia";
describe("Ekklesia Doctor — module lifecycle", () => {
/** A discovered module double with a real required-dependency edge, so blocking is exercised
* through the same resolver production uses rather than a hand-written eligibility record. */
function dependentModule(id: string, requires: string): DiscoveredModule {
const base = discoveredModule(id);
return {
...base,
manifest: {
...base.manifest,
dependencies: {
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: { [requires]: "^0.1.0" } },
},
} as unknown as ModuleManifest,
};
}
it("skips the check, rather than guessing a default, when DATABASE_URL is not configured", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
});
const report = await diagnoseEkklesia(options(), services);
expect(check(report, "modules.lifecycle").status).toBe("skipped");
expect(check(report, "modules.lifecycle").details).toEqual(["DATABASE_URL is not configured."]);
// Configuration lives in PostgreSQL; reporting "everything is enabled" without reading it would
// be a guess an operator could act on.
expect(services.readModuleLifecycle).not.toHaveBeenCalled();
});
it("skips the check when discovery itself failed", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => {
throw new Error("broken inventory");
}),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
expect(check(report, "modules.lifecycle").status).toBe("skipped");
expect(check(report, "modules.lifecycle").details).toEqual([
"Complete module discovery is required.",
]);
});
it("passes and lists each module when everything enabled can run", async () => {
const services = dependencies({
discoverModules: vi.fn(async () =>
Object.freeze([discoveredModule("storage"), discoveredModule("calendar", "0.2.0")]),
),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
expect(check(report, "modules.lifecycle").status).toBe("pass");
expect(check(report, "modules.lifecycle").details).toEqual([
"2 eligible · 0 disabled · 0 blocked",
"storage 0.1.0 · enabled",
"calendar 0.2.0 · enabled",
]);
});
it("still passes when a module is deliberately disabled — a decision is not a problem", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
readModuleLifecycle: vi.fn(async () =>
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "calendar" }]) }),
),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
expect(check(report, "modules.lifecycle").status).toBe("pass");
expect(check(report, "modules.lifecycle").details).toEqual([
"0 eligible · 1 disabled · 0 blocked",
"calendar 0.1.0 · disabled",
]);
});
it("distinguishes the platform default from a decision somebody made", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
// Nothing stored: the module is enabled by the platform default, and the detail says so.
expect(check(report, "modules.lifecycle").details).toContain("calendar 0.1.0 · enabled");
});
it("warns when an enabled module cannot run, naming the dependency an operator must act on", async () => {
const services = dependencies({
discoverModules: vi.fn(async () =>
Object.freeze([discoveredModule("storage"), dependentModule("announcements", "storage")]),
),
readModuleLifecycle: vi.fn(async () =>
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "storage" }]) }),
),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
const lifecycle = check(report, "modules.lifecycle");
// An enabled module that cannot run is the anomaly worth surfacing — and it is exactly the
// question an operator would otherwise take to the logs.
expect(lifecycle.status).toBe("warning");
expect(lifecycle.summary).toBe("1 enabled module cannot run");
expect(lifecycle.details).toEqual([
"0 eligible · 1 disabled · 1 blocked",
"storage 0.1.0 · disabled",
"announcements 0.1.0 · blocked · required_dependency_disabled · storage",
]);
expect(report.result).toBe("ready-with-warnings");
});
it("fails safely, without a driver detail, when the configuration cannot be read", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
readModuleLifecycle: vi.fn(async () => {
throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2");
}),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
const lifecycle = check(report, "modules.lifecycle");
expect(lifecycle.status).toBe("failure");
expect(lifecycle.details).toEqual([
"The stored module enablement configuration could not be read safely.",
]);
expect(JSON.stringify(lifecycle)).not.toContain("hunter2");
expect(JSON.stringify(lifecycle)).not.toContain("10.40.0.2");
});
it("reads with finite timeouts and the same validated transport policy every executable uses", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
});
await diagnoseEkklesia(options(databaseUrl), services);
expect(services.readModuleLifecycle).toHaveBeenCalledWith(
databaseUrl,
expect.objectContaining({
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
transport: expect.objectContaining({ mode: "verify-full" }),
}),
);
});
it("still reports lifecycle when an unrelated check fails, and never blocks on one", async () => {
const services = dependencies({
composePermissions: vi.fn(() => {
throw new Error("composition exploded");
}),
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
expect(check(report, "modules.permissions").status).toBe("failure");
expect(check(report, "modules.lifecycle").status).toBe("pass");
});
});
@@ -0,0 +1,417 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
createModuleLifecycleStore,
createModuleLifecycleView,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
resolveModuleEligibility,
} from "../src/modules/index.js";
import { parseModuleManifest } from "@ekklesia/contracts";
import type { DiscoveredModule } from "../src/modules/discovery.js";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
/**
* The module lifecycle configuration store against real PostgreSQL.
*
* `core/tests/module-lifecycle.test.ts` proves the resolution rules with no database at all; this
* proves the things only a database can: that migration `0004` really creates the table with the
* constraints the schema declares, that a decision survives a completely new connection, that the
* upsert really is idempotent on `module_id`, that `enabled` has **no** column default (so the
* platform's default for a new module is genuinely the absence of a row, not a materialized one),
* and that a record for a module that is no longer deployed is retained and simply ignored. The
* in-memory double the API route tests use reproduces none of that, which is why it lives here.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The module lifecycle integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The module lifecycle integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 10);
databaseCounter += 1;
return `ekk_lifecycle_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure stays static when connection cleanup also fails.
}
throw harnessError("The module lifecycle integration database connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(
path.join(tmpdir(), "ekklesia-lifecycle-no-modules-"),
);
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { force: true, recursive: true });
}
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
/** A minimal, in-memory `DiscoveredModule` double, built through `parseModuleManifest` so a fixture
* real discovery would reject can never stand in for one it would accept. Only `manifest` is read
* by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 4,
id,
name: `${id} module`,
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(dependencies ? { dependencies } : {}),
entrypoints: { server: "./dist/does-not-exist/index.js" },
});
return {
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
describe.sequential("live Core module lifecycle store", () => {
let database: { name: string; url: string };
let admin: Client;
let store: ModuleLifecycleStore;
let close: () => Promise<void>;
it("creates exactly the one lifecycle table, with its declared constraints and no enabled default", async () => {
database = await createDisposableDatabase("fresh");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
const tables = await safeQuery<{ table_name: string }>(
admin,
`select table_name from information_schema.tables
where table_schema = 'public' and table_name like 'ekklesia_core_module%'
order by table_name`,
);
const columns = await safeQuery<{
column_name: string;
is_nullable: string;
column_default: string | null;
}>(
admin,
`select column_name, is_nullable, column_default
from information_schema.columns
where table_name = 'ekklesia_core_module_state'
order by column_name`,
);
// Only the explicitly declared CHECK constraint: PostgreSQL 17 also materializes a system
// `..._not_null` CHECK per NOT NULL column, which is not something this schema declares.
const checks = await safeQuery<{ constraint_name: string }>(
admin,
`select tc.constraint_name
from information_schema.table_constraints tc
where tc.table_name = 'ekklesia_core_module_state' and tc.constraint_type = 'CHECK'
and tc.constraint_name not like '%_not_null'
order by tc.constraint_name`,
);
expect(tables.map((table) => table.table_name)).toEqual(["ekklesia_core_module_state"]);
// Deliberately minimal: an identity, a decision, and two audit timestamps. No install source,
// download URL, update channel, signature, version, or JSON blob — the columns an installer
// would need are absent, which is what keeps one from being built here by accident.
expect(columns.map((column) => column.column_name)).toEqual([
"created_at",
"enabled",
"module_id",
"updated_at",
]);
const enabled = columns.find((column) => column.column_name === "enabled");
expect(enabled?.is_nullable).toBe("NO");
// The load-bearing assertion: a row exists only because somebody decided. The platform's
// default for a newly discovered module is the *absence* of a row, resolved in
// `resolveModuleEligibility` — never a column default that would materialize a decision
// nobody made.
expect(enabled?.column_default).toBeNull();
expect(checks.map((check) => check.constraint_name)).toEqual([
"ekklesia_core_module_state_module_check",
]);
});
it("reads an empty configuration from a freshly migrated database", async () => {
const view = createValidatedPostgresPool(database.url, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
});
close = view.close;
store = createModuleLifecycleStore(createModuleLifecycleView(view.pool));
expect(await store.readConfiguration()).toEqual({ modules: [] });
});
it("persists an explicit decision and reads it back over a completely new connection", async () => {
await store.setEnabled({ enabled: false, moduleId: "announcements" });
const separate = createValidatedPostgresPool(database.url, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
});
try {
const reread = await createModuleLifecycleStore(
createModuleLifecycleView(separate.pool),
).readConfiguration();
expect(reread.modules).toEqual([{ enabled: false, moduleId: "announcements" }]);
} finally {
await separate.close();
}
});
it("is idempotent on module_id: the same decision twice leaves exactly one row", async () => {
await store.setEnabled({ enabled: false, moduleId: "announcements" });
await store.setEnabled({ enabled: false, moduleId: "announcements" });
const rows = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from ekklesia_core_module_state where module_id = 'announcements'`,
);
expect(rows[0]?.count).toBe("1");
});
it("replaces a decision in place, and bumps updated_at rather than inserting a second row", async () => {
const before = await safeQuery<{ created_at: Date; updated_at: Date }>(
admin,
`select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`,
);
await store.setEnabled({ enabled: true, moduleId: "announcements" });
const after = await safeQuery<{ created_at: Date; updated_at: Date }>(
admin,
`select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`,
);
expect(after).toHaveLength(1);
expect(after[0]?.created_at).toEqual(before[0]?.created_at);
expect(after[0]?.updated_at.getTime()).toBeGreaterThanOrEqual(
before[0]?.updated_at.getTime() ?? 0,
);
expect((await store.readConfiguration()).modules).toEqual([
{ enabled: true, moduleId: "announcements" },
]);
});
it("returns records in deterministic module-id order", async () => {
await store.setEnabled({ enabled: false, moduleId: "video" });
await store.setEnabled({ enabled: true, moduleId: "calendar" });
expect((await store.readConfiguration()).modules.map((record) => record.moduleId)).toEqual([
"announcements",
"calendar",
"video",
]);
});
it("rejects a malformed module identifier before it ever reaches PostgreSQL", async () => {
await expect(store.setEnabled({ enabled: false, moduleId: "Not A Module" })).rejects.toThrow(
ModuleLifecycleStoreError,
);
await expect(store.setEnabled({ enabled: false, moduleId: "" })).rejects.toMatchObject({
code: "MODULE_LIFECYCLE_MODULE_ID_INVALID",
});
const rows = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from ekklesia_core_module_state`,
);
expect(rows[0]?.count).toBe("3");
});
it("rejects an empty module id at the database level too, beneath the application contract", async () => {
await expect(
safeQuery(
admin,
`insert into ekklesia_core_module_state (module_id, enabled) values ('', true)`,
),
).rejects.toThrow();
});
it("retains a record for a module that is no longer deployed, and resolution ignores it", async () => {
// The whole configuration, including the row for `video`, which is not in this inventory.
const configuration = await store.readConfiguration();
const deployed = [fakeModule("announcements"), fakeModule("calendar")];
const resolution = resolveModuleEligibility(deployed, configuration);
expect(resolution.entries.map((entry) => entry.moduleId)).toEqual([
"announcements",
"calendar",
]);
expect(resolution.eligibleModules.map((module) => module.manifest.id)).toEqual([
"announcements",
"calendar",
]);
// Retained, not deleted: redeploying `video` restores the decision with no new write.
expect(configuration.modules.some((record) => record.moduleId === "video")).toBe(true);
const redeployed = resolveModuleEligibility([...deployed, fakeModule("video")], configuration);
expect(redeployed.eligibleModules.map((module) => module.manifest.id)).toEqual([
"announcements",
"calendar",
]);
});
it("drives real dependency blocking from real stored rows", async () => {
await store.setEnabled({ enabled: false, moduleId: "storage" });
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("announcements", { modules: { required: { storage: "^1.0.0" } } }),
],
await store.readConfiguration(),
);
expect(resolution.eligibleModules).toEqual([]);
expect(resolution.entries.find((entry) => entry.moduleId === "announcements")?.blocked).toEqual(
{
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
},
);
await close();
});
});
+529
View File
@@ -0,0 +1,529 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { parseModuleManifest } from "@ekklesia/contracts";
import { afterEach, describe, expect, it } from "vitest";
import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js";
import {
type ModuleLifecycleConfiguration,
resolveModuleEligibility,
resolveModuleLifecycleView,
} from "../src/modules/lifecycle.js";
/** A minimal, in-memory `DiscoveredModule` double, built the same way `module-permissions.test.ts`
* builds one: the manifest still goes through `parseModuleManifest`, so a fixture real discovery
* would reject can never silently stand in for one it would accept. Only `manifest` is read by
* eligibility resolution; the rest exist to satisfy the type. */
function fakeModule(
id: string,
options: {
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
readonly dependencies?: Record<string, unknown>;
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 4,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
capabilities: options.capabilities ?? [],
...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: {
// Deliberately a path nothing will ever resolve: eligibility resolution must never import,
// evaluate, or otherwise touch a module's server entrypoint.
server: "./dist/does-not-exist/index.js",
},
});
return {
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function configuration(
...records: readonly (readonly [string, boolean])[]
): ModuleLifecycleConfiguration {
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
}
function ids(modules: readonly DiscoveredModule[]): readonly string[] {
return modules.map((module) => module.manifest.id);
}
function entry(
resolution: ReturnType<typeof resolveModuleEligibility>,
moduleId: string,
): ReturnType<typeof resolveModuleEligibility>["entries"][number] {
const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId);
if (!found) {
throw new Error(`No eligibility entry for ${moduleId}.`);
}
return found;
}
describe("resolveModuleEligibility — default policy for a newly discovered module", () => {
it("treats a module with no stored record as enabled, so deploying a module is enough to run it", () => {
const resolution = resolveModuleEligibility([fakeModule("announcements")], configuration());
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
expect(entry(resolution, "announcements").configured).toEqual({
enabled: true,
explicit: false,
});
});
it("distinguishes the default from a decision somebody actually made", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements")],
configuration(["announcements", true]),
);
// Same effect, different provenance — which is exactly what an operator needs to know when
// they are wondering whether a module is deliberately on.
expect(entry(resolution, "announcements").configured).toEqual({
enabled: true,
explicit: true,
});
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
});
it("never writes, and never enables anything: an explicit disable always wins over the default", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements")],
configuration(["announcements", false]),
);
expect(resolution.eligibleModules).toEqual([]);
expect(entry(resolution, "announcements")).toEqual({
configured: { enabled: false, explicit: true },
eligible: false,
moduleId: "announcements",
version: "0.1.0",
});
// A disabled module is not "blocked" — it is doing exactly what it was told.
expect(entry(resolution, "announcements").blocked).toBeUndefined();
});
it("ignores a stored record for a module that is not deployed, rather than failing startup", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements")],
configuration(["announcements", true], ["removed-long-ago", false]),
);
expect(resolution.entries.map((candidate) => candidate.moduleId)).toEqual(["announcements"]);
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
});
it("restores a retained decision when the module is deployed again", () => {
const stale = configuration(["announcements", false]);
// Absent: the record is inert.
expect(resolveModuleEligibility([], stale).entries).toEqual([]);
// Redeployed: the same record decides again, with no new write.
expect(resolveModuleEligibility([fakeModule("announcements")], stale).eligibleModules).toEqual(
[],
);
});
});
describe("resolveModuleEligibility — required dependencies", () => {
it("keeps a dependent eligible while its required dependency is enabled", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("announcements", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
],
configuration(),
);
expect(ids(resolution.eligibleModules)).toEqual(["storage", "announcements"]);
});
it("blocks a dependent when its required module dependency is disabled, and names the dependency", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("announcements", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
],
configuration(["storage", false]),
);
expect(resolution.eligibleModules).toEqual([]);
expect(entry(resolution, "announcements").blocked).toEqual({
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
});
// Administrator intent is never overridden: disabling `storage` does not silently re-enable it
// because something needs it.
expect(entry(resolution, "storage").configured.enabled).toBe(false);
});
it("blocks a dependent when the required capability's only provider is disabled", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage", { capabilities: [{ id: "storage.objects", version: "1.0.0" }] }),
fakeModule("announcements", {
dependencies: { capabilities: { required: { "storage.objects": "^1.0.0" } } },
}),
],
configuration(["storage", false]),
);
expect(resolution.eligibleModules).toEqual([]);
// A required capability is exactly as required as a required module, and the reason names the
// module an administrator would have to enable — not just the capability id.
expect(entry(resolution, "announcements").blocked).toEqual({
dependency: { capabilityId: "storage.objects", kind: "capability", moduleId: "storage" },
reason: "required_dependency_disabled",
});
});
it("propagates blocking transitively, and distinguishes the direct cause from the inherited one", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("media", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
fakeModule("announcements", {
dependencies: { modules: { required: { media: "^0.1.0" } } },
}),
],
configuration(["storage", false]),
);
expect(resolution.eligibleModules).toEqual([]);
expect(entry(resolution, "media").blocked?.reason).toBe("required_dependency_disabled");
// Following the chain always terminates at a `..._disabled` entry, so an operator can walk from
// any blocked module to the one decision that caused it.
expect(entry(resolution, "announcements").blocked).toEqual({
dependency: { kind: "module", moduleId: "media" },
reason: "required_dependency_blocked",
});
});
it("blocks only the dependents of the disabled module, never an unrelated one", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("announcements", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
fakeModule("calendar"),
],
configuration(["storage", false]),
);
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
expect(entry(resolution, "calendar").blocked).toBeUndefined();
});
it("preserves discovery order in the eligible list rather than re-sorting it", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("media", { dependencies: { modules: { required: { storage: "^0.1.0" } } } }),
fakeModule("announcements", {
dependencies: { modules: { required: { media: "^0.1.0" } } },
}),
],
configuration(),
);
// Filtering, never re-ordering: a started module's required dependencies still precede it.
expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]);
});
});
describe("resolveModuleEligibility — optional dependencies", () => {
it("leaves a dependent eligible when its optional module dependency is disabled", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("analytics"),
fakeModule("announcements", {
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
}),
],
configuration(["analytics", false]),
);
// A module declaring an optional dependency already accepted that it may be absent — discovery
// permits an optional dependency to not be installed at all — and "disabled" is that same
// situation arriving a different way.
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
expect(entry(resolution, "announcements").blocked).toBeUndefined();
});
it("leaves a dependent eligible when its optional capability's provider is disabled", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("analytics", { capabilities: [{ id: "analytics.events", version: "1.0.0" }] }),
fakeModule("announcements", {
dependencies: { capabilities: { optional: { "analytics.events": "^1.0.0" } } },
}),
],
configuration(["analytics", false]),
);
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
});
it("leaves a dependent eligible when its optional dependency is itself blocked", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("analytics", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
fakeModule("announcements", {
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
}),
],
configuration(["storage", false]),
);
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
});
it("blocks on the required edge only, when a module declares both kinds", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("analytics"),
fakeModule("storage"),
fakeModule("announcements", {
dependencies: {
modules: { optional: { analytics: "^0.1.0" }, required: { storage: "^0.1.0" } },
},
}),
],
configuration(["analytics", false], ["storage", false]),
);
expect(entry(resolution, "announcements").blocked).toEqual({
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
});
});
});
describe("resolveModuleEligibility — over a real discovered inventory", () => {
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function writeModule(
modulesDirectory: string,
id: string,
dependencies?: Record<string, unknown>,
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 4,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(dependencies ? { dependencies } : {}),
entrypoints: { server: "./dist/server/index.js" },
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("eligibility resolution must never import a module server entrypoint");',
),
]);
}
it("resolves against the exact list real discovery produces, without importing any entrypoint", async () => {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
temporaryDirectories.push(modulesDirectory);
await writeModule(modulesDirectory, "storage");
await writeModule(modulesDirectory, "announcements", {
modules: { required: { storage: "^0.1.0" } },
});
const discovered = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
// Discovery's own topological order, which eligibility resolution relies on and never redoes.
expect(ids(discovered)).toEqual(["storage", "announcements"]);
const blocked = resolveModuleEligibility(discovered, configuration(["storage", false]));
expect(blocked.eligibleModules).toEqual([]);
// Every fixture entrypoint throws on import; reaching this assertion is direct evidence that
// nothing here imported one.
expect(entry(blocked, "announcements").blocked?.reason).toBe("required_dependency_disabled");
});
it("leaves a missing required dependency to discovery, rather than modelling it as a lifecycle state", async () => {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
temporaryDirectories.push(modulesDirectory);
await writeModule(modulesDirectory, "announcements", {
modules: { required: { storage: "^0.1.0" } },
});
// There is deliberately no eligibility state for "required dependency not installed": discovery
// rejects the whole inventory long before enablement is consulted, so there is exactly one place
// the dependency graph is validated.
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({ name: "ModuleDiscoveryError" });
});
});
describe("resolveModuleLifecycleView — configuration versus what this process actually did", () => {
const storage = fakeModule("storage");
const announcements = fakeModule("announcements", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
});
const discoveredModules = [storage, announcements];
function snapshotFor(bootConfiguration: ModuleLifecycleConfiguration) {
const resolution = resolveModuleEligibility(discoveredModules, bootConfiguration);
return {
discoveredModules,
eligibility: resolution.entries,
startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id),
};
}
it("reports a started module as running, with no restart pending", () => {
const view = resolveModuleLifecycleView(snapshotFor(configuration()), configuration());
expect(view).toEqual([
{
configured: { enabled: true, explicit: false },
moduleId: "storage",
restartRequired: false,
runtime: { state: "running" },
version: "0.1.0",
},
{
configured: { enabled: true, explicit: false },
moduleId: "announcements",
restartRequired: false,
runtime: { state: "running" },
version: "0.1.0",
},
]);
});
it("does not claim a module stopped when configuration changed but the process did not", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration()),
configuration(["announcements", false]),
);
const changed = view.find((entry) => entry.moduleId === "announcements");
// The honest triple: configured off, still running, restart pending. Reporting `disabled` here
// would be a lie about a process that is still serving that module's routes.
expect(changed?.configured).toEqual({ enabled: false, explicit: true });
expect(changed?.runtime).toEqual({ state: "running" });
expect(changed?.restartRequired).toBe(true);
// An unrelated module is untouched.
expect(view.find((entry) => entry.moduleId === "storage")?.restartRequired).toBe(false);
});
it("marks restart pending for a dependent too, because it really will stop on the next boot", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration()),
configuration(["storage", false]),
);
expect(view.map((entry) => [entry.moduleId, entry.restartRequired])).toEqual([
["storage", true],
["announcements", true],
]);
expect(view.every((entry) => entry.runtime.state === "running")).toBe(true);
});
it("marks restart pending in the other direction, without claiming the module started", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration(["announcements", false])),
configuration(["announcements", true]),
);
const changed = view.find((entry) => entry.moduleId === "announcements");
expect(changed?.configured).toEqual({ enabled: true, explicit: true });
expect(changed?.runtime).toEqual({ state: "disabled" });
expect(changed?.restartRequired).toBe(true);
});
it("explains a blocked module with the boot-time reason, not a recomputed one", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration(["storage", false])),
configuration(["storage", false]),
);
expect(view.find((entry) => entry.moduleId === "storage")?.runtime).toEqual({
state: "disabled",
});
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime).toEqual({
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
state: "blocked",
});
expect(view.every((entry) => entry.restartRequired === false)).toBe(true);
});
it("reports what the runtime actually started, not what eligibility predicted", () => {
// A snapshot whose started set disagrees with its own eligibility is not reachable while
// startup stays all-or-nothing. It is constructed here deliberately: the read model must be an
// observation of the runtime, so if that guarantee ever changes this can never silently report
// a module as `running` because it was merely eligible.
const eligible = resolveModuleEligibility(discoveredModules, configuration());
const view = resolveModuleLifecycleView(
{
discoveredModules,
eligibility: eligible.entries,
startedModuleIds: ["storage"],
},
configuration(),
);
expect(view.find((entry) => entry.moduleId === "storage")?.runtime.state).toBe("running");
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime.state).toBe("blocked");
expect(view.find((entry) => entry.moduleId === "announcements")?.restartRequired).toBe(true);
});
it("ignores a stored record for an undeployed module rather than inventing a row for it", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration()),
configuration(["removed-long-ago", false]),
);
expect(view.map((entry) => entry.moduleId)).toEqual(["storage", "announcements"]);
});
});
+5 -2
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_UI_READ,
CORE_PERMISSION_DECLARATIONS,
PermissionRegistryError,
@@ -69,8 +70,9 @@ describe("composePlatformPermissionRegistry", () => {
expect(registry.has("calendar.events.read")).toBe(true);
expect(registry.has("people.directory.read")).toBe(true);
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
// Core's own three declarations plus one from each module.
expect(registry.declarations).toHaveLength(5);
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
// Core's own four declarations plus one from each module.
expect(registry.declarations).toHaveLength(6);
});
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
@@ -88,6 +90,7 @@ describe("composePlatformPermissionRegistry", () => {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
"calendar.events.read",
"calendar.events.write",
"people.directory.read",
@@ -30,6 +30,15 @@ names nine stages, but nothing implements a state machine over them: there is no
enable/disable, no trust store, and no per-module status record. What exists is one accepted set —
the modules `discoverModules` validated at startup and `startDiscoveredModuleRuntime` then started.
> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).**
> The paragraph above described the platform accurately at the time. There is now a persistent
> enable/disable state and a resolved eligibility model, and `ModuleLifecycleStage` has been replaced
> by the two axes ADR 0028 records. "Publishable" consequently derives from the **eligible** module
> set rather than the discovered one, so a disabled or dependency-blocked module publishes no
> artifact. Everything else in this ADR — lookup by validated identity, the sibling prefix, exact
> version matching, the anonymous artifact route, and the caching contract — is unchanged and still
> accepted. There is still no installer.
## Decision
### Publication is derived from validated module identity, never from a request path
@@ -79,6 +79,14 @@ configure, and no module that declares no UI. Ekklesia has no enable/disable sta
`ModuleLifecycleStage` remains vocabulary with no runtime state machine behind it — and reporting one
would mean inventing it.
> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).**
> Ekklesia now does have enable/disable state, reported by its own resource
> (`GET /api/v1/module-lifecycle`) rather than by this one. This endpoint is unchanged and remains
> exactly what it was: the host-neutral list of what may be mounted, with no management verbs. What
> changed is upstream of it — the inventory now derives from the **eligible** module set, so a
> disabled or dependency-blocked module is simply absent, and the lifecycle vocabulary this
> paragraph names has been replaced by the two axes ADR 0028 records.
### Both URLs are server-produced, and that is the point
`ui.url` comes from `moduleUiArtifactUrl` (ADR 0025) and `api.baseUrl` from `moduleApiBaseUrl`, a new
@@ -0,0 +1,426 @@
# 0028: Module operational lifecycle — enablement, eligibility, and what "running" means
- Status: Accepted
- Date: 2026-08-22
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
[0025](0025-trusted-module-ui-asset-publication.md),
[0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md)
## Context
Everything upstream of this decision exists. Core discovers modules, validates manifests and
dependencies, orders them topologically, composes permissions, starts a runtime, mounts module HTTP
routes, publishes module-owned UI, and resolves a Dashboard arrangement over it.
What did not exist was any notion of a module being **on or off**. The accepted module set was
exactly "the modules found on disk at startup", and three separate places in the repository said so
in almost the same words:
> `ModuleLifecycleStage` (`@ekklesia/contracts`) names nine stages, but nothing implements a state
> machine over them: there is no installer, no enable/disable, no trust store, and no per-module
> status record.
> — ADR 0025
That vocabulary — `discovered`, `validated`, `configured`, `registered`, `migrated`, `initialized`,
`started`, `stopped`, `failed` — was aspiration written down as a contract. Nothing produced any of
those values, and nothing consumed them.
Before migrating a real product into the module system, the platform needs an answer to a small set
of operational questions that a church running this software will genuinely ask: *is this module
on? should it start? what happens to the things that depend on it? why is it not available?* This
ADR records the smallest model that answers them honestly.
Two constraints came from the repository rather than from principle.
**Startup is all-or-nothing, deliberately.** ADR 0015 and ADR 0020 built that guarantee carefully:
a module joins the rollback set the moment its `start` hook is invoked, capabilities and routes are
published only after every module registers and starts, and a failed start unwinds in reverse order
under bounded deadlines. ADR 0025 then *depends* on it — "the published set is also the running
set" — as does ADR 0026's inventory and ADR 0027's navigation.
**There is exactly one persistence precedent, and it is recent.** ADR 0027 added two Core-owned,
platform-global tables for Dashboard configuration, with the explicit rule that a module with no
stored row is still visible. That precedent shaped both the schema conventions and the default
policy below.
## Decision
### Two axes, never one status word
```text
configuration enabled / disabled persistent, administrator-owned
runtime running / disabled / blocked ephemeral, observed per boot
```
`MODULE_LIFECYCLE_STAGES` is **replaced** by these two, because a contract should name states that
something actually produces. Both are in `@ekklesia/contracts`; both are observable through
`GET /api/v1/module-lifecycle`.
They are kept apart because they answer different questions and change at different times.
Collapsing them into one word would force a lie in one direction or the other the moment an
administrator changes a setting on a process that is still running.
### `installed` means "a valid module package on disk that discovery accepted" — and nothing else
There is no installer, no installation registry, no package database, and no install source. This
pass deliberately did not create one. The honest chain today is:
```text
present on disk
↓ discovery validates manifest, package, entrypoints, dependencies, cycles
discovered
↓ platform-owned enablement configuration
enabled
↓ required-dependency resolution
eligible
↓ module runtime import, register, start
running
```
A future installer's job is to put a validated package on disk and record an enablement decision,
then reconcile on restart. Nothing in this contract assumes a source-control checkout, an image, or
a registry, so that installer remains possible without changing anything here.
### Enablement is host configuration, and a module can never see it
One Core table, `ekklesia_core_module_state` (migration `0004_module_lifecycle_state`), with
exactly `module_id`, `enabled`, and two audit timestamps. No install source, download URL, update
channel, signature, marketplace metadata, or JSON blob — those are the columns an installer would
need, and their absence is what stops one being built here by accident.
`module_id` carries **no foreign key**, for the same reason `ekklesia_core_dashboard_placement`
does not: modules are packages, not rows. A record for a module that is no longer deployed is
retained and ignored, so redeploying restores the decision rather than resetting it.
A module never declares, reads, or influences its enablement. Nothing in a manifest, a capability,
a permission, or a lifecycle hook can reach it, and a module is never told it is enabled — its host
either 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. A module asking
"am I enabled in Ekklesia?" would be a module that only runs inside Ekklesia.
### A newly discovered module is enabled, and the default is the absence of a row
`enabled` has no column default. The platform's default is expressed by there being no record at
all, and resolved in `resolveModuleEligibility`.
This preserves the behavior every earlier phase had — deploying a module is sufficient to run it —
requires no initialization write when a module first appears, and mirrors ADR 0027's rule that a
module with no placement is still visible. Default-disabled was rejected: with no management UI, it
would mean every deployment ships a platform where nothing works until someone runs a command, and
it would make "install a module" a two-step operation for no safety gain, since the code is already
trusted the moment it is on disk (see **Trust is unchanged**).
An explicit `enabled: true` record is still meaningful and is retained: it records that somebody
looked and left it on. The read model reports `explicit` alongside `enabled` for exactly that
reason.
### Required dependencies block; optional dependencies never do
Disabling `storage` does not silently re-enable it because something needs it. It makes every
module that **requires** it ineligible, transitively:
```text
storage disabled
↓
media blocked · required_dependency_disabled · storage
↓
announcements blocked · required_dependency_blocked · media
```
Following the chain always terminates at a `..._disabled` entry, so an operator can walk from any
blocked module to the one decision that caused it.
A required **capability** dependency blocks identically, because it is one: a capability's only
provider is a module, and if that module does not run, nothing registers the capability. The reason
names the *providing module* — the thing an administrator can act on — with the capability id
alongside it.
An optional dependency never blocks. A module declaring one 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: `context.capabilities.resolve(...)` returns `undefined`,
exactly as it does when the provider was never installed. Required and optional behavior are tested
separately and deliberately.
A dependency that is **missing, version-incompatible, duplicated, or cyclic** has no eligibility
state at all. Discovery rejects the whole inventory before enablement is ever consulted, so the
dependency graph is validated in exactly one place.
Eligibility is a **filter over discovery's order, never a re-sort**. Blocking propagates along the
same edges discovery used to produce a topological order, so removing an ineligible module can
never leave a module ahead of a required dependency it still has.
### Startup atomicity is preserved, deliberately
Startup remains all-or-nothing **across the eligible set**. A module that fails to load, register,
or start still aborts API startup and unwinds through Core's existing reverse-order rollback.
This was the sharpest question in the pass, and changing it would have been a much larger decision
than this ADR should make by accident. ADR 0015 and ADR 0020 built that guarantee on purpose, and
ADR 0025, ADR 0026, and ADR 0027 all rest on "the published set is the running set". Moving to
per-module failure isolation means the permission registry, the route snapshot, the publication
registry, and the navigation view each have to be derived from a set that is only known *after*
partial failure — a real design, and a separate one.
The cost is stated plainly rather than hidden: **one broken module still takes down the whole
platform.** Enablement is what makes that recoverable, and this pass closes the one path that would
otherwise be a deadlock — the HTTP lifecycle API is served by the process a bad module takes down,
so `pnpm modules:disable -- --module <id>` exists as an out-of-band operator command, in the same
shape as `pnpm authorization:bootstrap-owner`: explicit, never HTTP-exposed, never inferred.
There is consequently **no `failed` runtime state**, because no live process could ever report one.
Naming a state nothing can produce is exactly the mistake `MODULE_LIFECYCLE_STAGES` made. It is the
first thing a future isolated-startup pass adds.
### Runtime failure is never persisted as configuration
```text
enabled configuration → stored
failed to start during this boot runtime → not stored
```
A restart naturally retries every enabled module. There is no failure counter, no quarantine, no
backoff, and no "disabled because it crashed" write — a platform that disabled a module on its
behalf would be making an administrative decision nobody asked for, and the operator would have no
way to tell it apart from one they made.
### `running` means its `start` hook completed, and nothing more
It does **not** mean every external resource the module uses is reachable, now or ever. A module
that starts successfully and then cannot reach its object storage is a module with a health
problem, not a module with a lifecycle problem.
This pass adds no runtime health-probe contract, no readiness hook, and no per-module health
endpoint. A module that wants to report its own health does so through its own API and its own UI,
like any other domain concern. The limitation is recorded here rather than left to be discovered.
### Configuration changes take effect at the next start, and the API says so
There is no hot module installation, removal, or reloading (ADR 0015, unchanged), so `PUT` writes a
decision and changes nothing about the running process. The response reports all three facts:
```json
{ "id": "announcements", "version": "1.0.0",
"configured": { "enabled": false, "explicit": true },
"runtime": { "state": "running" },
"restartRequired": true }
```
Reporting `runtime: disabled` there would be a lie about a process still serving that module's
routes. `restartRequired` is computed by re-resolving eligibility against live configuration and
comparing with what the runtime **reports it actually started**, not with what eligibility
predicted — so it is symmetric (enabling a stopped module marks it too) and transitive (disabling a
dependency marks its dependents, because they really will stop).
Hot-unloading to make a button feel immediate was rejected: it would trade a real guarantee — that
a trusted module's ESM evaluation happens exactly once and can never be rolled back — for a
cosmetic one.
### Exposure follows eligibility, structurally rather than by a downstream check
`apps/api/src/server.ts` passes the **same eligible list** to `startDiscoveredModuleRuntime` and to
`createModuleUiPublicationRegistry`. There is one authoritative eligible set, and everything that
exposes a module consumes it:
- **HTTP routes.** A disabled module is never imported, so its `register` hook never runs and it
publishes no routes. There is no per-request "is this module enabled?" check anywhere, because
there is nothing mounted to check — an unmounted path gets the platform's ordinary `404`.
- **UI publication.** Its artifact URL returns the same `404` an uninstalled module does, even to a
caller who knows the exact id and version. ADR 0025's rule — *the existence of an artifact on disk
must not be what makes it servable* — now covers "discovered but disabled" as well.
- **Inventory and navigation.** Absent from `GET /api/v1/module-ui` and from
`GET /api/v1/dashboard/navigation`, by construction, since both derive from the publication
registry. Its Dashboard placement row is retained and simply renders nothing, exactly like an
uninstalled module's (ADR 0027).
- **Lifecycle hooks.** Never invoked, in either direction: shutdown runs no `stop` hook for a module
that never started.
A module whose enablement changed but whose process has not restarted stays fully exposed. It really
is still mounted and still serving; hiding it now would break a viewer mid-session for a change that
has not taken effect, and `restartRequired` is where that pending change is reported instead.
### Permission composition still spans the whole discovered inventory
Unchanged, deliberately. ADR 0015 fixed the startup order so that discovery and permission
composition complete **before any pool, Better Auth instance, or module entrypoint exists** — a
composition failure therefore leaves nothing to close. Filtering permissions by enablement would
require reading the database first and would invert that order for no safety gain.
A disabled module's permissions therefore stay registered and grantable. Nothing enforces them,
because it mounts no routes, and grants survive an enable/disable cycle intact — which is what an
administrator would expect, and lets a permission be granted ahead of enabling a module.
### Migration participation is deployment, not runtime state
```text
deployed schema/migration ownership ≠ runtime enabled state
```
Migration discovery, planning, and application continue to read the **deployed** inventory,
regardless of enablement. They are offline, Core-owned, explicitly operator-invoked commands and
must not depend on runtime configuration. A disabled module's tables must still be migrated, or
enabling it later would require a schema change in the middle of a restart. Nothing about the
planner, destructive-change approval, locking, checksums, or transactional guarantees changed.
### The URL is a sibling of the module namespace, not a child
```text
GET /api/v1/module-lifecycle
PUT /api/v1/module-lifecycle/<module-id>/enabled { "enabled": boolean }
```
The obvious `/api/v1/modules` and `/api/v1/modules/:id/enabled` are **wrong**, for the exact reason
ADR 0025 gave for `/api/v1/module-ui`. Core's route grammar accepts `enabled` as an ordinary static
segment, so a module named `announcements` may legitimately register `PUT /enabled` — mounted at
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.
`PUT` against a module id not in the boot inventory is a `404`. Unlike an inert Dashboard placement,
an enablement record is operational configuration; accepting one for an arbitrary identifier would
make this an unbounded write surface for a delegable permission. The CLI enforces the identical rule
by running the same manifest-only discovery. *Existing* records for undeployed modules are still
retained and ignored.
The body is closed and structured — any key beyond `enabled` invalidates the whole request rather
than being dropped — matching ADR 0027's mutation contract exactly. There is no configuration blob.
### One restricted permission, gating read as well as write
`core.modules.lifecycle.manage`, baseline `restricted`. The read is not a milder version of the
write: this resource 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.
It is a **platform** permission, never a module's, and holding it confers no authority inside any
module. Mutations additionally require an exact trusted `Origin` and a bounded body before
authorization work begins, like every other Core mutation.
Neither response carries a filesystem path, package name, manifest path, entrypoint, caught error,
or stack. The only identifiers are module ids, capability ids, and fixed reason codes from closed
sets — all values the platform itself produced and validated.
### Startup fails closed when the configuration cannot be read
`startApi` reads enablement before importing any module entrypoint. If that read fails — an
unmigrated or unreachable database — startup **refuses**, with Core's sanitized
`MODULE_LIFECYCLE_READ_FAILED`. It cannot know what an administrator decided, and starting a module
they disabled is worse than not starting at all. This is a behavior change: previously the API would
bind a listener against an unmigrated database and fail every request instead. Nothing is created or
repaired on that path.
### Diagnostics answer "why is this module unavailable?" without the logs
`pnpm doctor` gains one check, `modules.lifecycle`, in its existing Modules section — not a second
diagnostics framework. It is skipped when discovery failed or `DATABASE_URL` is unset (configuration
lives in PostgreSQL, and reporting a guessed default is worse than skipping). A **disabled** module
is a `pass` with a detail line: it is a decision, not a problem. A **blocked** module is a
`warning`: an enabled module that cannot run is the anomaly worth surfacing. Every rendered value is
re-validated before interpolation, matching the file's existing `safeModuleDetails` discipline.
### Trust is unchanged
Installed modules remain trusted compiled code running in the API process with its PostgreSQL
authority. Enablement is an **operational** control, not a security boundary: a module's code is on
disk and could be started by any operator with filesystem access. Nothing here sandboxes, isolates,
or restricts a module that does run, and nothing here should be described as if it did.
## Validated against a real product
The Announcements repository (`The_River/Announcements`, commit `9aab486`) was walked against this
model as a reference consumer. It was not modified, and nothing about it entered Core.
It is a small Express + Socket.IO server that polls an S3-compatible bucket listing every 30
seconds, advances a slide index on a 7-second timer, broadcasts state to a full-screen React
client, and toggles a blank-screen `mode` from an MQTT topic. It has no database and no
authentication.
Three things it does made the two-axis model earn its keep.
**Its storage is unreachable regularly, and that is not a lifecycle failure.** `loadSlides()`
already catches its own fetch failure, logs, and retries on the next tick. Under this model the
module stays `running`, because its `start` hook completed; "slides could not be loaded right now"
is the module's own health, surfaced through its own UI. Had `running` been defined as "everything
it needs is reachable", a router reboot would have looked like a lifecycle event.
**Its `mode: on/off` toggle is emphatically not enablement.** It blanks the screen, is flipped
frequently by whoever is running a service, and must take effect instantly. Module enablement is an
administrative deployment decision that takes effect at the next restart. Mapping one onto the other
would have pushed a per-service operational toggle into Core's lifecycle and made it restart-based —
which is precisely the kind of conflation the separate configuration and runtime axes exist to
prevent. (Per the platform direction, that control belongs to a future Companion module speaking the
module API; Ekklesia Core will not use MQTT, and this ADR designs nothing around it.)
**Its long-lived timers are what `stop` is for.** Neither `setInterval` is ever cleared today. As a
module it must clear both in a `stop` hook that is safe after a partially completed `start` (ADR
0020) — a migration task, not a Core gap, and comfortably inside the platform's 10-second hook
deadline.
The rest of the walkthrough came out as designed: disabled means no routes, no UI, no navigation
entry, no timers, and no hooks; its migrations would still be discovered and applied; a future
required dependency being disabled would block it with a named reason; and nothing in its feature
code would ever ask whether it is enabled, so the same implementation stays deployable standalone.
## Alternatives
- **Implementing the nine `MODULE_LIFECYCLE_STAGES` names.** Rejected: most name a transition rather
than a state, several are unobservable, and shipping them would have repeated the original mistake
of publishing a vocabulary nothing produces.
- **A `failed` runtime state now.** Rejected while startup is all-or-nothing: no live process could
ever report it.
- **Per-module failure isolation in this pass.** Deferred deliberately. It is a genuine improvement
and a genuinely larger decision; four accepted ADRs depend on the current guarantee.
- **Default-disabled for a newly discovered module.** Rejected: it breaks every earlier phase's
behavior, requires an administrative step with no UI to perform it in, and buys no safety the
trusted-code model does not already concede.
- **A `disabled` column default.** Rejected: it would materialize a decision nobody made and destroy
the distinction between "at the default" and "explicitly set".
- **Persisting startup failures.** Rejected: it turns a transient runtime fact into administrative
configuration an operator did not write and cannot distinguish from their own.
- **Filtering permission composition by enablement.** Rejected: it inverts ADR 0015's deliberate
startup order for no gain, and would make grants vanish across an enable/disable cycle.
- **Filtering migration discovery by enablement.** Rejected: schema ownership is a deployment fact,
and a disabled module's tables must already exist when it is enabled.
- **Enable/disable at `/api/v1/modules/...`.** Rejected: it collides with the module route namespace,
where registration order would decide which handler answers.
- **A separate read permission.** Rejected: the read exposes what the write controls.
- **Hot enable/disable without a restart.** Rejected: ESM evaluation cannot be undone, and the
platform must not claim a module stopped when its code is still resident.
- **A generalized runtime health-probe SDK.** Rejected for this pass: "running" is deliberately a
narrow claim, and a health contract is a design of its own.
- **A module installer, package registry, or install-source columns.** Explicitly out of scope; the
schema deliberately has nowhere to put them.
## Consequences
Ekklesia has an operational lifecycle: an administrator can turn a module off, that decision
survives restarts, its dependents behave deterministically, and every surface that exposes a module
— routes, artifacts, inventory, navigation — follows the one eligible set rather than a filter
somewhere downstream.
`createApiApp` takes a required `moduleLifecycle` option, matching the pattern `dashboard`,
`moduleUi`, and `moduleRoutes` already follow. Core gains a fourth permission, which moved several
existing assertions. `startApi` now reads the database before starting modules, so it refuses to
start against an unmigrated one — stricter than before, and stated as such.
Two costs worth naming. Enablement is platform-global and will need a scope column when an
organization model exists, exactly like the Dashboard tables. And a broken module still takes the
whole platform down; the CLI makes that recoverable without a database edit, but it does not make it
pleasant, and closing it properly is the isolated-startup pass.
## Deferred
Not implemented or promised here: per-module startup failure isolation and a `failed` runtime state;
a module installer, marketplace, package registry, download/update/signature infrastructure, or
install-source metadata; hot loading, hot unloading, and filesystem watchers; a module-management
Dashboard UI or any administrator-facing affordance beyond the API and the CLI; automatic migration
application, and any coupling between enablement and migrations; a runtime health-probe or readiness
contract; module-owned configuration and secrets; per-module or per-organization scoping of
enablement; a "reset to default" operation that deletes an explicit record; and process isolation or
sandboxing of any kind.
The permission-id typing gap carried since ADR 0022 is unchanged: `permissions.has` takes a plain
string, and the wire arrays are `string[]`.
+1
View File
@@ -36,3 +36,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0025: Trusted module UI asset publication](0025-trusted-module-ui-asset-publication.md) — Accepted
- [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted
- [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted
- [0028: Module operational lifecycle — enablement, eligibility, and what "running" means](0028-module-operational-lifecycle.md) — Accepted
@@ -308,3 +308,24 @@ Target architecture not yet implemented includes production approval-provider
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
scheduled by this document.
Phase 7A pass 1 adds the platform's operational module lifecycle
([ADR 0028](decisions/0028-module-operational-lifecycle.md)). Core now has two clearly separated
axes: **configuration** (`enabled`/`disabled`, persistent, administrator-owned, stored in one Core
table) and **runtime** (`running`/`disabled`/`blocked`, observed per boot). `resolveModuleEligibility`
is a pure function of the discovered inventory plus that configuration: a module with no stored
record is enabled by default, an explicit decision always wins, a disabled module transitively blocks
every module that *requires* it (module and capability edges alike, with a structured reason naming
the provider), and an optional dependency never blocks anything. `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 rather than a filter downstream.
Startup remains all-or-nothing across the eligible set — ADR 0015 and ADR 0020's rollback guarantee
is unchanged, and there is deliberately no `failed` runtime state, because no live process could
report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable` as the
out-of-band path. 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 discovery still spans the deployed inventory regardless of enablement, and permission
composition still spans the whole discovered set. `running` means a module's `start` hook completed
and nothing more; there is no runtime health-probe contract. Modules remain trusted compiled code —
disabling one is an operational control, never a sandbox. No installer exists.
+149 -9
View File
@@ -80,6 +80,8 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
deterministic `404 {"code":"NOT_FOUND"}`.
@@ -385,12 +387,16 @@ account.
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
pool (see [Module database access](#module-database-access)).
4. Construct the Core identity runtime, authorization store, and evaluator.
5. Start the module runtime from that exact same discovered inventory
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the
services object every module's `start`/`stop` hook receives.
6. Create the Hono application, mounting the started module runtime's published routes and passing
the identical services object to every module HTTP handler.
7. Bind the HTTP server.
5. Read the stored module enablement configuration and resolve, over that exact same discovered
inventory, which modules are **eligible** to run (`resolveModuleEligibility`,
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle)).
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
never rediscovered for this step — passing it the services object every module's `start`/`stop`
hook receives.
7. Create the Hono application, mounting the started module runtime's published routes, publishing
UI for that same eligible set, and passing the identical services object to every module HTTP
handler.
8. Bind the 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
@@ -404,9 +410,143 @@ for the permission-composition detail, and [Module development](module-developme
the manifest shape that produces these declarations.
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
successful production startup does import each module's compiled server entrypoint and run its
`register`/`start` hooks — this is the one place in the platform that deliberately executes module
code, and only after every other startup step already listed has succeeded.
successful production startup does import each **eligible** module's compiled server entrypoint and
run its `register`/`start` hooks — this is the one place in the platform that deliberately executes
module code, and only after every other startup step already listed has succeeded. A module that is
disabled, or blocked by a disabled required dependency, is never imported at all.
Step 5 makes startup depend on a reachable, migrated database: if the enablement configuration
cannot be read, `startApi` **refuses to start** 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. Nothing is created or
repaired on that path.
## Module lifecycle
```text
GET /api/v1/module-lifecycle
PUT /api/v1/module-lifecycle/<module-id>/enabled { "enabled": boolean }
```
The platform's operational view of its installed modules
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): which are enabled, what
this process actually did with each of them, and whether the two currently disagree.
It is a **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` is
(ADR 0025): `enabled` is a legal module route segment, so a module named `announcements` could
otherwise register `PUT /enabled` and collide with the platform at exactly
`/api/v1/modules/announcements/enabled`.
```json
{
"modules": [
{
"id": "storage",
"version": "1.0.0",
"configured": { "enabled": false, "explicit": true },
"runtime": { "state": "disabled" },
"restartRequired": false
},
{
"id": "announcements",
"version": "1.0.0",
"configured": { "enabled": true, "explicit": false },
"runtime": {
"state": "blocked",
"reason": "required_dependency_disabled",
"dependency": { "kind": "module", "moduleId": "storage" }
},
"restartRequired": false
}
]
}
```
### Two axes, deliberately not one status word
`configured` is the decision that survives restarts. `explicit: false` means nobody has decided and
the platform default (**enabled**) applies. `runtime` is what *this process* did:
| state | meaning |
| ---------- | ----------------------------------------------------------------------------- |
| `running` | the module was eligible and its `start` hook completed in this process |
| `disabled` | configuration says it must not run, so it was never imported |
| `blocked` | it should run, but a **required** dependency cannot — see `reason`/`dependency` |
`running` says the `start` hook completed. It says **nothing** about whether every external resource
that module uses is reachable right now; Ekklesia has no runtime health-probe contract, and this
state must not be read as one. There is deliberately no `failed` state: module startup is
all-or-nothing, so a module that fails to start aborts API startup and no live process could ever
report it.
`reason` is one of `required_dependency_disabled` (the named dependency was turned off) or
`required_dependency_blocked` (the named dependency is itself blocked). Following the chain always
terminates at a `..._disabled` entry. An **optional** dependency never blocks anything.
### Changing enablement requires a restart, and the response says so
There is no hot module installation or removal, so `PUT` writes a decision and changes nothing about
the running process:
```json
{ "id": "announcements", "version": "1.0.0",
"configured": { "enabled": false, "explicit": true },
"runtime": { "state": "running" },
"restartRequired": true }
```
Reporting `runtime: disabled` there would be a lie about a process still serving that module's
routes. `restartRequired` compares what the current configuration *would* start against what the
runtime reports it *did* start, so it is symmetric and transitive: disabling a module also marks its
dependents, because they really will stop on the next boot.
A module whose enablement changed but whose process has not restarted stays fully exposed — routes,
artifact, inventory, and navigation. It really is still mounted; hiding it now would break a viewer
mid-session for a change that has not taken effect.
### What a disabled module does not have
Never imported ⇒ no `register`, `start`, or `stop` hook runs; no HTTP routes are mounted (an
unmounted path gets the platform's ordinary `404`, with no per-request enablement check anywhere);
no capabilities are registered; `GET /api/v1/module-ui/<id>/<version>` returns the same `404` an
uninstalled module does; and it is absent from both `GET /api/v1/module-ui` and
`GET /api/v1/dashboard/navigation`.
Its **migrations are still discovered and applied**. Deployed schema ownership is not runtime
enabled state: a disabled module's tables must already exist when it is enabled, or enabling it would
require a schema change in the middle of a restart. Its **permissions also stay registered**, so
grants survive an enable/disable cycle and can be issued ahead of enabling a module; nothing enforces
them, because it mounts no routes.
### Authority
Both verbs require `core.modules.lifecycle.manage` (baseline `restricted`). The read is gated too,
because this resource 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
covers only what is mountable. Mutations additionally require an exact trusted `Origin` and a bounded
body (`CORE_MUTATION_MAX_BODY_BYTES`) before authorization work begins.
The body is closed: any key beyond `enabled` invalidates the whole request rather than being dropped.
A `PUT` naming a module this process did not discover is `404 {"code":"MODULE_NOT_INSTALLED"}` —
enablement is operational configuration, not an unbounded key-value store. Existing records for
modules that are no longer deployed are retained and ignored, so redeploying restores the decision.
No response carries a filesystem path, package name, manifest path, entrypoint, caught error, or
stack.
### Recovering from a module that breaks startup
Startup is all-or-nothing, so a module whose `start` hook throws takes the API down — including this
API. The out-of-band path is a Core CLI, in the same shape as `pnpm authorization:bootstrap-owner`
(explicit, never HTTP-exposed):
```bash
pnpm modules:disable -- --module announcements
pnpm modules:enable -- --module announcements
```
It reads `DATABASE_URL`, runs the same manifest-only discovery the API does (so it refuses a module
this deployment cannot see), writes the decision, and starts nothing. Restart the API to apply it.
## Module HTTP routes
+23 -6
View File
@@ -100,14 +100,31 @@ grant lookup — there is no wildcard, prefix, or namespace-inheritance matching
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
same declarations exported separately in deeply immutable form, for the module composition helper
below to build on without reconstructing Core's own list:
`CORE_PERMISSION_REGISTRY` is Core's own registry and remains what Core-only code and existing tests
use directly. `CORE_PERMISSION_DECLARATIONS` is the same declarations exported separately in deeply
immutable form, for the module composition helper below to build on without reconstructing Core's own
list:
| Permission | Baseline | Description |
| --- | --- | --- |
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
| `core.modules.ui.read` | `authenticated` | List the installed module UIs a browser host may mount. |
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
reading and changing module enablement
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): the read is not a
milder version of the write, because the lifecycle view is the only place a *disabled* module, a
*blocked* module, and the required-dependency edges between them are visible at all, whereas
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
inside any module.
Enablement never changes how a permission is enforced. A **disabled** module's permissions stay
registered and grantable — composition spans the whole discovered inventory, not the eligible subset
— so a grant survives an enable/disable cycle and can be issued ahead of enabling a module. Nothing
enforces those permissions while the module is disabled, because it mounts no routes at all.
## Module permission composition
@@ -206,8 +223,8 @@ generic explicit-grant HTTP API, enforcement middleware, and now its own mounted
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
it never re-discovers modules itself. A successful composition reports a pass with a safe
declaration count (for example `1 permission registered` with zero installed modules, since Core's
own declaration is always present); zero installed modules still composes successfully. If module
declaration count (for example `4 permissions registered` with zero installed modules, since Core's
own declarations are always present); zero installed modules still composes successfully. If module
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
pattern; if discovery succeeded but composition failed, schema and migration checks still run
normally — they are independent diagnostics that do not depend on the permission registry. A
+27
View File
@@ -168,6 +168,33 @@ what a request may do. Reading navigation requires the same `core.modules.ui.rea
inventory requires, so it discloses the same installed-module enumeration and nothing more; it
carries no filesystem path, package root, or manifest internal.
### Disabling a module is an operational control, not a security boundary
A module can be turned off, and that decision survives restarts
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): a disabled module is
never imported, so its code never runs, it mounts no routes, registers no capabilities, and publishes
no UI. That is a real and useful operational property — it is how an operator recovers from a module
that misbehaves or breaks startup.
**It is not containment.** The module's compiled code is still on disk, still trusted, and still
carries every authority listed above the moment anything starts it. Enablement is a row in
`ekklesia_core_module_state`; it constrains the platform's own composition, not the module. Anyone
who can put a module package on this filesystem, or who holds the database or operating-system access
to change that row, decides what runs. Nothing about disabling a module sandboxes, isolates, or
reduces the privileges of a module that *is* enabled.
Changing enablement requires `core.modules.lifecycle.manage` (baseline `restricted`) and — for the
mutation — an exact trusted `Origin` and a bounded body. Reading is gated by the same permission
because the lifecycle view is the only place a disabled module, a blocked module, and the
required-dependency edges between them are visible; `core.modules.ui.read` deliberately exposes only
the mountable set. Neither response carries a filesystem path, package name, manifest path,
entrypoint, caught error, or stack — only module ids, capability ids, and fixed reason codes.
Holding the lifecycle permission grants no authority *inside* any module: every module route stays
enforced against that module's own declared permission, and a disabled module's permissions remain
registered and grantable precisely so that an enable/disable cycle changes nothing about who holds
what.
### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds
+22
View File
@@ -152,6 +152,28 @@ key material is ever committed to this repository** — and the disposable conta
`afterAll` even when an assertion fails. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
A fifth dedicated suite (`core/tests/module-lifecycle-store.integration.ts`, included in the same
`pnpm test:migrations:integration` run) proves the module enablement store against real PostgreSQL:
that migration `0004` really creates `ekklesia_core_module_state` with exactly the four columns and
the one declared CHECK constraint the schema states — and, load-bearingly, that `enabled` has **no
column default**, so the platform's default for a newly discovered module is genuinely the absence of
a row rather than a materialized decision nobody made; that a decision survives a completely new
connection; that the upsert is idempotent on `module_id` and bumps `updated_at` in place rather than
inserting a second row; that a malformed identifier is rejected before it reaches PostgreSQL and the
empty-string case is refused by the database too; and that a record for a module which is no longer
deployed is retained and simply ignored by resolution, so redeploying restores the decision.
The API suite additionally drives the whole lifecycle end to end against a real database and real
fixture modules: a disabled module's compiled entrypoint is never imported (proven by its
`register`/`start`/`stop` markers staying empty), its routes return the platform's ordinary `404`,
its UI artifact and inventory and navigation entries are all absent, and its neighbours run normally;
a module blocked by a disabled required dependency is explained over HTTP with a structured reason
naming that dependency and no filesystem path anywhere in the payload; a change made through
`PUT /api/v1/module-lifecycle/<id>/enabled` reports `restartRequired: true` while the module
demonstrably keeps serving, and is applied only by the next start; and a disabled module's migrations
are still discovered, because deployed schema ownership is not runtime enabled state. See
[ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md).
### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
+58 -4
View File
@@ -962,6 +962,13 @@ PostgreSQL, including DDL rollback, advisory-lock contention, history behavior,
sanitized failures. It remains outside the Docker-free `pnpm verify` gate; see the
[testing guide](../development/testing.md).
`pnpm modules:enable -- --module <id>` and `pnpm modules:disable -- --module <id>` record the
platform's enablement decision for one installed module. They read `DATABASE_URL`, run the same
manifest-only discovery every other command does (so a module this deployment cannot see is refused),
write one row, and start nothing. The change takes effect at the next `pnpm api:start`. These exist
so a module that breaks startup can be turned off without the API — the HTTP lifecycle API is served
by the process such a module takes down.
`pnpm doctor` builds first and presents the same production discovery results as a concise,
read-only dashboard. It lists discovered module IDs and versions in dependency order, reports
schema and migration counts, and shows safe migration identities with truncated checksums. Schema
@@ -969,13 +976,60 @@ discovery imports the compiled schema-definition entrypoint, so its no-I/O and n
contract still applies. Doctor does not import or start module server entrypoints, apply migrations,
or exercise runtime hooks; lifecycle behavior remains covered by automated tests. PostgreSQL
planning is skipped when `DATABASE_URL` is absent, and object storage is skipped until Core has a
production storage integration. A skip or pending migration is visible as `READY WITH WARNINGS`,
production storage integration. Its `modules.lifecycle` check reports which installed modules are
enabled and which cannot run — also skipped without `DATABASE_URL`, since enablement is stored in
PostgreSQL. A deliberately disabled module is a pass with a detail line; an *enabled* module that
cannot run (blocked by a disabled required dependency) is a warning, because that is the anomaly. A skip or pending migration is visible as `READY WITH WARNINGS`,
not misreported as a successful check.
### Whether your module runs at all is your host's decision
Between discovery and startup, the platform consults its own **enablement configuration** and
resolves which discovered modules are eligible to run
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)):
```text
present on disk → discovered → enabled → eligible → running
```
Three things matter to you as a module author, and only three.
**You never see it.** Nothing in your manifest declares enablement, nothing in your `register`,
`start`, or `stop` context exposes it, and your module is never told it is enabled. Your host either
loads and starts you, or it does not. **Do not write feature code that asks whether it is enabled in
Ekklesia** — that is what keeps the same implementation runnable under a future standalone host,
whose loading decisions are its own.
**Disabled means nothing of yours runs.** A disabled module's compiled server entrypoint is never
imported, so no hook fires, no route is mounted, no capability is registered, and its UI artifact is
not published or listed. There is no per-request "is this enabled?" check to code against, because
nothing is mounted.
**A module with no stored decision is enabled.** Deploying your module is sufficient to run it; no
initialization step, database edit, or migration is needed to make it appear.
Your **required** dependencies are real: if a module (or the provider of a capability) you declare
under `dependencies.modules.required` / `dependencies.capabilities.required` is disabled, you are
blocked and will not start, with a structured reason naming it. Your **optional** dependencies are
not: if an optional provider is disabled, you start normally and
`context.capabilities.resolve(...)` returns `undefined` — exactly as it does when that provider was
never installed. Declare optional if you can degrade; declare required if you genuinely cannot run.
Your migrations are discovered and applied regardless of enablement — deployed schema ownership is a
deployment fact, not runtime state — so enabling your module later never needs a schema change in the
middle of a restart.
Finally, "running" means your `start` hook completed. It does **not** mean every external resource
you use is reachable. If your object storage or an upstream service is down after you started, that
is your module's own health to surface through your own API and UI; the platform has no health-probe
contract and will keep reporting you as running.
### Discovery and startup, step by step
Core completes discovery before importing any server entrypoint. It then loads and verifies every
runtime definition before running hooks, registers all modules dependency-first into an unpublished
staged capability registry and an unpublished staged HTTP route list side by side, and starts them in
the same order. Neither the capability registry nor the route list is published — visible on
eligible module's runtime definition before running hooks, registers all of them dependency-first
into an unpublished staged capability registry and an unpublished staged HTTP route list side by
side, and starts them in the same order. Neither the capability registry nor the route list is published — visible on
`StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is
reverse-order. If a later module fails to start, Core stops already-started modules in reverse order
and reports any cleanup failures without leaking caught exceptions or machine paths. A failing
+2
View File
@@ -29,6 +29,8 @@
"migrations:apply": "turbo run build && node --env-file-if-exists=.env core/dist/cli/apply-migrations.js",
"migrations:plan": "turbo run build && node --env-file-if-exists=.env core/dist/cli/plan-migrations.js",
"migrations:validate": "turbo run build && node core/dist/cli/validate-migrations.js",
"modules:disable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --disable",
"modules:enable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --enable",
"modules:validate": "turbo run build && node core/dist/cli/validate-modules.js",
"test": "turbo run test test:root",
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
+92 -12
View File
@@ -1,17 +1,97 @@
import { z } from "zod";
import { moduleIdSchema } from "./identifiers.js";
export const MODULE_LIFECYCLE_STAGES = [
"discovered",
"validated",
"configured",
"registered",
"migrated",
"initialized",
"started",
"stopped",
"failed",
/**
* The two axes of a module's operational state, kept deliberately separate because they answer
* different questions, change at different times, and have different owners.
*
* **Configuration** is what an administrator decided: is this module supposed to run? It is
* persistent, survives restarts, and is platform-owned — a module never declares, reads, or
* influences it (ADR 0028). **Runtime** is what this process actually did with the module during
* *this* boot. Because Ekklesia has no hot loading or unloading, the two can legitimately
* disagree between a configuration change and the next restart, and the read model reports both
* rather than pretending a change took effect immediately.
*
* This replaces the nine-name `MODULE_LIFECYCLE_STAGES` vocabulary that shipped with ADR 0006 and
* which nothing ever implemented — ADR 0025 and ADR 0026 both recorded it as "vocabulary with no
* runtime state machine behind it". Every name below is produced by real code and observable
* through `GET /api/v1/module-lifecycle`; a stage that could never be observed would be a
* contract that lies.
*/
export const MODULE_RUNTIME_STATES = ["running", "disabled", "blocked"] as const;
export const moduleRuntimeStateSchema = z.enum(MODULE_RUNTIME_STATES);
/**
* What this process did with one module.
*
* - `running` — the module was enabled and eligible, and the module runtime loaded, registered,
* and started it. It says the module's `start` hook completed; it says **nothing** about whether
* every external resource that module uses is reachable now. Ekklesia has no runtime
* health-probe contract, and this state must never be read as one.
* - `disabled` — configuration says it must not run, so the platform never imported its server
* entrypoint, never invoked a lifecycle hook, never registered a route, and never published its
* UI.
* - `blocked` — configuration says it should run, but a *required* dependency cannot, so starting
* it would violate the dependency contract. See `ModuleBlockedReason`.
*
* There is deliberately no `failed`. Module startup remains all-or-nothing (ADR 0015, ADR 0020,
* preserved by ADR 0028): a module that fails to load, register, or start aborts API startup
* entirely, so no live process can ever report it. Adding the name before the behavior exists would
* publish a state nothing can produce.
*/
export type ModuleRuntimeState = z.infer<typeof moduleRuntimeStateSchema>;
/**
* Why an enabled module is not running. A structured reason plus the exact dependency it names,
* rather than one state name per case — four states would encode the same two facts twice.
*
* `required_dependency_disabled` means the dependency an administrator turned off is the direct
* cause. `required_dependency_blocked` means the named dependency is itself blocked, so the cause
* is transitive; following the chain always terminates at a `..._disabled` entry, because a
* dependency that is merely absent or incompatible is rejected by discovery long before
* eligibility is resolved.
*/
export const MODULE_BLOCKED_REASONS = [
"required_dependency_disabled",
"required_dependency_blocked",
] as const;
export const moduleLifecycleStageSchema = z.enum(MODULE_LIFECYCLE_STAGES);
export const moduleBlockedReasonSchema = z.enum(MODULE_BLOCKED_REASONS);
export type ModuleLifecycleStage = z.infer<typeof moduleLifecycleStageSchema>;
export type ModuleBlockedReason = z.infer<typeof moduleBlockedReasonSchema>;
/** Both kinds of edge a module's manifest can declare as required: another module, or a capability
* some other module provides. Both block identically — a required capability is exactly as
* required as a required module — and both name the *providing module* so an operator has
* something they can act on. */
export const MODULE_DEPENDENCY_KINDS = ["module", "capability"] as const;
export const moduleDependencyKindSchema = z.enum(MODULE_DEPENDENCY_KINDS);
export type ModuleDependencyKind = z.infer<typeof moduleDependencyKindSchema>;
/**
* The dependency that caused a block: which module is unavailable, and — for a capability edge
* — which capability was being depended on. `moduleId` is always the module an administrator
* would have to enable; `capabilityId` is present only when the edge came from
* `dependencies.capabilities.required`.
*/
export interface ModuleBlockingDependency {
readonly kind: ModuleDependencyKind;
readonly moduleId: string;
readonly capabilityId?: string;
}
/** One persisted enablement decision. Deliberately the smallest possible record: an identity and a
* boolean. There is no install source, download URL, update channel, signature, marketplace
* metadata, or free-form JSON — none of those exist in this platform, and a column for one would
* invite inventing it (ADR 0028). */
export const moduleEnablementRecordSchema = z
.object({
moduleId: moduleIdSchema,
enabled: z.boolean(),
})
.strict();
export type ModuleEnablementRecord = z.infer<typeof moduleEnablementRecordSchema>;
@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
isVersionCompatible,
MODULE_LIFECYCLE_STAGES,
MODULE_BLOCKED_REASONS,
MODULE_DEPENDENCY_KINDS,
MODULE_MANIFEST_VERSION,
MODULE_RUNTIME_STATES,
packageRelativePathSchema,
parseModuleManifest,
parsePermissionId,
@@ -341,9 +343,18 @@ describe("module manifest contract", () => {
expect(isVersionCompatible("not-a-version", "*")).toBe(false);
});
it("publishes the complete lifecycle vocabulary", () => {
expect(MODULE_LIFECYCLE_STAGES).toContain("discovered");
expect(MODULE_LIFECYCLE_STAGES).toContain("failed");
it("publishes exactly the operational lifecycle vocabulary the platform implements", () => {
// Every name here is produced by real code and observable through the lifecycle read model
// (ADR 0028). The nine-stage `MODULE_LIFECYCLE_STAGES` vocabulary this replaced named states
// nothing ever entered, including a `failed` state that startup atomicity makes unobservable —
// asserting the exact sets is what keeps this contract from drifting back into aspiration.
expect(MODULE_RUNTIME_STATES).toEqual(["running", "disabled", "blocked"]);
expect(MODULE_RUNTIME_STATES).not.toContain("failed");
expect(MODULE_BLOCKED_REASONS).toEqual([
"required_dependency_disabled",
"required_dependency_blocked",
]);
expect(MODULE_DEPENDENCY_KINDS).toEqual(["module", "capability"]);
});
describe("module-owned UI entrypoint (ADR 0021)", () => {
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
"core/tests/authorization-store.integration.ts",
"core/tests/authorization-bootstrap.integration.ts",
"core/tests/dashboard-store.integration.ts",
"core/tests/module-lifecycle-store.integration.ts",
"apps/api/tests/api-http.integration.ts",
"apps/api/tests/module-database.integration.ts",
],