feat: deliver module database ABI by manifest
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s

This commit is contained in:
ekklesia
2026-10-07 17:29:32 -05:00
parent a1cdcb3ad1
commit 49b4d4165d
60 changed files with 948 additions and 237 deletions
+5 -5
View File
@@ -20,6 +20,7 @@ import type {
ModuleLifecycleSnapshot,
ModuleLifecycleStore,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup,
ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules";
@@ -62,11 +63,10 @@ export interface CreateApiAppOptions {
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
* empty, for the same reason as module routes: application composition never guesses what ran. */
readonly moduleRealtime: ModuleRealtimeRegistry;
/** 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;
/** The same per-module service lookup the caller's started module runtime used for every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
* reference-identical service object, never a fresh facade or executor built per request. */
readonly moduleServices: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
/** The platform's module UI publication policy — always the one Core builds from the exact
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
+5 -1
View File
@@ -1,4 +1,4 @@
import type { ModuleDatabase } from "@ekklesia/contracts";
import type { ModuleDatabase, ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
import {
type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView,
@@ -12,6 +12,7 @@ import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/c
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
import {
createModuleConfigurationView,
createModuleDatabaseExecutor,
createModuleDatabaseView,
createModuleLifecycleView,
type ModuleConfigurationDrizzleDatabase,
@@ -24,6 +25,8 @@ export interface ApiDatabaseHandle {
readonly dashboard: DashboardDrizzleDatabase;
readonly identity: IdentityDrizzleDatabase;
readonly module: ModuleDatabase;
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
/**
@@ -76,6 +79,7 @@ export function createApiDatabase(
dashboard: createDashboardDrizzleView(pool),
identity: createIdentityDrizzleView(pool),
module: createModuleDatabaseView(pool),
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
moduleConfiguration: createModuleConfigurationView(pool),
moduleLifecycle: createModuleLifecycleView(pool),
ping: async (statementTimeoutMs: number) => {
+19 -6
View File
@@ -1,6 +1,8 @@
import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleId,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
@@ -9,6 +11,7 @@ import {
import type {
ModuleConfigurationLookup,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
@@ -37,14 +40,14 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* 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;
readonly services: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
/**
* Per-module frozen configuration snapshots — the exact map the module runtime started from
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
* rather than two independently resolved values. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
*
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
* Like `services`, this is keyed by module id: a handler can only
* ever reach its own module's configuration, because that is the only entry looked up for it.
*/
readonly configurations: ModuleConfigurationLookup;
@@ -232,6 +235,11 @@ export function mountModuleHttpRoutes(
if (!options.registry.has(route.permission)) {
throw new ModuleRouteConfigurationError();
}
const services =
"get" in options.services ? options.services.get(route.moduleId) : options.services;
if (!services) {
throw new ModuleRouteConfigurationError();
}
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
@@ -243,19 +251,24 @@ export function mountModuleHttpRoutes(
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({
const context = Object.freeze({
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
moduleId: route.moduleId,
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c),
realtime: options.realtime.publisherFor(route.moduleId),
services: options.services,
});
services,
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
let response: Response;
try {
response = await route.handler(c.req.raw, context);
response = await (
route.handler as (
request: Request,
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => PromiseLike<Response> | Response
)(c.req.raw, context);
} catch {
return sanitizedModuleHandlerFailure(c);
}
+14 -9
View File
@@ -2,8 +2,8 @@ import type { Socket } from "node:net";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
type AnyModuleRuntimeServices,
EMPTY_MODULE_SECRET_SOURCE,
type ModuleRuntimeServices,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import {
@@ -27,6 +27,7 @@ import {
type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleEligibilityResolution,
type ModuleRuntimeServicesLookup,
resolveModuleEligibility,
type StartedModuleRuntime,
startDiscoveredModuleRuntime,
@@ -393,16 +394,13 @@ function bindServer(
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
* facade each get a typed Drizzle view over it — see `./database.js`).
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
* then the Core identity runtime, authorization store, and evaluator.
* 3. Construct the Core identity runtime, authorization store, and evaluator from that pool.
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
* rediscovering modules a second time — passing it the same services object every module's
* `start`/`stop` hook receives.
* rediscovering modules a second time — passing the manifest-selected service map.
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
* UI for that same eligible set, and passing the exact same services object to every module HTTP
* handler.
* UI for that same eligible set, and passing the same per-module service map to HTTP handlers.
* 7. Bind the Node HTTP server.
*
* Discovery and permission composition happen before any pool, Better Auth instance, module
@@ -459,12 +457,10 @@ export async function startApi(
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
let deviceStore: ReturnType<typeof createDeviceStore>;
try {
services = Object.freeze({ database: databaseHandle.module });
runtime = createIdentityRuntime({
config: config.identity,
db: databaseHandle.identity,
@@ -532,6 +528,15 @@ export async function startApi(
throw error;
}
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>(
eligibility.eligibleModules.map((module) => [
module.manifest.id,
module.manifest.database?.abi === 1
? Object.freeze({ database: databaseHandle.moduleExecutor(module.manifest.id) })
: Object.freeze({ database: databaseHandle.module }),
]),
);
// One scoped facade per eligible module, over the exact buckets that module's own manifest
// declares. Built from the eligible set so a blocked module never receives storage at all.
const moduleStorages = createModuleStorageLookup(
+3 -3
View File
@@ -342,7 +342,7 @@ async function createGatedRouteModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0",
@@ -386,7 +386,7 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -524,7 +524,7 @@ async function createUiModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
version,
}),
+1 -1
View File
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: fixture.name,
version: "1.0.0",
}),
+1 -1
View File
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
server: "./dist/server/index.js",
},
id: "announcements",
manifestVersion: 7,
manifestVersion: 8,
name: "Announcements fixture",
permissions: [
{
+2 -2
View File
@@ -104,7 +104,7 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -270,7 +270,7 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions,
version: "0.1.0",
@@ -108,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" },
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
version: "1.0.0",
}),
@@ -8,6 +8,7 @@ import {
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { moduleDatabaseExecutorModuleId } from "@ekklesia/core/modules";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
@@ -441,6 +442,11 @@ describe.sequential("live API — module database explicit migration ownership",
const handle = createApiDatabase(database.url);
expect(handle.module).toBeDefined();
const alpha = handle.moduleExecutor("alpha");
const beta = handle.moduleExecutor("beta");
expect(alpha).not.toBe(beta);
expect(moduleDatabaseExecutorModuleId(alpha)).toBe("alpha");
expect(moduleDatabaseExecutorModuleId(beta)).toBe("beta");
await handle.close();
expect(await tableExists(database.url)).toBe(false);
+1 -1
View File
@@ -105,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
: {}),
},
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: fixture.display
? [
+41 -10
View File
@@ -6,8 +6,8 @@ import {
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
moduleRoutesOverlap,
parsePermissionId,
type PermissionId,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import {
@@ -20,20 +20,21 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleRuntimeServicesLookup,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import {
canonicalizeVaryHeader,
mergedVaryHeader,
ModuleRouteConfigurationError,
mergedVaryHeader,
} from "../src/module-http-routes.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
@@ -107,7 +108,7 @@ function buildModuleApp(options: {
readonly registry: PermissionRegistry;
readonly routes: readonly RegisteredModuleHttpRoute[];
readonly runtime?: EkklesiaIdentityRuntime;
readonly services?: ModuleRuntimeServices;
readonly services?: ModuleRuntimeServices | ModuleRuntimeServicesLookup;
readonly trustedOrigins?: readonly string[];
}) {
const evaluator = options.evaluator ?? {
@@ -145,6 +146,36 @@ function permissionRegistry(
}
describe("module HTTP route mounting", () => {
it("selects the owning module's stable service entry for its handler", async () => {
const calendar = fakeModuleServices();
const people = fakeModuleServices();
const services: ModuleRuntimeServicesLookup = new Map([
["calendar", calendar],
["people", people],
]);
let received: unknown;
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler: (_request, context) => {
received = context.services;
return Response.json({ ok: true });
},
method: "GET",
moduleId: "calendar",
path: "/services",
permission: readPermission,
}),
],
services,
});
expect((await app.request("/api/v1/modules/calendar/services")).status).toBe(200);
expect(received).toBe(calendar);
expect(received).not.toBe(people);
});
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
const { app } = buildModuleApp({
@@ -508,7 +539,7 @@ describe("module route authorization", () => {
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(handler).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
});
it("does not invoke the handler when principal resolution fails", async () => {
@@ -637,7 +668,7 @@ describe("module route trusted-origin enforcement", () => {
}
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
});
it("allows a trusted-origin state-changing request through to the handler", async () => {
@@ -766,7 +797,7 @@ describe("module route CORS", () => {
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
});
it("never reflects an untrusted origin for a module route", async () => {
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
version: "1.0.0",
}),
+1 -1
View File
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id: "announcements",
manifestVersion: 7,
manifestVersion: 8,
name: "Announcements fixture",
permissions: [
{
+1 -1
View File
@@ -129,7 +129,7 @@ async function createFixtureModule(
: {}),
},
id,
manifestVersion: 7,
manifestVersion: 8,
name,
permissions: [
{
+1 -1
View File
@@ -99,7 +99,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0",
})),
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({
...permission,
+1 -1
View File
@@ -139,7 +139,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0",
})),
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined