feat: define module database access
Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView) over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and wires it into startApi's fixed startup order ahead of Identity/Authorization construction. Registration stays database-free; a module reaches its database only through an authorized start, stop, or HTTP handler invocation.
This commit is contained in:
+11
-1
@@ -1,4 +1,8 @@
|
||||
import { isAuthenticatedPrincipal, type RegisteredModuleHttpRoute } from "@ekklesia/contracts";
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type {
|
||||
AuthorizationEvaluator,
|
||||
AuthorizationStore,
|
||||
@@ -28,6 +32,11 @@ export interface CreateApiAppOptions {
|
||||
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
||||
* implicit fallback. */
|
||||
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
||||
/** The same frozen services object the caller's started module runtime already passed to every
|
||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
||||
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
||||
* `ModuleDatabase` for the lifetime of this API runtime. */
|
||||
readonly moduleServices: ModuleRuntimeServices;
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||
@@ -147,6 +156,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
services: options.moduleServices,
|
||||
trustedOrigins: allowedOrigins,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||
import { createModuleDatabaseView } from "@ekklesia/core/modules";
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly authorization: AuthorizationDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
readonly module: ModuleDatabase;
|
||||
/** 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the one physical PostgreSQL pool this process uses for both Core's identity and
|
||||
* authorization schemas: one validated, timeout-bounded `pg.Pool` (`createValidatedPostgresPool`,
|
||||
* `@ekklesia/core/database`), wrapped by two independent typed Drizzle views
|
||||
* (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Neither Core 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.
|
||||
* 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()`
|
||||
* 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.
|
||||
*/
|
||||
export function createApiDatabase(
|
||||
databaseUrl: string,
|
||||
@@ -32,5 +36,6 @@ export function createApiDatabase(
|
||||
authorization: createAuthorizationDrizzleView(pool),
|
||||
close,
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
module: createModuleDatabaseView(pool),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ModuleId,
|
||||
ModuleRouteHandlerContext,
|
||||
ModuleRuntimeServices,
|
||||
RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { Context, Hono } from "hono";
|
||||
@@ -24,6 +25,11 @@ const STRIPPED_CORS_RESPONSE_HEADERS = [
|
||||
|
||||
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
/** The same frozen services object the started module runtime's `start`/`stop` hooks already
|
||||
* received for this API runtime — never a fresh one built per request, and never touched for a
|
||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||
* after authorization succeeds. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
}
|
||||
|
||||
/** Never carries the offending permission id, route path, module id, or any other configuration
|
||||
@@ -149,7 +155,11 @@ function sanitizeModuleResponse(response: Response): Response {
|
||||
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
|
||||
* response body afterward — there is no separate `HEAD` registration or permission path.
|
||||
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
|
||||
* frozen context — never a Hono `Context`, database, store, or configuration.
|
||||
* frozen context carrying exactly `moduleId`, `principal`, `params`, and `services` — never a Hono
|
||||
* `Context`, authorization store, or configuration; `services.database` is the module's only
|
||||
* reachable database surface, and is unreachable to anything short of a successfully authorized
|
||||
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
|
||||
* this handler at all).
|
||||
*
|
||||
* Because the routes and registry both derive from the same discovered inventory
|
||||
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
||||
@@ -183,6 +193,7 @@ export function mountModuleHttpRoutes(
|
||||
moduleId: route.moduleId,
|
||||
params: Object.freeze({ ...c.req.param() }),
|
||||
principal: getAuthorizedPrincipal(c),
|
||||
services: options.services,
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
|
||||
+28
-18
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
@@ -84,12 +85,15 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
||||
* 1. Discover every installed module's manifest exactly once and compose the one immutable
|
||||
* platform permission registry from Core's own declarations plus every module's
|
||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||
* 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
|
||||
* Drizzle view over it — see `./database.js`).
|
||||
* 3. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
|
||||
* 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.
|
||||
* 5. Create the Hono application, mounting the started module runtime's published routes.
|
||||
* (`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.
|
||||
*
|
||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
@@ -97,12 +101,13 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
||||
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
||||
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
||||
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
|
||||
* 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 or
|
||||
* generates schema — the database must already be migrated through `pnpm migrations:apply`.
|
||||
* 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`.
|
||||
*/
|
||||
export async function startApi(
|
||||
config: ApiRuntimeConfig,
|
||||
@@ -122,9 +127,11 @@ export async function startApi(
|
||||
operationTimeoutMs,
|
||||
});
|
||||
|
||||
let services: ModuleRuntimeServices;
|
||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||
try {
|
||||
services = Object.freeze({ database: databaseHandle.module });
|
||||
runtime = createIdentityRuntime({
|
||||
config: config.identity,
|
||||
db: databaseHandle.identity,
|
||||
@@ -146,7 +153,7 @@ export async function startApi(
|
||||
// handle to stop here, only the pool this failure leaves open.
|
||||
let moduleRuntime: StartedModuleRuntime;
|
||||
try {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules);
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services);
|
||||
} catch (error) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
@@ -161,6 +168,7 @@ export async function startApi(
|
||||
store: authorizationStore,
|
||||
},
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleServices: services,
|
||||
runtime,
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
});
|
||||
@@ -184,12 +192,14 @@ export async function startApi(
|
||||
// would previously get an already-resolved `undefined` immediately, before the listener had
|
||||
// actually drained or the pool had actually closed. Storing the promise itself means every
|
||||
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
|
||||
// observes it complete (or fail) exactly once. Order matters: the HTTP listener is drained and
|
||||
// closed first so no new or in-flight request can reach a module handler while the module
|
||||
// runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the
|
||||
// startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine
|
||||
// shutdown result a caller should observe — but database cleanup still always runs first, so a
|
||||
// stop failure never leaves the pool open.
|
||||
// observes it complete (or fail) exactly once. Order matters, and is now additionally
|
||||
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
|
||||
// no new or in-flight request can reach a module handler while the module runtime is stopping;
|
||||
// then the module runtime stops — its stop hooks may still use `services.database`, which is
|
||||
// safe because the pool is not yet closed; only then does the shared pool close, so no module
|
||||
// code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop
|
||||
// failure here is not swallowed — it is a genuine shutdown result a caller should observe — but
|
||||
// database cleanup still always runs first, so a stop failure never leaves the pool open.
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
@@ -21,6 +26,22 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
||||
};
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||
* never real database behavior, so no PostgreSQL is required. */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
select: vi.fn(),
|
||||
selectDistinct: vi.fn(),
|
||||
selectDistinctOn: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}) as unknown as ModuleDatabase;
|
||||
return Object.freeze({ database });
|
||||
}
|
||||
|
||||
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
|
||||
* specific decision or store behavior override the relevant method explicitly. The registry
|
||||
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
|
||||
@@ -53,6 +74,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -69,6 +91,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -86,6 +109,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -105,6 +129,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -126,6 +151,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -147,6 +173,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -165,6 +192,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -182,6 +210,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -196,6 +225,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -212,6 +242,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -227,6 +258,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -240,6 +272,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -260,6 +293,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -283,6 +317,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -309,6 +344,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
};
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: evaluatorAuthorize },
|
||||
registry: emptyRegistry,
|
||||
@@ -326,6 +362,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -111,6 +111,19 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
||||
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||
}
|
||||
|
||||
/** Same globalThis-symbol technique as `createEventRecorder`, but captures an actual object
|
||||
* reference (such as `context.services.database`) rather than a string label, so a test can prove
|
||||
* two values pushed from different, independently dynamically-imported invocations (a module's own
|
||||
* `start` hook and one of its HTTP handlers) are the exact same object. */
|
||||
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
||||
const key = `ekklesia.api.lifecycle.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||
const symbol = Symbol.for(key);
|
||||
const values: unknown[] = [];
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
|
||||
eventSymbols.push(symbol);
|
||||
return { values, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||
}
|
||||
|
||||
interface WorkingFixtureModuleOptions {
|
||||
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
||||
readonly withRoute?: boolean;
|
||||
@@ -659,6 +672,47 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("gives a module's HTTP handler the exact same database facade object its own start hook received", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const refs = createReferenceRecorder();
|
||||
await createFixtureModule(modulesDirectory, "db-identity", {
|
||||
permissions: [{ baseline: "public", description: "Ping.", id: "db-identity.ping.read" }],
|
||||
});
|
||||
await writeFile(
|
||||
path.join(modulesDirectory, "db-identity", "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "db-identity",
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/ping",
|
||||
permission: "db-identity.ping.read",
|
||||
handler: async (_request, context) => {
|
||||
${refs.expression}.push(context.services.database);
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
},
|
||||
start(context) {
|
||||
${refs.expression}.push(context.services.database);
|
||||
}
|
||||
};`,
|
||||
);
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
const response = await running.app.request("/api/v1/modules/db-identity/ping");
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(refs.values).toHaveLength(2);
|
||||
expect(refs.values[0]).toBeDefined();
|
||||
expect(refs.values[0]).toBe(refs.values[1]);
|
||||
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
await createFixtureModule(modulesDirectory, "broken-export");
|
||||
@@ -789,6 +843,31 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
|
||||
});
|
||||
|
||||
it("gives a module's stop hook the exact same database facade object its start hook received, while the pool is still open", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const refs = createReferenceRecorder();
|
||||
await createFixtureModule(modulesDirectory, "db-stop-identity");
|
||||
await writeFile(
|
||||
path.join(modulesDirectory, "db-stop-identity", "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "db-stop-identity",
|
||||
version: "0.1.0",
|
||||
start(context) { ${refs.expression}.push(context.services.database); },
|
||||
stop(context) { ${refs.expression}.push(context.services.database); }
|
||||
};`,
|
||||
);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
await running.shutdown();
|
||||
|
||||
expect(refs.values).toHaveLength(2);
|
||||
expect(refs.values[0]).toBeDefined();
|
||||
expect(refs.values[0]).toBe(refs.values[1]);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
type PermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
@@ -51,24 +53,56 @@ function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRou
|
||||
return Object.freeze({ ...fields });
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
||||
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
||||
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
select: vi.fn(),
|
||||
selectDistinct: vi.fn(),
|
||||
selectDistinctOn: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}) as unknown as ModuleDatabase;
|
||||
return Object.freeze({ database });
|
||||
}
|
||||
|
||||
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function buildModuleApp(options: {
|
||||
readonly evaluator?: AuthorizationEvaluator;
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly services?: ModuleRuntimeServices;
|
||||
readonly trustedOrigins?: readonly string[];
|
||||
}) {
|
||||
const evaluator = options.evaluator ?? {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const runtime = options.runtime ?? fakeRuntime();
|
||||
const services = options.services ?? fakeModuleServices();
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
||||
moduleRoutes: options.routes,
|
||||
moduleServices: services,
|
||||
runtime,
|
||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||
});
|
||||
return { app, evaluator, runtime };
|
||||
return { app, evaluator, runtime, services };
|
||||
}
|
||||
|
||||
function permissionRegistry(
|
||||
@@ -262,6 +296,7 @@ describe("module route handler adaptation", () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
||||
});
|
||||
const services = fakeModuleServices();
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
@@ -274,6 +309,7 @@ describe("module route handler adaptation", () => {
|
||||
}),
|
||||
],
|
||||
runtime,
|
||||
services,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", {
|
||||
@@ -288,13 +324,25 @@ describe("module route handler adaptation", () => {
|
||||
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
||||
expect(capturedRequest).toBeInstanceOf(Request);
|
||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
||||
expect(capturedContext).toEqual({
|
||||
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
|
||||
// or configuration reaches a module handler.
|
||||
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
"services",
|
||||
]);
|
||||
expect(capturedContext).toMatchObject({
|
||||
moduleId: "calendar",
|
||||
params: { eventId: "evt-1" },
|
||||
principal: { kind: "authenticated", userId: "user_1" },
|
||||
});
|
||||
// Reference-identical to the exact services object the caller passed in — never a fresh one
|
||||
// built per request.
|
||||
expect(capturedContext?.services).toBe(services);
|
||||
expect(Object.isFrozen(capturedContext)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
||||
});
|
||||
|
||||
it("gives static and root routes a frozen, empty params object", async () => {
|
||||
@@ -394,12 +442,12 @@ describe("module route authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not invoke the handler when authorization denies", async () => {
|
||||
it("does not invoke the handler or touch the module database when authorization denies", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
const { app, services } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
@@ -417,6 +465,7 @@ describe("module route authorization", () => {
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
});
|
||||
|
||||
it("does not invoke the handler when principal resolution fails", async () => {
|
||||
@@ -518,7 +567,7 @@ describe("module route trusted-origin enforcement", () => {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
const { app } = buildModuleApp({
|
||||
const { app, services } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: methods.map((method) =>
|
||||
@@ -545,6 +594,7 @@ describe("module route trusted-origin enforcement", () => {
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
});
|
||||
|
||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||
@@ -647,12 +697,12 @@ describe("module route CORS", () => {
|
||||
expect(tokens.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("never invokes authorization or the handler for an OPTIONS preflight", async () => {
|
||||
it("never invokes authorization, the handler, or the module database for an OPTIONS preflight", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
const { app, services } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
@@ -673,6 +723,7 @@ describe("module route CORS", () => {
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
});
|
||||
|
||||
it("never reflects an untrusted origin for a module route", async () => {
|
||||
|
||||
Reference in New Issue
Block a user