feat: add module database access #13

Merged
Zayin_Admin merged 3 commits from phase-5/module-database-access into main 2026-07-26 03:01:34 +00:00
42 changed files with 2330 additions and 142 deletions
+13 -2
View File
@@ -7,8 +7,8 @@ surfaces.
## Current status
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
Phase 4C (Core authorization), and Phase 5A (module authorization integration) have landed. The
repository includes
Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP
transport), and Phase 5C (module database access) have landed. The repository includes
public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
@@ -80,6 +80,17 @@ See [API](docs/development/api.md#module-http-routes),
[Module development](docs/module-development/README.md#http-routes), and
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred).
Phase 5C gives a module's own lifecycle hooks and HTTP handlers typed access to their own
PostgreSQL tables. `context.services.database` — a restricted, typed `ModuleDatabase` view over
Drizzle's `select`/`selectDistinct`/`selectDistinctOn`/`insert`/`update`/`delete`/`execute`/
`transaction`, never a raw `pg` client, pool, or connection string — is available to `start`,
`stop`, and every authorized route handler, and reuses the one PostgreSQL pool `apps/api` already
owns; registration remains declarative and database-free. Modules continue to own and import their
own Drizzle schemas and generated migrations, applied explicitly and never automatically at
startup. See [API](docs/development/api.md#module-database-access),
[Module development](docs/module-development/README.md#database-access), and
[ADR 0016](docs/architecture/decisions/0016-module-database-access.md).
## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
+11 -1
View File
@@ -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,
});
+12 -7
View File
@@ -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),
});
}
+12 -1
View File
@@ -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
View File
@@ -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) {
+38 -1
View File
@@ -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],
+79
View File
@@ -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();
@@ -0,0 +1,708 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { createApiDatabase } from "../src/database.js";
import { startApi, type RunningApi } from "../src/server.js";
/**
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
* (`core/tests/fixtures/module-database-integration/db-facade/`) whose lifecycle hooks and
* permissioned HTTP handlers perform real typed Drizzle operations — insert, select, update,
* transaction, execute — against its own migrated table, exclusively through the facade the
* platform hands it. Follows the exact harness conventions `apps/api/tests/api-http.integration.ts`
* and `core/tests/postgres-migration.integration.ts` already establish (self-contained per-file
* helpers, one disposable PostgreSQL database per scenario, `describe.sequential` blocks sharing a
* running API across related assertions) rather than introducing a second integration-test
* framework or Docker orchestration path.
*
* The fixture module is committed under `core/tests/fixtures/...`, not `apps/api/tests/fixtures/`,
* for one concrete reason: its schema imports `drizzle-orm/pg-core` and `@ekklesia/contracts`,
* neither of which `@ekklesia/api` declares as a direct dependency (pnpm's strict linker will not
* resolve them from anywhere under `apps/api/`), while `@ekklesia/core` already depends on both
* directly, so any file physically located under `core/`'s directory tree resolves them correctly
* regardless of which package's test actually points discovery at it. It is never registered as a
* pnpm workspace package (it matches none of `pnpm-workspace.yaml`'s glob patterns), never sits in
* the repository's live `modules/` directory, and is only ever discovered when this file explicitly
* passes its directory as `modulesDirectoryOverride` — normal production startup, Doctor, and
* `pnpm modules:validate` never see it.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const thisDirectory = path.dirname(fileURLToPath(import.meta.url));
const coreMigrationsDirectory = path.join(thisDirectory, "..", "..", "..", "core", "drizzle");
const fixtureModulesDirectory = path.join(
thisDirectory,
"..",
"..",
"..",
"core",
"tests",
"fixtures",
"module-database-integration",
);
const RECORDS_TABLE = "db_facade_records";
const RECORDER_GLOBAL_KEY = "ekklesia.integration.db-facade.recorder";
// Must match `STARTUP_RECORD_ID` in the fixture's `server/index.js` exactly.
const STARTUP_RECORD_ID = "11111111-1111-1111-1111-111111111111";
const identityBaseUrl = "https://identity.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The module database integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The module database integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
let databaseCounter = 0;
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 14);
databaseCounter += 1;
return `ekk_dbf_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomUUID().slice(0, 8)}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure remains static when connection cleanup also fails.
}
throw harnessError("The module database integration connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
const trackedDatabases = new Set<string>();
async function createDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function activeConnectionCount(databaseName: string): Promise<number> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
const rows = await safeQuery<{ count: string }>(
admin,
"SELECT count(*)::text AS count FROM pg_stat_activity WHERE datname = $1",
[databaseName],
);
return Number(rows[0]?.count ?? "0");
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
/** Applies only Core's own migrations, against an empty modules directory — the module fixture's
* migration is deliberately absent, used only by the "no auto-migration" negative scenario below. */
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(
path.join(tmpdir(), "ekklesia-db-facade-no-modules-"),
);
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { recursive: true, force: true });
}
}
/** Applies Core's migrations plus the `db-facade` fixture module's own migration — explicitly,
* before any module runtime exists — proving the platform never infers or auto-generates this
* table. Pass-1's committed core migrations and checksums must not have drifted while this pass
* was built. */
async function applyMigrationsWithModule(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: fixtureModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
"db-facade:0000_initial",
]);
expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
);
expect(aggregate.migrations[1]?.checksum).toBe(
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
}
async function startMigratedApi(
url: string,
modulesDirectoryOverride?: string,
): Promise<RunningApi> {
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "production" },
);
return startApi(config, {
rateLimitOverride: { max: 1000, window: 1 },
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
});
}
function apiOrigin(running: RunningApi): string {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw harnessError("The API integration server has no bound address.");
}
return `http://127.0.0.1:${address.port}`;
}
interface SignUpResult {
readonly userId: string;
}
async function signUp(origin: string, email: string, password: string): Promise<SignUpResult> {
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
body: JSON.stringify({ email, name: "Integration Test User", password }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const body = (await response.json()) as { user: { id: string } };
return { userId: body.user.id };
}
async function signIn(origin: string, email: string, password: string): Promise<string> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const setCookie = response.headers.get("set-cookie");
if (!setCookie) {
throw harnessError("Sign-in did not return a session cookie.");
}
return setCookie.split(",")[0]?.split(";")[0]?.trim() ?? "";
}
interface RecordedEvent {
readonly type: "register" | "start" | "stop" | "handler";
readonly services?: { readonly database: unknown };
readonly route?: string;
readonly contextKeys?: readonly string[];
}
function globalRecord(): Record<string, unknown> {
return globalThis as unknown as Record<string, unknown>;
}
function resetRecorder(): void {
globalRecord()[RECORDER_GLOBAL_KEY] = [];
}
function readRecorder(): RecordedEvent[] {
return (globalRecord()[RECORDER_GLOBAL_KEY] as RecordedEvent[] | undefined) ?? [];
}
async function tableExists(url: string): Promise<boolean> {
const admin = await openAdministrativeClient(url);
try {
const rows = await safeQuery<{ exists: boolean }>(
admin,
"select exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = $1) as exists",
[RECORDS_TABLE],
);
return rows[0]?.exists ?? false;
} finally {
await safeEnd(admin);
}
}
async function fetchRecordRow(
url: string,
id: string,
): Promise<{ id: string; value: string } | undefined> {
const admin = await openAdministrativeClient(url);
try {
const rows = await safeQuery<{ id: string; value: string }>(
admin,
`select id, value from "${RECORDS_TABLE}" where id = $1`,
[id],
);
return rows[0];
} finally {
await safeEnd(admin);
}
}
async function recordExists(url: string, id: string): Promise<boolean> {
return (await fetchRecordRow(url, id)) !== undefined;
}
async function migrationHistoryCount(url: string, identity: string): Promise<number> {
const admin = await openAdministrativeClient(url);
try {
const rows = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_migrations" where identity = $1`,
[identity],
);
return Number(rows[0]?.count ?? "0");
} finally {
await safeEnd(admin);
}
}
afterAll(async () => {
delete globalRecord()[RECORDER_GLOBAL_KEY];
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
describe.sequential("live API — module database explicit migration ownership", () => {
it("creates no table merely by applying core migrations or constructing the pool and facade — only an explicit module migration does", async () => {
const database = await createDisposableDatabase("mig_absent");
try {
expect(await tableExists(database.url)).toBe(false);
await applyCoreMigrations(database.url);
expect(await tableExists(database.url)).toBe(false);
const handle = createApiDatabase(database.url);
expect(handle.module).toBeDefined();
await handle.close();
expect(await tableExists(database.url)).toBe(false);
await applyMigrationsWithModule(database.url);
expect(await tableExists(database.url)).toBe(true);
} finally {
await dropDatabase(database.name);
}
});
it("applies the module's migration exactly once, never regenerating or reapplying it across repeated startups", async () => {
const database = await createDisposableDatabase("mig_once");
try {
await applyMigrationsWithModule(database.url);
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
const first = await startMigratedApi(database.url, fixtureModulesDirectory);
await first.shutdown();
const second = await startMigratedApi(database.url, fixtureModulesDirectory);
await second.shutdown();
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
expect(await migrationHistoryCount(database.url, "core:0000_identity_core_tables")).toBe(1);
expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe(
1,
);
} finally {
await dropDatabase(database.name);
}
});
it("fails module startup rather than silently creating the table when the module's own migration was never applied", async () => {
const database = await createDisposableDatabase("mig_skip");
try {
await applyCoreMigrations(database.url);
resetRecorder();
await expect(startMigratedApi(database.url, fixtureModulesDirectory)).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "db-facade" })],
});
// No schema inference, no automatic table creation as a side effect of the failed attempt.
expect(await tableExists(database.url)).toBe(false);
} finally {
await dropDatabase(database.name);
}
});
});
describe.sequential("live API — module database access through ModuleRuntimeServices", () => {
let database: { name: string; url: string };
let running: RunningApi;
let origin: string;
let ownerCookie: string;
it("starts a freshly migrated API; register receives no services; start writes the startup record through the real facade", async () => {
database = await createDisposableDatabase("db_facade");
await applyMigrationsWithModule(database.url);
resetRecorder();
running = await startMigratedApi(database.url, fixtureModulesDirectory);
origin = apiOrigin(running);
const events = readRecorder();
const registerEvents = events.filter((event) => event.type === "register");
const startEvents = events.filter((event) => event.type === "start");
expect(registerEvents).toHaveLength(1);
expect(registerEvents[0]?.services).toBeUndefined();
expect(startEvents).toHaveLength(1);
expect(startEvents[0]?.services?.database).toBeDefined();
const startupRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
expect(signedUp.userId).toBeDefined();
});
it("authorized read reflects real PostgreSQL state, exposes exactly moduleId/principal/params/services, and the handler's database is reference-equal to start's", async () => {
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
const events = readRecorder();
const startEvent = events.find((event) => event.type === "start");
const readHandlerEvent = [...events]
.reverse()
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
expect(readHandlerEvent?.contextKeys).toEqual(["moduleId", "params", "principal", "services"]);
expect(readHandlerEvent?.services).toBeDefined();
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
});
it("requires a trusted Origin for the module's mutating route, performs a real insert, and a later read observes it", async () => {
const id = randomUUID();
const noOrigin = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
body: JSON.stringify({ id, value: "no-origin" }),
headers: { "content-type": "application/json", cookie: ownerCookie },
method: "POST",
});
expect(noOrigin.status).toBe(403);
expect(await noOrigin.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(await recordExists(database.url, id)).toBe(false);
const created = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
body: JSON.stringify({ id, value: "created-value" }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(created.status).toBe(201);
expect(await created.json()).toEqual({ id, value: "created-value" });
const readBack = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`);
expect(readBack.status).toBe(200);
expect(await readBack.json()).toEqual({ id, value: "created-value" });
const patched = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`, {
body: JSON.stringify({ value: "patched-value" }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "PATCH",
});
expect(patched.status).toBe(200);
expect(await patched.json()).toEqual({ id, value: "patched-value" });
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "patched-value" });
});
it("denies the mutating route to an unauthenticated caller, invoking neither the handler nor a database mutation", async () => {
const id = randomUUID();
const eventsBefore = readRecorder().length;
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
body: JSON.stringify({ id, value: "should-not-persist" }),
headers: {
"content-type": "application/json",
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(readRecorder().length).toBe(eventsBefore);
expect(await recordExists(database.url, id)).toBe(false);
});
it("rejects an untrusted-Origin mutation before principal resolution, invoking neither the handler nor a database mutation", async () => {
const id = randomUUID();
const eventsBefore = readRecorder().length;
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
body: JSON.stringify({ id, value: "should-not-persist" }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: untrustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(readRecorder().length).toBe(eventsBefore);
expect(await recordExists(database.url, id)).toBe(false);
});
it("keeps OPTIONS platform-owned: never invokes module code, never mutates the table, and returns correct final CORS/Vary", async () => {
const id = randomUUID();
const eventsBefore = readRecorder().length;
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
headers: {
"access-control-request-method": "POST",
origin: dashboardOrigin,
},
method: "OPTIONS",
});
expect(response.status).toBe(204);
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
const vary = (response.headers.get("vary") ?? "").split(",").map((token) => token.trim());
expect(vary.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
expect(readRecorder().length).toBe(eventsBefore);
expect(await recordExists(database.url, id)).toBe(false);
});
it("cannot let HEAD bypass the authorized GET chain, and creates no mutation", async () => {
const anonymousHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
method: "HEAD",
});
expect(anonymousHead.status).toBe(403);
expect(await anonymousHead.text()).toBe("");
const authorizedHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
headers: { cookie: ownerCookie },
method: "HEAD",
});
expect(authorizedHead.status).toBe(200);
expect(await authorizedHead.text()).toBe("");
const countBefore = await countRecordRowsViaGet(origin, ownerCookie);
const countAfter = await countRecordRowsViaGet(origin, ownerCookie);
expect(countAfter).toBe(countBefore);
});
it("commits a real transaction and persists its result", async () => {
const id = randomUUID();
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
body: JSON.stringify({ id, value: "committed-value" }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ committed: true });
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "committed-value" });
});
it("rolls back a real transaction on a unique-constraint failure, leaving no partial mutation", async () => {
const id = randomUUID();
const conflictId = randomUUID();
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
body: JSON.stringify({ id, value: "should-roll-back", conflictId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(409);
expect(await response.json()).toEqual({ committed: false });
// The whole transaction rolled back atomically — including the first, otherwise-valid insert.
expect(await recordExists(database.url, id)).toBe(false);
expect(await recordExists(database.url, conflictId)).toBe(false);
});
it("preserves a module's own sanitized response for a database failure it deliberately catches", async () => {
const response = await fetch(`${origin}/api/v1/modules/db-facade/safe-boom`);
expect(response.status).toBe(409);
expect(await response.json()).toEqual({ code: "BOOM_HANDLED" });
});
it("sanitizes an uncaught real PostgreSQL failure to the existing MODULE_HANDLER_FAILED boundary, leaking no detail", async () => {
const response = await fetch(`${origin}/api/v1/modules/db-facade/boom`);
const text = await response.text();
expect(response.status).toBe(500);
expect(text).toBe(JSON.stringify({ code: "MODULE_HANDLER_FAILED" }));
expect(response.headers.get("cache-control")).toBe("no-store");
const lowered = text.toLowerCase();
for (const forbidden of [
"division",
"sql",
"postgres",
"22012",
"select",
"relation",
"stack",
"node_modules",
fixtureModulesDirectory.toLowerCase(),
]) {
expect(lowered).not.toContain(forbidden);
}
});
it("stops cleanly: stop receives the same services and database as start, its write completes before the pool closes, and the API shuts down", async () => {
const events = readRecorder();
const startEvent = events.find((event) => event.type === "start");
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0);
const eventsAfterShutdown = readRecorder();
const stopEvents = eventsAfterShutdown.filter((event) => event.type === "stop");
expect(stopEvents).toHaveLength(1);
expect(stopEvents[0]?.services?.database).toBe(startEvent?.services?.database);
// Read via a separate administrative connection, entirely independent of the API's own
// (now-closed) pool — proving the stop write completed and persisted before that pool closed.
const stoppedRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
expect(stoppedRow).toEqual({ id: STARTUP_RECORD_ID, value: "stopped-by-shutdown" });
await dropDatabase(database.name);
});
});
async function countRecordRowsViaGet(origin: string, cookie: string): Promise<number> {
const response = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
headers: { cookie },
});
const body = (await response.json()) as { count: number };
return body.count;
}
+58 -7
View File
@@ -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 () => {
+43
View File
@@ -0,0 +1,43 @@
import type { ModuleDatabase } from "@ekklesia/contracts";
import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
/**
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
* `createAuthorizationDrizzleView` (`core/src/authorization/database.ts`): the same "one pool, one
* typed view per domain" composition, extended to a third domain. Never constructs, registers an
* error listener on, or closes a pool itself — the caller (`apps/api/src/database.ts`) retains that
* responsibility exactly once, regardless of how many typed views it creates over the same pool.
*
* Carries no schema, unlike the identity and authorization views: a module brings its own Drizzle
* table objects to each call rather than selecting against a schema this facade would otherwise
* have to know about, so there is no `TFullSchema` for `drizzle(...)` to accept here. Building this
* view performs no query and no migration — `drizzle(pool)` only wraps the given client, the same
* synchronous, I/O-free construction `createIdentityDrizzleView`/`createAuthorizationDrizzleView`
* already rely on.
*
* Deliberately returns a fresh object with each operation explicitly bound to the underlying
* Drizzle instance, rather than the `drizzle(pool)` instance itself: Drizzle's own query methods
* are plain prototype methods that read `this.session`/`this.dialect` internally (not
* arrow-function instance properties), so handing that instance straight to a module would both
* break the moment a module destructured a method off it (`const { select } = database` loses
* `this`) and, more importantly, would leave every non-public property (`$client`, `_`, `query`,
* `$cache`, `with`) physically reachable on the object at runtime — reachable to a module written
* in plain JavaScript or one that bypasses the `ModuleDatabase` type with a cast, not just hidden
* by TypeScript. Building this literal, `Object.freeze`d object closes both gaps: only the eight
* intended operations exist on it at all, each correctly bound, and nothing else does.
*/
export function createModuleDatabaseView(pool: Pool): ModuleDatabase {
const db = drizzle(pool);
return Object.freeze({
delete: db.delete.bind(db),
execute: db.execute.bind(db),
insert: db.insert.bind(db),
select: db.select.bind(db),
selectDistinct: db.selectDistinct.bind(db),
selectDistinctOn: db.selectDistinctOn.bind(db),
transaction: db.transaction.bind(db),
update: db.update.bind(db),
});
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./database.js";
export * from "./discovery.js";
export * from "./migration-discovery.js";
export * from "./permissions.js";
+22 -8
View File
@@ -10,6 +10,7 @@ import {
type ModuleRouteDefinitionInput,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
moduleRouteShapeKey,
parseModuleRuntimeDefinition,
type RegisteredCapability,
@@ -70,7 +71,12 @@ export interface StartedModuleRuntime {
stop(): Promise<void>;
}
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {}
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {
/** The same frozen services object every started module's `start`/`stop` hooks and (once
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
readonly services: ModuleRuntimeServices;
}
interface LoadedModule {
readonly definition: ModuleRuntimeDefinition;
@@ -201,6 +207,7 @@ class StagedCapabilityRegistry {
createRuntimeContext(
module: DiscoveredModule,
phase: "rollback" | "start" | "stop",
services: ModuleRuntimeServices,
): ModuleRuntimeContext {
const capabilities: CapabilityResolver = Object.freeze({
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
@@ -208,7 +215,7 @@ class StagedCapabilityRegistry {
return this.#registered.get(capabilityId);
},
});
return Object.freeze({ capabilities, moduleId: module.manifest.id });
return Object.freeze({ capabilities, moduleId: module.manifest.id, services });
}
publish(): CapabilityRegistry {
@@ -466,11 +473,14 @@ async function stopModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
phase: "rollback" | "stop",
services: ModuleRuntimeServices,
): Promise<readonly ModuleRuntimeIssue[]> {
const issues: ModuleRuntimeIssue[] = [];
for (const module of [...modules].reverse()) {
try {
await module.definition.stop?.(registry.createRuntimeContext(module.discovered, phase));
await module.definition.stop?.(
registry.createRuntimeContext(module.discovered, phase, services),
);
} catch (error) {
if (error instanceof ModuleRuntimeSignal) {
issues.push(error.issue);
@@ -492,11 +502,14 @@ async function stopModules(
async function startModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
services: ModuleRuntimeServices,
): Promise<void> {
const started: LoadedModule[] = [];
for (const module of modules) {
try {
await module.definition.start?.(registry.createRuntimeContext(module.discovered, "start"));
await module.definition.start?.(
registry.createRuntimeContext(module.discovered, "start", services),
);
started.push(module);
} catch (error) {
const startIssue =
@@ -508,7 +521,7 @@ async function startModules(
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} startup failed.`,
);
const rollbackIssues = await stopModules(started, registry, "rollback");
const rollbackIssues = await stopModules(started, registry, "rollback", services);
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
}
}
@@ -524,12 +537,13 @@ async function startModules(
*/
export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[],
services: ModuleRuntimeServices,
): Promise<StartedModuleRuntime> {
const modules = await loadRuntimeDefinitions(discoveredModules);
const capabilityRegistry = new StagedCapabilityRegistry();
const httpRoutes = new StagedModuleHttpRoutes();
await registerModules(modules, capabilityRegistry, httpRoutes);
await startModules(modules, capabilityRegistry);
await startModules(modules, capabilityRegistry, services);
const capabilities = capabilityRegistry.publish();
const routes = httpRoutes.publish();
@@ -549,7 +563,7 @@ export async function startDiscoveredModuleRuntime(
routes,
stop: (): Promise<void> => {
stopPromise ??= (async () => {
const issues = await stopModules(modules, capabilityRegistry, "stop");
const issues = await stopModules(modules, capabilityRegistry, "stop", services);
if (issues.length > 0) {
throw new ModuleRuntimeError(issues);
}
@@ -563,5 +577,5 @@ export async function startModuleRuntime(
options: StartModuleRuntimeOptions,
): Promise<StartedModuleRuntime> {
const discovered = await discoverModules(options);
return startDiscoveredModuleRuntime(discovered);
return startDiscoveredModuleRuntime(discovered, options.services);
}
@@ -0,0 +1,4 @@
CREATE TABLE "db_facade_records" (
"id" uuid PRIMARY KEY NOT NULL,
"value" text NOT NULL
);
@@ -0,0 +1,44 @@
{
"id": "8764dbfb-a411-4727-a382-530e4cdf3a2c",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.db_facade_records": {
"name": "db_facade_records",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,12 @@
{
"version": 1,
"moduleId": "db-facade",
"entries": [
{
"tag": "0000_initial",
"destructive": {
"mode": "none"
}
}
]
}
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1700000000000,
"tag": "0000_initial",
"breakpoints": true
}
]
}
@@ -0,0 +1,28 @@
{
"manifestVersion": 3,
"id": "db-facade",
"name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
"version": "0.1.0",
"compatibility": {
"platform": "^0.1.0",
"moduleSdk": "^0.1.0"
},
"permissions": [
{
"id": "db-facade.records.read",
"baseline": "public",
"description": "Read database facade test records."
},
{
"id": "db-facade.records.write",
"baseline": "authenticated",
"description": "Write database facade test records."
}
],
"entrypoints": {
"server": "./server/index.js",
"schema": "./schema/index.js",
"migrations": "./drizzle"
}
}
@@ -0,0 +1,9 @@
{
"name": "@test/module-db-facade",
"version": "0.1.0",
"type": "module",
"private": true,
"ekklesia": {
"layer": "module"
}
}
@@ -0,0 +1,14 @@
import { moduleTableName } from "@ekklesia/contracts";
import { pgTableCreator, text, uuid } from "drizzle-orm/pg-core";
const moduleTable = pgTableCreator((localName) => moduleTableName("db-facade", localName));
export const records = moduleTable("records", {
id: uuid("id").primaryKey(),
value: text("value").notNull(),
});
export default {
moduleId: "db-facade",
tables: { records },
};
@@ -0,0 +1,211 @@
import { eq, sql } from "drizzle-orm";
import { records } from "../schema/index.js";
// A fixed (not per-run-random) globalThis symbol key: this fixture is a static, committed file
// (not a per-test generated template string), so it cannot bake a unique key into itself the way
// every other dynamically-written fixture in this codebase does. A fixed key is safe here because
// the PostgreSQL integration suite never runs its files in parallel (`fileParallelism: false`,
// `vitest.migrations.integration.config.ts`), so there is never more than one active recorder at a
// time. The test file resets this array immediately before each scenario and deletes the property
// during cleanup.
const RECORDER_KEY = "ekklesia.integration.db-facade.recorder";
// A fixed row id `start()` writes and `stop()` later updates, so both the integration test and the
// module's own HTTP handlers can address the exact same row deterministically, without needing a
// side channel to learn a generated id.
export const STARTUP_RECORD_ID = "11111111-1111-1111-1111-111111111111";
function recordEvent(event) {
const globalValues = globalThis;
if (!Array.isArray(globalValues[RECORDER_KEY])) {
globalValues[RECORDER_KEY] = [];
}
globalValues[RECORDER_KEY].push(event);
}
export default {
id: "db-facade",
version: "0.1.0",
register({ http }) {
// Registration is declarative and database-free: there is no `services` field on this
// context at all (see `ModuleRegistrationContext`), so `context.services` reads as
// `undefined` here — recorded for the integration test to assert on directly.
recordEvent({ services: undefined, type: "register" });
http.route({
method: "GET",
path: "/records/:id",
permission: "db-facade.records.read",
async handler(_request, context) {
recordEvent({
contextKeys: Object.keys(context).sort(),
services: context.services,
type: "handler",
route: "GET /records/:id",
});
const [row] = await context.services.database
.select({ id: records.id, value: records.value })
.from(records)
.where(eq(records.id, context.params.id));
if (!row) {
return Response.json({ code: "RECORD_NOT_FOUND" }, { status: 404 });
}
return Response.json(row, { status: 200 });
},
});
http.route({
method: "GET",
path: "/records-count",
// Deliberately the "write" (authenticated) permission, not "read" (public) — this is the
// fixture's one authenticated-baseline GET route, used to prove HEAD cannot bypass
// authorization the way it could if HEAD skipped the same middleware chain GET uses.
permission: "db-facade.records.write",
async handler(_request, context) {
recordEvent({
contextKeys: Object.keys(context).sort(),
services: context.services,
type: "handler",
route: "GET /records-count",
});
const result = await context.services.database.execute(
sql`select count(*)::int as count from ${records}`,
);
const row = Array.isArray(result) ? result[0] : result.rows?.[0];
return Response.json({ count: row?.count ?? 0 }, { status: 200 });
},
});
http.route({
method: "POST",
path: "/records",
permission: "db-facade.records.write",
async handler(request, context) {
recordEvent({
contextKeys: Object.keys(context).sort(),
services: context.services,
type: "handler",
route: "POST /records",
});
const { id, value } = await request.json();
await context.services.database.insert(records).values({ id, value });
return Response.json({ id, value }, { status: 201 });
},
});
http.route({
method: "PATCH",
path: "/records/:id",
permission: "db-facade.records.write",
async handler(request, context) {
recordEvent({
contextKeys: Object.keys(context).sort(),
services: context.services,
type: "handler",
route: "PATCH /records/:id",
});
const { value } = await request.json();
const updated = await context.services.database
.update(records)
.set({ value })
.where(eq(records.id, context.params.id))
.returning({ id: records.id, value: records.value });
if (updated.length === 0) {
return Response.json({ code: "RECORD_NOT_FOUND" }, { status: 404 });
}
return Response.json(updated[0], { status: 200 });
},
});
http.route({
method: "POST",
path: "/records/transactional",
permission: "db-facade.records.write",
async handler(request, context) {
recordEvent({
contextKeys: Object.keys(context).sort(),
services: context.services,
type: "handler",
route: "POST /records/transactional",
});
const { id, value, conflictId } = await request.json();
try {
await context.services.database.transaction(async (tx) => {
await tx.insert(records).values({ id, value });
if (conflictId) {
// The same primary key inserted twice inside one transaction: PostgreSQL rejects
// the second insert with a unique-violation, and the whole transaction — including
// the first, otherwise-valid insert above — must roll back atomically.
await tx.insert(records).values({ id: conflictId, value: "conflict" });
await tx.insert(records).values({ id: conflictId, value: "conflict-again" });
}
});
return Response.json({ committed: true }, { status: 201 });
} catch {
return Response.json({ committed: false }, { status: 409 });
}
},
});
http.route({
method: "GET",
path: "/boom",
permission: "db-facade.records.read",
async handler(_request, context) {
recordEvent({
contextKeys: Object.keys(context).sort(),
services: context.services,
type: "handler",
route: "GET /boom",
});
// Deliberately uncaught: a real PostgreSQL error (division by zero) must escape this
// handler so the platform's own sanitization boundary is exercised end to end.
await context.services.database.execute(sql`select 1/0`);
return Response.json({ unreachable: true });
},
});
http.route({
method: "GET",
path: "/safe-boom",
permission: "db-facade.records.read",
async handler(_request, context) {
recordEvent({
contextKeys: Object.keys(context).sort(),
services: context.services,
type: "handler",
route: "GET /safe-boom",
});
try {
await context.services.database.execute(sql`select 1/0`);
return Response.json({ unreachable: true });
} catch {
// A module deliberately catching its own database failure and returning a valid,
// sanitized response of its own choosing — the platform must preserve this untouched.
return Response.json({ code: "BOOM_HANDLED" }, { status: 409 });
}
},
});
},
async start(context) {
recordEvent({ services: context.services, type: "start" });
// `onConflictDoNothing` makes repeated startups against an already-started database safe —
// a real module's own idempotency choice, not a platform requirement — so a test can start
// the same migrated database more than once without `start()` itself failing on the fixed
// `STARTUP_RECORD_ID` row a previous run already inserted.
await context.services.database
.insert(records)
.values({ id: STARTUP_RECORD_ID, value: "startup-value" })
.onConflictDoNothing();
},
async stop(context) {
recordEvent({ services: context.services, type: "stop" });
await context.services.database
.update(records)
.set({ value: "stopped-by-shutdown" })
.where(eq(records.id, STARTUP_RECORD_ID));
},
};
+96
View File
@@ -0,0 +1,96 @@
import { integer, pgTable, text } from "drizzle-orm/pg-core";
import type { Pool } from "pg";
import { describe, expect, it, vi } from "vitest";
import { createModuleDatabaseView } from "../src/modules/database.js";
const testTable = pgTable("core_module_database_test", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
});
function fakePool(): Pool {
return {
connect: vi.fn(),
end: vi.fn(),
on: vi.fn(),
query: vi.fn(),
} as unknown as Pool;
}
describe("createModuleDatabaseView", () => {
it("wraps an externally owned pool without constructing, connecting to, registering an error listener on, or closing one itself", () => {
const pool = fakePool();
const database = createModuleDatabaseView(pool);
expect(database).toBeDefined();
expect(vi.mocked(pool.connect)).not.toHaveBeenCalled();
expect(vi.mocked(pool.end)).not.toHaveBeenCalled();
expect(vi.mocked(pool.on)).not.toHaveBeenCalled();
expect(vi.mocked(pool.query)).not.toHaveBeenCalled();
});
it("exposes exactly the intended query surface, frozen, with no raw pool/client/session/dialect escape hatch", () => {
const database = createModuleDatabaseView(fakePool());
expect(Object.keys(database).sort()).toEqual([
"delete",
"execute",
"insert",
"select",
"selectDistinct",
"selectDistinctOn",
"transaction",
"update",
]);
expect(Object.isFrozen(database)).toBe(true);
for (const forbidden of ["$client", "_", "query", "session", "dialect", "pool", "with"]) {
expect(forbidden in database).toBe(false);
}
});
it("keeps every forwarded operation correctly bound to its underlying Drizzle instance, even when destructured off the facade", async () => {
const database = createModuleDatabaseView(fakePool());
const {
select,
selectDistinct,
selectDistinctOn,
insert,
update,
delete: remove,
execute,
transaction,
} = database;
// Each of these synchronously builds a query object from `this.session`/`this.dialect` without
// performing I/O — if the forwarded method lost its `this` binding when destructured (a real JS
// pitfall for plain prototype methods), the very first property access inside would throw.
expect(() => select()).not.toThrow();
expect(() => selectDistinct()).not.toThrow();
expect(() => selectDistinctOn([testTable.name])).not.toThrow();
expect(() => insert(testTable)).not.toThrow();
expect(() => update(testTable)).not.toThrow();
expect(() => remove(testTable)).not.toThrow();
expect(() => execute("select 1")).not.toThrow();
let transactionResult: unknown;
expect(() => {
transactionResult = transaction(async () => undefined);
}).not.toThrow();
expect(transactionResult).toBeInstanceOf(Promise);
// The fake pool cannot complete a real transaction; only the synchronous binding matters here.
await Promise.resolve(transactionResult).catch(() => undefined);
});
it("never constructs a second pool — the given pool instance is the only one ever touched", () => {
const pool = fakePool();
createModuleDatabaseView(pool);
createModuleDatabaseView(pool);
// Two views built from the same pool never allocate a new connection — there is nothing on
// `pool` for either call to have invoked at all (see the construction test above), so calling
// this twice is itself the proof that no per-call pool allocation ever happens.
expect(vi.mocked(pool.connect)).not.toHaveBeenCalled();
});
});
+141 -3
View File
@@ -1,6 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverModules } from "../src/modules/discovery.js";
import {
@@ -108,11 +109,50 @@ function createEventRecorder(): { events: string[]; expression: string } {
};
}
function runtimeOptions(modulesDirectory: string) {
/** Same globalThis-symbol technique as `createEventRecorder`, but for capturing an actual object
* reference (such as `context.services`) pushed from inside a dynamically imported fixture module,
* rather than a string label — dynamic `import()` of a file:// URL still shares this process's
* heap, so a reference pushed from the fixture and read back here is the exact same object. */
function createReferenceRecorder(): { values: unknown[]; expression: string } {
const key = `ekklesia.runtime.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)})]`,
};
}
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for these unit tests, which only
* ever assert on context wiring and object identity, never on actual query behavior. */
function fakeModuleDatabase(): ModuleDatabase {
return 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;
}
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
return Object.freeze({ database: fakeModuleDatabase() });
}
function runtimeOptions(
modulesDirectory: string,
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
) {
return {
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services,
};
}
@@ -822,7 +862,7 @@ describe("already-discovered module runtime", () => {
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
expect(discoverModules).toHaveBeenCalledTimes(1);
const runtime = await startDiscoveredModuleRuntime(discovered);
const runtime = await startDiscoveredModuleRuntime(discovered, fakeModuleRuntimeServices());
expect(discoverModules).toHaveBeenCalledTimes(1);
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
await runtime.stop();
@@ -840,7 +880,10 @@ describe("already-discovered module runtime", () => {
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
const runtime = await startDiscoveredModuleRuntime([...discovered].reverse());
const runtime = await startDiscoveredModuleRuntime(
[...discovered].reverse(),
fakeModuleRuntimeServices(),
);
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
await runtime.stop();
});
@@ -857,3 +900,98 @@ describe("already-discovered module runtime", () => {
await runtime.stop();
});
});
describe("module runtime services", () => {
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const registerServices = createReferenceRecorder();
const lifecycleServices = createReferenceRecorder();
await createRuntimeModule(modulesDirectory, "wired", {
serverSource: `export default {
id: "wired", version: "0.1.0",
register(context) { ${registerServices.expression}.push(context.services); },
start(context) { ${lifecycleServices.expression}.push(context.services); },
stop(context) { ${lifecycleServices.expression}.push(context.services); }
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
await runtime.stop();
expect(registerServices.values).toEqual([undefined]);
expect(lifecycleServices.values).toHaveLength(2);
expect(lifecycleServices.values[0]).toBe(services);
expect(lifecycleServices.values[1]).toBe(services);
});
it("shares the exact same services object identity across every module's start hook", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
await createRuntimeModule(modulesDirectory, "alpha", {
serverSource: `export default {
id: "alpha", version: "0.1.0",
start(context) { ${recorder.expression}.push(context.services); }
};`,
});
await createRuntimeModule(modulesDirectory, "beta", {
manifest: manifestFor("beta", {
dependencies: { modules: { required: { alpha: "^0.1.0" } } },
}),
serverSource: `export default {
id: "beta", version: "0.1.0",
start(context) { ${recorder.expression}.push(context.services); }
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([services, services]);
await runtime.stop();
});
it("gives a rollback stop hook the same services object a start failure left available", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
await createRuntimeModule(modulesDirectory, "started", {
serverSource: `export default {
id: "started", version: "0.1.0",
start(context) { ${recorder.expression}.push(context.services); },
stop(context) { ${recorder.expression}.push(context.services); }
};`,
});
await createRuntimeModule(modulesDirectory, "failing", {
manifest: manifestFor("failing", {
dependencies: { modules: { required: { started: "^0.1.0" } } },
}),
serverSource: `export default {
id: "failing", version: "0.1.0",
start() { throw new Error("boom"); }
};`,
});
await expect(
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
).rejects.toBeInstanceOf(ModuleRuntimeError);
expect(recorder.values).toEqual([services, services]);
});
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
await runtime.stop();
expect(runtime.modules).toEqual([]);
expect(vi.mocked(services.database.select)).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();
});
});
@@ -28,14 +28,16 @@ registry, which conflicts with install-by-cloning discovery.
> ordering — remains accepted and unchanged. This section is left as originally accepted, as the
> historical record of the manifest's prior shape.
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).**
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md) and
> [ADR 0016](0016-module-database-access.md).**
> "HTTP routing... remain[s] outside this slice" (Consequences, below) and "The injected contexts
> contain only the module ID and a capability registrar or resolver" (this section, below) described
> this ADR's own scope accurately at the time. ADR 0015 implements module HTTP routing: it extends
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar, while
> `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes) remains exactly as
> limited to module ID and capability access as originally decided here. Every other statement in
> this ADR remains accepted and unchanged.
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar. ADR 0016
> further extends `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes)
> with a `services` field carrying the module database facade — `ModuleRegistrationContext` itself
> gains no such field and remains exactly as database-free as originally decided here. Every other
> statement in this ADR remains accepted and unchanged.
`ekklesia.module.json` remains the one canonical static manifest and moves to
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
@@ -162,6 +162,16 @@ configured trusted origin — never a wildcard, and never anything module-influe
### A frozen, minimal handler context and sanitized failure boundary
> **Cross-reference — see [ADR 0016](0016-module-database-access.md).**
> "A frozen context of exactly `moduleId`... and an immutable, Hono-decoded parameter record" and "A
> handler never receives a Hono `Context`, a database connection... or any other middleware-internal
> state," below, described the handler context's complete shape accurately at the time. ADR 0016
> adds a fourth field, `services`, carrying `ModuleRuntimeServices` (currently just a typed
> `ModuleDatabase` facade) — never a raw database connection, `pg.Pool`, `PoolClient`, connection
> string, or other infrastructure object. Every other statement in this section remains accepted and
> unchanged: a handler still never receives a Hono `Context`, the authorization store or evaluator,
> raw PostgreSQL access, configuration, or secrets.
A handler receives the unmodified Fetch `Request` (`c.req.raw`, so `Request.url`, headers, method,
body, and a streaming request all behave normally, and the full query string remains available
without a competing query abstraction) and a frozen context of exactly `moduleId`, the
@@ -276,6 +286,10 @@ runs, since nothing about this design sandboxes it.
## Deferred
> **Cross-reference — see [ADR 0016](0016-module-database-access.md).**
> Module database or Drizzle access, listed immediately below as deferred, is now implemented.
> Every other item in this list remains deferred and unimplemented.
Module database or Drizzle access; module storage access; jobs and workflow/event automation;
module-owned configuration and secrets services; a `ModuleRuntimeContext` authorization service;
relationship-derived grant operations; reinstall/reactivation semantics for a permission or route
@@ -0,0 +1,227 @@
# 0016: Module database access
- Status: Accepted
- Date: 2026-07-25
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) and
[0015](0015-static-module-http-routing-and-runtime-lifecycle.md)
## Context
ADR 0007 and ADR 0008 already let a module own a Drizzle schema and a validated, checksummed
migration; ADR 0009 already applies those migrations transactionally. ADR 0015 let a module mount
permissioned HTTP routes and receive an authorized principal. None of these gave a module's
*executable* code — a `start`/`stop` lifecycle hook or a route handler — any supported way to
actually query or write the tables its own schema declares. A module wanting to persist or read
product data had no platform-sanctioned path to PostgreSQL at all.
The platform already owns exactly one physical PostgreSQL pool per API runtime
(`apps/api/src/database.ts`), wrapped in typed Drizzle views for Core's own identity and
authorization schemas (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Handing a
module that raw pool, a connection string, or an independently constructed Drizzle instance would
either leak platform connection infrastructure into module code or require a second, competing
pool — both rejected by the same reasoning ADR 0006 and ADR 0015 already applied to capability
registration and HTTP routing. This ADR records the third typed view over that one pool, and the
runtime-services boundary that delivers it to trusted module code without exposing the pool
itself.
## Decision
### `ModuleDatabase`: a restricted, typed view over `NodePgDatabase`
`packages/contracts/src/module-database.ts` defines `ModuleDatabase` as
`Pick<NodePgDatabase, "select" | "selectDistinct" | "selectDistinctOn" | "insert" | "update" |
"delete" | "execute" | "transaction">` — derived from Drizzle's own `node-postgres` database type
with `Pick`, not hand-declared, so every method keeps Drizzle's own table-aware argument and
result inference exactly as-is. A module calling `database.select().from(table)` or
`database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
would; no `any` or `unknown` erasure exists anywhere in the public type. `ModuleDatabase` carries
no schema generic: a module brings its own Drizzle table objects to each call, so there is no
global relational-query object spanning every installed module's tables for this type to expose.
The type excludes everything else `NodePgDatabase` or the `drizzle(...)` factory otherwise
carries: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
`query` (a schema-bound relational query builder), `$cache`, `$count`, `$with`, `with`, and
`refreshMaterializedView`. There is no connection string, pool, session, or dialect object
reachable through the type.
### The facade physically closes the same gaps at runtime, not only in the type system
`core/src/modules/database.ts`'s `createModuleDatabaseView(pool)` does not return the
`drizzle(pool)` instance directly. Drizzle's own query methods are plain prototype methods that
read `this.session`/`this.dialect` internally, not arrow-function instance properties, so handing
that instance to a module directly would both break the moment a module destructured a method off
it (`const { select } = database` loses `this`) and — more importantly — would leave every
non-public property physically reachable on the object at runtime, reachable to a module written
in plain JavaScript or one that bypasses the `ModuleDatabase` type with a cast, not merely hidden
by TypeScript. `createModuleDatabaseView` instead builds a fresh, `Object.freeze`d object with
each of the eight operations explicitly bound to the underlying Drizzle instance: only those eight
exist on the object at all, each correctly bound, and nothing else does. Building this view
performs no query and no migration — `drizzle(pool)` only wraps the given client, synchronously
and without I/O, the same construction `createIdentityDrizzleView`/`createAuthorizationDrizzleView`
already rely on. Core never constructs a `pg.Pool` itself; `apps/api/src/database.ts` remains the
one place that does, and `createModuleDatabaseView` wraps the pool it is given.
### `ModuleRuntimeServices` is the one typed boundary carrying it into trusted module code
`ModuleRuntimeServices` (`packages/contracts/src/module-database.ts`) currently contains exactly
one field: `database: ModuleDatabase`. It is frozen, and it is never handed to a module's
`register` hook — `ModuleRegistrationContext` (ADR 0006, extended by ADR 0015 with `http`) gains no
`services` field at all, so registration remains exactly as declarative and database-free as
originally decided. `ModuleRuntimeContext` (the shared `start`/`stop` context) and
`ModuleRouteHandlerContext` (ADR 0015's authorized-handler context, now exactly `moduleId`,
`principal`, `params`, `services`) both gain a `services` field instead. For a given API runtime,
`start`, `stop`, and every authorized HTTP handler receive the *same* `ModuleRuntimeServices`
object — the same object identity, not a fresh one built per hook or per request — so a module
never needs to stash a database reference in module-global state to share it between lifecycle
code and its own route handlers; the platform simply provides it directly, every time, to every
authorized call site.
### One pool, one facade, one services object, per API runtime
`apps/api/src/server.ts`'s `startApi` composes in a fixed order: discover the module inventory once
and compose the permission registry from it; construct the one runtime `pg.Pool`
(`createApiDatabase`, wrapping identity, authorization, and module views over it); construct the
frozen `ModuleRuntimeServices` from that same pool's module view; construct Identity, the
authorization store, and the evaluator; start the already-discovered module runtime, passing it
that services object; create the Hono application and mount the started runtime's routes, passing
the identical services object; bind the HTTP server. `startDiscoveredModuleRuntime` threads the
same services object into `ModuleRuntimeContext` for every module's `start` and `stop`;
`mountModuleHttpRoutes` threads the identical object into every authorized handler's
`ModuleRouteHandlerContext`. No second pool, no second facade, and no second services object is
ever constructed for one running API. A services/Identity/Authorization construction failure
closes the one pool and starts no module; a module-runtime failure closes the pool without binding
a server; an app-construction or bind failure stops the module runtime, then closes the pool —
matching the cleanup-ordering discipline ADR 0015 already established, now additionally covering
the services object's own dependencies.
### Shutdown remains drain → stop → close, now load-bearing for in-flight database use
Normal shutdown is unchanged from ADR 0015: drain and close the HTTP listener first, so no new or
in-flight request can reach a module handler while the module runtime is stopping; stop the module
runtime next — a module's `stop` hook may still use `services.database`, which is safe because the
pool has not yet closed; only then close the shared pool, so no module code can run against it
afterward. This ordering was already correct under ADR 0015; it is now additionally load-bearing,
because a `stop` hook doing real database work depends on the pool still being open when it runs.
Shutdown remains memoized — one shared promise every concurrent or later caller awaits — and a
module-stop failure still does not prevent pool closure, though (unlike a startup-cleanup path) it
is not swallowed: it remains a genuine result a caller observes.
### Module schemas and migrations are unchanged; the facade is not a migration tool
A module continues to own and import its own Drizzle table definitions (ADR 0007) and its own
generated, checksummed migration (ADR 0008), applied transactionally and explicitly (ADR 0009).
`ModuleDatabase` exposes no migration operation, and nothing in the module runtime infers a
schema, creates a table automatically, or runs a migration as a side effect of module discovery,
registration, start, or an HTTP request. A module's schema and migration changes still follow the
existing generation, validation, policy, and deployment flow unchanged; they must be applied,
exactly as before, before starting module code that depends on the new shape.
## Trust and security boundary
Modules remain trusted, compiled backend plugins — the same trust model ADR 0006 established for
capability registration and ADR 0015 extended to HTTP handlers. `ModuleDatabase` is a deliberately
restricted *integration* boundary, not a security sandbox: it exists to keep a module's code
Drizzle-typed and decoupled from how the platform physically connects to PostgreSQL, not to defend
against a malicious module, which already runs arbitrary code in this process and could reach
PostgreSQL through other means regardless of what this facade does or does not expose. It provides
no tenant isolation, no PostgreSQL-role-level separation between modules, and no runtime
enforcement that one module's code only ever queries its own tables — table ownership is an
architectural and migration-policy convention (ADR 0007's naming/prefix rules), not something this
facade inspects or restricts at the SQL level; `execute` in particular lets a trusted module issue
arbitrary SQL, exactly as `select`/`insert`/`update`/`delete`/`transaction` already let it construct
arbitrary queries against any table object it imports. Authorization still fully protects the HTTP
path into that trusted code: `requirePermission` and, for state-changing methods,
`requireTrustedOrigin` (ADR 0013, ADR 0015) complete before a handler — and therefore before
`services.database` is ever reachable — runs. A handler-thrown database error, caught or not,
remains sanitized by the existing module-handler boundary (ADR 0015): an uncaught failure becomes
exactly `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` and no SQL,
PostgreSQL message or code, table/constraint name, stack, connection detail, or filesystem path;
this ADR introduces no second, database-specific public error contract.
## Consequences
### Positive
- A module can persist and query its own product data with full Drizzle type inference, without
ever owning a connection, a pool, or connection configuration.
- The platform still owns exactly one physical pool and one shutdown lifecycle per API runtime;
adding module database access did not require a second one.
- Lifecycle hooks and HTTP handlers share one consistent services boundary
(`ModuleRuntimeServices`), extensible the same deliberate way in the future.
- Registration remains declarative and side-effect-free, preserving every existing discovery,
Doctor, and `modules:validate` guarantee (none of them constructs the facade or executes module
code).
### Tradeoffs
- Adding a new Drizzle operation to the module-facing surface requires deliberately extending
`ModuleDatabase`'s `Pick`, not merely relying on whatever `NodePgDatabase` happens to expose.
- A trusted module can still issue arbitrary SQL through `execute`; this facade does not, and does
not claim to, prevent that.
- Table ownership remains a naming/migration-policy convention, not a runtime-enforced sandbox —
the same tradeoff ADR 0007 already accepted for schema ownership.
- Static installation and API-restart requirements are unchanged: a module's database access, like
its routes and permissions, only takes effect on the next restart.
- A module's migration must be applied before starting code that depends on it; the platform does
not infer or run it automatically, so an operator error here surfaces as a real database error
from the module's own code, sanitized like any other handler failure.
## Rejected alternatives
- **Exposing `pg.Pool` or `$client` directly** was rejected: it would leak platform connection
infrastructure into module code and make every module responsible for connection lifecycle the
platform already owns correctly.
- **Supplying `DATABASE_URL` to modules** was rejected for the same reason, plus the operational
risk of a module opening its own competing connections outside the platform's one validated pool.
- **One pool per module** was rejected: it would multiply connection overhead and configuration
surface for no isolation benefit this facade does not already provide at the type level, and
contradicts the one-pool-per-runtime model ADR 0015 already established.
- **Letting a module construct its own Drizzle instance** was rejected: it would reintroduce
exactly the raw-pool/connection-string exposure this ADR exists to avoid.
- **Database access during `register`** was rejected: registration remains declarative — the same
reasoning ADR 0006 already applied to capability provision — and a database-free registration
hook can never have a partially-open connection to clean up if discovery aborts.
- **Module-global service assignment** (a module stashing `services` in a top-level variable from
`start` for its handlers to read later) was rejected as the *documented* pattern: the platform
provides the identical object directly to every authorized call site, so no module needs to
invent its own storage for it.
- **Running migrations automatically at startup** was rejected: it would violate the existing,
explicit, operator-invoked migration model (ADR 0009) and risk running unreviewed DDL as a
side effect of an API restart.
- **A generic, string-keyed service locator** (`services.get("database")`) was rejected in favor of
a fixed, typed `ModuleRuntimeServices` shape: a locator would erase type information at exactly
the boundary this ADR exists to keep typed, and would let a module probe for services that do
not exist yet.
- **A global schema/query object spanning every installed module** (Drizzle's own schema-bound
`query` relational builder, extended to cover every module) was rejected: it would require the
platform to aggregate every module's schema into one object at runtime, reintroducing exactly the
cross-module coupling ADR 0007's per-module schema ownership already rejected.
- **SQL parsing, rewriting, or query interception as a runtime isolation boundary** was rejected:
modules are trusted compiled code already capable of running arbitrary queries through `execute`;
a parsing layer would be both bypassable and a false promise of a security boundary this facade
does not attempt to provide.
## Testing evidence
- Unit coverage (contracts, Module SDK, Core, `apps/api`) proves: the public `ModuleDatabase`
surface preserves Drizzle's table-aware inference with no `any`; raw pool/client/session/dialect
properties are absent from the type and, separately, from the constructed facade at runtime;
every forwarded method remains correctly bound when destructured; `register` receives no
services/database field; `start`/`stop` receive the same frozen services object; a module HTTP
handler's database is reference-equal to its module's lifecycle database; denied,
trusted-origin-rejected, and `OPTIONS` requests never touch the database fake; a zero-module
runtime never constructs or touches a database; startup/shutdown cleanup ordering holds under
every failure path exercised.
- A real installed-module PostgreSQL integration fixture (`core/tests/fixtures/
module-database-integration/db-facade/`, exercised from `apps/api/tests/
module-database.integration.ts`) proves the same guarantees against a real, disposable
PostgreSQL database: the module's table does not exist before its migration is explicitly
applied; constructing the pool and facade performs no DDL; starting the API never runs or
regenerates a migration; a real `start` hook write is visible to a later authorized HTTP read
through the identical facade object; a real authorized mutation persists and a later read
observes it, gated by permission enforcement and trusted-Origin checking exactly as ADR 0015
requires; a real committed transaction persists and a real rollback (a deliberate unique-
constraint violation) leaves no partial mutation; a module deliberately catching a real
PostgreSQL failure returns its own response untouched, while an uncaught real failure sanitizes
to the existing `MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; a
`stop` hook's write completes and is independently observable before the runtime's pool closes.
+1
View File
@@ -24,3 +24,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted
- [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted
- [0016: Module database access](0016-module-database-access.md) — Accepted
+25 -8
View File
@@ -95,12 +95,29 @@ identical lowest-privilege baseline status, and signup order never grants elevat
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
[the API guide](../development/api.md), and
[the identity development guide](../development/identity.md) for the underlying schema and
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out; it
is not yet possible to verify an email, recover a password, reach a Dashboard, or hold any role or
permission.
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out.
The next architectural phase is Phase 4C: Core authorization (public / authenticated-baseline /
relationship-derived / grants / platform-owner) and an explicit, auditable platform-owner bootstrap
command. Other target architecture not yet implemented includes production approval-provider
composition, physical storage-bucket provisioning, Surface SDK, richer public resource and action
contracts, dashboard, Builder, and reference feature modules.
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
registry — and an explicit, single-use, auditable platform-owner bootstrap command, plus reusable
Hono enforcement and an `/api/v1/grants` HTTP API in `apps/api`. See
[ADR 0013](decisions/0013-core-authorization-and-owner-bootstrap.md) and
[the authorization guide](../development/authorization.md).
Phase 5A wired module-declared permissions into that same authorization system — a version-3
manifest, one composed platform permission registry spanning Core's own declarations and every
installed module's, enforced through the identical middleware Core's own routes use. Phase 5B let a
module mount its own framework-neutral HTTP routes in production, under
`/api/v1/modules/<module-id>`, enforced and response-sanitized exactly like Core's own routes. This
branch completes Phase 5C: typed module database access (`context.services.database`) for a
module's lifecycle hooks and authorized HTTP handlers, over the platform's one existing PostgreSQL
pool. See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-registry.md),
[ADR 0015](decisions/0015-static-module-http-routing-and-runtime-lifecycle.md),
[ADR 0016](decisions/0016-module-database-access.md), and the repository root
[README](../../README.md#current-status) for the complete, current status of every phase.
The project continues finishing the module platform's backend foundations before Phase 6 Surface
work begins. Target architecture not yet implemented includes production approval-provider
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
scheduled by this document.
+75 -27
View File
@@ -215,11 +215,15 @@ abuse-control design.
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
`apps/api/src/platform-permissions.ts`.
2. Construct the one shared PostgreSQL pool.
3. Construct the Core identity runtime, authorization store, and evaluator.
4. Start the module runtime from that exact same discovered inventory
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step.
5. Create the Hono application, mounting the started module runtime's published routes.
6. Bind the HTTP server.
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
pool (see [Module database access](#module-database-access)).
4. Construct the Core identity runtime, authorization store, and evaluator.
5. Start the module runtime from that exact same discovered inventory
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the
services object every module's `start`/`stop` hook receives.
6. Create the Hono application, mounting the started module runtime's published routes and passing
the identical services object to every module HTTP handler.
7. Bind the HTTP server.
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
@@ -257,9 +261,11 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
(same permission check, response body stripped); a module cannot separately author `HEAD`.
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
body all behave normally, and the full query string remains available) plus a frozen context of
exactly `moduleId`, the authorized `EkklesiaPrincipal`, and frozen, Hono-decoded path parameters
(an empty frozen object for a route with none) — never a Hono `Context`, a database connection,
the authorization store, storage, jobs, configuration, or secrets.
exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an
empty frozen object for a route with none), and `services` — the same frozen
`ModuleRuntimeServices` object the module's own `start`/`stop` hooks received (see
[Module database access](#module-database-access)) — never a Hono `Context`, the authorization
store, storage, jobs, configuration, or secrets.
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
@@ -276,6 +282,46 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
registry: installing, removing, or editing a module's routes takes effect only on the next API
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
## Module database access
`context.services.database` gives a module's `start`/`stop` lifecycle hooks and every authorized
HTTP handler a typed Drizzle query surface over its own tables — never during `register`, which
stays declarative and database-free. See
[Module development](module-development/README.md#database-access) for the module-authoring
contract; this section covers only how `apps/api` composes and wires it.
- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase`
(`packages/contracts/src/module-database.ts`), exposing exactly `select`, `selectDistinct`,
`selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving
Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal
session/dialect state, no schema-bound relational `query` builder, and no connection string,
pool, or raw `pg` client — not merely hidden by the TypeScript type, but physically absent from
the constructed object at runtime (`core/src/modules/database.ts`'s `createModuleDatabaseView`
builds a frozen object with each operation explicitly bound, rather than handing out Drizzle's
own instance).
- **One pool, one facade, one services object.** `apps/api/src/database.ts`'s `createApiDatabase`
wraps the one shared PostgreSQL pool in three independent typed Drizzle views — identity,
authorization, and now `module` (a `ModuleDatabase`). `startApi` builds one frozen
`ModuleRuntimeServices` object (currently just `{ database }`) from that pool and passes the
*same* object to every module's `start` and `stop` hook and to every module HTTP handler for the
lifetime of one running API — never a fresh object per hook or per request, and never a second
pool or facade.
- **No database access without authorization.** A handler's context — and therefore
`services.database` — is unreachable until `requirePermission` (and, for a state-changing
method, `requireTrustedOrigin`) has already succeeded; a denied request, a rejected-Origin
request, and an `OPTIONS` preflight never invoke a module handler at all, so they never touch a
module's database.
- **Errors.** A handler-thrown database error is sanitized through the exact same
`MODULE_HANDLER_FAILED` boundary described above — there is no separate, database-specific public
error contract. A module that deliberately catches its own database failure may still return any
valid `Response` of its own choosing.
- **Migrations stay explicit.** Constructing the facade performs no query and no migration.
Module schema and migration changes still follow the existing generation, validation, policy, and
deployment flow (see [Module development](module-development/README.md#database-access)); nothing
in module discovery, registration, start, or an HTTP request infers a schema or creates a table
automatically. See [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the
complete design and its rejected alternatives.
## Database connection limits
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
@@ -287,10 +333,10 @@ a query is already running on one. The operation timeout additionally sets `pg`'
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
constructed, sanitized the same way an invalid connection string already is. Both the identity and
authorization domains read from this one pool through independent typed Drizzle views — see
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
not two.
constructed, sanitized the same way an invalid connection string already is. The identity,
authorization, and module domains all read from this one pool through independent typed Drizzle
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
one pool, not two.
## Graceful shutdown
@@ -300,19 +346,22 @@ shared promise the first time it is called: every caller, concurrent or sequenti
exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight
requests finish; then stops the module runtime (so no module resource stops while a route handler
might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization
both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a
startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a
genuine result a caller should observe — but pool cleanup always still runs first, so a stop
failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is
extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
might still be in flight) — a module's `stop` hook may still use `services.database` here, since
the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization,
and the module database facade all read from it — see
[Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup
path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should
observe — but pool cleanup always still runs first, so a stop failure never leaves the pool open,
and no module code can run against it afterward. The executable entry point's `SIGINT`/`SIGTERM`
wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
signal arrived" without sending a real process signal or invoking the real `process.exit`.
A startup failure after the database pool was already created always closes that pool before the
failure propagates: identity/authorization construction failing (no module runtime was started
yet); the server failing to bind its listen address; or an app-construction failure, both of which
also stop the module runtime first, since it may have already started successfully. A module
failure propagates: module-services/identity/authorization construction failing (no module runtime
was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the
same pool); the server failing to bind its listen address; or an app-construction failure, both of
which also stop the module runtime first, since it may have already started successfully. A module
load/register/start failure uses Core's own existing rollback (already-started modules stop, in
reverse order, before the failure propagates) and then closes the pool — there is no separate
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
@@ -354,10 +403,9 @@ Cookie: better-auth.session_token=<redacted, now revoked>
{"kind":"anonymous"}
```
## Next phase
## What's next
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
Dashboard Surface, which still depends on an authorization model that is only now getting started.
See the repository root [README](../../README.md#current-status) and
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
for what has landed across every phase and what remains before Surface work begins. This document
tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve.
+16 -12
View File
@@ -396,15 +396,19 @@ Every response sets `Cache-Control: no-store`.
`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api`
touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown,
and on any startup failure after the pool was already constructed (mirroring the existing
identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with three
independent typed Drizzle views — `createIdentityDrizzleView(pool)`,
`createAuthorizationDrizzleView(pool)`, and `createModuleDatabaseView(pool)` (the `ModuleDatabase`
facade module code queries through `context.services.database`; see
[API](api.md#module-database-access) and
[Module development](../module-development/README.md#database-access)) — rather than three
separate pools. `close()` on the returned handle is the only thing that ever calls `pool.end()`;
no domain view owns or closes the pool itself. `apps/api/src/server.ts` calls `createApiDatabase`
once during `startApi()`, builds the identity runtime from `.identity`, the
`AuthorizationStore`/`AuthorizationEvaluator` from `.authorization`, and the frozen
`ModuleRuntimeServices` from `.module` off that same handle, and closes the handle exactly once —
on graceful shutdown, and on any startup failure after the pool was already constructed (mirroring
the existing identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
The existing per-domain factories (`createIdentityDrizzleDatabase`,
`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are
@@ -413,9 +417,9 @@ unaffected and remain available for standalone/testable use — the CLI bootstra
the authorization schema and has no reason to share a pool with anything else.
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization
usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers
there are or whether the failure happened during startup.
`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and
module database usage), and exactly one `pool.end()` call regardless of how many concurrent
`shutdown()` callers there are or whether the failure happened during startup.
## Tests
+5 -4
View File
@@ -169,9 +169,10 @@ rejected the same sanitized way an invalid connection string already is.
## What is not operational yet
No email verification, password recovery, invitation flow, email delivery, social or OAuth
provider, passkey, magic link, two-factor authentication, or roles/permissions/platform-owner model
provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants model
exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are**
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why
public registration is intentional and grants no elevation. The next expected slice is Phase 4C:
Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard
Surface, which still depends on an authorization model that does not exist yet.
public registration is intentional and grants no elevation. Core's authorization foundation
(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is
covered separately — see [Authorization](authorization.md) — not a Dashboard Surface, which still
depends on an authorization model that only now exists and has not yet been built on.
+17
View File
@@ -97,6 +97,23 @@ database failure and asserts, against actual `console.error`/`warn`/`log` output
Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never
emits a raw exception, SQL, connection detail, or the rejected request's origin.
A third dedicated suite (`apps/api/tests/module-database.integration.ts`, included in the same
`pnpm test:migrations:integration` run) proves `context.services.database` against a real,
installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the
repository's live `modules/` directory, and invisible to normal discovery/Doctor/`modules:validate`
unless a test explicitly points discovery at it): the module's own table does not exist until its
migration is explicitly applied, and constructing the pool or facade never creates it; `start`,
`stop`, and an authorized HTTP handler all receive the identical `ModuleRuntimeServices` object,
proven by reference equality; a real authorized mutation persists and a later read observes it,
gated by the same permission/trusted-Origin enforcement every other module route uses; a real
committed transaction persists and a real rollback (a deliberate unique-constraint violation)
leaves no partial mutation; a module deliberately catching a real PostgreSQL failure returns its
own response untouched, while an uncaught one sanitizes to the existing
`MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; and a `stop` hook's write
completes and is independently verifiable, through the harness's own administrative connection —
never the API's own pool — before that pool closes. See
[ADR 0016](../architecture/decisions/0016-module-database-access.md).
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
the following pull-request status context to `main` branch protection:
+80 -8
View File
@@ -157,9 +157,9 @@ export default defineModuleRuntime({
capabilities.provide("calendar.events", calendarEvents);
},
async start({ capabilities }) {
async start({ capabilities, services }) {
const people = capabilities.resolve("people.records");
// Acquire runtime resources here.
await services.database.insert(events).values({ id: "seed", title: "First event" });
},
async stop() {
@@ -170,8 +170,11 @@ export default defineModuleRuntime({
ID and version are intentional build-artifact identity markers and must match the manifest. Core
rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its
own capabilities and the capability dependencies declared in its manifest. The runtime context is
deliberately limited to the module ID and capability access.
own capabilities and the capability dependencies declared in its manifest. The `register` context
is deliberately limited to `moduleId`, capability access, and the HTTP route registrar (below) —
never `services`. `start` and `stop` share one context shape and both additionally receive
`services` — the same frozen `ModuleRuntimeServices` object for the lifetime of one running API,
currently exposing only `database` — see [Database access](#database-access).
Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
@@ -235,10 +238,12 @@ method and path (or two paths that are identical once every parameter is treated
placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route.
The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's
already-authorized `EkklesiaPrincipal`, and the immutable decoded path parameters — never a database
connection, authorization store, grant/platform-owner state, storage, jobs, configuration, secrets,
a framework-specific request context, or a raw Better Auth session. The full query string remains
available through `Request.url`; there is no separate query or body abstraction.
already-authorized `EkklesiaPrincipal`, the immutable decoded path parameters, and `services` — the
same frozen `ModuleRuntimeServices` object the module's own `start`/`stop` hooks receive, currently
exposing only `database` (see [Database access](#database-access)) — never an authorization store,
grant/platform-owner state, storage, jobs, configuration, secrets, a framework-specific request
context, or a raw Better Auth session. The full query string remains available through
`Request.url`; there is no separate query or body abstraction.
Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production
@@ -344,6 +349,73 @@ destructive behavior. Review those properties explicitly. Core is the only migra
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
falls back to non-transactional execution.
## Database access
`context.services.database` — available in `start`, `stop`, and every authorized route handler,
never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`,
`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`,
preserving Drizzle's own table-aware argument and result inference. It is the platform's only
supported way for module code to reach PostgreSQL:
```ts
import { defineModuleRuntime } from "@ekklesia/module-sdk";
import { events } from "./schema/tables.js";
export default defineModuleRuntime({
id: "calendar",
version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/events",
permission: "calendar.events.read",
async handler(_request, context) {
const rows = await context.services.database
.select({ id: events.id, title: events.title })
.from(events);
return Response.json(rows);
},
});
},
});
```
Import your module's own table objects (`src/schema/tables.js` — the same declarations
[Drizzle schema definition](#drizzle-schema-definition) already covers) exactly as you would with
an independently constructed Drizzle instance; the only difference is that `services.database`,
not a client you built yourself, executes the query. Never:
- Construct a `Pool`, a `Client`, or a Drizzle instance in module code.
- Read `DATABASE_URL` or any other connection configuration.
- Access `$client`, a session, or a dialect object — `ModuleDatabase` exposes none of them, and not
merely at the type level: the constructed facade physically has no such property.
- Stash `services` (or `services.database`) in a module-global variable. The platform provides the
identical object directly to `start`, `stop`, and every authorized handler; there is nothing to
cache.
- Run or generate a migration from `register`, `start`, `stop`, or a route handler. Schema and
migration changes remain [explicit deployment work](#drizzle-migrations) — generate, review,
commit, and apply them the same way as any other module migration, *before* starting code that
depends on the new shape. Nothing in discovery, registration, or startup infers a schema or
creates a table automatically.
A handler's database error is sanitized through the same `500 {"code":"MODULE_HANDLER_FAILED"}`
boundary as any other handler failure (see [HTTP routes](#http-routes)) — there is no separate,
database-specific error contract. Catch a failure yourself if your route should return something
more specific; an uncaught one is sanitized automatically.
`ModuleDatabase` is an integration boundary for trusted, compiled backend module code, not a
sandbox: it exists so your module never has to construct or configure a PostgreSQL connection
itself, not to defend against malicious code, since a module already runs arbitrary code in this
process. It provides no tenant isolation and does not stop your own code from querying a table it
imports; `execute` in particular lets trusted code run arbitrary SQL, exactly as every other
operation already lets it construct arbitrary queries. Keep querying only your own module's tables
— the same convention [Drizzle schema definition](#drizzle-schema-definition) already establishes
for imports, just extended to runtime queries. See
[API](../development/api.md#module-database-access) for how `apps/api` composes and wires the
facade, and [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the complete
design.
## Discovery and lifecycle
From the repository root, run:
+1
View File
@@ -1,6 +1,7 @@
export * from "./authorization.js";
export * from "./identifiers.js";
export * from "./identity.js";
export * from "./module-database.js";
export * from "./module-http.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
+50
View File
@@ -0,0 +1,50 @@
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
/**
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
* would, with no `any`/`unknown` erasure anywhere in the chain.
*
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
* `query` (a relational-query builder tied to one schema, which would have to span every
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
* objects (`@ekklesia/module-sdk`'s `defineModuleSchema`) to each call, so there is no global
* relational schema spanning every installed module for this type to leak.
*
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
* malicious module, which this facade cannot do and does not attempt to do.
*/
export type ModuleDatabase = Pick<
NodePgDatabase,
| "select"
| "selectDistinct"
| "selectDistinctOn"
| "insert"
| "update"
| "delete"
| "execute"
| "transaction"
>;
/**
* The complete set of platform-provided services a trusted module's `start`/`stop` lifecycle hooks
* and authorized HTTP route handlers receive — for this phase, exactly one: `database`. Frozen, and
* never handed to a module's `register` hook (`ModuleRegistrationContext` has no `services` field):
* registration stays declarative and database-free, exactly as before this contract existed.
* Deliberately has no storage, jobs, configuration, secrets, or authorization field yet — those
* remain out of scope until a later phase explicitly adds them here, one at a time, the same way
* `database` was added.
*/
export interface ModuleRuntimeServices {
readonly database: ModuleDatabase;
}
+9 -5
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { EkklesiaPrincipal } from "./identity.js";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
import type { ModuleRuntimeServices } from "./module-database.js";
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
// it would create a source dependency cycle with that module, which also imports from this one for
@@ -124,16 +125,19 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
}
/**
* What a module's route handler receives about the caller and the match — never a database
* connection, authorization-store, grant-management, storage, jobs, configuration, secrets, the
* Hono `Context`, or a raw Better Auth session. `principal` is already authorized by the time a
* handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route before ever
* invoking its handler; this contracts-layer module never invokes a handler itself.
* What a module's route handler receives about the caller, the match, and the platform services
* available to it — never an authorization-store, grant-management, storage, jobs, configuration,
* secrets, the Hono `Context`, or a raw Better Auth session. `principal` is already authorized by
* the time a handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route
* before ever invoking its handler; this contracts-layer module never invokes a handler itself.
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
* hooks receive for this API runtime — never a fresh one built per request.
*/
export interface ModuleRouteHandlerContext {
readonly moduleId: ModuleId;
readonly principal: EkklesiaPrincipal;
readonly params: Readonly<Record<string, string>>;
readonly services: ModuleRuntimeServices;
}
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
+6
View File
@@ -1,5 +1,6 @@
import type { CapabilityId, ModuleId } from "./identifiers.js";
import { moduleIdSchema } from "./identifiers.js";
import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
import { semanticVersionSchema } from "./versions.js";
@@ -29,9 +30,14 @@ export interface ModuleRegistrationContext {
readonly moduleId: ModuleId;
}
/** The same context shape for both `start` and `stop`: whichever hook runs, it receives the exact
* same frozen `services` object identity as the other, for a given API runtime — never a fresh
* `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never
* carries `http`: a route can only ever be declared during `register`. */
export interface ModuleRuntimeContext {
readonly capabilities: CapabilityResolver;
readonly moduleId: ModuleId;
readonly services: ModuleRuntimeServices;
}
export interface ModuleRuntimeDefinition {
@@ -0,0 +1,95 @@
import { integer, pgTable, text } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
const testTable = pgTable("contracts_module_database_test", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
});
/**
* Never invoked — exists solely so `pnpm typecheck` (part of `pnpm verify`) proves `ModuleDatabase`
* preserves Drizzle's own table-aware argument and result inference for every required operation,
* with no `any`/`unknown` erasure anywhere in the chain. A regression that widened any of these
* signatures to `any` would still compile a *call* to the method but would break one of the
* explicit row-shape assertions below.
*/
function typeOnlyOperationInference(database: ModuleDatabase): void {
const selected = database.select({ id: testTable.id, name: testTable.name }).from(testTable);
const selectedRow: { id: number; name: string } = {} as Awaited<typeof selected>[number];
void selectedRow;
const distinct = database.selectDistinct().from(testTable);
const distinctRow: { id: number; name: string } = {} as Awaited<typeof distinct>[number];
void distinctRow;
const distinctOn = database.selectDistinctOn([testTable.name]).from(testTable);
const distinctOnRow: { id: number; name: string } = {} as Awaited<typeof distinctOn>[number];
void distinctOnRow;
const inserted = database.insert(testTable).values({ id: 1, name: "a" });
void inserted;
const updated = database.update(testTable).set({ name: "b" });
void updated;
const deleted = database.delete(testTable);
void deleted;
const executed = database.execute("select 1");
void executed;
void database.transaction(async (tx) => {
const rows: Array<{ id: number }> = await tx.select({ id: testTable.id }).from(testTable);
return rows;
});
}
void typeOnlyOperationInference;
/**
* Never invoked — proves at typecheck time that `ModuleDatabase` structurally excludes every raw
* PostgreSQL/Drizzle escape hatch a module must never reach through this facade. If any of these
* properties were ever (re)added to the picked surface, the corresponding `@ts-expect-error` would
* itself become a type error ("unused '@ts-expect-error' directive"), failing `pnpm typecheck`.
*/
function typeOnlyExclusions(database: ModuleDatabase): void {
// @ts-expect-error `$client` (the raw pg client/pool) is not part of the public facade.
void database.$client;
// @ts-expect-error `_` (internal session/dialect/schema state) is not part of the public facade.
void database._;
// @ts-expect-error `query` (a schema-bound relational query builder) is not part of the public facade.
void database.query;
// @ts-expect-error a raw connection pool is not part of the public facade.
void database.pool;
// @ts-expect-error a raw connection string is not part of the public facade.
void database.connectionString;
// @ts-expect-error migration operations are not part of the public facade.
void database.migrate;
}
void typeOnlyExclusions;
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
const database: ModuleDatabase = services.database;
void database;
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
void services.storage;
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
void services.jobs;
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
void services.authorization;
}
void typeOnlyServicesShape;
describe("ModuleDatabase", () => {
it("is a real, importable type contract enforced by pnpm typecheck", () => {
// The meaningful assertions for this module-level type contract are compile-time
// (`typeOnlyOperationInference`, `typeOnlyExclusions`, `typeOnlyServicesShape` above, checked
// by `pnpm typecheck`); this keeps the file a genuine, executed Vitest suite rather than a
// file Vitest would otherwise skip for declaring no runnable test.
expect(typeof typeOnlyOperationInference).toBe("function");
expect(typeof typeOnlyExclusions).toBe("function");
expect(typeof typeOnlyServicesShape).toBe("function");
});
});
+12 -4
View File
@@ -2,8 +2,11 @@
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
generated module-owned migration, and logical storage-bucket declarations. Migration execution,
API routing, and storage provisioning are intentionally not included.
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
route (`GET /records`, declared in `register`) that reads that table through
`context.services.database` — the platform-provided `ModuleDatabase` facade, never a
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
provisioning are intentionally not included.
To create a module inside this repository:
@@ -14,7 +17,10 @@ To create a module inside this repository:
4. Keep the manifest capability versions and dependency ranges aligned with the contracts the
module actually implements and consumes.
5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version
must match the manifest; do not duplicate the rest of the manifest in TypeScript.
must match the manifest; do not duplicate the rest of the manifest in TypeScript. Declare HTTP
routes only in `register`, against a permission the manifest already declares; `start`, `stop`,
and every route handler — never `register` — receive `context.services.database`, a typed
`ModuleDatabase` view over the platform's one PostgreSQL pool.
6. Replace the pure Drizzle declarations in `src/schema/tables.ts` and keep its table prefix aligned
with the module ID. Keep the `defineModuleSchema` ownership wrapper in `src/schema/index.ts`
aligned too, and never import another module's schema.
@@ -29,7 +35,9 @@ To create a module inside this repository:
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
and release them in `stop`.
and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a
raw `pg` client — `context.services.database` is the only supported way to query this module's own
tables, and it is never available during `register`.
The migration directory uses the pinned stable Drizzle Kit `0.31.10` PostgreSQL journal and
snapshot format. Migrations are forward-only and may modify only this module's objects. Offline
+3 -2
View File
@@ -1,6 +1,7 @@
import { pgTableCreator, text, uuid } from "drizzle-orm/pg-core";
import { createModuleTable } from "@ekklesia/module-sdk";
import { text, uuid } from "drizzle-orm/pg-core";
const moduleTable = pgTableCreator((localName) => `example_${localName}`);
const moduleTable = createModuleTable("example");
export const records = moduleTable("records", {
id: uuid("id").primaryKey(),
+20 -1
View File
@@ -1,4 +1,5 @@
import { defineModuleRuntime } from "@ekklesia/module-sdk";
import { records } from "../schema/tables.js";
export const recordsCapability = Object.freeze({
kind: "example-records",
@@ -7,7 +8,25 @@ export const recordsCapability = Object.freeze({
export default defineModuleRuntime({
id: "example",
version: "0.1.0",
register({ capabilities }) {
register({ capabilities, http }) {
capabilities.provide("example.records", recordsCapability);
// The accepted module database boundary: `context.services.database` — provided only to an
// authorized handler, never to `register` — is this module's one way to reach PostgreSQL. It
// is a fully typed Drizzle query surface over the module's own table (`../schema/tables.js`),
// never a raw `pg` client, a connection string, or a Drizzle instance this module built
// itself. `example.records.read` is declared in `ekklesia.module.json`, exactly like every
// other permission a module route enforces.
http.route({
method: "GET",
path: "/records",
permission: "example.records.read",
async handler(_request, context) {
const rows = await context.services.database
.select({ id: records.id, value: records.value })
.from(records);
return Response.json(rows);
},
});
},
});
+69 -18
View File
@@ -1,7 +1,12 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import {
ANONYMOUS_PRINCIPAL,
type CapabilityId,
type ModuleDatabase,
type ModuleRouteDefinitionInput,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
parseDrizzleMigrationJournal,
parseModuleManifest,
parseModuleMigrationPolicy,
@@ -12,6 +17,38 @@ import { describe, expect, it } from "vitest";
import moduleSchema, { records } from "../src/schema/index.js";
import moduleRuntime, { recordsCapability } from "../src/server/index.js";
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for this unit test, which only
* proves the example handler queries through the facade and shapes its response correctly. */
function fakeModuleDatabase(rows: readonly { id: string; value: string }[]): ModuleDatabase {
const selectBuilder = { from: () => Promise.resolve(rows) };
return { select: () => selectBuilder } as unknown as ModuleDatabase;
}
async function registerAndCaptureRoutes(): Promise<{
provided: Map<CapabilityId, unknown>;
routes: ModuleRouteDefinitionInput[];
}> {
const provided = new Map<CapabilityId, unknown>();
const routes: ModuleRouteDefinitionInput[] = [];
await moduleRuntime.register?.({
capabilities: {
provide(capabilityId, implementation) {
provided.set(capabilityId, implementation);
},
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
return undefined;
},
},
http: {
route(definition) {
routes.push(definition);
},
},
moduleId: "example",
});
return { provided, routes };
}
describe("module template", () => {
it("ships a valid backend-only manifest", async () => {
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
@@ -59,27 +96,41 @@ describe("module template", () => {
expect(sql.toString("utf8")).toContain('CREATE TABLE "example_records"');
});
it("exports the typed runtime definition used by the compiled server entrypoint", async () => {
const provided = new Map<CapabilityId, unknown>();
await moduleRuntime.register?.({
capabilities: {
provide(capabilityId, implementation) {
provided.set(capabilityId, implementation);
},
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
return undefined;
},
},
http: {
route() {
throw new Error("The template does not register any HTTP route.");
},
},
moduleId: "example",
});
it("exports the typed runtime definition used by the compiled server entrypoint, registering its one example route database-free", async () => {
const { provided, routes } = await registerAndCaptureRoutes();
expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" });
expect(provided.get("example.records")).toBe(recordsCapability);
expect(routes).toHaveLength(1);
expect(routes[0]).toMatchObject({
method: "GET",
path: "/records",
permission: "example.records.read",
});
});
it("queries the module's own table through context.services.database in the example route handler", async () => {
const { routes } = await registerAndCaptureRoutes();
const route = routes[0];
if (!route) {
throw new Error("Expected the example route to be registered.");
}
const fakeRows = [{ id: "9d3f9f7e-3b1a-4c9a-8b8e-2b6b1e6f9a11", value: "hello" }];
const services: ModuleRuntimeServices = Object.freeze({
database: fakeModuleDatabase(fakeRows),
});
const context: ModuleRouteHandlerContext = Object.freeze({
moduleId: "example",
params: Object.freeze({}),
principal: ANONYMOUS_PRINCIPAL,
services,
});
const response = await route.handler(new Request("http://localhost/records"), context);
expect(response).toBeInstanceOf(Response);
expect(await response.json()).toEqual(fakeRows);
});
it("exports a namespaced module-owned Drizzle schema", () => {
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
"core/tests/authorization-store.integration.ts",
"core/tests/authorization-bootstrap.integration.ts",
"apps/api/tests/api-http.integration.ts",
"apps/api/tests/module-database.integration.ts",
],
passWithNoTests: false,
testTimeout: 30_000,