feat: add the minimal operational module lifecycle
Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
+149
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user