feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement, and module-runtime lifecycle integration. apps/api now discovers the installed module inventory exactly once (prepareStartupModuleInventory), composes the platform permission registry from it, starts the module runtime from that same discovered list (startDiscoveredModuleRuntime, never rediscovering), and mounts StartedModuleRuntime's published routes under /api/v1/modules/<module-id> via a new apps/api-owned Hono adapter (module-http-routes.ts). Every module route is protected by the existing requirePermission middleware against the composed registry, state-changing methods additionally require a trusted Origin, OPTIONS remains entirely platform-owned, and a handler that throws, rejects, or returns a non-Response becomes a fixed, sanitized 500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped and every module response is forced to Cache-Control: no-store with Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime between draining the HTTP server and closing the shared pool, and every startup failure path stops the runtime and/or closes the pool before propagating, without masking the original failure. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e296e083ec
commit
dfaf324a65
@@ -13,6 +13,13 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
|
||||
// gets the real Hono application; only the one test that deliberately forces an app-construction
|
||||
// failure overrides this with `mockImplementationOnce`.
|
||||
vi.mock("../src/app.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/app.js")>();
|
||||
return { ...actual, createApiApp: vi.fn(actual.createApiApp) };
|
||||
});
|
||||
|
||||
import { parseModuleManifest } from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -21,6 +28,7 @@ import {
|
||||
ModulePermissionCompositionError,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||
|
||||
@@ -87,6 +95,76 @@ async function createFixtureModule(
|
||||
]);
|
||||
}
|
||||
|
||||
const eventSymbols: symbol[] = [];
|
||||
|
||||
/** Same technique as `core/tests/module-runtime.test.ts`: a dynamically imported module fixture
|
||||
* cannot close over this test file's local variables, so lifecycle events cross that boundary
|
||||
* through a `globalThis` symbol instead. Returns the same `events` array the fixture's generated
|
||||
* source pushes into, so test code can both read it directly and push its own markers (pool
|
||||
* close, server close) onto the identical array to observe cross-cutting shutdown order. */
|
||||
function createEventRecorder(): { events: string[]; expression: string } {
|
||||
const key = `ekklesia.api.lifecycle.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||
const symbol = Symbol.for(key);
|
||||
const events: string[] = [];
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
|
||||
eventSymbols.push(symbol);
|
||||
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||
}
|
||||
|
||||
interface WorkingFixtureModuleOptions {
|
||||
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
||||
readonly withRoute?: boolean;
|
||||
readonly registerThrows?: boolean;
|
||||
readonly startThrows?: boolean;
|
||||
readonly stopThrows?: boolean;
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
|
||||
* throw on import; it pushes lifecycle markers onto `recorder.events` and can be told to fail at a
|
||||
* specific hook, so tests can prove exactly what a successful (or a specifically-failing) module
|
||||
* runtime startup actually executes and in what order, relative to pool/server construction. */
|
||||
async function createWorkingFixtureModule(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
recorder: { expression: string },
|
||||
options: WorkingFixtureModuleOptions = {},
|
||||
): Promise<void> {
|
||||
const permissionId = `${id}.ping.read`;
|
||||
await createFixtureModule(modulesDirectory, id, {
|
||||
permissions: options.withRoute
|
||||
? [{ baseline: "public", description: "Ping.", id: permissionId }]
|
||||
: [],
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
});
|
||||
const routeSource = options.withRoute
|
||||
? `http.route({
|
||||
method: "GET",
|
||||
path: "/ping",
|
||||
permission: ${JSON.stringify(permissionId)},
|
||||
handler: async () => new Response("pong", { status: 200 }),
|
||||
});`
|
||||
: "";
|
||||
const serverSource = `export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
|
||||
${routeSource}
|
||||
${options.registerThrows ? 'throw new Error("register failed");' : ""}
|
||||
},
|
||||
start() {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
|
||||
${options.startThrows ? 'throw new Error("start failed");' : ""}
|
||||
},
|
||||
stop() {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
||||
${options.stopThrows ? 'throw new Error("stop failed");' : ""}
|
||||
},
|
||||
};`;
|
||||
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
|
||||
}
|
||||
|
||||
/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used
|
||||
* only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is
|
||||
* unreachable through real filesystem discovery (module ids are unique and every permission id is
|
||||
@@ -518,3 +596,252 @@ describe("Ekklesia API module permission composition at startup", () => {
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
afterEach(async () => {
|
||||
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
|
||||
for (const symbol of eventSymbols.splice(0)) {
|
||||
delete globalValues[symbol];
|
||||
}
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
poolConstructor.mockReset();
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
serveMock.mockReset();
|
||||
vi.mocked(createApiApp).mockClear();
|
||||
});
|
||||
|
||||
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
return { poolEnd };
|
||||
}
|
||||
|
||||
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "alpha", recorder, { withRoute: true });
|
||||
await createWorkingFixtureModule(modulesDirectory, "beta", recorder, { withRoute: true });
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
|
||||
expect(recorder.events).toEqual([
|
||||
"alpha:register",
|
||||
"beta:register",
|
||||
"alpha:start",
|
||||
"beta:start",
|
||||
]);
|
||||
// Hono records one internal route entry per handler in a route's middleware chain, not one
|
||||
// per logical route, so entries for the same method/path are deduplicated (preserving first-
|
||||
// seen order) before asserting mounting order.
|
||||
const moduleRoutePaths = [
|
||||
...new Set(
|
||||
running.app.routes
|
||||
.filter((route) => route.path.startsWith("/api/v1/modules/"))
|
||||
.map((route) => `${route.method} ${route.path}`),
|
||||
),
|
||||
];
|
||||
expect(moduleRoutePaths).toEqual([
|
||||
"GET /api/v1/modules/alpha/ping",
|
||||
"GET /api/v1/modules/beta/ping",
|
||||
]);
|
||||
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
await createFixtureModule(modulesDirectory, "broken-export");
|
||||
await writeFile(
|
||||
path.join(modulesDirectory, "broken-export", "dist/server/index.js"),
|
||||
"export default { thisIsNotAValidRuntimeDefinition: true };",
|
||||
);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_RUNTIME_EXPORT_INVALID" })],
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the pool exactly once and binds no server when a module's register hook fails", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "register-fails", recorder, {
|
||||
registerThrows: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_REGISTER_FAILED" })],
|
||||
});
|
||||
|
||||
expect(recorder.events).toEqual(["register-fails:register"]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the pool exactly once and binds no server when a module's start hook fails, preserving Core's rollback of already-started modules", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
|
||||
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
|
||||
dependencies: { modules: { required: { one: "^0.1.0" } } },
|
||||
startThrows: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
|
||||
});
|
||||
|
||||
expect(recorder.events).toEqual([
|
||||
"one:register",
|
||||
"two:register",
|
||||
"one:start",
|
||||
"two:start",
|
||||
"one:stop",
|
||||
]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the module runtime and closes the pool, binding no server, when app construction fails", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
vi.mocked(createApiApp).mockImplementationOnce(() => {
|
||||
throw new Error("forced app construction failure");
|
||||
});
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toThrow("forced app construction failure");
|
||||
|
||||
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the module runtime and closes the pool when the server fails to bind", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
mockFailingServe(new Error("EADDRINUSE"));
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drains the HTTP server, then stops the module runtime, then closes the pool, in that exact order", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "ordered", recorder);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return {
|
||||
end: vi.fn(async () => {
|
||||
recorder.events.push("pool:end");
|
||||
}),
|
||||
on: vi.fn(),
|
||||
};
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { server } = mockSuccessfulServe();
|
||||
const originalClose = server.close;
|
||||
server.close = vi.fn((callback?: () => void) => {
|
||||
recorder.events.push("server:close");
|
||||
return originalClose(callback);
|
||||
});
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
recorder.events.length = 0;
|
||||
await running.shutdown();
|
||||
|
||||
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
|
||||
});
|
||||
|
||||
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "stop-fails", recorder, {
|
||||
stopThrows: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
|
||||
await expect(running.shutdown()).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
|
||||
});
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a direct discoveredModulesOverride driving both permission composition and module runtime startup", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { discoveredModulesOverride: discovered });
|
||||
|
||||
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
|
||||
// Hono records one internal route entry per handler in a route's middleware chain, not one
|
||||
// per logical route, so entries for the same method/path are deduplicated (preserving first-
|
||||
// seen order) before asserting mounting order.
|
||||
const moduleRoutePaths = [
|
||||
...new Set(
|
||||
running.app.routes
|
||||
.filter((route) => route.path.startsWith("/api/v1/modules/"))
|
||||
.map((route) => `${route.method} ${route.path}`),
|
||||
),
|
||||
];
|
||||
expect(moduleRoutePaths).toEqual(["GET /api/v1/modules/calendar/ping"]);
|
||||
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("performs no filesystem I/O, module import, pool construction, Better Auth construction, or server binding merely by importing the module-route adapter", async () => {
|
||||
const module = await import("../src/module-http-routes.js");
|
||||
|
||||
expect(typeof module.mountModuleHttpRoutes).toBe("function");
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user