feat: add the minimal operational module lifecycle
Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
+35
-1
@@ -10,7 +10,11 @@ import type {
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||
import type {
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleLifecycleStore,
|
||||
ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
@@ -18,6 +22,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||
|
||||
@@ -49,6 +54,20 @@ export interface CreateApiAppOptions {
|
||||
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
|
||||
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||
/**
|
||||
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
|
||||
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
|
||||
* Required rather than defaulted, like every other module option here — a caller exercising no
|
||||
* modules says so with an empty snapshot, never falls through to an implicit one.
|
||||
*
|
||||
* The snapshot must come from the same boot as `moduleRoutes` and `moduleUi`; that is what makes
|
||||
* "running" an observation of this process rather than a prediction. See
|
||||
* `./module-lifecycle-routes.js`.
|
||||
*/
|
||||
readonly moduleLifecycle: {
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly store: ModuleLifecycleStore;
|
||||
};
|
||||
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
|
||||
* rather than defaulted, like every other module option here: a caller that exercises no
|
||||
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
|
||||
@@ -216,6 +235,21 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}),
|
||||
);
|
||||
|
||||
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
|
||||
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
||||
// rather than inside it. See `./module-lifecycle-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createModuleLifecycleRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
snapshot: options.moduleLifecycle.snapshot,
|
||||
store: options.moduleLifecycle.store,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
|
||||
@@ -9,13 +9,18 @@ import {
|
||||
} from "@ekklesia/core/dashboard";
|
||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||
import { createModuleDatabaseView } from "@ekklesia/core/modules";
|
||||
import {
|
||||
createModuleDatabaseView,
|
||||
createModuleLifecycleView,
|
||||
type ModuleLifecycleDrizzleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly authorization: AuthorizationDrizzleDatabase;
|
||||
readonly dashboard: DashboardDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
readonly module: ModuleDatabase;
|
||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||
readonly close: () => Promise<void>;
|
||||
@@ -24,9 +29,10 @@ export interface ApiDatabaseHandle {
|
||||
/**
|
||||
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by three
|
||||
* independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||
* `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()`
|
||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
||||
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleDatabaseView`). No domain
|
||||
* owns or closes the pool in this composition — `close()`
|
||||
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||
* for callers that only need one domain.
|
||||
@@ -43,5 +49,6 @@ export function createApiDatabase(
|
||||
dashboard: createDashboardDrizzleView(pool),
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
module: createModuleDatabaseView(pool),
|
||||
moduleLifecycle: createModuleLifecycleView(pool),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
type ModuleEligibilityResolution,
|
||||
resolveModuleEligibility,
|
||||
type StartedModuleRuntime,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
@@ -321,12 +324,15 @@ function bindServer(
|
||||
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
||||
* then the Core identity runtime, authorization store, and evaluator.
|
||||
* 4. Start the module runtime from that exact same discovered inventory
|
||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
|
||||
* same services object every module's `start`/`stop` hook receives.
|
||||
* 5. Create the Hono application, mounting the started module runtime's published routes and
|
||||
* passing the exact same services object to every module HTTP handler.
|
||||
* 6. Bind the Node HTTP server.
|
||||
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
||||
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
||||
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
||||
* rediscovering modules a second time — passing it the same services object every module's
|
||||
* `start`/`stop` hook receives.
|
||||
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
|
||||
* UI for that same eligible set, and passing the exact same services object to every module HTTP
|
||||
* handler.
|
||||
* 7. Bind the Node HTTP server.
|
||||
*
|
||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
|
||||
@@ -336,10 +342,20 @@ function bindServer(
|
||||
* module database facade, identity, authorization) exists before any executable module lifecycle
|
||||
* code runs, and every module route is published only after the entire module runtime has
|
||||
* registered and started successfully, so no request can ever reach a module before every module
|
||||
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point
|
||||
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
|
||||
* applies migrations, generates schema, or issues a module database query merely because modules
|
||||
* were discovered — the database must already be migrated through `pnpm migrations:apply`.
|
||||
* has started. A module that is disabled, or blocked by a disabled required dependency, is never
|
||||
* imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
|
||||
* published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
|
||||
* there is nothing mounted to check.
|
||||
*
|
||||
* Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration
|
||||
* cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` rather
|
||||
* than starting every discovered module: the platform cannot know what an administrator decided, and
|
||||
* starting a module they disabled is worse than not starting at all.
|
||||
*
|
||||
* Returns a handle with an idempotent `shutdown()` so both the executable entry point and tests can
|
||||
* stop the server, the module runtime, and the shared pool deterministically. Never applies
|
||||
* migrations, generates schema, or issues a module database query merely because modules were
|
||||
* discovered — the database must already be migrated through `pnpm migrations:apply`.
|
||||
*/
|
||||
export async function startApi(
|
||||
config: ApiRuntimeConfig,
|
||||
@@ -384,12 +400,33 @@ export async function startApi(
|
||||
platformOwners: authorizationStore,
|
||||
});
|
||||
|
||||
// Read the administrator's enablement decisions and resolve which discovered modules may actually
|
||||
// start, before a single module entrypoint is imported. A failure here fails startup rather than
|
||||
// falling back to "start everything": the platform cannot know what it was told, and starting a
|
||||
// module an administrator disabled is worse than not starting at all.
|
||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||
let eligibility: ModuleEligibilityResolution;
|
||||
try {
|
||||
eligibility = resolveModuleEligibility(
|
||||
inventory.modules,
|
||||
await moduleLifecycleStore.readConfiguration(),
|
||||
);
|
||||
} catch (error) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Module load/register/start failures use Core's own existing rollback (already-started
|
||||
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
||||
// handle to stop here, only the pool this failure leaves open.
|
||||
//
|
||||
// Only eligible modules are started, and startup remains all-or-nothing across *that* set: an
|
||||
// ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no
|
||||
// routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves
|
||||
// discovery's topological order, so a started module's required dependencies still precede it.
|
||||
let moduleRuntime: StartedModuleRuntime;
|
||||
try {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||
...(options.moduleHookTimeoutMsOverride === undefined
|
||||
? {}
|
||||
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||
@@ -409,11 +446,23 @@ export async function startApi(
|
||||
},
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleServices: services,
|
||||
// Built from the exact same discovered inventory, and only once the module runtime has
|
||||
// started — module startup is all-or-nothing, so every module in this list is a running
|
||||
// module, and a module that failed to load, register, or start never reaches this line at
|
||||
// all. Modules declaring no `entrypoints.ui` are simply absent from the registry.
|
||||
moduleUi: createModuleUiPublicationRegistry(inventory.modules),
|
||||
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||
// inventory, and only once startup has succeeded — so the published set is the running set. A
|
||||
// disabled or dependency-blocked module therefore publishes no artifact, appears in no browser
|
||||
// inventory, and appears in no resolved Dashboard navigation, by construction rather than by a
|
||||
// filter somewhere downstream. Modules declaring no `entrypoints.ui` are simply absent.
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
moduleLifecycle: {
|
||||
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
|
||||
// reports it actually started — read from the runtime rather than assumed equal to the
|
||||
// eligible set.
|
||||
snapshot: Object.freeze({
|
||||
discoveredModules: inventory.modules,
|
||||
eligibility: eligibility.entries,
|
||||
startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)),
|
||||
}),
|
||||
store: moduleLifecycleStore,
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
runtime,
|
||||
|
||||
@@ -195,6 +195,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -612,6 +613,32 @@ async function signIn(
|
||||
return { cookiePair: setCookie ? firstCookiePair(setCookie) : undefined, response };
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs up an account over real HTTP, grants it platform-owner authority through Core's own real
|
||||
* bootstrap operation (exactly what `pnpm authorization:bootstrap-owner` performs), and signs it
|
||||
* in — so a scenario needing an administrator exercises the genuine evaluator path rather than a
|
||||
* stubbed decision.
|
||||
*/
|
||||
async function signUpPlatformOwner(
|
||||
origin: string,
|
||||
url: string,
|
||||
): Promise<{ readonly cookie: string; readonly userId: string }> {
|
||||
const email = `owner-${randomBytes(6).toString("hex")}@example.test`;
|
||||
const password = "correct horse battery staple";
|
||||
const created = await signUp(origin, email, password);
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(url);
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(created.body.user.id) });
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
const session = await signIn(origin, email, password);
|
||||
if (!session.cookiePair) {
|
||||
throw harnessError("The platform-owner session cookie was not issued.");
|
||||
}
|
||||
return { cookie: session.cookiePair, userId: created.body.user.id };
|
||||
}
|
||||
|
||||
async function fetchPrincipal(
|
||||
origin: string,
|
||||
headers: Record<string, string> = {},
|
||||
@@ -1055,7 +1082,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
||||
});
|
||||
|
||||
describe.sequential("live API — startup and migration guarantees", () => {
|
||||
it("does not create any tables or migration history when started against an unmigrated database", async () => {
|
||||
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
|
||||
const database = await createDisposableDatabase("unmigrated");
|
||||
try {
|
||||
const config = createApiRuntimeConfig(
|
||||
@@ -1071,33 +1098,36 @@ describe.sequential("live API — startup and migration guarantees", () => {
|
||||
port: 0,
|
||||
},
|
||||
// Development validation, for the same reason `startConfiguredApi` documents: this scenario
|
||||
// drives a real sign-up request, which production configuration refuses to enable at all.
|
||||
// uses the disposable plaintext loopback database, which production configuration refuses.
|
||||
{ environment: "development" },
|
||||
);
|
||||
const running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||
const origin = apiOrigin(running);
|
||||
|
||||
// Startup reads the stored module enablement configuration before importing any module
|
||||
// entrypoint (ADR 0028). Against an unmigrated database that read cannot succeed, and the
|
||||
// platform fails closed rather than starting every discovered module: it cannot know what an
|
||||
// administrator decided, and starting a module they disabled is worse than not starting.
|
||||
// The failure is Core's own sanitized store error — no connection string, host, SQL, driver
|
||||
// message, or stack.
|
||||
let captured: unknown;
|
||||
try {
|
||||
// A request that touches the (nonexistent) identity tables fails, but must not repair
|
||||
// or create anything. The exact failure body is not asserted here (Better Auth's
|
||||
// internal-error shape for this case is not a stable public contract); only that it is
|
||||
// not a successful sign-up.
|
||||
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||
body: JSON.stringify({
|
||||
email: "[email protected]",
|
||||
name: "Integration Test User",
|
||||
password: "correct horse battery staple",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).not.toBe(200);
|
||||
} finally {
|
||||
await running.shutdown();
|
||||
await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({
|
||||
code: "MODULE_LIFECYCLE_READ_FAILED",
|
||||
name: "ModuleLifecycleStoreError",
|
||||
});
|
||||
const rendered = `${String(captured)} ${JSON.stringify(captured)}`;
|
||||
expect(rendered).not.toContain(database.name);
|
||||
expect(rendered).not.toContain("127.0.0.1");
|
||||
expect(rendered).not.toContain("select");
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
// The invariant this scenario has always existed to protect, unchanged and now proven on a
|
||||
// path that never even reached Better Auth: a failed start repairs nothing, creates
|
||||
// nothing, and applies no migration.
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
"select count(*)::text as count from information_schema.tables where table_schema = 'public'",
|
||||
@@ -2159,9 +2189,10 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||
expect(permissionsCheck).toMatchObject({
|
||||
// Core's own three (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`) plus the fixture module's one.
|
||||
details: ["4 permissions registered"],
|
||||
// Core's own four (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`) plus the fixture
|
||||
// module's one.
|
||||
details: ["5 permissions registered"],
|
||||
status: "pass",
|
||||
});
|
||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||
@@ -2979,6 +3010,283 @@ describe.sequential("live API — Node header/request timeouts and client errors
|
||||
* disposable database is closed in `finally`, and each scenario carries its own outer deadline so
|
||||
* a regression fails rather than hangs.
|
||||
*/
|
||||
describe.sequential("live API — module lifecycle governs what actually runs", () => {
|
||||
/** Writes one explicit enablement decision through the real table, before the API starts —
|
||||
* exactly what `pnpm modules:disable` or a prior `PUT` would have left behind. Nothing in this
|
||||
* suite reaches into the store through a double. */
|
||||
async function storeEnablement(url: string, moduleId: string, enabled: boolean): Promise<void> {
|
||||
const client = await openAdministrativeClient(url);
|
||||
try {
|
||||
await safeQuery(
|
||||
client,
|
||||
`insert into ekklesia_core_module_state (module_id, enabled) values ($1, $2)
|
||||
on conflict (module_id) do update set enabled = excluded.enabled`,
|
||||
[moduleId, enabled],
|
||||
);
|
||||
} finally {
|
||||
await safeEnd(client);
|
||||
}
|
||||
}
|
||||
|
||||
it("never imports, registers, starts, or mounts a disabled module, and still runs its neighbours", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
||||
permissions: [
|
||||
{ baseline: "public", description: "Read calendar events.", id: "calendar.events.read" },
|
||||
],
|
||||
});
|
||||
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||
permissions: [
|
||||
{
|
||||
baseline: "public",
|
||||
description: "Read announcements.",
|
||||
id: "announcements.events.read",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_off");
|
||||
await applyCoreMigrations(database.url);
|
||||
await storeEnablement(database.url, "announcements", false);
|
||||
|
||||
const running = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
|
||||
// The enabled module really did run: its markers are present, and its route answers.
|
||||
expect(recorder.events).toContain("calendar:register");
|
||||
expect(recorder.events).toContain("calendar:start");
|
||||
const calendarRoute = await fetch(`${origin}/api/v1/modules/calendar/events/abc`);
|
||||
expect(calendarRoute.status).toBe(200);
|
||||
|
||||
// The disabled module ran nothing at all. Not "registered but skipped", not "started and
|
||||
// gated per request" — its compiled entrypoint was never imported, so no hook could run.
|
||||
expect(recorder.events).not.toContain("announcements:register");
|
||||
expect(recorder.events).not.toContain("announcements:start");
|
||||
expect(recorder.events.filter((event) => event.startsWith("announcements:"))).toEqual([]);
|
||||
|
||||
// No route was ever mounted, so the platform's ordinary 404 answers — there is no per-request
|
||||
// "is this module enabled?" check anywhere, because there is nothing mounted to check.
|
||||
const disabledRoute = await fetch(`${origin}/api/v1/modules/announcements/events/abc`);
|
||||
expect(disabledRoute.status).toBe(404);
|
||||
expect(await disabledRoute.json()).toEqual({ code: "NOT_FOUND" });
|
||||
|
||||
// Shutdown never invokes a stop hook for a module that never started.
|
||||
await running.shutdown();
|
||||
expect(recorder.events).toContain("calendar:stop");
|
||||
expect(recorder.events).not.toContain("announcements:stop");
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks a dependent of a disabled required dependency, and explains why over HTTP", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createFixtureModule(modulesDirectory, "storage", recorder);
|
||||
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
});
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_block");
|
||||
await applyCoreMigrations(database.url);
|
||||
await storeEnablement(database.url, "storage", false);
|
||||
|
||||
const running = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
// Neither ran: the disabled dependency by decision, the dependent because starting it would
|
||||
// violate its own required-dependency contract.
|
||||
expect(recorder.events).toEqual([]);
|
||||
|
||||
const owner = await signUpPlatformOwner(origin, database.url);
|
||||
const lifecycle = await fetch(`${origin}/api/v1/module-lifecycle`, {
|
||||
headers: { cookie: owner.cookie, origin: dashboardOrigin },
|
||||
});
|
||||
expect(lifecycle.status).toBe(200);
|
||||
const body = (await lifecycle.json()) as {
|
||||
readonly modules: readonly {
|
||||
readonly id: string;
|
||||
readonly configured: { readonly enabled: boolean; readonly explicit: boolean };
|
||||
readonly runtime: {
|
||||
readonly state: string;
|
||||
readonly reason?: string;
|
||||
readonly dependency?: { readonly kind: string; readonly moduleId: string };
|
||||
};
|
||||
readonly restartRequired: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
expect(body.modules).toEqual([
|
||||
{
|
||||
configured: { enabled: false, explicit: true },
|
||||
id: "storage",
|
||||
restartRequired: false,
|
||||
runtime: { state: "disabled" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
id: "announcements",
|
||||
restartRequired: false,
|
||||
runtime: {
|
||||
dependency: { kind: "module", moduleId: "storage" },
|
||||
reason: "required_dependency_disabled",
|
||||
state: "blocked",
|
||||
},
|
||||
version: "0.1.0",
|
||||
},
|
||||
]);
|
||||
// The operator's question — "why is this module unavailable?" — answered without a log line,
|
||||
// and without a filesystem path anywhere in the payload.
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain(modulesDirectory);
|
||||
expect(raw).not.toContain("dist/");
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies a change made over HTTP only at the next start, and says so in the meantime", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
||||
permissions: [
|
||||
{ baseline: "public", description: "Read calendar events.", id: "calendar.events.read" },
|
||||
],
|
||||
});
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_restart");
|
||||
await applyCoreMigrations(database.url);
|
||||
|
||||
const first = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(first);
|
||||
const owner = await signUpPlatformOwner(origin, database.url);
|
||||
|
||||
const mutation = await fetch(`${origin}/api/v1/module-lifecycle/calendar/enabled`, {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: owner.cookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
expect(mutation.status).toBe(200);
|
||||
expect(await mutation.json()).toEqual({
|
||||
configured: { enabled: false, explicit: true },
|
||||
id: "calendar",
|
||||
restartRequired: true,
|
||||
// Honest: this process really is still serving that module.
|
||||
runtime: { state: "running" },
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
// And it demonstrably still is — nothing was hot-unloaded to make the change feel immediate.
|
||||
expect((await fetch(`${origin}/api/v1/modules/calendar/events/abc`)).status).toBe(200);
|
||||
} finally {
|
||||
await first.shutdown().catch(() => undefined);
|
||||
}
|
||||
|
||||
recorder.events.length = 0;
|
||||
const second = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(second);
|
||||
// The next start applies the stored decision: nothing imported, nothing mounted.
|
||||
expect(recorder.events).toEqual([]);
|
||||
expect((await fetch(`${origin}/api/v1/modules/calendar/events/abc`)).status).toBe(404);
|
||||
} finally {
|
||||
await second.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a disabled module's UI out of the inventory and out of Dashboard navigation", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
await createUiModule(
|
||||
modulesDirectory,
|
||||
"shown",
|
||||
"1.0.0",
|
||||
'export default { id: "shown", version: "1.0.0", mount() {} };\n',
|
||||
);
|
||||
await createUiModule(
|
||||
modulesDirectory,
|
||||
"hidden",
|
||||
"1.0.0",
|
||||
'export default { id: "hidden", version: "1.0.0", mount() {} };\n',
|
||||
);
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_ui");
|
||||
await applyCoreMigrations(database.url);
|
||||
await storeEnablement(database.url, "hidden", false);
|
||||
|
||||
const running = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
const owner = await signUpPlatformOwner(origin, database.url);
|
||||
const headers = { cookie: owner.cookie, origin: dashboardOrigin };
|
||||
|
||||
const inventory = (await (await fetch(`${origin}/api/v1/module-ui`, { headers })).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
const navigation = (await (
|
||||
await fetch(`${origin}/api/v1/dashboard/navigation`, { headers })
|
||||
).json()) as {
|
||||
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
|
||||
};
|
||||
|
||||
expect(inventory.modules.map((entry) => entry.id)).toEqual(["shown"]);
|
||||
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["shown"]);
|
||||
|
||||
// The artifact itself is unreachable too, even to a caller that knows both halves of its
|
||||
// identity — publication is derived from the eligible set, not from the file's existence.
|
||||
const artifact = await fetch(`${origin}/api/v1/module-ui/hidden/1.0.0`);
|
||||
expect(artifact.status).toBe(404);
|
||||
expect((await fetch(`${origin}/api/v1/module-ui/shown/1.0.0`)).status).toBe(200);
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("still discovers a disabled module's migrations: deployed schema ownership is not runtime state", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createFixtureModule(modulesDirectory, "calendar", recorder);
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_migrations");
|
||||
await applyCoreMigrations(database.url);
|
||||
await storeEnablement(database.url, "calendar", false);
|
||||
|
||||
// Migration discovery is offline and reads the deployed inventory, never the enablement table.
|
||||
// A disabled module's tables must still be migrated, or enabling it later would need a schema
|
||||
// change in the middle of a restart.
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — bounded HTTP drain and shutdown", () => {
|
||||
async function withDeadline<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -88,6 +89,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -107,6 +109,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -127,6 +130,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -149,6 +153,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -173,6 +178,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -197,6 +203,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -218,6 +225,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -241,6 +249,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -261,6 +270,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -283,6 +293,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -312,6 +323,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -342,6 +354,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -377,6 +390,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -394,6 +408,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -413,6 +428,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -431,6 +447,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -447,6 +464,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -470,6 +488,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -496,6 +515,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
@@ -525,6 +545,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
@@ -545,6 +566,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
|
||||
@@ -26,6 +26,10 @@ import { createApiApp } from "../src/app.js";
|
||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -137,6 +141,10 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
@@ -48,6 +48,21 @@ function validConfig() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one query behavior every fake pool in this file needs: startup reads the stored module
|
||||
* enablement configuration (`ModuleLifecycleStore.readConfiguration`) before it imports any module
|
||||
* entrypoint, and an installation that has never disabled anything genuinely has zero rows. Returning
|
||||
* an empty result set is therefore the accurate stand-in for a real, migrated, unconfigured database
|
||||
* — not a way to skip the read. The tests that care about a *specific* configuration drive the real
|
||||
* resolver directly instead (`core/tests/module-lifecycle.test.ts`) or supply real rows through the
|
||||
* real store (`core/tests/module-lifecycle-store.integration.ts`).
|
||||
*/
|
||||
function emptyQuery() {
|
||||
return vi
|
||||
.fn()
|
||||
.mockResolvedValue({ command: "SELECT", fields: [], oid: 0, rowCount: 0, rows: [] });
|
||||
}
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
async function createTemporaryModulesDirectory(): Promise<string> {
|
||||
@@ -359,7 +374,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { server } = mockSuccessfulServe();
|
||||
@@ -375,7 +390,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -395,7 +410,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { server } = mockSuccessfulServe();
|
||||
@@ -411,7 +426,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("closes an already-created database pool when identity construction fails during startup", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockImplementation(() => {
|
||||
throw new Error("postgresql://admin:[email protected]/x");
|
||||
@@ -429,7 +444,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -467,7 +482,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -520,7 +535,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockFailingServe(new Error("EADDRINUSE"));
|
||||
@@ -584,7 +599,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
||||
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -621,7 +636,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
||||
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -673,7 +688,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
||||
async function startAndCaptureClientErrorHandler() {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { clientErrorHandler } = mockSuccessfulServe();
|
||||
@@ -891,7 +906,7 @@ describe("Ekklesia API module permission composition at startup", () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -933,7 +948,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
return { poolEnd };
|
||||
@@ -1131,6 +1146,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
recorder.events.push("pool:end");
|
||||
}),
|
||||
on: vi.fn(),
|
||||
query: emptyQuery(),
|
||||
};
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
@@ -221,6 +221,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -247,6 +248,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
@@ -444,6 +446,7 @@ describe.sequential("live API — module database explicit migration ownership",
|
||||
expect(
|
||||
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
|
||||
).toBe(1);
|
||||
expect(await migrationHistoryCount(database.url, "core:0004_module_lifecycle_state")).toBe(1);
|
||||
} finally {
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
mergedVaryHeader,
|
||||
ModuleRouteConfigurationError,
|
||||
} from "../src/module-http-routes.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -109,6 +110,7 @@ function buildModuleApp(options: {
|
||||
moduleRoutes: options.routes,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type ModuleLifecycleStore,
|
||||
ModuleLifecycleStoreError,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: `@test/module-${fixture.id}`,
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const defaultFixtures: readonly ModuleFixture[] = [
|
||||
{ id: "storage" },
|
||||
{ id: "announcements", dependencies: { modules: { required: { storage: "^1.0.0" } } } },
|
||||
];
|
||||
|
||||
interface InstallOptions {
|
||||
readonly fixtures?: readonly ModuleFixture[];
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly platformOwner?: boolean;
|
||||
readonly bootDisabled?: readonly string[];
|
||||
readonly store?: ModuleLifecycleStore;
|
||||
/** Permissions this principal actually holds through a grant, evaluated by Core's real evaluator
|
||||
* — never a bypass, so a delegated holder is exercised the same way a real one would be. */
|
||||
readonly grants?: readonly string[];
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
readonly app: Hono;
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
readonly store: ModuleLifecycleStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
|
||||
* composition: resolve eligibility from the stored configuration, then build the UI publication
|
||||
* registry and the boot snapshot from the **eligible** list rather than the discovered one. Doing it
|
||||
* the same way here is what makes these assertions evidence about production composition rather than
|
||||
* about a bespoke test wiring.
|
||||
*/
|
||||
async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-lifecycle-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
for (const fixture of options.fixtures ?? defaultFixtures) {
|
||||
await createModule(modulesDirectory, fixture);
|
||||
}
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
const store =
|
||||
options.store ??
|
||||
createInMemoryModuleLifecycleStore(
|
||||
(options.bootDisabled ?? []).map((moduleId) => ({ enabled: false, moduleId })),
|
||||
);
|
||||
const bootConfiguration = await store.readConfiguration();
|
||||
const eligibility = resolveModuleEligibility(modules, bootConfiguration);
|
||||
|
||||
const held = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
|
||||
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const authorizationStore = {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
// Permission composition still runs over the whole discovered inventory, unchanged: a
|
||||
// disabled module's permissions stay registered and grantable, and nothing enforces them
|
||||
// because it mounts no routes.
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleLifecycle: {
|
||||
snapshot: {
|
||||
discoveredModules: modules,
|
||||
eligibility: eligibility.entries,
|
||||
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
|
||||
},
|
||||
store,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
return { app, modules, store };
|
||||
}
|
||||
|
||||
function owner(): InstallOptions {
|
||||
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
|
||||
}
|
||||
|
||||
interface LifecycleEntry {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly configured: { readonly enabled: boolean; readonly explicit: boolean };
|
||||
readonly runtime: {
|
||||
readonly state: string;
|
||||
readonly reason?: string;
|
||||
readonly dependency?: { readonly kind: string; readonly moduleId: string };
|
||||
};
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
|
||||
async function readLifecycle(app: Hono): Promise<readonly LifecycleEntry[]> {
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as { readonly modules: readonly LifecycleEntry[] };
|
||||
return body.modules;
|
||||
}
|
||||
|
||||
function setEnabled(app: Hono, moduleId: string, enabled: boolean, origin = trustedOrigin) {
|
||||
return app.request(`${MODULE_LIFECYCLE_PATH}/${moduleId}/enabled`, {
|
||||
body: JSON.stringify({ enabled }),
|
||||
headers: { "content-type": "application/json", origin },
|
||||
method: "PUT",
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/v1/module-lifecycle", () => {
|
||||
it("reports every discovered module with both axes separated", async () => {
|
||||
const { app } = await install(owner());
|
||||
|
||||
expect(await readLifecycle(app)).toEqual([
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
id: "storage",
|
||||
restartRequired: false,
|
||||
runtime: { state: "running" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
id: "announcements",
|
||||
restartRequired: false,
|
||||
runtime: { state: "running" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("explains a disabled module and the dependent it blocks, with a structured reason", async () => {
|
||||
const { app } = await install({ ...owner(), bootDisabled: ["storage"] });
|
||||
|
||||
expect(await readLifecycle(app)).toEqual([
|
||||
{
|
||||
configured: { enabled: false, explicit: true },
|
||||
id: "storage",
|
||||
restartRequired: false,
|
||||
runtime: { state: "disabled" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
id: "announcements",
|
||||
restartRequired: false,
|
||||
runtime: {
|
||||
dependency: { kind: "module", moduleId: "storage" },
|
||||
reason: "required_dependency_disabled",
|
||||
state: "blocked",
|
||||
},
|
||||
version: "1.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("is not cached and varies on origin and cookie", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(response.headers.get("Vary")).toContain("Cookie");
|
||||
});
|
||||
|
||||
it("refuses an anonymous caller", async () => {
|
||||
const { app } = await install();
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
});
|
||||
|
||||
it("refuses an ordinary authenticated user who holds no lifecycle permission", async () => {
|
||||
// The same principal that `core.modules.ui.read` (baseline `authenticated`) would admit: this
|
||||
// resource is deliberately restricted, because it is the only place a disabled module is
|
||||
// visible at all.
|
||||
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||
const lifecycle = await app.request(MODULE_LIFECYCLE_PATH);
|
||||
|
||||
expect(inventory.status).toBe(200);
|
||||
expect(lifecycle.status).toBe(403);
|
||||
});
|
||||
|
||||
it("admits a non-owner holding exactly core.modules.lifecycle.manage through a real grant", async () => {
|
||||
const { app } = await install({
|
||||
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
expect((await app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
|
||||
// Holding it confers no module authority: the Dashboard's own manage permission is untouched by
|
||||
// this grant, so a lifecycle administrator cannot reorganize navigation.
|
||||
const reorganize = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||
body: JSON.stringify({ label: "Media", order: 10 }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
expect(reorganize.status).toBe(403);
|
||||
});
|
||||
|
||||
it("reports a sanitized 503 when the configuration cannot be read, never a driver detail", async () => {
|
||||
const failing: ModuleLifecycleStore = {
|
||||
readConfiguration: async () => {
|
||||
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_READ_FAILED");
|
||||
},
|
||||
setEnabled: async () => {
|
||||
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED");
|
||||
},
|
||||
};
|
||||
// Boot with a working store, then swap in the failing one for the request path only.
|
||||
const { app } = await install({ ...owner(), store: failingAfterBoot(failing) });
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_LIFECYCLE_READ_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
/** Answers the one boot-time read with an empty configuration, then fails every later call — so a
|
||||
* request-path failure can be exercised without making startup composition itself impossible. */
|
||||
function failingAfterBoot(failing: ModuleLifecycleStore): ModuleLifecycleStore {
|
||||
let bootRead = false;
|
||||
return {
|
||||
async readConfiguration() {
|
||||
if (!bootRead) {
|
||||
bootRead = true;
|
||||
return { modules: [] };
|
||||
}
|
||||
return failing.readConfiguration();
|
||||
},
|
||||
setEnabled: (input) => failing.setEnabled(input),
|
||||
};
|
||||
}
|
||||
|
||||
describe("PUT /api/v1/module-lifecycle/:moduleId/enabled", () => {
|
||||
it("persists a decision and reports honestly that the running process has not changed", async () => {
|
||||
const { app } = await install(owner());
|
||||
|
||||
const response = await setEnabled(app, "announcements", false);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
configured: { enabled: false, explicit: true },
|
||||
id: "announcements",
|
||||
// Not `disabled`: this process is still serving that module. Claiming otherwise would be a
|
||||
// lie about a module that is still running.
|
||||
restartRequired: true,
|
||||
runtime: { state: "running" },
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the dependents of a newly disabled module as restart-pending too", async () => {
|
||||
const { app } = await install(owner());
|
||||
|
||||
await setEnabled(app, "storage", false);
|
||||
|
||||
expect((await readLifecycle(app)).map((entry) => [entry.id, entry.restartRequired])).toEqual([
|
||||
["storage", true],
|
||||
["announcements", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("is idempotent, and re-enabling clears the pending restart", async () => {
|
||||
const { app } = await install(owner());
|
||||
|
||||
await setEnabled(app, "announcements", false);
|
||||
await setEnabled(app, "announcements", false);
|
||||
const reEnabled = await setEnabled(app, "announcements", true);
|
||||
|
||||
expect(await reEnabled.json()).toEqual({
|
||||
configured: { enabled: true, explicit: true },
|
||||
id: "announcements",
|
||||
restartRequired: false,
|
||||
runtime: { state: "running" },
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a module this process cannot see, rather than storing an unverifiable record", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await setEnabled(app, "not-installed", false);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a malformed module identifier without reaching the store", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await setEnabled(app, "Not%20A%20Module", false);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an untrusted origin before authorization or the store runs", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await setEnabled(app, "announcements", false, untrustedOrigin);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a missing origin", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("refuses an authenticated caller without the restricted permission", async () => {
|
||||
const { app, store } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||
|
||||
const response = await setEnabled(app, "announcements", false);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-boolean value", { enabled: "false" }],
|
||||
["a missing field", {}],
|
||||
["an unexpected key alongside a valid one", { enabled: false, force: true }],
|
||||
["an array", []],
|
||||
["a bare string", "disabled"],
|
||||
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
|
||||
const { app, store } = await install(owner());
|
||||
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
|
||||
const { app } = await install(owner());
|
||||
const oversized = JSON.stringify({ enabled: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) });
|
||||
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
||||
body: oversized,
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("never leaks a filesystem path, package name, or manifest path through either verb", async () => {
|
||||
const { app } = await install(owner());
|
||||
const listed = await (
|
||||
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
|
||||
).text();
|
||||
const mutated = await (await setEnabled(app, "announcements", false)).text();
|
||||
|
||||
for (const payload of [listed, mutated]) {
|
||||
expect(payload).not.toContain("/tmp");
|
||||
expect(payload).not.toContain("@test/module-");
|
||||
expect(payload).not.toContain("ekklesia.module.json");
|
||||
expect(payload).not.toContain("dist/");
|
||||
expect(payload).not.toContain("entrypoint");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("lifecycle state governs module UI exposure", () => {
|
||||
it("keeps a disabled module out of the mountable inventory and out of Dashboard navigation", async () => {
|
||||
const { app } = await install({
|
||||
bootDisabled: ["announcements"],
|
||||
platformOwner: true,
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
const navigation = (await (await app.request(DASHBOARD_NAVIGATION_PATH)).json()) as {
|
||||
readonly groups: readonly { readonly modules: readonly { readonly name: string }[] }[];
|
||||
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
|
||||
};
|
||||
|
||||
// Discovered, validated, and declaring a real compiled UI artifact — and still absent, because
|
||||
// publication is built from the eligible set rather than the discovered one.
|
||||
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage"]);
|
||||
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["storage"]);
|
||||
expect(navigation.groups).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a dependency-blocked module out of both, exactly like an explicitly disabled one", async () => {
|
||||
const { app } = await install({
|
||||
bootDisabled: ["storage"],
|
||||
platformOwner: true,
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
|
||||
expect(inventory.modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not remove a module from navigation merely because a restart is pending", async () => {
|
||||
const { app } = await install({
|
||||
platformOwner: true,
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
await setEnabled(app, "announcements", false);
|
||||
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
|
||||
// The module really is still mounted and still serving; hiding its UI now would break a viewer
|
||||
// mid-session for a change that has not taken effect. `restartRequired` is where that pending
|
||||
// change is reported.
|
||||
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage", "announcements"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lifecycle prefix is a sibling of the module route namespace", () => {
|
||||
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
|
||||
// `moduleRoutePathSchema` accepts `enabled` as an ordinary static segment, so a module named
|
||||
// `announcements` may legitimately register `PUT /enabled` — mounted at exactly
|
||||
// `/api/v1/modules/announcements/enabled`. If the platform published its lifecycle mutation
|
||||
// there, registration order would decide which one answers.
|
||||
expect(MODULE_LIFECYCLE_PATH.startsWith("/api/v1/modules/")).toBe(false);
|
||||
expect(MODULE_LIFECYCLE_PATH).toBe("/api/v1/module-lifecycle");
|
||||
|
||||
const { app } = await install(owner());
|
||||
const collision = await app.request("/api/v1/modules/announcements/enabled", {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(collision.status).toBe(404);
|
||||
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
|
||||
/**
|
||||
* The whole chain, with nothing between the halves faked:
|
||||
@@ -213,6 +217,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
|
||||
@@ -28,6 +28,10 @@ import { createApiApp } from "../src/app.js";
|
||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||
@@ -169,6 +173,10 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
MODULE_UI_CONTENT_TYPE,
|
||||
moduleUiArtifactUrl,
|
||||
} from "../src/module-ui-routes.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
|
||||
@@ -169,6 +173,10 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ModuleEnablementRecord } from "@ekklesia/contracts";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
ModuleLifecycleConfiguration,
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleLifecycleStore,
|
||||
SetModuleEnabledInput,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { ModuleLifecycleStoreError, resolveModuleEligibility } from "@ekklesia/core/modules";
|
||||
|
||||
/**
|
||||
* An in-memory `ModuleLifecycleStore` for tests that exercise **routing and resolution**, not
|
||||
* persistence.
|
||||
*
|
||||
* It reproduces only what those tests depend on — records survive a write and read back in
|
||||
* module-id order, and an invalid identifier is rejected — and deliberately reproduces nothing
|
||||
* else. The real table's constraints, the upsert's conflict target, and the fact that a record for
|
||||
* an undeployed module is retained are proven against real PostgreSQL in
|
||||
* `core/tests/module-lifecycle-store.integration.ts`, so this double can never quietly stand in for
|
||||
* a persistence guarantee nobody checked.
|
||||
*/
|
||||
export function createInMemoryModuleLifecycleStore(
|
||||
seed: readonly ModuleEnablementRecord[] = [],
|
||||
): ModuleLifecycleStore {
|
||||
const records = new Map<string, boolean>(
|
||||
seed.map((record) => [record.moduleId, record.enabled] as const),
|
||||
);
|
||||
return Object.freeze({
|
||||
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
|
||||
return Object.freeze({
|
||||
modules: Object.freeze(
|
||||
[...records.entries()]
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([moduleId, enabled]) => Object.freeze({ enabled, moduleId })),
|
||||
),
|
||||
});
|
||||
},
|
||||
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
|
||||
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(input.moduleId)) {
|
||||
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
|
||||
}
|
||||
records.set(input.moduleId, input.enabled);
|
||||
return Object.freeze({ enabled: input.enabled, moduleId: input.moduleId });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the boot snapshot the same way `startApi` does — resolve eligibility from the discovered
|
||||
* inventory and the stored configuration, then record what would have started — rather than
|
||||
* hand-writing one, so a test can never assert against a snapshot the real composition would never
|
||||
* produce.
|
||||
*/
|
||||
export function moduleLifecycleSnapshotFor(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
configuration: ModuleLifecycleConfiguration = { modules: [] },
|
||||
): ModuleLifecycleSnapshot {
|
||||
const resolution = resolveModuleEligibility(discoveredModules, configuration);
|
||||
return Object.freeze({
|
||||
discoveredModules,
|
||||
eligibility: resolution.entries,
|
||||
startedModuleIds: Object.freeze(resolution.eligibleModules.map((module) => module.manifest.id)),
|
||||
});
|
||||
}
|
||||
|
||||
/** The lifecycle option for a `createApiApp` caller that exercises no modules at all — the explicit
|
||||
* "no modules" statement the required option asks for, never an implicit default. */
|
||||
export function emptyModuleLifecycle(): {
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly store: ModuleLifecycleStore;
|
||||
} {
|
||||
return {
|
||||
snapshot: moduleLifecycleSnapshotFor([]),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user