From 0e308eb9adecb8357d4da62dca1ab3e93d1e644a Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 25 Jul 2026 20:57:32 -0500 Subject: [PATCH 1/3] feat: define module database access Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView) over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and wires it into startApi's fixed startup order ahead of Identity/Authorization construction. Registration stays database-free; a module reaches its database only through an authorized start, stop, or HTTP handler invocation. --- apps/api/src/app.ts | 12 +- apps/api/src/database.ts | 19 ++- apps/api/src/module-http-routes.ts | 13 +- apps/api/src/server.ts | 46 +++--- apps/api/tests/app.test.ts | 39 ++++- apps/api/tests/lifecycle.test.ts | 79 ++++++++++ apps/api/tests/module-http-routes.test.ts | 65 +++++++- core/src/modules/database.ts | 43 ++++++ core/src/modules/index.ts | 1 + core/src/modules/runtime.ts | 30 +++- core/tests/module-database.test.ts | 96 ++++++++++++ core/tests/module-runtime.test.ts | 144 +++++++++++++++++- packages/contracts/src/index.ts | 1 + packages/contracts/src/module-database.ts | 50 ++++++ packages/contracts/src/module-http.ts | 14 +- packages/contracts/src/module-runtime.ts | 6 + .../contracts/tests/module-database.test.ts | 95 ++++++++++++ 17 files changed, 702 insertions(+), 51 deletions(-) create mode 100644 core/src/modules/database.ts create mode 100644 core/tests/module-database.test.ts create mode 100644 packages/contracts/src/module-database.ts create mode 100644 packages/contracts/tests/module-database.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c6fd13d..4a10815 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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, }); diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index b8b6b10..928b531 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -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; } /** - * 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), }); } diff --git a/apps/api/src/module-http-routes.ts b/apps/api/src/module-http-routes.ts index 3a6faef..52c373d 100644 --- a/apps/api/src/module-http-routes.ts +++ b/apps/api/src/module-http-routes.ts @@ -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; + /** 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; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 59ef64e..6981599 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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 * 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 * 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; let authorizationStore: ReturnType; 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 | undefined; const shutdown = (): Promise => { if (!shutdownPromise) { diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index 272344c..8daa650 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -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 = {}): 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], diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index b404456..3628d25 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -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)[symbol] = values; + eventSymbols.push(symbol); + return { values, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` }; +} + interface WorkingFixtureModuleOptions { /** Declares one `GET /ping` route against `.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(); diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index f2346e0..d0fbdbb 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -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(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(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 () => { diff --git a/core/src/modules/database.ts b/core/src/modules/database.ts new file mode 100644 index 0000000..dc88dc3 --- /dev/null +++ b/core/src/modules/database.ts @@ -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), + }); +} diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index b324e7e..e2cb6ef 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -1,3 +1,4 @@ +export * from "./database.js"; export * from "./discovery.js"; export * from "./migration-discovery.js"; export * from "./permissions.js"; diff --git a/core/src/modules/runtime.ts b/core/src/modules/runtime.ts index 88157d9..60a63fd 100644 --- a/core/src/modules/runtime.ts +++ b/core/src/modules/runtime.ts @@ -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; } -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 { 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 { 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 { 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 => { 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 { const discovered = await discoverModules(options); - return startDiscoveredModuleRuntime(discovered); + return startDiscoveredModuleRuntime(discovered, options.services); } diff --git a/core/tests/module-database.test.ts b/core/tests/module-database.test.ts new file mode 100644 index 0000000..15662e8 --- /dev/null +++ b/core/tests/module-database.test.ts @@ -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(); + }); +}); diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts index aefc443..51a1f78 100644 --- a/core/tests/module-runtime.test.ts +++ b/core/tests/module-runtime.test.ts @@ -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)[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(); + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8577526..a134206 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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"; diff --git a/packages/contracts/src/module-database.ts b/packages/contracts/src/module-database.ts new file mode 100644 index 0000000..89de610 --- /dev/null +++ b/packages/contracts/src/module-database.ts @@ -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; +} diff --git a/packages/contracts/src/module-http.ts b/packages/contracts/src/module-http.ts index 4678f4e..9dd9749 100644 --- a/packages/contracts/src/module-http.ts +++ b/packages/contracts/src/module-http.ts @@ -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>; + readonly services: ModuleRuntimeServices; } /** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full diff --git a/packages/contracts/src/module-runtime.ts b/packages/contracts/src/module-runtime.ts index e61d5c7..521a3a9 100644 --- a/packages/contracts/src/module-runtime.ts +++ b/packages/contracts/src/module-runtime.ts @@ -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 { diff --git a/packages/contracts/tests/module-database.test.ts b/packages/contracts/tests/module-database.test.ts new file mode 100644 index 0000000..b6457e2 --- /dev/null +++ b/packages/contracts/tests/module-database.test.ts @@ -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[number]; + void selectedRow; + + const distinct = database.selectDistinct().from(testTable); + const distinctRow: { id: number; name: string } = {} as Awaited[number]; + void distinctRow; + + const distinctOn = database.selectDistinctOn([testTable.name]).from(testTable); + const distinctOnRow: { id: number; name: string } = {} as Awaited[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"); + }); +}); -- 2.54.0 From d1b9efd4d229dc4f6601ed493f46258b70dd7210 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 25 Jul 2026 21:26:15 -0500 Subject: [PATCH 2/3] test: prove module database access with PostgreSQL Add a real installed-module fixture (core/tests/fixtures/module-database-integration/db-facade) with its own manifest, Drizzle schema, drizzle-kit-generated migration, and permissioned HTTP routes, then exercise it end to end in a new apps/api PostgreSQL integration suite: explicit migration application before the module runtime starts (never auto-created), start/stop/HTTP handlers sharing one ModuleDatabase facade by reference, authorized read/mutation/transaction commit and rollback against real PostgreSQL, denied/rejected-origin/OPTIONS/HEAD requests never reaching the module or its table, and both a module-caught and an uncaught real database failure sanitizing exactly as pass 1 established. Registered alongside the existing suites in vitest.migrations.integration.config.ts; no production code, schema, or migration changed. --- apps/api/tests/module-database.integration.ts | 708 ++++++++++++++++++ .../db-facade/drizzle/0000_initial.sql | 4 + .../db-facade/drizzle/meta/0000_snapshot.json | 44 ++ .../db-facade/drizzle/meta/_ekklesia.json | 12 + .../db-facade/drizzle/meta/_journal.json | 13 + .../db-facade/ekklesia.module.json | 28 + .../db-facade/package.json | 9 + .../db-facade/schema/index.js | 14 + .../db-facade/server/index.js | 211 ++++++ vitest.migrations.integration.config.ts | 1 + 10 files changed, 1044 insertions(+) create mode 100644 apps/api/tests/module-database.integration.ts create mode 100644 core/tests/fixtures/module-database-integration/db-facade/drizzle/0000_initial.sql create mode 100644 core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/0000_snapshot.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_ekklesia.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_journal.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/package.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/schema/index.js create mode 100644 core/tests/fixtures/module-database-integration/db-facade/server/index.js diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts new file mode 100644 index 0000000..82112a7 --- /dev/null +++ b/apps/api/tests/module-database.integration.ts @@ -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 { + 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( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +const trackedDatabases = new Set(); + +async function createDatabase(databaseName: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return globalThis as unknown as Record; +} + +function resetRecorder(): void { + globalRecord()[RECORDER_GLOBAL_KEY] = []; +} + +function readRecorder(): RecordedEvent[] { + return (globalRecord()[RECORDER_GLOBAL_KEY] as RecordedEvent[] | undefined) ?? []; +} + +async function tableExists(url: string): Promise { + 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 { + return (await fetchRecordRow(url, id)) !== undefined; +} + +async function migrationHistoryCount(url: string, identity: string): Promise { + 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, "db-facade-owner@example.test", "correct horse battery"); + ownerCookie = await signIn(origin, "db-facade-owner@example.test", "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 { + 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; +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/drizzle/0000_initial.sql b/core/tests/fixtures/module-database-integration/db-facade/drizzle/0000_initial.sql new file mode 100644 index 0000000..59a1b3f --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/drizzle/0000_initial.sql @@ -0,0 +1,4 @@ +CREATE TABLE "db_facade_records" ( + "id" uuid PRIMARY KEY NOT NULL, + "value" text NOT NULL +); diff --git a/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/0000_snapshot.json b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..cf3f05a --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_ekklesia.json b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_ekklesia.json new file mode 100644 index 0000000..bac0d21 --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_ekklesia.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "moduleId": "db-facade", + "entries": [ + { + "tag": "0000_initial", + "destructive": { + "mode": "none" + } + } + ] +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_journal.json b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_journal.json new file mode 100644 index 0000000..e50ac98 --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1700000000000, + "tag": "0000_initial", + "breakpoints": true + } + ] +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json new file mode 100644 index 0000000..2cde66c --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json @@ -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" + } +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/package.json b/core/tests/fixtures/module-database-integration/db-facade/package.json new file mode 100644 index 0000000..b92baab --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/module-db-facade", + "version": "0.1.0", + "type": "module", + "private": true, + "ekklesia": { + "layer": "module" + } +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/schema/index.js b/core/tests/fixtures/module-database-integration/db-facade/schema/index.js new file mode 100644 index 0000000..dffb014 --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/schema/index.js @@ -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 }, +}; diff --git a/core/tests/fixtures/module-database-integration/db-facade/server/index.js b/core/tests/fixtures/module-database-integration/db-facade/server/index.js new file mode 100644 index 0000000..bf04f57 --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/server/index.js @@ -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)); + }, +}; diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 49e7c36..108e0c0 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -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, -- 2.54.0 From c38b572778b234fd640efbbc0ba4e5d3d9266a5e Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 25 Jul 2026 21:50:03 -0500 Subject: [PATCH 3/3] docs: document module database access Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015 where their original text is now inaccurate. Sweep docs/development/api.md, docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md, and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness; correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching gap in README's current-status narrative. Extend the module template with a permissioned GET /records route reading through context.services.database, using the canonical createModuleTable helper, with matching template tests. --- README.md | 15 +- ...apabilities-and-compiled-module-runtime.md | 12 +- ...dule-http-routing-and-runtime-lifecycle.md | 14 ++ .../decisions/0016-module-database-access.md | 227 ++++++++++++++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 33 ++- docs/development/api.md | 102 +++++--- docs/development/authorization.md | 28 ++- docs/development/identity.md | 9 +- docs/development/testing.md | 17 ++ docs/module-development/README.md | 88 ++++++- templates/module/README.md | 16 +- templates/module/src/schema/tables.ts | 5 +- templates/module/src/server/index.ts | 21 +- templates/module/tests/manifest.test.ts | 87 +++++-- 15 files changed, 584 insertions(+), 91 deletions(-) create mode 100644 docs/architecture/decisions/0016-module-database-access.md diff --git a/README.md b/README.md index 2256c2b..9780bc2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md b/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md index d176af2..4d9a5b1 100644 --- a/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md +++ b/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md @@ -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 diff --git a/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md b/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md index 4d99ddd..e0b0058 100644 --- a/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md +++ b/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md @@ -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 diff --git a/docs/architecture/decisions/0016-module-database-access.md b/docs/architecture/decisions/0016-module-database-access.md new file mode 100644 index 0000000..fe3da72 --- /dev/null +++ b/docs/architecture/decisions/0016-module-database-access.md @@ -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` — 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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index ce1d199..a2eca9f 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index ff85b4a..f4e1772 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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/`, 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. diff --git a/docs/development/api.md b/docs/development/api.md index 9010016..0ec7f99 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -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/`, 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/`, 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= {"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. diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 7f1954c..cfca816 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -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 diff --git a/docs/development/identity.md b/docs/development/identity.md index 8b32990..f1ba1c5 100644 --- a/docs/development/identity.md +++ b/docs/development/identity.md @@ -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. diff --git a/docs/development/testing.md b/docs/development/testing.md index aba4e2e..0b56345 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index d465eeb..2c2dbdb 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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: diff --git a/templates/module/README.md b/templates/module/README.md index 1fb6950..db25c44 100644 --- a/templates/module/README.md +++ b/templates/module/README.md @@ -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 diff --git a/templates/module/src/schema/tables.ts b/templates/module/src/schema/tables.ts index d6cef3e..63a4d71 100644 --- a/templates/module/src/schema/tables.ts +++ b/templates/module/src/schema/tables.ts @@ -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(), diff --git a/templates/module/src/server/index.ts b/templates/module/src/server/index.ts index 3fe1ecc..0b00625 100644 --- a/templates/module/src/server/index.ts +++ b/templates/module/src/server/index.ts @@ -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); + }, + }); }, }); diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 32b429f..4af5b8a 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -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; + routes: ModuleRouteDefinitionInput[]; +}> { + const provided = new Map(); + 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(); - 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", () => { -- 2.54.0