feat: add module database access #13
+11
-1
@@ -1,4 +1,8 @@
|
|||||||
import { isAuthenticatedPrincipal, type RegisteredModuleHttpRoute } from "@ekklesia/contracts";
|
import {
|
||||||
|
isAuthenticatedPrincipal,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
AuthorizationEvaluator,
|
AuthorizationEvaluator,
|
||||||
AuthorizationStore,
|
AuthorizationStore,
|
||||||
@@ -28,6 +32,11 @@ export interface CreateApiAppOptions {
|
|||||||
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
||||||
* implicit fallback. */
|
* implicit fallback. */
|
||||||
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
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;
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
/** 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
|
* 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,
|
evaluator: options.authorization.evaluator,
|
||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
|
services: options.moduleServices,
|
||||||
trustedOrigins: allowedOrigins,
|
trustedOrigins: allowedOrigins,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
|
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type AuthorizationDrizzleDatabase,
|
type AuthorizationDrizzleDatabase,
|
||||||
createAuthorizationDrizzleView,
|
createAuthorizationDrizzleView,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||||
|
import { createModuleDatabaseView } from "@ekklesia/core/modules";
|
||||||
|
|
||||||
export interface ApiDatabaseHandle {
|
export interface ApiDatabaseHandle {
|
||||||
readonly authorization: AuthorizationDrizzleDatabase;
|
readonly authorization: AuthorizationDrizzleDatabase;
|
||||||
readonly identity: IdentityDrizzleDatabase;
|
readonly identity: IdentityDrizzleDatabase;
|
||||||
|
readonly module: ModuleDatabase;
|
||||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
/** 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. */
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||||
readonly close: () => Promise<void>;
|
readonly close: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes the one physical PostgreSQL pool this process uses for both Core's identity and
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||||
* authorization schemas: one validated, timeout-bounded `pg.Pool` (`createValidatedPostgresPool`,
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
* `@ekklesia/core/database`), wrapped by two independent typed Drizzle views
|
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by three
|
||||||
* (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Neither Core domain owns or
|
* independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||||
* closes the pool in this composition — `close()` here is the only thing that ever calls
|
* `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()`
|
||||||
* `pool.end()`. `createIdentityDrizzleDatabase`/`createAuthorizationDrizzleDatabase` remain
|
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||||
* available, unaffected, and independently testable for callers that only need one domain.
|
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||||
|
* for callers that only need one domain.
|
||||||
*/
|
*/
|
||||||
export function createApiDatabase(
|
export function createApiDatabase(
|
||||||
databaseUrl: string,
|
databaseUrl: string,
|
||||||
@@ -32,5 +36,6 @@ export function createApiDatabase(
|
|||||||
authorization: createAuthorizationDrizzleView(pool),
|
authorization: createAuthorizationDrizzleView(pool),
|
||||||
close,
|
close,
|
||||||
identity: createIdentityDrizzleView(pool),
|
identity: createIdentityDrizzleView(pool),
|
||||||
|
module: createModuleDatabaseView(pool),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ModuleId,
|
ModuleId,
|
||||||
ModuleRouteHandlerContext,
|
ModuleRouteHandlerContext,
|
||||||
|
ModuleRuntimeServices,
|
||||||
RegisteredModuleHttpRoute,
|
RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
@@ -24,6 +25,11 @@ const STRIPPED_CORS_RESPONSE_HEADERS = [
|
|||||||
|
|
||||||
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
readonly trustedOrigins: ReadonlySet<string>;
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
/** The same frozen services object the started module runtime's `start`/`stop` hooks already
|
||||||
|
* received for this API runtime — never a fresh one built per request, and never touched for a
|
||||||
|
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||||
|
* after authorization succeeds. */
|
||||||
|
readonly services: ModuleRuntimeServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Never carries the offending permission id, route path, module id, or any other configuration
|
/** 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
|
* 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.
|
* 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
|
* `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
|
* Because the routes and registry both derive from the same discovered inventory
|
||||||
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
||||||
@@ -183,6 +193,7 @@ export function mountModuleHttpRoutes(
|
|||||||
moduleId: route.moduleId,
|
moduleId: route.moduleId,
|
||||||
params: Object.freeze({ ...c.req.param() }),
|
params: Object.freeze({ ...c.req.param() }),
|
||||||
principal: getAuthorizedPrincipal(c),
|
principal: getAuthorizedPrincipal(c),
|
||||||
|
services: options.services,
|
||||||
});
|
});
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|||||||
+28
-18
@@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
createAuthorizationEvaluator,
|
createAuthorizationEvaluator,
|
||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
@@ -84,12 +85,15 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
|||||||
* 1. Discover every installed module's manifest exactly once and compose the one immutable
|
* 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
|
* platform permission registry from Core's own declarations plus every module's
|
||||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||||
* 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
|
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
|
||||||
* Drizzle view over it — see `./database.js`).
|
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||||
* 3. Construct the Core identity runtime, authorization store, and evaluator.
|
* 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
|
* 4. Start the module runtime from that exact same discovered inventory
|
||||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time.
|
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
|
||||||
* 5. Create the Hono application, mounting the started module runtime's published routes.
|
* 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.
|
* 6. Bind the Node HTTP server.
|
||||||
*
|
*
|
||||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||||
@@ -97,12 +101,13 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
|||||||
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
||||||
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
* 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,
|
* 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 database facade, identity, authorization) exists before any executable module lifecycle
|
||||||
* module route is published only after the entire module runtime has registered and started
|
* code runs, and every module route is published only after the entire module runtime has
|
||||||
* successfully, so no request can ever reach a module before every module has started. Returns a
|
* registered and started successfully, so no request can ever reach a module before every module
|
||||||
* handle with an idempotent `shutdown()` so both the executable entry point and tests can stop the
|
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point
|
||||||
* server, the module runtime, and the shared pool deterministically. Never applies migrations or
|
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
|
||||||
* generates schema — the database must already be migrated through `pnpm migrations:apply`.
|
* 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(
|
export async function startApi(
|
||||||
config: ApiRuntimeConfig,
|
config: ApiRuntimeConfig,
|
||||||
@@ -122,9 +127,11 @@ export async function startApi(
|
|||||||
operationTimeoutMs,
|
operationTimeoutMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let services: ModuleRuntimeServices;
|
||||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||||
try {
|
try {
|
||||||
|
services = Object.freeze({ database: databaseHandle.module });
|
||||||
runtime = createIdentityRuntime({
|
runtime = createIdentityRuntime({
|
||||||
config: config.identity,
|
config: config.identity,
|
||||||
db: databaseHandle.identity,
|
db: databaseHandle.identity,
|
||||||
@@ -146,7 +153,7 @@ export async function startApi(
|
|||||||
// handle to stop here, only the pool this failure leaves open.
|
// handle to stop here, only the pool this failure leaves open.
|
||||||
let moduleRuntime: StartedModuleRuntime;
|
let moduleRuntime: StartedModuleRuntime;
|
||||||
try {
|
try {
|
||||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules);
|
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -161,6 +168,7 @@ export async function startApi(
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
moduleRoutes: moduleRuntime.routes,
|
moduleRoutes: moduleRuntime.routes,
|
||||||
|
moduleServices: services,
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: config.identity.trustedOrigins,
|
trustedOrigins: config.identity.trustedOrigins,
|
||||||
});
|
});
|
||||||
@@ -184,12 +192,14 @@ export async function startApi(
|
|||||||
// would previously get an already-resolved `undefined` immediately, before the listener had
|
// 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
|
// 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
|
// 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
|
// observes it complete (or fail) exactly once. Order matters, and is now additionally
|
||||||
// closed first so no new or in-flight request can reach a module handler while the module
|
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
|
||||||
// runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the
|
// no new or in-flight request can reach a module handler while the module runtime is stopping;
|
||||||
// startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine
|
// then the module runtime stops — its stop hooks may still use `services.database`, which is
|
||||||
// shutdown result a caller should observe — but database cleanup still always runs first, so a
|
// safe because the pool is not yet closed; only then does the shared pool close, so no module
|
||||||
// stop failure never leaves the pool open.
|
// code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop
|
||||||
|
// failure here is not swallowed — it is a genuine shutdown result a caller should observe — but
|
||||||
|
// database cleanup still always runs first, so a stop failure never leaves the pool open.
|
||||||
let shutdownPromise: Promise<void> | undefined;
|
let shutdownPromise: Promise<void> | undefined;
|
||||||
const shutdown = (): Promise<void> => {
|
const shutdown = (): Promise<void> => {
|
||||||
if (!shutdownPromise) {
|
if (!shutdownPromise) {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts";
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type AuthorizationEvaluator,
|
type AuthorizationEvaluator,
|
||||||
type AuthorizationStore,
|
type AuthorizationStore,
|
||||||
@@ -21,6 +26,22 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||||
|
* never real database behavior, so no PostgreSQL is required. */
|
||||||
|
function fakeModuleServices(): ModuleRuntimeServices {
|
||||||
|
const database = Object.freeze({
|
||||||
|
delete: vi.fn(),
|
||||||
|
execute: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
selectDistinct: vi.fn(),
|
||||||
|
selectDistinctOn: vi.fn(),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
}) as unknown as ModuleDatabase;
|
||||||
|
return Object.freeze({ database });
|
||||||
|
}
|
||||||
|
|
||||||
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
|
/** 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
|
* 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
|
* 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 runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -69,6 +91,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -86,6 +109,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -105,6 +129,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -126,6 +151,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -147,6 +173,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -165,6 +192,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -182,6 +210,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
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 () => {
|
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -212,6 +242,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -227,6 +258,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -240,6 +272,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -260,6 +293,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -283,6 +317,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -309,6 +344,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
};
|
};
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
evaluator: { authorize: evaluatorAuthorize },
|
evaluator: { authorize: evaluatorAuthorize },
|
||||||
registry: emptyRegistry,
|
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 () => {
|
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -111,6 +111,19 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
|||||||
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same globalThis-symbol technique as `createEventRecorder`, but captures an actual object
|
||||||
|
* reference (such as `context.services.database`) rather than a string label, so a test can prove
|
||||||
|
* two values pushed from different, independently dynamically-imported invocations (a module's own
|
||||||
|
* `start` hook and one of its HTTP handlers) are the exact same object. */
|
||||||
|
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
||||||
|
const key = `ekklesia.api.lifecycle.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||||
|
const symbol = Symbol.for(key);
|
||||||
|
const values: unknown[] = [];
|
||||||
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
|
||||||
|
eventSymbols.push(symbol);
|
||||||
|
return { values, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||||
|
}
|
||||||
|
|
||||||
interface WorkingFixtureModuleOptions {
|
interface WorkingFixtureModuleOptions {
|
||||||
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
||||||
readonly withRoute?: boolean;
|
readonly withRoute?: boolean;
|
||||||
@@ -659,6 +672,47 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
await running.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 () => {
|
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
await createFixtureModule(modulesDirectory, "broken-export");
|
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"]);
|
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 () => {
|
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
@@ -51,24 +53,56 @@ function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRou
|
|||||||
return Object.freeze({ ...fields });
|
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: {
|
function buildModuleApp(options: {
|
||||||
readonly evaluator?: AuthorizationEvaluator;
|
readonly evaluator?: AuthorizationEvaluator;
|
||||||
readonly registry: PermissionRegistry;
|
readonly registry: PermissionRegistry;
|
||||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||||
readonly runtime?: EkklesiaIdentityRuntime;
|
readonly runtime?: EkklesiaIdentityRuntime;
|
||||||
|
readonly services?: ModuleRuntimeServices;
|
||||||
readonly trustedOrigins?: readonly string[];
|
readonly trustedOrigins?: readonly string[];
|
||||||
}) {
|
}) {
|
||||||
const evaluator = options.evaluator ?? {
|
const evaluator = options.evaluator ?? {
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
};
|
};
|
||||||
const runtime = options.runtime ?? fakeRuntime();
|
const runtime = options.runtime ?? fakeRuntime();
|
||||||
|
const services = options.services ?? fakeModuleServices();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
||||||
moduleRoutes: options.routes,
|
moduleRoutes: options.routes,
|
||||||
|
moduleServices: services,
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||||
});
|
});
|
||||||
return { app, evaluator, runtime };
|
return { app, evaluator, runtime, services };
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionRegistry(
|
function permissionRegistry(
|
||||||
@@ -262,6 +296,7 @@ describe("module route handler adaptation", () => {
|
|||||||
const runtime = fakeRuntime({
|
const runtime = fakeRuntime({
|
||||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
||||||
});
|
});
|
||||||
|
const services = fakeModuleServices();
|
||||||
const { app } = buildModuleApp({
|
const { app } = buildModuleApp({
|
||||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -274,6 +309,7 @@ describe("module route handler adaptation", () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
runtime,
|
runtime,
|
||||||
|
services,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", {
|
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(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
||||||
expect(capturedRequest).toBeInstanceOf(Request);
|
expect(capturedRequest).toBeInstanceOf(Request);
|
||||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
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",
|
moduleId: "calendar",
|
||||||
params: { eventId: "evt-1" },
|
params: { eventId: "evt-1" },
|
||||||
principal: { kind: "authenticated", userId: "user_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)).toBe(true);
|
||||||
expect(Object.isFrozen(capturedContext?.params)).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 () => {
|
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 handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
const evaluator: AuthorizationEvaluator = {
|
const evaluator: AuthorizationEvaluator = {
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||||
};
|
};
|
||||||
const { app } = buildModuleApp({
|
const { app, services } = buildModuleApp({
|
||||||
evaluator,
|
evaluator,
|
||||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -417,6 +465,7 @@ describe("module route authorization", () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expectDatabaseFakeUntouched(services);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not invoke the handler when principal resolution fails", async () => {
|
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 }),
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
};
|
};
|
||||||
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
||||||
const { app } = buildModuleApp({
|
const { app, services } = buildModuleApp({
|
||||||
evaluator,
|
evaluator,
|
||||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
routes: methods.map((method) =>
|
routes: methods.map((method) =>
|
||||||
@@ -545,6 +594,7 @@ describe("module route trusted-origin enforcement", () => {
|
|||||||
}
|
}
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
expectDatabaseFakeUntouched(services);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
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);
|
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 handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
const evaluator: AuthorizationEvaluator = {
|
const evaluator: AuthorizationEvaluator = {
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
};
|
};
|
||||||
const { app } = buildModuleApp({
|
const { app, services } = buildModuleApp({
|
||||||
evaluator,
|
evaluator,
|
||||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -673,6 +723,7 @@ describe("module route CORS", () => {
|
|||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
expectDatabaseFakeUntouched(services);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never reflects an untrusted origin for a module route", async () => {
|
it("never reflects an untrusted origin for a module route", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
||||||
|
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
||||||
|
* `createAuthorizationDrizzleView` (`core/src/authorization/database.ts`): the same "one pool, one
|
||||||
|
* typed view per domain" composition, extended to a third domain. Never constructs, registers an
|
||||||
|
* error listener on, or closes a pool itself — the caller (`apps/api/src/database.ts`) retains that
|
||||||
|
* responsibility exactly once, regardless of how many typed views it creates over the same pool.
|
||||||
|
*
|
||||||
|
* Carries no schema, unlike the identity and authorization views: a module brings its own Drizzle
|
||||||
|
* table objects to each call rather than selecting against a schema this facade would otherwise
|
||||||
|
* have to know about, so there is no `TFullSchema` for `drizzle(...)` to accept here. Building this
|
||||||
|
* view performs no query and no migration — `drizzle(pool)` only wraps the given client, the same
|
||||||
|
* synchronous, I/O-free construction `createIdentityDrizzleView`/`createAuthorizationDrizzleView`
|
||||||
|
* already rely on.
|
||||||
|
*
|
||||||
|
* Deliberately returns a fresh object with each operation explicitly bound to the underlying
|
||||||
|
* Drizzle instance, rather than the `drizzle(pool)` instance itself: Drizzle's own query methods
|
||||||
|
* are plain prototype methods that read `this.session`/`this.dialect` internally (not
|
||||||
|
* arrow-function instance properties), so handing that instance straight to a module would both
|
||||||
|
* break the moment a module destructured a method off it (`const { select } = database` loses
|
||||||
|
* `this`) and, more importantly, would leave every non-public property (`$client`, `_`, `query`,
|
||||||
|
* `$cache`, `with`) physically reachable on the object at runtime — reachable to a module written
|
||||||
|
* in plain JavaScript or one that bypasses the `ModuleDatabase` type with a cast, not just hidden
|
||||||
|
* by TypeScript. Building this literal, `Object.freeze`d object closes both gaps: only the eight
|
||||||
|
* intended operations exist on it at all, each correctly bound, and nothing else does.
|
||||||
|
*/
|
||||||
|
export function createModuleDatabaseView(pool: Pool): ModuleDatabase {
|
||||||
|
const db = drizzle(pool);
|
||||||
|
return Object.freeze({
|
||||||
|
delete: db.delete.bind(db),
|
||||||
|
execute: db.execute.bind(db),
|
||||||
|
insert: db.insert.bind(db),
|
||||||
|
select: db.select.bind(db),
|
||||||
|
selectDistinct: db.selectDistinct.bind(db),
|
||||||
|
selectDistinctOn: db.selectDistinctOn.bind(db),
|
||||||
|
transaction: db.transaction.bind(db),
|
||||||
|
update: db.update.bind(db),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./database.js";
|
||||||
export * from "./discovery.js";
|
export * from "./discovery.js";
|
||||||
export * from "./migration-discovery.js";
|
export * from "./migration-discovery.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type ModuleRouteDefinitionInput,
|
type ModuleRouteDefinitionInput,
|
||||||
type ModuleRuntimeContext,
|
type ModuleRuntimeContext,
|
||||||
type ModuleRuntimeDefinition,
|
type ModuleRuntimeDefinition,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
moduleRouteShapeKey,
|
moduleRouteShapeKey,
|
||||||
parseModuleRuntimeDefinition,
|
parseModuleRuntimeDefinition,
|
||||||
type RegisteredCapability,
|
type RegisteredCapability,
|
||||||
@@ -70,7 +71,12 @@ export interface StartedModuleRuntime {
|
|||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {}
|
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {
|
||||||
|
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
||||||
|
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
||||||
|
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
||||||
|
readonly services: ModuleRuntimeServices;
|
||||||
|
}
|
||||||
|
|
||||||
interface LoadedModule {
|
interface LoadedModule {
|
||||||
readonly definition: ModuleRuntimeDefinition;
|
readonly definition: ModuleRuntimeDefinition;
|
||||||
@@ -201,6 +207,7 @@ class StagedCapabilityRegistry {
|
|||||||
createRuntimeContext(
|
createRuntimeContext(
|
||||||
module: DiscoveredModule,
|
module: DiscoveredModule,
|
||||||
phase: "rollback" | "start" | "stop",
|
phase: "rollback" | "start" | "stop",
|
||||||
|
services: ModuleRuntimeServices,
|
||||||
): ModuleRuntimeContext {
|
): ModuleRuntimeContext {
|
||||||
const capabilities: CapabilityResolver = Object.freeze({
|
const capabilities: CapabilityResolver = Object.freeze({
|
||||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||||
@@ -208,7 +215,7 @@ class StagedCapabilityRegistry {
|
|||||||
return this.#registered.get(capabilityId);
|
return this.#registered.get(capabilityId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return Object.freeze({ capabilities, moduleId: module.manifest.id });
|
return Object.freeze({ capabilities, moduleId: module.manifest.id, services });
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(): CapabilityRegistry {
|
publish(): CapabilityRegistry {
|
||||||
@@ -466,11 +473,14 @@ async function stopModules(
|
|||||||
modules: readonly LoadedModule[],
|
modules: readonly LoadedModule[],
|
||||||
registry: StagedCapabilityRegistry,
|
registry: StagedCapabilityRegistry,
|
||||||
phase: "rollback" | "stop",
|
phase: "rollback" | "stop",
|
||||||
|
services: ModuleRuntimeServices,
|
||||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||||
const issues: ModuleRuntimeIssue[] = [];
|
const issues: ModuleRuntimeIssue[] = [];
|
||||||
for (const module of [...modules].reverse()) {
|
for (const module of [...modules].reverse()) {
|
||||||
try {
|
try {
|
||||||
await module.definition.stop?.(registry.createRuntimeContext(module.discovered, phase));
|
await module.definition.stop?.(
|
||||||
|
registry.createRuntimeContext(module.discovered, phase, services),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModuleRuntimeSignal) {
|
if (error instanceof ModuleRuntimeSignal) {
|
||||||
issues.push(error.issue);
|
issues.push(error.issue);
|
||||||
@@ -492,11 +502,14 @@ async function stopModules(
|
|||||||
async function startModules(
|
async function startModules(
|
||||||
modules: readonly LoadedModule[],
|
modules: readonly LoadedModule[],
|
||||||
registry: StagedCapabilityRegistry,
|
registry: StagedCapabilityRegistry,
|
||||||
|
services: ModuleRuntimeServices,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const started: LoadedModule[] = [];
|
const started: LoadedModule[] = [];
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
try {
|
try {
|
||||||
await module.definition.start?.(registry.createRuntimeContext(module.discovered, "start"));
|
await module.definition.start?.(
|
||||||
|
registry.createRuntimeContext(module.discovered, "start", services),
|
||||||
|
);
|
||||||
started.push(module);
|
started.push(module);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const startIssue =
|
const startIssue =
|
||||||
@@ -508,7 +521,7 @@ async function startModules(
|
|||||||
module.discovered.manifest.id,
|
module.discovered.manifest.id,
|
||||||
`Module ${module.discovered.manifest.id} startup failed.`,
|
`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]);
|
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,12 +537,13 @@ async function startModules(
|
|||||||
*/
|
*/
|
||||||
export async function startDiscoveredModuleRuntime(
|
export async function startDiscoveredModuleRuntime(
|
||||||
discoveredModules: readonly DiscoveredModule[],
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
|
services: ModuleRuntimeServices,
|
||||||
): Promise<StartedModuleRuntime> {
|
): Promise<StartedModuleRuntime> {
|
||||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||||
const capabilityRegistry = new StagedCapabilityRegistry();
|
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||||
const httpRoutes = new StagedModuleHttpRoutes();
|
const httpRoutes = new StagedModuleHttpRoutes();
|
||||||
await registerModules(modules, capabilityRegistry, httpRoutes);
|
await registerModules(modules, capabilityRegistry, httpRoutes);
|
||||||
await startModules(modules, capabilityRegistry);
|
await startModules(modules, capabilityRegistry, services);
|
||||||
|
|
||||||
const capabilities = capabilityRegistry.publish();
|
const capabilities = capabilityRegistry.publish();
|
||||||
const routes = httpRoutes.publish();
|
const routes = httpRoutes.publish();
|
||||||
@@ -549,7 +563,7 @@ export async function startDiscoveredModuleRuntime(
|
|||||||
routes,
|
routes,
|
||||||
stop: (): Promise<void> => {
|
stop: (): Promise<void> => {
|
||||||
stopPromise ??= (async () => {
|
stopPromise ??= (async () => {
|
||||||
const issues = await stopModules(modules, capabilityRegistry, "stop");
|
const issues = await stopModules(modules, capabilityRegistry, "stop", services);
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
throw new ModuleRuntimeError(issues);
|
throw new ModuleRuntimeError(issues);
|
||||||
}
|
}
|
||||||
@@ -563,5 +577,5 @@ export async function startModuleRuntime(
|
|||||||
options: StartModuleRuntimeOptions,
|
options: StartModuleRuntimeOptions,
|
||||||
): Promise<StartedModuleRuntime> {
|
): Promise<StartedModuleRuntime> {
|
||||||
const discovered = await discoverModules(options);
|
const discovered = await discoverModules(options);
|
||||||
return startDiscoveredModuleRuntime(discovered);
|
return startDiscoveredModuleRuntime(discovered, options.services);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { discoverModules } from "../src/modules/discovery.js";
|
import { discoverModules } from "../src/modules/discovery.js";
|
||||||
import {
|
import {
|
||||||
@@ -108,11 +109,50 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeOptions(modulesDirectory: string) {
|
/** Same globalThis-symbol technique as `createEventRecorder`, but for capturing an actual object
|
||||||
|
* reference (such as `context.services`) pushed from inside a dynamically imported fixture module,
|
||||||
|
* rather than a string label — dynamic `import()` of a file:// URL still shares this process's
|
||||||
|
* heap, so a reference pushed from the fixture and read back here is the exact same object. */
|
||||||
|
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
||||||
|
const key = `ekklesia.runtime.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||||
|
const symbol = Symbol.for(key);
|
||||||
|
const values: unknown[] = [];
|
||||||
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
|
||||||
|
eventSymbols.push(symbol);
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for these unit tests, which only
|
||||||
|
* ever assert on context wiring and object identity, never on actual query behavior. */
|
||||||
|
function fakeModuleDatabase(): ModuleDatabase {
|
||||||
|
return Object.freeze({
|
||||||
|
delete: vi.fn(),
|
||||||
|
execute: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
selectDistinct: vi.fn(),
|
||||||
|
selectDistinctOn: vi.fn(),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
}) as unknown as ModuleDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: fakeModuleDatabase() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeOptions(
|
||||||
|
modulesDirectory: string,
|
||||||
|
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.1.0",
|
||||||
|
services,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,7 +862,7 @@ describe("already-discovered module runtime", () => {
|
|||||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const runtime = await startDiscoveredModuleRuntime(discovered);
|
const runtime = await startDiscoveredModuleRuntime(discovered, fakeModuleRuntimeServices());
|
||||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||||
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
|
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
|
||||||
await runtime.stop();
|
await runtime.stop();
|
||||||
@@ -840,7 +880,10 @@ describe("already-discovered module runtime", () => {
|
|||||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||||
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
|
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"]);
|
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
|
||||||
await runtime.stop();
|
await runtime.stop();
|
||||||
});
|
});
|
||||||
@@ -857,3 +900,98 @@ describe("already-discovered module runtime", () => {
|
|||||||
await runtime.stop();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from "./authorization.js";
|
export * from "./authorization.js";
|
||||||
export * from "./identifiers.js";
|
export * from "./identifiers.js";
|
||||||
export * from "./identity.js";
|
export * from "./identity.js";
|
||||||
|
export * from "./module-database.js";
|
||||||
export * from "./module-http.js";
|
export * from "./module-http.js";
|
||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
export * from "./module-manifest.js";
|
export * from "./module-manifest.js";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { EkklesiaPrincipal } from "./identity.js";
|
import type { EkklesiaPrincipal } from "./identity.js";
|
||||||
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.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
|
// 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
|
// 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
|
* What a module's route handler receives about the caller, the match, and the platform services
|
||||||
* connection, authorization-store, grant-management, storage, jobs, configuration, secrets, the
|
* available to it — never an authorization-store, grant-management, storage, jobs, configuration,
|
||||||
* Hono `Context`, or a raw Better Auth session. `principal` is already authorized by the time a
|
* secrets, the Hono `Context`, or a raw Better Auth session. `principal` is already authorized by
|
||||||
* handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route before ever
|
* the time a handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route
|
||||||
* invoking its handler; this contracts-layer module never invokes a handler itself.
|
* 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 {
|
export interface ModuleRouteHandlerContext {
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
readonly principal: EkklesiaPrincipal;
|
readonly principal: EkklesiaPrincipal;
|
||||||
readonly params: Readonly<Record<string, string>>;
|
readonly params: Readonly<Record<string, string>>;
|
||||||
|
readonly services: ModuleRuntimeServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||||
import { moduleIdSchema } from "./identifiers.js";
|
import { moduleIdSchema } from "./identifiers.js";
|
||||||
|
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||||
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
||||||
import { semanticVersionSchema } from "./versions.js";
|
import { semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
@@ -29,9 +30,14 @@ export interface ModuleRegistrationContext {
|
|||||||
readonly moduleId: ModuleId;
|
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 {
|
export interface ModuleRuntimeContext {
|
||||||
readonly capabilities: CapabilityResolver;
|
readonly capabilities: CapabilityResolver;
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
|
readonly services: ModuleRuntimeServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleRuntimeDefinition {
|
export interface ModuleRuntimeDefinition {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
|
||||||
|
|
||||||
|
const testTable = pgTable("contracts_module_database_test", {
|
||||||
|
id: integer("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never invoked — exists solely so `pnpm typecheck` (part of `pnpm verify`) proves `ModuleDatabase`
|
||||||
|
* preserves Drizzle's own table-aware argument and result inference for every required operation,
|
||||||
|
* with no `any`/`unknown` erasure anywhere in the chain. A regression that widened any of these
|
||||||
|
* signatures to `any` would still compile a *call* to the method but would break one of the
|
||||||
|
* explicit row-shape assertions below.
|
||||||
|
*/
|
||||||
|
function typeOnlyOperationInference(database: ModuleDatabase): void {
|
||||||
|
const selected = database.select({ id: testTable.id, name: testTable.name }).from(testTable);
|
||||||
|
const selectedRow: { id: number; name: string } = {} as Awaited<typeof selected>[number];
|
||||||
|
void selectedRow;
|
||||||
|
|
||||||
|
const distinct = database.selectDistinct().from(testTable);
|
||||||
|
const distinctRow: { id: number; name: string } = {} as Awaited<typeof distinct>[number];
|
||||||
|
void distinctRow;
|
||||||
|
|
||||||
|
const distinctOn = database.selectDistinctOn([testTable.name]).from(testTable);
|
||||||
|
const distinctOnRow: { id: number; name: string } = {} as Awaited<typeof distinctOn>[number];
|
||||||
|
void distinctOnRow;
|
||||||
|
|
||||||
|
const inserted = database.insert(testTable).values({ id: 1, name: "a" });
|
||||||
|
void inserted;
|
||||||
|
|
||||||
|
const updated = database.update(testTable).set({ name: "b" });
|
||||||
|
void updated;
|
||||||
|
|
||||||
|
const deleted = database.delete(testTable);
|
||||||
|
void deleted;
|
||||||
|
|
||||||
|
const executed = database.execute("select 1");
|
||||||
|
void executed;
|
||||||
|
|
||||||
|
void database.transaction(async (tx) => {
|
||||||
|
const rows: Array<{ id: number }> = await tx.select({ id: testTable.id }).from(testTable);
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void typeOnlyOperationInference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never invoked — proves at typecheck time that `ModuleDatabase` structurally excludes every raw
|
||||||
|
* PostgreSQL/Drizzle escape hatch a module must never reach through this facade. If any of these
|
||||||
|
* properties were ever (re)added to the picked surface, the corresponding `@ts-expect-error` would
|
||||||
|
* itself become a type error ("unused '@ts-expect-error' directive"), failing `pnpm typecheck`.
|
||||||
|
*/
|
||||||
|
function typeOnlyExclusions(database: ModuleDatabase): void {
|
||||||
|
// @ts-expect-error `$client` (the raw pg client/pool) is not part of the public facade.
|
||||||
|
void database.$client;
|
||||||
|
// @ts-expect-error `_` (internal session/dialect/schema state) is not part of the public facade.
|
||||||
|
void database._;
|
||||||
|
// @ts-expect-error `query` (a schema-bound relational query builder) is not part of the public facade.
|
||||||
|
void database.query;
|
||||||
|
// @ts-expect-error a raw connection pool is not part of the public facade.
|
||||||
|
void database.pool;
|
||||||
|
// @ts-expect-error a raw connection string is not part of the public facade.
|
||||||
|
void database.connectionString;
|
||||||
|
// @ts-expect-error migration operations are not part of the public facade.
|
||||||
|
void database.migrate;
|
||||||
|
}
|
||||||
|
void typeOnlyExclusions;
|
||||||
|
|
||||||
|
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
|
||||||
|
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
|
||||||
|
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
|
||||||
|
const database: ModuleDatabase = services.database;
|
||||||
|
void database;
|
||||||
|
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
|
||||||
|
void services.storage;
|
||||||
|
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
|
||||||
|
void services.jobs;
|
||||||
|
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
|
||||||
|
void services.authorization;
|
||||||
|
}
|
||||||
|
void typeOnlyServicesShape;
|
||||||
|
|
||||||
|
describe("ModuleDatabase", () => {
|
||||||
|
it("is a real, importable type contract enforced by pnpm typecheck", () => {
|
||||||
|
// The meaningful assertions for this module-level type contract are compile-time
|
||||||
|
// (`typeOnlyOperationInference`, `typeOnlyExclusions`, `typeOnlyServicesShape` above, checked
|
||||||
|
// by `pnpm typecheck`); this keeps the file a genuine, executed Vitest suite rather than a
|
||||||
|
// file Vitest would otherwise skip for declaring no runnable test.
|
||||||
|
expect(typeof typeOnlyOperationInference).toBe("function");
|
||||||
|
expect(typeof typeOnlyExclusions).toBe("function");
|
||||||
|
expect(typeof typeOnlyServicesShape).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user