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"); + }); +});