feat: define module database access

Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen
ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object
through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler
context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView)
over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and
wires it into startApi's fixed startup order ahead of Identity/Authorization construction.
Registration stays database-free; a module reaches its database only through an authorized start,
stop, or HTTP handler invocation.
This commit is contained in:
ekklesia
2026-07-25 20:57:32 -05:00
parent 9c9ede0418
commit 0e308eb9ad
17 changed files with 702 additions and 51 deletions
+58 -7
View File
@@ -1,8 +1,10 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
parsePermissionId,
type PermissionId,
type RegisteredModuleHttpRoute,
@@ -51,24 +53,56 @@ function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRou
return Object.freeze({ ...fields });
}
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
* required. Each `database` method stays a distinct spy so a test can assert none of them were
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
function fakeModuleServices(): ModuleRuntimeServices {
const database = Object.freeze({
delete: vi.fn(),
execute: vi.fn(),
insert: vi.fn(),
select: vi.fn(),
selectDistinct: vi.fn(),
selectDistinctOn: vi.fn(),
transaction: vi.fn(),
update: vi.fn(),
}) as unknown as ModuleDatabase;
return Object.freeze({ database });
}
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
}
function buildModuleApp(options: {
readonly evaluator?: AuthorizationEvaluator;
readonly registry: PermissionRegistry;
readonly routes: readonly RegisteredModuleHttpRoute[];
readonly runtime?: EkklesiaIdentityRuntime;
readonly services?: ModuleRuntimeServices;
readonly trustedOrigins?: readonly string[];
}) {
const evaluator = options.evaluator ?? {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
const runtime = options.runtime ?? fakeRuntime();
const services = options.services ?? fakeModuleServices();
const app = createApiApp({
authorization: { evaluator, registry: options.registry, store: fakeStore() },
moduleRoutes: options.routes,
moduleServices: services,
runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
});
return { app, evaluator, runtime };
return { app, evaluator, runtime, services };
}
function permissionRegistry(
@@ -262,6 +296,7 @@ describe("module route handler adaptation", () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
});
const services = fakeModuleServices();
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
@@ -274,6 +309,7 @@ describe("module route handler adaptation", () => {
}),
],
runtime,
services,
});
const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", {
@@ -288,13 +324,25 @@ describe("module route handler adaptation", () => {
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
expect(capturedRequest).toBeInstanceOf(Request);
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
expect(capturedContext).toEqual({
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
// or configuration reaches a module handler.
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
"moduleId",
"params",
"principal",
"services",
]);
expect(capturedContext).toMatchObject({
moduleId: "calendar",
params: { eventId: "evt-1" },
principal: { kind: "authenticated", userId: "user_1" },
});
// Reference-identical to the exact services object the caller passed in — never a fresh one
// built per request.
expect(capturedContext?.services).toBe(services);
expect(Object.isFrozen(capturedContext)).toBe(true);
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
});
it("gives static and root routes a frozen, empty params object", async () => {
@@ -394,12 +442,12 @@ describe("module route authorization", () => {
);
});
it("does not invoke the handler when authorization denies", async () => {
it("does not invoke the handler or touch the module database when authorization denies", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
};
const { app } = buildModuleApp({
const { app, services } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
routes: [
@@ -417,6 +465,7 @@ describe("module route authorization", () => {
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(handler).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
});
it("does not invoke the handler when principal resolution fails", async () => {
@@ -518,7 +567,7 @@ describe("module route trusted-origin enforcement", () => {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
const { app } = buildModuleApp({
const { app, services } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: methods.map((method) =>
@@ -545,6 +594,7 @@ describe("module route trusted-origin enforcement", () => {
}
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
});
it("allows a trusted-origin state-changing request through to the handler", async () => {
@@ -647,12 +697,12 @@ describe("module route CORS", () => {
expect(tokens.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
});
it("never invokes authorization or the handler for an OPTIONS preflight", async () => {
it("never invokes authorization, the handler, or the module database for an OPTIONS preflight", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
const { app } = buildModuleApp({
const { app, services } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
@@ -673,6 +723,7 @@ describe("module route CORS", () => {
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
});
it("never reflects an untrusted origin for a module route", async () => {