feat: add the minimal operational module lifecycle #27
@@ -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
|
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
|
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,
|
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
|
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
|
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
||||||
pass 5 closure below and
|
pass 5 closure below and
|
||||||
@@ -386,9 +388,70 @@ restores its position; deleting a group ungroups its modules through the databas
|
|||||||
application cleanup. See
|
application cleanup. See
|
||||||
[ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md).
|
[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
|
**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
|
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
||||||
routing. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
|
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
|
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do
|
||||||
not exist.
|
not exist.
|
||||||
|
|
||||||
@@ -446,16 +509,20 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
1 permission registered
|
4 permissions registered
|
||||||
|
○ Module lifecycle skipped
|
||||||
|
DATABASE_URL is not configured.
|
||||||
|
|
||||||
Data definitions
|
Data definitions
|
||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
0 modules · 0 tables
|
0 modules · 0 tables
|
||||||
✓ Migrations validated
|
✓ Migrations validated
|
||||||
3 migrations · checksums valid
|
5 migrations · checksums valid
|
||||||
core:0000_identity_core_tables · f8f2086ef06f
|
core:0000_identity_core_tables · f8f2086ef06f
|
||||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||||
core:0002_authorization_integrity · 634294731180
|
core:0002_authorization_integrity · 634294731180
|
||||||
|
core:0003_dashboard_navigation_tables · 2cde27807aa4
|
||||||
|
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
✓ Database TLS verified
|
✓ Database TLS verified
|
||||||
@@ -469,6 +536,8 @@ Identity
|
|||||||
○ Identity configuration skipped
|
○ Identity configuration skipped
|
||||||
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
|
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.
|
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
|
Object storage
|
||||||
○ Check unavailable
|
○ 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
|
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
|
||||||
workflow.
|
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
|
`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
|
(`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
|
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
|
||||||
|
|||||||
+35
-1
@@ -10,7 +10,11 @@ import type {
|
|||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
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 { Hono } from "hono";
|
||||||
import type { Context, MiddlewareHandler } from "hono";
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
import { cors } from "hono/cors";
|
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 { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-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 { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||||
import { createModuleUiRoutes } from "./module-ui-routes.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
|
* 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`. */
|
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
||||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
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
|
/** 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
|
* 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
|
* 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, {
|
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
||||||
evaluator: options.authorization.evaluator,
|
evaluator: options.authorization.evaluator,
|
||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ import {
|
|||||||
} from "@ekklesia/core/dashboard";
|
} from "@ekklesia/core/dashboard";
|
||||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
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 {
|
export interface ApiDatabaseHandle {
|
||||||
readonly authorization: AuthorizationDrizzleDatabase;
|
readonly authorization: AuthorizationDrizzleDatabase;
|
||||||
readonly dashboard: DashboardDrizzleDatabase;
|
readonly dashboard: DashboardDrizzleDatabase;
|
||||||
readonly identity: IdentityDrizzleDatabase;
|
readonly identity: IdentityDrizzleDatabase;
|
||||||
readonly module: ModuleDatabase;
|
readonly module: ModuleDatabase;
|
||||||
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
/** 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. */
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||||
readonly close: () => Promise<void>;
|
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
|
* 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
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by three
|
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
||||||
* independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||||
* `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()`
|
* `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`/
|
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||||
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||||
* for callers that only need one domain.
|
* for callers that only need one domain.
|
||||||
@@ -43,5 +49,6 @@ export function createApiDatabase(
|
|||||||
dashboard: createDashboardDrizzleView(pool),
|
dashboard: createDashboardDrizzleView(pool),
|
||||||
identity: createIdentityDrizzleView(pool),
|
identity: createIdentityDrizzleView(pool),
|
||||||
module: createModuleDatabaseView(pool),
|
module: createModuleDatabaseView(pool),
|
||||||
|
moduleLifecycle: createModuleLifecycleView(pool),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import type {
|
||||||
|
ModuleBlockedReason,
|
||||||
|
ModuleBlockingDependency,
|
||||||
|
ModuleRuntimeState,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
type ModuleLifecycleSnapshot,
|
||||||
|
type ModuleLifecycleStore,
|
||||||
|
ModuleLifecycleStoreError,
|
||||||
|
type ModuleLifecycleView,
|
||||||
|
resolveModuleLifecycleView,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
requirePermission,
|
||||||
|
requireTrustedOrigin,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **sibling** of `/api/v1/modules/`, never a child of it — the same rule ADR 0025 established for
|
||||||
|
* `/api/v1/module-ui`, and for the same concrete reason. Every module HTTP route is mounted under
|
||||||
|
* `/api/v1/modules/<module-id>/<local path>`, and `moduleRoutePathSchema` accepts `enabled` as an
|
||||||
|
* ordinary static segment, so a module named `announcements` registering `PUT /enabled` would land
|
||||||
|
* on exactly `/api/v1/modules/announcements/enabled`. Publishing the platform's own lifecycle
|
||||||
|
* mutation there would put Core and an installed module in one URL namespace with registration
|
||||||
|
* order deciding which one answers — for a route that changes whether modules run. A sibling prefix
|
||||||
|
* cannot collide.
|
||||||
|
*/
|
||||||
|
export const MODULE_LIFECYCLE_PATH = "/api/v1/module-lifecycle";
|
||||||
|
const MODULE_LIFECYCLE_ENABLED_PATH = `${MODULE_LIFECYCLE_PATH}/:moduleId/enabled`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module as an operator sees it. Two clearly separated axes, deliberately never merged into a
|
||||||
|
* single status word.
|
||||||
|
*
|
||||||
|
* `configured` is the decision that survives restarts. `runtime` is what *this process* did. They
|
||||||
|
* can legitimately disagree — Ekklesia does not hot-load or hot-unload modules — and
|
||||||
|
* `restartRequired` names that disagreement instead of hiding it behind a status that would be a
|
||||||
|
* lie in one direction or the other.
|
||||||
|
*
|
||||||
|
* Nothing here carries a filesystem path, a package name, a manifest path, a directory, an
|
||||||
|
* entrypoint, a caught error, or a stack. The only identifiers are module ids, capability ids, and
|
||||||
|
* a fixed reason code from a closed set — all of them values the platform itself produced and
|
||||||
|
* validated.
|
||||||
|
*/
|
||||||
|
export interface ModuleLifecycleModuleView {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly configured: {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
/** `false` means nobody has decided and the platform default (enabled) applies. */
|
||||||
|
readonly explicit: boolean;
|
||||||
|
};
|
||||||
|
readonly runtime: {
|
||||||
|
readonly state: ModuleRuntimeState;
|
||||||
|
readonly reason?: ModuleBlockedReason;
|
||||||
|
readonly dependency?: ModuleBlockingDependency;
|
||||||
|
};
|
||||||
|
readonly restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleLifecycleResponse {
|
||||||
|
readonly modules: readonly ModuleLifecycleModuleView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The boot snapshot: the discovered inventory this process started from, the eligibility it
|
||||||
|
* resolved, and the modules the runtime reports it actually started. Captured once at startup and
|
||||||
|
* never recomputed — this endpoint describes the platform that is running, not one that would run
|
||||||
|
* if it were restarted right now. */
|
||||||
|
readonly snapshot: ModuleLifecycleSnapshot;
|
||||||
|
readonly store: ModuleLifecycleStore;
|
||||||
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary, exactly as
|
||||||
|
* `dashboard-routes.ts` does. */
|
||||||
|
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
|
||||||
|
if (error instanceof ModuleLifecycleStoreError) {
|
||||||
|
if (
|
||||||
|
error.code === "MODULE_LIFECYCLE_READ_FAILED" ||
|
||||||
|
error.code === "MODULE_LIFECYCLE_WRITE_FAILED"
|
||||||
|
) {
|
||||||
|
return sanitizedFailure(c, 503, error.code);
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
return sanitizedFailure(
|
||||||
|
c,
|
||||||
|
503,
|
||||||
|
fallback === "read" ? "MODULE_LIFECYCLE_READ_FAILED" : "MODULE_LIFECYCLE_WRITE_FAILED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENABLED_BODY_KEYS = new Set(["enabled"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured, closed validation — never a configuration blob. Any key beyond `enabled` makes the
|
||||||
|
* whole body invalid rather than being silently dropped, so a caller cannot smuggle a field a
|
||||||
|
* future version might interpret, and a typo fails loudly instead of quietly doing nothing. Mirrors
|
||||||
|
* `parseGroupBody`/`parsePlacementBody` (`./dashboard-routes.js`) exactly.
|
||||||
|
*/
|
||||||
|
function parseEnabledBody(raw: unknown): { enabled: boolean } | undefined {
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !ENABLED_BODY_KEYS.has(key))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(c: Context): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return (await c.req.json()) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function present(view: ModuleLifecycleView): ModuleLifecycleModuleView {
|
||||||
|
return {
|
||||||
|
configured: { enabled: view.configured.enabled, explicit: view.configured.explicit },
|
||||||
|
id: view.moduleId,
|
||||||
|
restartRequired: view.restartRequired,
|
||||||
|
runtime: {
|
||||||
|
state: view.runtime.state,
|
||||||
|
...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }),
|
||||||
|
...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }),
|
||||||
|
},
|
||||||
|
version: view.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's operational lifecycle resource: which installed modules are enabled, what this
|
||||||
|
* process actually did with each of them, and the narrow, structured API for changing that.
|
||||||
|
*
|
||||||
|
* ## Enablement is host configuration, and structurally cannot be anything else
|
||||||
|
*
|
||||||
|
* A module neither declares nor reads its own enabled state. Nothing in a manifest, a capability, a
|
||||||
|
* permission declaration, or a lifecycle hook can influence a value stored here, and a module is
|
||||||
|
* never told it is enabled — its host simply loads and starts it, or does not. That is what keeps
|
||||||
|
* the same module implementation reusable under a future standalone host, whose loading decisions
|
||||||
|
* are its own (ADR 0028).
|
||||||
|
*
|
||||||
|
* ## Changing configuration does not change the running process
|
||||||
|
*
|
||||||
|
* `PUT` writes a decision. It does not start, stop, load, unload, or reconfigure anything: there is
|
||||||
|
* no hot module installation or removal in this platform (ADR 0015, unchanged). The response
|
||||||
|
* therefore reports the new configuration *and* the unchanged runtime observation *and*
|
||||||
|
* `restartRequired`, rather than pretending the module stopped. Hot-unloading a module to make a
|
||||||
|
* button feel immediate would trade a real guarantee — that a module's ESM evaluation happens
|
||||||
|
* exactly once, and can never be rolled back — for a cosmetic one.
|
||||||
|
*
|
||||||
|
* ## Only a module this process can see may be configured
|
||||||
|
*
|
||||||
|
* `PUT` against an id that is not in the boot inventory is a `404`. Unlike an inert Dashboard
|
||||||
|
* placement, an enablement record is operational configuration, and accepting one for an arbitrary
|
||||||
|
* identifier would make this an unbounded write surface for a delegable permission. *Existing*
|
||||||
|
* records for modules that are no longer deployed are still retained and simply ignored, so
|
||||||
|
* redeploying a module restores the decision (see `resolveModuleEligibility`).
|
||||||
|
*
|
||||||
|
* ## Reading is administrative too
|
||||||
|
*
|
||||||
|
* Both verbs require `core.modules.lifecycle.manage` (restricted). This resource is the only place
|
||||||
|
* a disabled module, a blocked module, and the required-dependency edges between them are visible;
|
||||||
|
* `core.modules.ui.read` deliberately covers only what is mountable. Mutations additionally require
|
||||||
|
* an exact trusted `Origin` and a bounded body before authorization work begins, exactly like every
|
||||||
|
* other Core mutation.
|
||||||
|
*/
|
||||||
|
export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutesOptions): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
const requireManage = requirePermission(
|
||||||
|
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||||
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
|
);
|
||||||
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
const installedModuleIds = new Set(
|
||||||
|
options.snapshot.discoveredModules.map((module) => module.manifest.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(MODULE_LIFECYCLE_PATH, requireManage, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
// Not per-principal in content, but it is cookie-authorized, so it varies for the same reason
|
||||||
|
// every other authorized Core read does.
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
try {
|
||||||
|
const configuration = await options.store.readConfiguration();
|
||||||
|
const modules = resolveModuleLifecycleView(options.snapshot, configuration).map(present);
|
||||||
|
return c.json({ modules } satisfies ModuleLifecycleResponse, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error, "read");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
MODULE_LIFECYCLE_ENABLED_PATH,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const moduleId = c.req.param("moduleId");
|
||||||
|
const body = parseEnabledBody(await readJsonBody(c));
|
||||||
|
if (!body) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
if (!installedModuleIds.has(moduleId)) {
|
||||||
|
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.store.setEnabled({ enabled: body.enabled, moduleId });
|
||||||
|
// Re-read rather than predict: the response describes the configuration that is actually
|
||||||
|
// stored now, resolved against the same boot snapshot the collection read uses, so the two
|
||||||
|
// endpoints can never disagree about `restartRequired`.
|
||||||
|
const configuration = await options.store.readConfiguration();
|
||||||
|
const view = resolveModuleLifecycleView(options.snapshot, configuration).find(
|
||||||
|
(entry) => entry.moduleId === moduleId,
|
||||||
|
);
|
||||||
|
if (!view) {
|
||||||
|
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
|
||||||
|
}
|
||||||
|
return c.json(present(view), 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error, "write");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
+65
-16
@@ -9,8 +9,11 @@ import {
|
|||||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
|
createModuleLifecycleStore,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
|
type ModuleEligibilityResolution,
|
||||||
|
resolveModuleEligibility,
|
||||||
type StartedModuleRuntime,
|
type StartedModuleRuntime,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
@@ -321,12 +324,15 @@ function bindServer(
|
|||||||
* facade each get a typed Drizzle view over it — see `./database.js`).
|
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||||
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
||||||
* then the Core identity runtime, authorization store, and evaluator.
|
* then the Core identity runtime, authorization store, and evaluator.
|
||||||
* 4. Start the module runtime from that exact same discovered inventory
|
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
||||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
|
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
||||||
* same services object every module's `start`/`stop` hook receives.
|
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
||||||
* 5. Create the Hono application, mounting the started module runtime's published routes and
|
* rediscovering modules a second time — passing it the same services object every module's
|
||||||
* passing the exact same services object to every module HTTP handler.
|
* `start`/`stop` hook receives.
|
||||||
* 6. Bind the Node HTTP server.
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* has started. A module that is disabled, or blocked by a disabled required dependency, is never
|
||||||
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
|
* imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
|
||||||
* applies migrations, generates schema, or issues a module database query merely because modules
|
* published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
|
||||||
* were discovered — the database must already be migrated through `pnpm migrations:apply`.
|
* 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(
|
export async function startApi(
|
||||||
config: ApiRuntimeConfig,
|
config: ApiRuntimeConfig,
|
||||||
@@ -384,12 +400,33 @@ export async function startApi(
|
|||||||
platformOwners: authorizationStore,
|
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
|
// 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
|
// 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.
|
// 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;
|
let moduleRuntime: StartedModuleRuntime;
|
||||||
try {
|
try {
|
||||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, {
|
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||||
...(options.moduleHookTimeoutMsOverride === undefined
|
...(options.moduleHookTimeoutMsOverride === undefined
|
||||||
? {}
|
? {}
|
||||||
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||||
@@ -409,11 +446,23 @@ export async function startApi(
|
|||||||
},
|
},
|
||||||
moduleRoutes: moduleRuntime.routes,
|
moduleRoutes: moduleRuntime.routes,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
// Built from the exact same discovered inventory, and only once the module runtime has
|
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||||
// started — module startup is all-or-nothing, so every module in this list is a running
|
// inventory, and only once startup has succeeded — so the published set is the running set. A
|
||||||
// module, and a module that failed to load, register, or start never reaches this line at
|
// disabled or dependency-blocked module therefore publishes no artifact, appears in no browser
|
||||||
// all. Modules declaring no `entrypoints.ui` are simply absent from the registry.
|
// inventory, and appears in no resolved Dashboard navigation, by construction rather than by a
|
||||||
moduleUi: createModuleUiPublicationRegistry(inventory.modules),
|
// 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.
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||||
runtime,
|
runtime,
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
|
"core:0004_module_lifecycle_state",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
@@ -612,6 +613,32 @@ async function signIn(
|
|||||||
return { cookiePair: setCookie ? firstCookiePair(setCookie) : undefined, response };
|
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(
|
async function fetchPrincipal(
|
||||||
origin: string,
|
origin: string,
|
||||||
headers: Record<string, 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", () => {
|
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");
|
const database = await createDisposableDatabase("unmigrated");
|
||||||
try {
|
try {
|
||||||
const config = createApiRuntimeConfig(
|
const config = createApiRuntimeConfig(
|
||||||
@@ -1071,33 +1098,36 @@ describe.sequential("live API — startup and migration guarantees", () => {
|
|||||||
port: 0,
|
port: 0,
|
||||||
},
|
},
|
||||||
// Development validation, for the same reason `startConfiguredApi` documents: this scenario
|
// 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" },
|
{ 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 {
|
try {
|
||||||
// A request that touches the (nonexistent) identity tables fails, but must not repair
|
await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||||
// or create anything. The exact failure body is not asserted here (Better Auth's
|
} catch (error) {
|
||||||
// internal-error shape for this case is not a stable public contract); only that it is
|
captured = error;
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
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);
|
const admin = await openAdministrativeClient(database.url);
|
||||||
try {
|
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 }>(
|
const rows = await safeQuery<{ count: string }>(
|
||||||
admin,
|
admin,
|
||||||
"select count(*)::text as count from information_schema.tables where table_schema = 'public'",
|
"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).toMatchObject({ status: "pass" });
|
||||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||||
expect(permissionsCheck).toMatchObject({
|
expect(permissionsCheck).toMatchObject({
|
||||||
// Core's own three (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
// Core's own four (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||||
// `core.dashboard.navigation.manage`) plus the fixture module's one.
|
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`) plus the fixture
|
||||||
details: ["4 permissions registered"],
|
// module's one.
|
||||||
|
details: ["5 permissions registered"],
|
||||||
status: "pass",
|
status: "pass",
|
||||||
});
|
});
|
||||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
// 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
|
* disposable database is closed in `finally`, and each scenario carries its own outer deadline so
|
||||||
* a regression fails rather than hangs.
|
* 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", () => {
|
describe.sequential("live API — bounded HTTP drain and shutdown", () => {
|
||||||
async function withDeadline<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
async function withDeadline<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.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 trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -88,6 +89,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -107,6 +109,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -127,6 +130,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -149,6 +153,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -173,6 +178,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -197,6 +203,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -218,6 +225,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -241,6 +249,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -261,6 +270,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -283,6 +293,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -312,6 +323,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -342,6 +354,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -377,6 +390,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -394,6 +408,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -413,6 +428,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -431,6 +447,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -447,6 +464,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -470,6 +488,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -496,6 +515,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
@@ -525,6 +545,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
@@ -545,6 +566,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleUi: emptyModuleUiPublication(),
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.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,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as ModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
async function createTemporaryModulesDirectory(): Promise<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 () => {
|
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);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
const { server } = mockSuccessfulServe();
|
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 () => {
|
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);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
@@ -395,7 +410,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
const { server } = mockSuccessfulServe();
|
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 () => {
|
it("closes an already-created database pool when identity construction fails during startup", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockImplementation(() => {
|
betterAuthMock.mockImplementation(() => {
|
||||||
throw new Error("postgresql://admin:[email protected]/x");
|
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 () => {
|
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);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
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 () => {
|
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);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
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 () => {
|
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockFailingServe(new Error("EADDRINUSE"));
|
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 () => {
|
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
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 () => {
|
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
@@ -673,7 +688,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
|||||||
async function startAndCaptureClientErrorHandler() {
|
async function startAndCaptureClientErrorHandler() {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
const { clientErrorHandler } = mockSuccessfulServe();
|
const { clientErrorHandler } = mockSuccessfulServe();
|
||||||
@@ -891,7 +906,7 @@ describe("Ekklesia API module permission composition at startup", () => {
|
|||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
@@ -933,7 +948,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
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() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
return { poolEnd };
|
return { poolEnd };
|
||||||
@@ -1131,6 +1146,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
recorder.events.push("pool:end");
|
recorder.events.push("pool:end");
|
||||||
}),
|
}),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
|
query: emptyQuery(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
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:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
|
"core:0004_module_lifecycle_state",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -247,6 +248,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
|
"core:0004_module_lifecycle_state",
|
||||||
"db-facade:0000_initial",
|
"db-facade:0000_initial",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
@@ -444,6 +446,7 @@ describe.sequential("live API — module database explicit migration ownership",
|
|||||||
expect(
|
expect(
|
||||||
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
|
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
|
expect(await migrationHistoryCount(database.url, "core:0004_module_lifecycle_state")).toBe(1);
|
||||||
} finally {
|
} finally {
|
||||||
await dropDatabase(database.name);
|
await dropDatabase(database.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
mergedVaryHeader,
|
mergedVaryHeader,
|
||||||
ModuleRouteConfigurationError,
|
ModuleRouteConfigurationError,
|
||||||
} from "../src/module-http-routes.js";
|
} from "../src/module-http-routes.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -109,6 +110,7 @@ function buildModuleApp(options: {
|
|||||||
moduleRoutes: options.routes,
|
moduleRoutes: options.routes,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry([]),
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { createApiApp } from "../src/app.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:
|
* The whole chain, with nothing between the halves faked:
|
||||||
@@ -213,6 +217,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
moduleRoutes: moduleRuntime.routes,
|
moduleRoutes: moduleRuntime.routes,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: ["https://dashboard.example.test"],
|
trustedOrigins: ["https://dashboard.example.test"],
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { moduleUiArtifactUrl } from "../src/module-ui-routes.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 trustedOrigin = "https://dashboard.example.test";
|
||||||
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||||
@@ -169,6 +173,10 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import {
|
|||||||
MODULE_UI_CONTENT_TYPE,
|
MODULE_UI_CONTENT_TYPE,
|
||||||
moduleUiArtifactUrl,
|
moduleUiArtifactUrl,
|
||||||
} from "../src/module-ui-routes.js";
|
} from "../src/module-ui-routes.js";
|
||||||
|
import {
|
||||||
|
createInMemoryModuleLifecycleStore,
|
||||||
|
moduleLifecycleSnapshotFor,
|
||||||
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
|
||||||
@@ -169,6 +173,10 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
|||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
"./src/identity/schema.ts",
|
"./src/identity/schema.ts",
|
||||||
"./src/authorization/schema.ts",
|
"./src/authorization/schema.ts",
|
||||||
"./src/dashboard/schema.ts",
|
"./src/dashboard/schema.ts",
|
||||||
|
"./src/modules/lifecycle-schema.ts",
|
||||||
],
|
],
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
migrations: {
|
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)
|
||||||
|
);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,12 @@
|
|||||||
"destructive": {
|
"destructive": {
|
||||||
"mode": "none"
|
"mode": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0004_module_lifecycle_state",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1787408382271,
|
"when": 1787408382271,
|
||||||
"tag": "0003_dashboard_navigation_tables",
|
"tag": "0003_dashboard_navigation_tables",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787423121678,
|
||||||
|
"tag": "0004_module_lifecycle_state",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,24 @@ export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId(
|
|||||||
"core.dashboard.navigation.manage",
|
"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
|
* 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
|
* 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.",
|
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
|
||||||
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Read and change which installed modules are enabled.",
|
||||||
|
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
createModuleLifecycleDatabase,
|
||||||
|
createModuleLifecycleStore,
|
||||||
|
discoverModules,
|
||||||
|
ModuleDiscoveryError,
|
||||||
|
ModuleLifecycleStoreError,
|
||||||
|
resolveModuleEligibility,
|
||||||
|
} from "../modules/index.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables or disables one installed module, out of band from the running API.
|
||||||
|
*
|
||||||
|
* This exists because module startup is deliberately all-or-nothing (ADR 0015, ADR 0020, preserved
|
||||||
|
* by ADR 0028): a module whose `start` hook throws takes API startup down with it, and the HTTP
|
||||||
|
* lifecycle API that could otherwise turn it off is served by that same process. Without an
|
||||||
|
* out-of-band path, the only recovery from one bad module would be editing the database by hand.
|
||||||
|
* This command is that path — the same shape as `pnpm authorization:bootstrap-owner`: explicit,
|
||||||
|
* operator-invoked, never HTTP-exposed, and never inferred.
|
||||||
|
*
|
||||||
|
* It writes configuration and nothing else. It starts nothing, stops nothing, imports no module
|
||||||
|
* server entrypoint, runs no lifecycle hook, and applies no migration. The change takes effect on
|
||||||
|
* the next API start.
|
||||||
|
*/
|
||||||
|
class ModuleLifecycleCliError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ModuleLifecycleCliError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage =
|
||||||
|
"Usage: pnpm modules:enable -- --module <module-id> | pnpm modules:disable -- --module <module-id>";
|
||||||
|
|
||||||
|
interface ParsedArgs {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly moduleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts`: this command
|
||||||
|
* takes one named flag plus one mode flag, so a general-purpose argument dependency is not
|
||||||
|
* warranted. `--enable` and `--disable` are mutually exclusive and one is required, so the intent
|
||||||
|
* is always stated rather than defaulted. */
|
||||||
|
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||||
|
let moduleId: string | undefined;
|
||||||
|
let enabled: boolean | undefined;
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const flag = argv[index];
|
||||||
|
if (flag === "--module") {
|
||||||
|
moduleId = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else if (flag === "--enable") {
|
||||||
|
if (enabled === false) {
|
||||||
|
throw new ModuleLifecycleCliError(usage);
|
||||||
|
}
|
||||||
|
enabled = true;
|
||||||
|
} else if (flag === "--disable") {
|
||||||
|
if (enabled === true) {
|
||||||
|
throw new ModuleLifecycleCliError(usage);
|
||||||
|
}
|
||||||
|
enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moduleId === undefined || enabled === undefined) {
|
||||||
|
throw new ModuleLifecycleCliError(usage);
|
||||||
|
}
|
||||||
|
return { enabled, moduleId };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageVersion {
|
||||||
|
readonly version?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPackageVersion(packagePath: string): Promise<string> {
|
||||||
|
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
|
||||||
|
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
|
||||||
|
throw new ModuleLifecycleCliError("The repository package metadata could not be read.");
|
||||||
|
}
|
||||||
|
return packageData.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const { enabled, moduleId } = parseArgs(process.argv.slice(2));
|
||||||
|
const repositoryRoot = process.cwd();
|
||||||
|
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||||
|
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||||
|
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The same rule the HTTP API enforces: a module this deployment cannot currently see may not be
|
||||||
|
// configured. Manifest-only, exactly like `pnpm modules:validate` — no module server entrypoint
|
||||||
|
// is imported and no lifecycle hook runs, so a module that crashes during `start` is still fully
|
||||||
|
// configurable here, which is the case this command exists for.
|
||||||
|
const discovered = await discoverModules({
|
||||||
|
moduleSdkVersion,
|
||||||
|
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||||
|
platformVersion,
|
||||||
|
});
|
||||||
|
if (!discovered.some((module) => module.manifest.id === moduleId)) {
|
||||||
|
throw new ModuleLifecycleCliError(
|
||||||
|
`No installed module declares the id "${moduleId}". No changes were made.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read only when this command actually executes — never merely by importing this module.
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||||
|
throw new ModuleLifecycleCliError("DATABASE_URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asserted before the connection is constructed, so an unsafe production transport never opens a
|
||||||
|
// client at all.
|
||||||
|
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||||
|
const { close, db } = createModuleLifecycleDatabase(databaseUrl, {
|
||||||
|
connectionTimeoutMs: 10_000,
|
||||||
|
operationTimeoutMs: 10_000,
|
||||||
|
transport,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const store = createModuleLifecycleStore(db);
|
||||||
|
await store.setEnabled({ enabled, moduleId });
|
||||||
|
const resolution = resolveModuleEligibility(discovered, await store.readConfiguration());
|
||||||
|
console.log(
|
||||||
|
`Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`,
|
||||||
|
);
|
||||||
|
const blocked = resolution.entries.filter((entry) => entry.blocked !== undefined);
|
||||||
|
for (const entry of blocked) {
|
||||||
|
console.log(
|
||||||
|
`- "${entry.moduleId}" will not start: ${entry.blocked?.reason} (${entry.blocked?.dependency.moduleId}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportCliError(error: unknown): void {
|
||||||
|
if (error instanceof ModuleLifecycleCliError) {
|
||||||
|
console.error(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof ModuleLifecycleStoreError) {
|
||||||
|
console.error(`[${error.code}] ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof ModuleDiscoveryError) {
|
||||||
|
console.error(error.message);
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const issuePath = issue.path ? ` (${issue.path})` : "";
|
||||||
|
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("The module enablement change failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectExecution(): boolean {
|
||||||
|
const entrypoint = process.argv[1];
|
||||||
|
return (
|
||||||
|
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectExecution()) {
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
reportCliError(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,7 +11,12 @@ function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSecti
|
|||||||
|
|
||||||
const sections: readonly DoctorSection[] = Object.freeze([
|
const sections: readonly DoctorSection[] = Object.freeze([
|
||||||
section("Platform", ["platform.runtime"]),
|
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("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||||
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
||||||
section("Identity", ["identity.configuration", "identity.client-address"]),
|
section("Identity", ["identity.configuration", "identity.client-address"]),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
|
capabilityIdSchema,
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
permissionIdSchema,
|
permissionIdSchema,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
} from "../identity/config.js";
|
} from "../identity/config.js";
|
||||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
||||||
import { identityAdapterSchema } from "../identity/schema.js";
|
import { identityAdapterSchema } from "../identity/schema.js";
|
||||||
|
import type { PostgresPoolOptions } from "../database/pool.js";
|
||||||
import {
|
import {
|
||||||
createPostgresMigrationDatabase,
|
createPostgresMigrationDatabase,
|
||||||
discoverAllMigrations,
|
discoverAllMigrations,
|
||||||
@@ -39,13 +41,18 @@ import {
|
|||||||
} from "../migrations/index.js";
|
} from "../migrations/index.js";
|
||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleLifecycleDatabase,
|
||||||
|
createModuleLifecycleStore,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type ModuleEligibility,
|
||||||
ModulePermissionCompositionError,
|
ModulePermissionCompositionError,
|
||||||
type ModuleDiscoveryOptions,
|
type ModuleDiscoveryOptions,
|
||||||
|
type ModuleLifecycleConfiguration,
|
||||||
type ModuleMigrationAggregate,
|
type ModuleMigrationAggregate,
|
||||||
type ModuleSchemaAggregate,
|
type ModuleSchemaAggregate,
|
||||||
|
resolveModuleEligibility,
|
||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
|
|
||||||
export type DoctorCheckId =
|
export type DoctorCheckId =
|
||||||
@@ -54,6 +61,7 @@ export type DoctorCheckId =
|
|||||||
| "modules.discovery"
|
| "modules.discovery"
|
||||||
| "modules.dependencies"
|
| "modules.dependencies"
|
||||||
| "modules.permissions"
|
| "modules.permissions"
|
||||||
|
| "modules.lifecycle"
|
||||||
| "schemas.discovery"
|
| "schemas.discovery"
|
||||||
| "migrations.discovery"
|
| "migrations.discovery"
|
||||||
| "postgresql.connection"
|
| "postgresql.connection"
|
||||||
@@ -113,6 +121,13 @@ export interface DoctorDependencies {
|
|||||||
options: ModuleDiscoveryOptions,
|
options: ModuleDiscoveryOptions,
|
||||||
) => Promise<readonly DiscoveredModule[]>;
|
) => Promise<readonly DiscoveredModule[]>;
|
||||||
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
|
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 planMigrations: (options: {
|
||||||
readonly aggregate: ModuleMigrationAggregate;
|
readonly aggregate: ModuleMigrationAggregate;
|
||||||
readonly database: MigrationDatabase;
|
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({
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||||
composePermissions: composePlatformPermissionRegistry,
|
composePermissions: composePlatformPermissionRegistry,
|
||||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||||
@@ -141,6 +170,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
|||||||
discoverModules,
|
discoverModules,
|
||||||
discoverSchemas: discoverModuleSchemas,
|
discoverSchemas: discoverModuleSchemas,
|
||||||
planMigrations: planModuleMigrations,
|
planMigrations: planModuleMigrations,
|
||||||
|
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||||
runtimeSatisfies: isVersionCompatible,
|
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 {
|
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||||
const details =
|
const details =
|
||||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
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 schemaCheck: DoctorCheck;
|
||||||
let migrationCheck: DoctorCheck;
|
let migrationCheck: DoctorCheck;
|
||||||
let migrations: ModuleMigrationAggregate | undefined;
|
let migrations: ModuleMigrationAggregate | undefined;
|
||||||
@@ -668,6 +794,7 @@ export async function diagnoseEkklesia(
|
|||||||
moduleCheck,
|
moduleCheck,
|
||||||
dependencyCheck,
|
dependencyCheck,
|
||||||
permissionsCheck,
|
permissionsCheck,
|
||||||
|
lifecycleCheck,
|
||||||
schemaCheck,
|
schemaCheck,
|
||||||
migrationCheck,
|
migrationCheck,
|
||||||
transportCheck,
|
transportCheck,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export * from "./database.js";
|
export * from "./database.js";
|
||||||
export * from "./discovery.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 "./migration-discovery.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { boolean, check, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core-owned storage for the one operational decision an administrator makes about an installed
|
||||||
|
* module: should it run?
|
||||||
|
*
|
||||||
|
* This is **configuration, not runtime state**. Nothing about what happened during a boot is stored
|
||||||
|
* here — not a start failure, not a last-seen timestamp, not a health result. A restart naturally
|
||||||
|
* retries every enabled module, which is the behavior an operator expects and which persisting a
|
||||||
|
* transient failure would quietly break (ADR 0028).
|
||||||
|
*
|
||||||
|
* It is also **not an installation registry**. A module is "installed" by being a valid module
|
||||||
|
* package on disk that discovery accepts; this table records nothing about where it came from, how
|
||||||
|
* it got there, or what version it is. There is deliberately no install source, download URL,
|
||||||
|
* update channel, signature, marketplace metadata, or free-form JSON column: none of those concepts
|
||||||
|
* exist in this platform, and a column for one is how an installer gets built by accident.
|
||||||
|
*
|
||||||
|
* Platform-global, for the same reason the Dashboard tables are (ADR 0027): Ekklesia has no
|
||||||
|
* organization or tenant model to scope it to. When one exists, this table gains a scope column.
|
||||||
|
*
|
||||||
|
* Table and constraint names are explicit and namespaced exactly like
|
||||||
|
* `core/src/authorization/schema.ts` and `core/src/dashboard/schema.ts`, so ownership is
|
||||||
|
* unambiguous inside the shared public PostgreSQL schema.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One explicit enablement decision.
|
||||||
|
*
|
||||||
|
* **A row exists only because somebody decided.** `enabled` has no column default on purpose: the
|
||||||
|
* platform's default for a newly discovered module is expressed by the *absence* of a row and
|
||||||
|
* resolved in `resolveModuleEligibility`, not by a default that would silently materialize a
|
||||||
|
* decision nobody made. Storing `enabled = true` explicitly is still meaningful — it records that
|
||||||
|
* an administrator looked at this module and left it on.
|
||||||
|
*
|
||||||
|
* `module_id` carries **no foreign key to any module**, because modules are packages discovered on
|
||||||
|
* disk, not rows — the same reasoning `ekklesia_core_dashboard_placement.module_id` already
|
||||||
|
* follows. A record for a module that is not currently deployed is therefore normal and is
|
||||||
|
* retained, so redeploying a module restores the decision instead of resetting it.
|
||||||
|
* `resolveModuleEligibility` ignores such a record entirely.
|
||||||
|
*/
|
||||||
|
export const ekklesiaCoreModuleState = pgTable(
|
||||||
|
"ekklesia_core_module_state",
|
||||||
|
{
|
||||||
|
moduleId: text("module_id").primaryKey(),
|
||||||
|
enabled: boolean("enabled").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
// Defense in depth beneath the application-layer `moduleIdSchema` contract, which never
|
||||||
|
// produces this: an empty module id would be an unaddressable, permanently inert record.
|
||||||
|
check("ekklesia_core_module_state_module_check", sql`length(${table.moduleId}) > 0`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Explicit map passed to Drizzle, mirroring `authorizationSchema` and `dashboardSchema`. */
|
||||||
|
export const moduleLifecycleSchema = {
|
||||||
|
moduleState: ekklesiaCoreModuleState,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { asc } from "drizzle-orm";
|
||||||
|
import { type ModuleEnablementRecord, moduleIdSchema } from "@ekklesia/contracts";
|
||||||
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
import { createValidatedPostgresPool, type PostgresPoolOptions } from "../database/index.js";
|
||||||
|
import type { ModuleLifecycleConfiguration } from "./lifecycle.js";
|
||||||
|
import { ekklesiaCoreModuleState, moduleLifecycleSchema } from "./lifecycle-schema.js";
|
||||||
|
|
||||||
|
export type ModuleLifecycleDrizzleDatabase = NodePgDatabase<typeof moduleLifecycleSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed Drizzle view over an externally owned pool, exactly like `createDashboardDrizzleView` and
|
||||||
|
* `createAuthorizationDrizzleView`: it never constructs, registers an error listener on, or closes
|
||||||
|
* a pool. The caller owns the pool once, however many typed views it creates over it.
|
||||||
|
*/
|
||||||
|
export function createModuleLifecycleView(pool: Pool): ModuleLifecycleDrizzleDatabase {
|
||||||
|
return drizzle(pool, { schema: moduleLifecycleSchema });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleLifecycleDatabaseHandle {
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
readonly db: ModuleLifecycleDrizzleDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A self-contained handle for a caller that needs *only* this domain and owns the connection for
|
||||||
|
* the length of one command — Doctor's read-only lifecycle check and the enable/disable CLI. It
|
||||||
|
* composes the shared, already-validated pool factory rather than repeating a fourth copy of
|
||||||
|
* connection-string and timeout validation. `apps/api`, which shares one physical pool across every
|
||||||
|
* domain, uses `createModuleLifecycleView` instead.
|
||||||
|
*/
|
||||||
|
export function createModuleLifecycleDatabase(
|
||||||
|
connectionString: string,
|
||||||
|
options: PostgresPoolOptions = {},
|
||||||
|
): ModuleLifecycleDatabaseHandle {
|
||||||
|
const { close, pool } = createValidatedPostgresPool(connectionString, options);
|
||||||
|
return Object.freeze({ close, db: createModuleLifecycleView(pool) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModuleLifecycleStoreErrorCode =
|
||||||
|
| "MODULE_LIFECYCLE_MODULE_ID_INVALID"
|
||||||
|
| "MODULE_LIFECYCLE_READ_FAILED"
|
||||||
|
| "MODULE_LIFECYCLE_WRITE_FAILED";
|
||||||
|
|
||||||
|
const storeErrorMessages: Readonly<Record<ModuleLifecycleStoreErrorCode, string>> = Object.freeze({
|
||||||
|
MODULE_LIFECYCLE_MODULE_ID_INVALID: "A module identifier is invalid.",
|
||||||
|
MODULE_LIFECYCLE_READ_FAILED: "The module lifecycle configuration could not be read.",
|
||||||
|
MODULE_LIFECYCLE_WRITE_FAILED: "The module lifecycle configuration could not be saved.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Never carries the underlying database error, SQL text, host, connection string, or stack —
|
||||||
|
* matching `AuthorizationStoreError` and `DashboardStoreError` exactly. */
|
||||||
|
export class ModuleLifecycleStoreError extends Error {
|
||||||
|
readonly code: ModuleLifecycleStoreErrorCode;
|
||||||
|
|
||||||
|
constructor(code: ModuleLifecycleStoreErrorCode) {
|
||||||
|
super(storeErrorMessages[code]);
|
||||||
|
this.name = "ModuleLifecycleStoreError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetModuleEnabledInput {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleLifecycleStore {
|
||||||
|
/** The whole stored configuration, in deterministic module-id order. Small by nature — at most one
|
||||||
|
* row per module ever decided about — so it is read whole rather than paginated. */
|
||||||
|
readConfiguration(): Promise<ModuleLifecycleConfiguration>;
|
||||||
|
/** Records one explicit decision, creating or replacing it. Idempotent: the same input twice
|
||||||
|
* leaves the same single row. Writing configuration never starts, stops, loads, or unloads
|
||||||
|
* anything — the running process is untouched until it is restarted. */
|
||||||
|
setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidModuleId(value: string): void {
|
||||||
|
if (!moduleIdSchema.safeParse(value).success) {
|
||||||
|
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rethrowAsStoreError(error: unknown, code: ModuleLifecycleStoreErrorCode): never {
|
||||||
|
if (error instanceof ModuleLifecycleStoreError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ModuleLifecycleStoreError(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's persistence for which installed modules are enabled.
|
||||||
|
*
|
||||||
|
* Validation lives here rather than only at an HTTP boundary, so the invariant holds for every
|
||||||
|
* caller — the API, the CLI, and any future one: an identifier is checked against
|
||||||
|
* `moduleIdSchema`, the platform's one module-identifier grammar, rather than a second grammar
|
||||||
|
* invented here.
|
||||||
|
*
|
||||||
|
* Nothing in this file reads a manifest, imports module code, resolves a dependency, or decides
|
||||||
|
* whether a module may start. It stores a decision; `resolveModuleEligibility` interprets it. That
|
||||||
|
* separation is what keeps enablement host-owned configuration instead of something a module could
|
||||||
|
* ever influence.
|
||||||
|
*/
|
||||||
|
export function createModuleLifecycleStore(
|
||||||
|
db: ModuleLifecycleDrizzleDatabase,
|
||||||
|
): ModuleLifecycleStore {
|
||||||
|
return Object.freeze({
|
||||||
|
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
enabled: ekklesiaCoreModuleState.enabled,
|
||||||
|
moduleId: ekklesiaCoreModuleState.moduleId,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreModuleState)
|
||||||
|
.orderBy(asc(ekklesiaCoreModuleState.moduleId));
|
||||||
|
return Object.freeze({
|
||||||
|
modules: Object.freeze(rows.map((row) => Object.freeze({ ...row }))),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "MODULE_LIFECYCLE_READ_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
|
||||||
|
assertValidModuleId(input.moduleId);
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(ekklesiaCoreModuleState)
|
||||||
|
.values({ enabled: input.enabled, moduleId: input.moduleId })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
set: { enabled: input.enabled, updatedAt: new Date() },
|
||||||
|
target: ekklesiaCoreModuleState.moduleId,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
enabled: ekklesiaCoreModuleState.enabled,
|
||||||
|
moduleId: ekklesiaCoreModuleState.moduleId,
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
return Object.freeze(row);
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "MODULE_LIFECYCLE_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import type {
|
||||||
|
ModuleBlockedReason,
|
||||||
|
ModuleBlockingDependency,
|
||||||
|
ModuleEnablementRecord,
|
||||||
|
ModuleRuntimeState,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import type { DiscoveredModule } from "./discovery.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The complete stored enablement configuration: only the modules somebody explicitly decided
|
||||||
|
* about. A module with no record here is not "missing" — it is at its default, and the default is
|
||||||
|
* enabled (see `resolveModuleEligibility`).
|
||||||
|
*/
|
||||||
|
export interface ModuleLifecycleConfiguration {
|
||||||
|
readonly modules: readonly ModuleEnablementRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a module's configured state was arrived at. `explicit` distinguishes "an administrator chose
|
||||||
|
* this" from "nobody has decided, so the platform default applies", which is exactly the difference
|
||||||
|
* an operator needs when they are wondering whether a module is deliberately off. */
|
||||||
|
export interface ModuleConfiguredState {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly explicit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module's resolved eligibility: what configuration says, and whether the dependency graph
|
||||||
|
* permits it to start.
|
||||||
|
*
|
||||||
|
* `eligible` is the single fact runtime composition consumes. Everything else on this record exists
|
||||||
|
* to explain it.
|
||||||
|
*/
|
||||||
|
export interface ModuleEligibility {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly configured: ModuleConfiguredState;
|
||||||
|
readonly eligible: boolean;
|
||||||
|
/** Present exactly when the module is configured enabled but not eligible. A disabled module is
|
||||||
|
* not "blocked" — it is doing what it was told. */
|
||||||
|
readonly blocked?: {
|
||||||
|
readonly reason: ModuleBlockedReason;
|
||||||
|
readonly dependency: ModuleBlockingDependency;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleEligibilityResolution {
|
||||||
|
/**
|
||||||
|
* The modules the platform may start, **in the exact discovery order they were given**. Filtered,
|
||||||
|
* never re-sorted: discovery already produced a topological order, and blocking propagates along
|
||||||
|
* the same edges, so removing an ineligible module can never leave a module ahead of a required
|
||||||
|
* dependency it still has.
|
||||||
|
*/
|
||||||
|
readonly eligibleModules: readonly DiscoveredModule[];
|
||||||
|
/** Every discovered module, in discovery order, eligible or not. This is the explanation surface;
|
||||||
|
* `eligibleModules` is the executable one. */
|
||||||
|
readonly entries: readonly ModuleEligibility[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableEligibility {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly configured: ModuleConfiguredState;
|
||||||
|
eligible: boolean;
|
||||||
|
blocked?: {
|
||||||
|
readonly reason: ModuleBlockedReason;
|
||||||
|
readonly dependency: ModuleBlockingDependency;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
|
||||||
|
return Object.freeze({
|
||||||
|
configured: Object.freeze({ ...entry.configured }),
|
||||||
|
eligible: entry.eligible,
|
||||||
|
moduleId: entry.moduleId,
|
||||||
|
version: entry.version,
|
||||||
|
...(entry.blocked
|
||||||
|
? {
|
||||||
|
blocked: Object.freeze({
|
||||||
|
dependency: Object.freeze({ ...entry.blocked.dependency }),
|
||||||
|
reason: entry.blocked.reason,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves which of the already-discovered modules the platform may actually start.
|
||||||
|
*
|
||||||
|
* Pure and synchronous, exactly like `resolveDashboardNavigation`: no database, no filesystem, no
|
||||||
|
* clock, no module code. It is handed the discovered inventory and the stored configuration and
|
||||||
|
* returns what to start plus why anything else is not starting, so every rule below is testable
|
||||||
|
* without PostgreSQL.
|
||||||
|
*
|
||||||
|
* ## The rules, exactly
|
||||||
|
*
|
||||||
|
* - **A module with no stored record is enabled.** This is the platform's default policy, chosen
|
||||||
|
* so that deploying a module is sufficient to run it — the behavior every earlier phase already
|
||||||
|
* had — and so that discovering a module never requires an initialization write. It mirrors
|
||||||
|
* ADR 0027's rule that a module with no Dashboard placement is still visible.
|
||||||
|
* - **An explicit record always wins over the default,** in both directions. A stored
|
||||||
|
* `enabled: true` is indistinguishable in effect from no record at all, and is retained anyway
|
||||||
|
* so an administrator can see that somebody decided.
|
||||||
|
* - **A stored record for a module that is not installed is ignored, not an error.** It is
|
||||||
|
* retained by the store for the same reason a Dashboard placement is: redeploying the module
|
||||||
|
* restores the decision instead of silently resetting it.
|
||||||
|
* - **A disabled module blocks every module that *requires* it,** transitively, and the block
|
||||||
|
* names the dependency rather than silently re-enabling it. Administrator intent is never
|
||||||
|
* overridden.
|
||||||
|
* - **An optional dependency never blocks anything.** A module that declares an optional
|
||||||
|
* dependency has already accepted that it may be absent — discovery permits an optional
|
||||||
|
* dependency to not be installed at all — and "disabled" is the same situation arriving a
|
||||||
|
* different way. The dependent starts, and `context.capabilities.resolve(...)` simply returns
|
||||||
|
* `undefined`, exactly as it does when the provider was never installed.
|
||||||
|
* - **A required *capability* dependency blocks exactly like a required module dependency**,
|
||||||
|
* because it is one: the capability's only provider is a module, and if that module does not
|
||||||
|
* run, nothing registers the capability.
|
||||||
|
* - **A missing, incompatible, duplicated, or cyclic dependency is not represented here at all.**
|
||||||
|
* Discovery rejects all four before this function is ever reached, so there is no eligibility
|
||||||
|
* state for them and no second place where the dependency graph is validated.
|
||||||
|
*
|
||||||
|
* ## What this function structurally cannot do
|
||||||
|
*
|
||||||
|
* It never reads a manifest field beyond identity, version, and the declared dependency maps;
|
||||||
|
* never imports or evaluates module code; never writes; and never enables anything. Disabling a
|
||||||
|
* dependency can only ever *remove* modules from the eligible set.
|
||||||
|
*/
|
||||||
|
export function resolveModuleEligibility(
|
||||||
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
|
configuration: ModuleLifecycleConfiguration,
|
||||||
|
): ModuleEligibilityResolution {
|
||||||
|
const configuredByModuleId = new Map(
|
||||||
|
configuration.modules.map((record) => [record.moduleId, record.enabled] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = new Map<string, MutableEligibility>();
|
||||||
|
for (const discovered of discoveredModules) {
|
||||||
|
const moduleId = discovered.manifest.id;
|
||||||
|
const stored = configuredByModuleId.get(moduleId);
|
||||||
|
const enabled = stored ?? true;
|
||||||
|
entries.set(moduleId, {
|
||||||
|
configured: { enabled, explicit: stored !== undefined },
|
||||||
|
eligible: enabled,
|
||||||
|
moduleId,
|
||||||
|
version: discovered.manifest.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The provider of each capability, taken from the same manifests discovery already validated.
|
||||||
|
// A capability has exactly one provider in a valid inventory (discovery rejects a duplicate
|
||||||
|
// module id, and a manifest may only declare capabilities inside its own namespace).
|
||||||
|
const capabilityProviders = new Map<string, string>();
|
||||||
|
for (const discovered of discoveredModules) {
|
||||||
|
for (const declaration of discovered.manifest.capabilities) {
|
||||||
|
capabilityProviders.set(declaration.id, discovered.manifest.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One pass in discovery order is sufficient and is what makes transitive blocking deterministic:
|
||||||
|
// discovery hands back a topological order, so every required dependency of a module has already
|
||||||
|
// been resolved by the time that module is examined.
|
||||||
|
for (const discovered of discoveredModules) {
|
||||||
|
const entry = entries.get(discovered.manifest.id);
|
||||||
|
if (!entry?.eligible) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredEdges: readonly ModuleBlockingDependency[] = Object.freeze([
|
||||||
|
...Object.keys(discovered.manifest.dependencies.modules.required).map(
|
||||||
|
(dependencyId): ModuleBlockingDependency => ({
|
||||||
|
kind: "module",
|
||||||
|
moduleId: dependencyId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...Object.keys(discovered.manifest.dependencies.capabilities.required).flatMap(
|
||||||
|
(capabilityId): readonly ModuleBlockingDependency[] => {
|
||||||
|
const providerId = capabilityProviders.get(capabilityId);
|
||||||
|
// Unreachable through real discovery, which rejects a required capability nothing
|
||||||
|
// provides. Skipping rather than inventing a state keeps this function from having an
|
||||||
|
// opinion discovery already owns.
|
||||||
|
return providerId === undefined
|
||||||
|
? []
|
||||||
|
: [{ capabilityId, kind: "capability", moduleId: providerId }];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const dependency of requiredEdges) {
|
||||||
|
const provider = entries.get(dependency.moduleId);
|
||||||
|
if (!provider || provider.eligible) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.eligible = false;
|
||||||
|
entry.blocked = {
|
||||||
|
dependency,
|
||||||
|
reason: provider.configured.enabled
|
||||||
|
? "required_dependency_blocked"
|
||||||
|
: "required_dependency_disabled",
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedEntries = Object.freeze(
|
||||||
|
discoveredModules.map((discovered) => {
|
||||||
|
const entry = entries.get(discovered.manifest.id);
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error("Internal lifecycle error: a discovered module was not indexed.");
|
||||||
|
}
|
||||||
|
return frozenEligibility(entry);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const eligibleIds = new Set(
|
||||||
|
resolvedEntries.filter((entry) => entry.eligible).map((entry) => entry.moduleId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
eligibleModules: Object.freeze(
|
||||||
|
discoveredModules.filter((discovered) => eligibleIds.has(discovered.manifest.id)),
|
||||||
|
),
|
||||||
|
entries: resolvedEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one module looks like to an operator: the decision, what this process actually did, and
|
||||||
|
* whether the two currently disagree.
|
||||||
|
*/
|
||||||
|
export interface ModuleLifecycleView {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly configured: ModuleConfiguredState;
|
||||||
|
readonly runtime: {
|
||||||
|
readonly state: ModuleRuntimeState;
|
||||||
|
readonly reason?: ModuleBlockedReason;
|
||||||
|
readonly dependency?: ModuleBlockingDependency;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* `true` when applying the *current* configuration would produce a different running set than
|
||||||
|
* this process actually has. Computed by re-resolving eligibility against live configuration and
|
||||||
|
* comparing with what the runtime really started — never by assuming the two agree.
|
||||||
|
*
|
||||||
|
* It is deliberately symmetric and transitive: disabling a module that others require marks those
|
||||||
|
* dependents too, because they really will stop on the next restart.
|
||||||
|
*/
|
||||||
|
readonly restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleLifecycleSnapshot {
|
||||||
|
/** The exact discovered inventory this process booted from. Never re-discovered afterwards: this
|
||||||
|
* process runs the modules it found at startup, and reporting anything else would describe a
|
||||||
|
* platform that is not running. */
|
||||||
|
readonly discoveredModules: readonly DiscoveredModule[];
|
||||||
|
/** The eligibility this process resolved at boot, which is what explains a module that is not
|
||||||
|
* running right now. */
|
||||||
|
readonly eligibility: readonly ModuleEligibility[];
|
||||||
|
/** The module ids the runtime reports it actually started (`StartedModuleRuntime.modules`), not
|
||||||
|
* the ids this process predicted it would start. Under the preserved all-or-nothing startup model
|
||||||
|
* these are the same set; reading the runtime's own answer is what keeps that an observation
|
||||||
|
* rather than an assumption. */
|
||||||
|
readonly startedModuleIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes the operator-facing read model from the boot snapshot plus the configuration as it
|
||||||
|
* stands *now*.
|
||||||
|
*
|
||||||
|
* The two inputs are deliberately from different moments, because the honest answer is: "here is
|
||||||
|
* what this process is doing, here is what you have since asked for, and here is whether those
|
||||||
|
* differ." Collapsing them would either hide a pending change or claim a change already took
|
||||||
|
* effect. Ekklesia does not hot-load or hot-unload a module (ADR 0015, unchanged), so a
|
||||||
|
* configuration change is only ever applied by the next restart.
|
||||||
|
*/
|
||||||
|
export function resolveModuleLifecycleView(
|
||||||
|
snapshot: ModuleLifecycleSnapshot,
|
||||||
|
configuration: ModuleLifecycleConfiguration,
|
||||||
|
): readonly ModuleLifecycleView[] {
|
||||||
|
const started = new Set(snapshot.startedModuleIds);
|
||||||
|
const bootEligibility = new Map(
|
||||||
|
snapshot.eligibility.map((entry) => [entry.moduleId, entry] as const),
|
||||||
|
);
|
||||||
|
const pending = resolveModuleEligibility(snapshot.discoveredModules, configuration);
|
||||||
|
const wouldRun = new Set(pending.eligibleModules.map((module) => module.manifest.id));
|
||||||
|
|
||||||
|
return Object.freeze(
|
||||||
|
pending.entries.map((current) => {
|
||||||
|
const boot = bootEligibility.get(current.moduleId);
|
||||||
|
const isRunning = started.has(current.moduleId);
|
||||||
|
const runtime = isRunning
|
||||||
|
? { state: "running" as const }
|
||||||
|
: boot === undefined || !boot.configured.enabled
|
||||||
|
? { state: "disabled" as const }
|
||||||
|
: boot.blocked
|
||||||
|
? {
|
||||||
|
dependency: boot.blocked.dependency,
|
||||||
|
reason: boot.blocked.reason,
|
||||||
|
state: "blocked" as const,
|
||||||
|
}
|
||||||
|
: // Enabled and unblocked at boot, yet not started: unreachable while startup stays
|
||||||
|
// all-or-nothing, and reported as blocked-by-nothing rather than as `running`, so a
|
||||||
|
// future change to startup atomicity can never make this silently claim success.
|
||||||
|
{ state: "blocked" as const };
|
||||||
|
return Object.freeze({
|
||||||
|
configured: current.configured,
|
||||||
|
moduleId: current.moduleId,
|
||||||
|
restartRequired: wouldRun.has(current.moduleId) !== isRunning,
|
||||||
|
runtime: Object.freeze(runtime),
|
||||||
|
version: current.version,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,26 +65,28 @@ export interface ModuleUiPublicationRegistry {
|
|||||||
/**
|
/**
|
||||||
* Builds the platform's module UI publication policy from an already-discovered module list.
|
* 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:
|
* The policy is deliberately small. "Publishable" means exactly:
|
||||||
* 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:
|
|
||||||
*
|
*
|
||||||
* 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
|
* 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file
|
||||||
* inside the module package.
|
* inside the module package.
|
||||||
*
|
*
|
||||||
* A module directory sitting on disk satisfies neither on its own — it has to have survived
|
* 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
|
* 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
|
* 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
|
* imports, evaluates, or otherwise executes a module's UI artifact. Production composition
|
||||||
* discovered list; production composition (`apps/api/src/server.ts`) builds this only after the
|
* (`apps/api/src/server.ts`) passes the exact list it just started, and only after
|
||||||
* module runtime has started, so the published set is also the running set — module startup is
|
* `startDiscoveredModuleRuntime` succeeded — startup is all-or-nothing across that set, so a module
|
||||||
* all-or-nothing (`startDiscoveredModuleRuntime`, `./runtime.js`), so a module that failed to load,
|
* that failed to load, register, or start takes API startup down with it rather than lingering as a
|
||||||
* register, or start takes the whole API startup down with it rather than lingering as a
|
|
||||||
* publishable-but-not-running module.
|
* publishable-but-not-running module.
|
||||||
*/
|
*/
|
||||||
export function createModuleUiPublicationRegistry(
|
export function createModuleUiPublicationRegistry(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_PERMISSION_DECLARATIONS,
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
CORE_PERMISSION_REGISTRY,
|
CORE_PERMISSION_REGISTRY,
|
||||||
@@ -110,11 +111,22 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
|||||||
expect(declaration?.id).toBe("core.dashboard.navigation.manage");
|
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([
|
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
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.",
|
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
|
||||||
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
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)).toBe(true);
|
||||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true);
|
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true);
|
expect(Object.isFrozen(declaration)).toBe(true);
|
||||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[2])).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", () => {
|
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:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
|
"core:0004_module_lifecycle_state",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -228,6 +229,7 @@ describe.sequential("live Core authorization store", () => {
|
|||||||
"ekklesia_core_dashboard_placement",
|
"ekklesia_core_dashboard_placement",
|
||||||
"ekklesia_core_grant",
|
"ekklesia_core_grant",
|
||||||
"ekklesia_core_migrations",
|
"ekklesia_core_migrations",
|
||||||
|
"ekklesia_core_module_state",
|
||||||
"ekklesia_core_platform_owner",
|
"ekklesia_core_platform_owner",
|
||||||
"ekklesia_core_platform_owner_bootstrap",
|
"ekklesia_core_platform_owner_bootstrap",
|
||||||
"ekklesia_core_session",
|
"ekklesia_core_session",
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"core:0002_authorization_integrity",
|
||||||
"core:0003_dashboard_navigation_tables",
|
"core:0003_dashboard_navigation_tables",
|
||||||
|
"core:0004_module_lifecycle_state",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
|
|||||||
planMigrations: async () => {
|
planMigrations: async () => {
|
||||||
throw new Error("database must not be planned");
|
throw new Error("database must not be planned");
|
||||||
},
|
},
|
||||||
|
readModuleLifecycle: async () => {
|
||||||
|
throw new Error("module lifecycle configuration must not be read");
|
||||||
|
},
|
||||||
runtimeSatisfies: () => true,
|
runtimeSatisfies: () => true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -78,7 +81,9 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
3 permissions registered
|
4 permissions registered
|
||||||
|
○ Module lifecycle skipped
|
||||||
|
DATABASE_URL is not configured.
|
||||||
|
|
||||||
Data definitions
|
Data definitions
|
||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
|||||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||||
planMigrations: vi.fn(async () => plan()),
|
planMigrations: vi.fn(async () => plan()),
|
||||||
|
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||||
runtimeSatisfies: vi.fn(() => true),
|
runtimeSatisfies: vi.fn(() => true),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -204,6 +205,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
"modules.discovery",
|
"modules.discovery",
|
||||||
"modules.dependencies",
|
"modules.dependencies",
|
||||||
"modules.permissions",
|
"modules.permissions",
|
||||||
|
"modules.lifecycle",
|
||||||
"schemas.discovery",
|
"schemas.discovery",
|
||||||
"migrations.discovery",
|
"migrations.discovery",
|
||||||
"database.transport",
|
"database.transport",
|
||||||
@@ -844,3 +846,173 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_PERMISSION_DECLARATIONS,
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
PermissionRegistryError,
|
PermissionRegistryError,
|
||||||
@@ -69,8 +70,9 @@ describe("composePlatformPermissionRegistry", () => {
|
|||||||
expect(registry.has("calendar.events.read")).toBe(true);
|
expect(registry.has("calendar.events.read")).toBe(true);
|
||||||
expect(registry.has("people.directory.read")).toBe(true);
|
expect(registry.has("people.directory.read")).toBe(true);
|
||||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||||
// Core's own three declarations plus one from each module.
|
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||||
expect(registry.declarations).toHaveLength(5);
|
// 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", () => {
|
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_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
"calendar.events.read",
|
"calendar.events.read",
|
||||||
"calendar.events.write",
|
"calendar.events.write",
|
||||||
"people.directory.read",
|
"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 —
|
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.
|
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
|
## Decision
|
||||||
|
|
||||||
### Publication is derived from validated module identity, never from a request path
|
### 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
|
`ModuleLifecycleStage` remains vocabulary with no runtime state machine behind it — and reporting one
|
||||||
would mean inventing it.
|
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
|
### Both URLs are server-produced, and that is the point
|
||||||
|
|
||||||
`ui.url` comes from `moduleUiArtifactUrl` (ADR 0025) and `api.baseUrl` from `moduleApiBaseUrl`, a new
|
`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[]`.
|
||||||
@@ -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
|
- [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
|
- [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
|
- [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
|
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
|
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
|
||||||
scheduled by this document.
|
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
@@ -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). |
|
| `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). |
|
| `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-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
|
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||||
deterministic `404 {"code":"NOT_FOUND"}`.
|
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||||
@@ -385,12 +387,16 @@ account.
|
|||||||
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||||||
pool (see [Module database access](#module-database-access)).
|
pool (see [Module database access](#module-database-access)).
|
||||||
4. Construct the Core identity runtime, authorization store, and evaluator.
|
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||||||
5. Start the module runtime from that exact same discovered inventory
|
5. Read the stored module enablement configuration and resolve, over that exact same discovered
|
||||||
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the
|
inventory, which modules are **eligible** to run (`resolveModuleEligibility`,
|
||||||
services object every module's `start`/`stop` hook receives.
|
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle)).
|
||||||
6. Create the Hono application, mounting the started module runtime's published routes and passing
|
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
|
||||||
the identical services object to every module HTTP handler.
|
never rediscovered for this step — passing it the services object every module's `start`/`stop`
|
||||||
7. Bind the HTTP server.
|
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
|
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
|
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.
|
the manifest shape that produces these declarations.
|
||||||
|
|
||||||
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
|
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
|
successful production startup does import each **eligible** module's compiled server entrypoint and
|
||||||
`register`/`start` hooks — this is the one place in the platform that deliberately executes module
|
run its `register`/`start` hooks — this is the one place in the platform that deliberately executes
|
||||||
code, and only after every other startup step already listed has succeeded.
|
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
|
## 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`,
|
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
|
||||||
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
|
`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
|
`CORE_PERMISSION_REGISTRY` is Core's own registry and remains what Core-only code and existing tests
|
||||||
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
|
use directly. `CORE_PERMISSION_DECLARATIONS` is the same declarations exported separately in deeply
|
||||||
same declarations exported separately in deeply immutable form, for the module composition helper
|
immutable form, for the module composition helper below to build on without reconstructing Core's own
|
||||||
below to build on without reconstructing Core's own list:
|
list:
|
||||||
|
|
||||||
| Permission | Baseline | Description |
|
| Permission | Baseline | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
| `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
|
## 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
|
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
||||||
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
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
|
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
|
declaration count (for example `4 permissions registered` with zero installed modules, since Core's
|
||||||
own declaration is always present); zero installed modules still composes successfully. If module
|
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'
|
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
|
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
|
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
|
inventory requires, so it discloses the same installed-module enumeration and nothing more; it
|
||||||
carries no filesystem path, package root, or manifest internal.
|
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
|
### Client-side permission checks are not a security boundary
|
||||||
|
|
||||||
A host may tell a module UI what permissions it believes the viewer holds
|
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
|
`afterAll` even when an assertion fails. See
|
||||||
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
[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
|
### Which kind of timing proof lives where
|
||||||
|
|
||||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||||
|
|||||||
@@ -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
|
sanitized failures. It remains outside the Docker-free `pnpm verify` gate; see the
|
||||||
[testing guide](../development/testing.md).
|
[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,
|
`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
|
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
|
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,
|
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
|
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
|
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.
|
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
|
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
|
eligible module's runtime definition before running hooks, registers all of them dependency-first
|
||||||
staged capability registry and an unpublished staged HTTP route list side by side, and starts them in
|
into an unpublished staged capability registry and an unpublished staged HTTP route list side by
|
||||||
the same order. Neither the capability registry nor the route list is published — visible on
|
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
|
`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
|
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
|
and reports any cleanup failures without leaking caught exceptions or machine paths. A failing
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
"migrations:apply": "turbo run build && node --env-file-if-exists=.env core/dist/cli/apply-migrations.js",
|
"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: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",
|
"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",
|
"modules:validate": "turbo run build && node core/dist/cli/validate-modules.js",
|
||||||
"test": "turbo run test test:root",
|
"test": "turbo run test test:root",
|
||||||
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
|
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
|
||||||
|
|||||||
@@ -1,17 +1,97 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { moduleIdSchema } from "./identifiers.js";
|
||||||
|
|
||||||
export const MODULE_LIFECYCLE_STAGES = [
|
/**
|
||||||
"discovered",
|
* The two axes of a module's operational state, kept deliberately separate because they answer
|
||||||
"validated",
|
* different questions, change at different times, and have different owners.
|
||||||
"configured",
|
*
|
||||||
"registered",
|
* **Configuration** is what an administrator decided: is this module supposed to run? It is
|
||||||
"migrated",
|
* persistent, survives restarts, and is platform-owned — a module never declares, reads, or
|
||||||
"initialized",
|
* influences it (ADR 0028). **Runtime** is what this process actually did with the module during
|
||||||
"started",
|
* *this* boot. Because Ekklesia has no hot loading or unloading, the two can legitimately
|
||||||
"stopped",
|
* disagree between a configuration change and the next restart, and the read model reports both
|
||||||
"failed",
|
* 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;
|
] 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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
MODULE_LIFECYCLE_STAGES,
|
MODULE_BLOCKED_REASONS,
|
||||||
|
MODULE_DEPENDENCY_KINDS,
|
||||||
MODULE_MANIFEST_VERSION,
|
MODULE_MANIFEST_VERSION,
|
||||||
|
MODULE_RUNTIME_STATES,
|
||||||
packageRelativePathSchema,
|
packageRelativePathSchema,
|
||||||
parseModuleManifest,
|
parseModuleManifest,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
@@ -341,9 +343,18 @@ describe("module manifest contract", () => {
|
|||||||
expect(isVersionCompatible("not-a-version", "*")).toBe(false);
|
expect(isVersionCompatible("not-a-version", "*")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("publishes the complete lifecycle vocabulary", () => {
|
it("publishes exactly the operational lifecycle vocabulary the platform implements", () => {
|
||||||
expect(MODULE_LIFECYCLE_STAGES).toContain("discovered");
|
// Every name here is produced by real code and observable through the lifecycle read model
|
||||||
expect(MODULE_LIFECYCLE_STAGES).toContain("failed");
|
// (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)", () => {
|
describe("module-owned UI entrypoint (ADR 0021)", () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
|||||||
"core/tests/authorization-store.integration.ts",
|
"core/tests/authorization-store.integration.ts",
|
||||||
"core/tests/authorization-bootstrap.integration.ts",
|
"core/tests/authorization-bootstrap.integration.ts",
|
||||||
"core/tests/dashboard-store.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/api-http.integration.ts",
|
||||||
"apps/api/tests/module-database.integration.ts",
|
"apps/api/tests/module-database.integration.ts",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user