Merge pull request 'feat: add module HTTP transport' (#12) from phase-5/module-http-transport into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 27s

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
The_River
2026-07-25 15:44:40 +00:00
26 changed files with 3874 additions and 160 deletions
+15 -2
View File
@@ -61,12 +61,25 @@ enforceable and grantable through the exact same `requirePermission` middleware
claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission
composition. Installing, removing, or editing a module currently requires an API restart; removing composition. Installing, removing, or editing a module currently requires an API restart; removing
one makes its permission unavailable to new checks and new grants without deleting or revoking any one makes its permission unavailable to new checks and new grants without deleting or revoking any
historical grant or audit row. Module HTTP route registration and relationship-derived grant historical grant or audit row. See
creation remain later work. See
[Authorization](docs/development/authorization.md#module-permission-composition), [Authorization](docs/development/authorization.md#module-permission-composition),
[Module development](docs/module-development/README.md), and [Module development](docs/module-development/README.md), and
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md). [ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md).
Phase 5B mounts a module's own HTTP routes in production. A module registers framework-neutral
Fetch (`Request`/`Response`) route handlers during its existing `register` hook; Core collects them
into an immutable snapshot alongside its capabilities, and `apps/api` discovers modules exactly
once, starts the module runtime from that same discovered inventory, and mounts its published
routes under `/api/v1/modules/<module-id>` — after every module has registered and started
successfully, so no request can reach a module before startup completes. Every route is enforced
through the identical `requirePermission` middleware and composed permission registry Core's own
routes use (a module can never supply its own baseline), state-changing methods require a trusted
`Origin`, and a handler failure or non-`Response` return becomes a fixed, sanitized `500` — never a
raw error, stack, path, or permission id. Relationship-derived grant creation remains later work.
See [API](docs/development/api.md#module-http-routes),
[Module development](docs/module-development/README.md#http-routes), and
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred).
## Quick start ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
+70 -11
View File
@@ -1,4 +1,4 @@
import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; import { isAuthenticatedPrincipal, type RegisteredModuleHttpRoute } from "@ekklesia/contracts";
import type { import type {
AuthorizationEvaluator, AuthorizationEvaluator,
AuthorizationStore, AuthorizationStore,
@@ -6,26 +6,33 @@ import type {
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import { Hono } from "hono"; import { Hono } from "hono";
import type { Context } from "hono"; import type { Context, MiddlewareHandler } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
export interface CreateApiAppAuthorizationOptions { export interface CreateApiAppAuthorizationOptions {
readonly evaluator: AuthorizationEvaluator; readonly evaluator: AuthorizationEvaluator;
/** The complete platform permission registry (Core's own declarations plus every installed /** The complete platform permission registry (Core's own declarations plus every installed
* module's) — always the caller-composed registry `startApi` produces via * module's) — always the caller-composed registry `startApi` produces via
* `composeStartupPermissionRegistry`, never a Core-only fallback constructed here. */ * `prepareStartupModuleInventory`, never a Core-only fallback constructed here. */
readonly registry: PermissionRegistry; readonly registry: PermissionRegistry;
readonly store: AuthorizationStore; readonly store: AuthorizationStore;
} }
export interface CreateApiAppOptions { export interface CreateApiAppOptions {
readonly authorization: CreateApiAppAuthorizationOptions; readonly authorization: CreateApiAppAuthorizationOptions;
/** The started module runtime's published, immutable route snapshot — always the caller-started
* runtime's exact `routes` (`startDiscoveredModuleRuntime`, `apps/api/src/server.ts`), mounted in
* publication order under `/api/v1/modules/<module-id>`. Required rather than defaulted so a
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
* implicit fallback. */
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
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
* `requireTrustedOrigin` checks for grant-mutation routes — there is no second, competing origin * `requireTrustedOrigin` checks for grant- and module-route-mutation routes — there is no second,
* configuration. */ * competing origin configuration. */
readonly trustedOrigins: readonly string[]; readonly trustedOrigins: readonly string[];
} }
@@ -36,9 +43,52 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
} }
/** /**
* Pure Hono application factory: no I/O, no environment reads, no process side effects. Suitable * Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a
* for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime * dynamic-function `origin` — including one that already has its own `Vary` (module responses
* and authorization evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. * force `Origin`/`Cookie`; `/api/v1/principal` sets `Origin, Cookie` directly; an `OPTIONS`
* preflight sets its own) — so the header `cors()` itself leaves behind can still be an invalid
* combined "*, Origin" or contain a case-different duplicate. This wraps `middleware` and
* canonicalizes whatever `Vary` remains only *after* it has fully finished, using
* `canonicalizeVaryHeader` — the same shared parsing/dedup primitive `mergedVaryHeader` (module
* responses) builds on, so there is one implementation to keep in sync, not two that could diverge
* — but never `mergedVaryHeader` itself: this boundary wraps *every* response, `OPTIONS` preflights
* included, and must never inject `Origin`/`Cookie` onto a response that never had them, only
* de-duplicate what is already there. Two return shapes have to be handled: a normal request lets
* `middleware` call `next()` and mutate `c.res` in place, so the fix-up goes through `c.header()`
* (reassigning `c.res` directly triggers Hono's own header-preserving merge and would silently
* restore the uncanonicalized value); an `OPTIONS` preflight makes `cors()` return a new `Response`
* directly without ever touching `c.res`, so the fix-up mutates that returned response's headers
* instead. Neither path reads or clones the response body, so a streaming body is untouched either
* way. Status, body, content type, ordinary headers, authorization, trusted-origin behavior, and
* route ordering are all unaffected — only the final `Vary` value changes.
*/
function withCanonicalVary(middleware: MiddlewareHandler): MiddlewareHandler {
return async (c, next) => {
const result = await middleware(c, next);
if (result instanceof Response) {
const existingVary = result.headers.get("Vary");
if (existingVary !== null) {
result.headers.set("Vary", canonicalizeVaryHeader(existingVary));
}
return result;
}
const existingVary = c.res.headers.get("Vary");
if (existingVary !== null) {
c.header("Vary", canonicalizeVaryHeader(existingVary));
}
return result;
};
}
/**
* Pure Hono application factory: no I/O, no environment reads, no process side effects, and it
* never invokes a module route handler while composing routes. Suitable for unit and integration
* tests via `app.fetch` directly, with a fully injected identity runtime and authorization
* evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. Synchronously
* throws `ModuleRouteConfigurationError` (`./module-http-routes.js`) if `moduleRoutes` and
* `authorization.registry` are inconsistent — a route naming a permission the registry does not
* register, which should never happen since both derive from the same discovered inventory, but is
* checked explicitly rather than silently mounting an unenforceable route.
*/ */
export function createApiApp(options: CreateApiAppOptions): Hono { export function createApiApp(options: CreateApiAppOptions): Hono {
const { runtime } = options; const { runtime } = options;
@@ -46,13 +96,15 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
const app = new Hono(); const app = new Hono();
const corsMiddleware = cors({ const corsMiddleware = withCanonicalVary(
cors({
allowHeaders: ["Content-Type"], allowHeaders: ["Content-Type"],
allowMethods: ["GET", "POST", "OPTIONS"], allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
credentials: true, credentials: true,
maxAge: 600, maxAge: 600,
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined), origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
}); }),
);
app.use("/api/auth/*", corsMiddleware); app.use("/api/auth/*", corsMiddleware);
app.use("/api/v1/*", corsMiddleware); app.use("/api/v1/*", corsMiddleware);
@@ -91,6 +143,13 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}), }),
); );
mountModuleHttpRoutes(app, options.moduleRoutes, {
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
runtime,
trustedOrigins: allowedOrigins,
});
app.notFound((c) => { app.notFound((c) => {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");
return c.json({ code: "NOT_FOUND" }, 404); return c.json({ code: "NOT_FOUND" }, 404);
+209
View File
@@ -0,0 +1,209 @@
import type {
ModuleId,
ModuleRouteHandlerContext,
RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
const MODULE_ROUTE_PREFIX = "/api/v1/modules";
const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["DELETE", "PATCH", "POST", "PUT"]);
const STRIPPED_CORS_RESPONSE_HEADERS = [
"access-control-allow-credentials",
"access-control-allow-headers",
"access-control-allow-methods",
"access-control-allow-origin",
"access-control-expose-headers",
"access-control-max-age",
];
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
readonly trustedOrigins: ReadonlySet<string>;
}
/** Never carries the offending permission id, route path, module id, or any other configuration
* detail — a forged or inconsistent route snapshot is a startup-time composition bug, not
* something to describe in detail to whatever eventually logs it. */
export class ModuleRouteConfigurationError extends Error {
constructor() {
super("A published module HTTP route references a permission that is not registered.");
this.name = "ModuleRouteConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** "/" is the module's own root and maps to the bare module prefix, never a required trailing
* slash; every other local path appends directly. Root and non-root therefore never collide, and
* `/api/v1/modules/<module-id>/` (the root path with a trailing slash) is never produced here. */
function moduleRoutePath(moduleId: ModuleId, routePath: string): string {
return routePath === "/"
? `${MODULE_ROUTE_PREFIX}/${moduleId}`
: `${MODULE_ROUTE_PREFIX}/${moduleId}${routePath}`;
}
function sanitizedModuleHandlerFailure(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_HANDLER_FAILED" }, 500);
}
/**
* The one shared `Vary` parsing/canonicalization primitive: splits and trims comma-separated
* tokens and de-duplicates them case-insensitively — `Vary` lists header *names*, which are
* themselves case-insensitive, so a module's own `origin` must collapse with a later `Origin`
* rather than appear as a separate, redundant entry — while preserving first-seen casing and
* relative order. Never returns early on `*`; callers decide what a `*` token means for their own
* policy (see `mergedVaryHeader` and `canonicalizeVaryHeader` below, the two callers that build on
* this instead of re-implementing parsing/dedup independently).
*/
function parseVaryTokens(existingVary: string | null): Map<string, string> {
const canonicalByLowercase = new Map<string, string>();
for (const token of (existingVary ?? "").split(",")) {
const trimmed = token.trim();
if (trimmed.length === 0) {
continue;
}
const key = trimmed.toLowerCase();
if (!canonicalByLowercase.has(key)) {
canonicalByLowercase.set(key, trimmed);
}
}
return canonicalByLowercase;
}
/** A literal `*` (RFC 9110 §12.5.5) must never be combined with any other value; since `*` already
* means "may vary on anything," a strictly broader signal than any explicit list, its presence
* collapses the result to `*` alone rather than producing an invalid combination. */
function serializeVaryTokens(tokens: ReadonlyMap<string, string>): string {
if (tokens.has("*")) {
return "*";
}
return [...tokens.values()].join(", ");
}
/**
* Used only for a module's own response: always emits the canonical `Origin`/`Cookie` casing
* regardless of how (or whether) a module already listed them, since every module response is
* forced to vary on both (see `sanitizeModuleResponse`) — this is a deliberate product policy for
* module responses specifically, not a generic property every response through the platform's CORS
* boundary should have forced onto it (an `OPTIONS` preflight, for one, has no `Cookie` concept at
* all).
*/
export function mergedVaryHeader(existingVary: string | null): string {
const tokens = parseVaryTokens(existingVary);
if (tokens.has("*")) {
return "*";
}
tokens.set("origin", "Origin");
tokens.set("cookie", "Cookie");
return serializeVaryTokens(tokens);
}
/**
* Used at the app-level CORS boundary (`apps/api/src/app.ts`), which wraps every response —
* `OPTIONS` preflights and Core's own routes included, not only module ones — after Hono's own
* `cors()` has already appended its own `Vary: Origin`. Unlike `mergedVaryHeader`, this never
* injects a token that was not already present; it only de-duplicates and collapses `*`, since a
* generic boundary must not assume every response wants `Origin`/`Cookie` forced onto it.
*/
export function canonicalizeVaryHeader(existingVary: string): string {
return serializeVaryTokens(parseVaryTokens(existingVary));
}
/**
* The platform owns CORS and caching for every module route: strips any CORS-control header a
* handler set (defense in depth — the outer `cors()` middleware already computes the authoritative
* values for the actual response) and forces `Cache-Control: no-store` plus a `Vary` that always
* includes `Origin` and `Cookie`, since even a public-baseline handler receives an authorized
* principal and may personalize its response. A separate cache-policy contract is future work;
* this is the conservative default until then. A module's own legitimate `Vary` values (if any)
* are preserved alongside the forced ones. Never reads or buffers `response.body` — it is passed
* through to the new `Response` unread, so a streaming body is neither consumed nor blocked here.
*/
function sanitizeModuleResponse(response: Response): Response {
const headers = new Headers(response.headers);
for (const headerName of STRIPPED_CORS_RESPONSE_HEADERS) {
headers.delete(headerName);
}
headers.set("Cache-Control", "no-store");
headers.set("Vary", mergedVaryHeader(headers.get("Vary")));
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
/**
* Mounts `StartedModuleRuntime`'s published, immutable route snapshot into `app`, in publication
* order — never sorted or mutated, and never invoking a handler while mounting. Every route is
* protected by the exact same `requirePermission` middleware Core's own routes use, with
* `route.permission` as the only permission id ever consulted and its baseline coming exclusively
* from `options.registry`; a state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`) additionally
* requires `requireTrustedOrigin` before authorization ever runs. `GET` inherits Hono's own
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
* response body afterward — there is no separate `HEAD` registration or permission path.
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
* frozen context — never a Hono `Context`, database, store, or configuration.
*
* Because the routes and registry both derive from the same discovered inventory
* (`prepareStartupModuleInventory`), every registered route permission should already be
* registered; this loop still checks that invariant explicitly and throws
* `ModuleRouteConfigurationError` before mounting anything further if it ever does not hold, so a
* forged or hand-built route snapshot fails app construction instead of silently mounting an
* unenforceable route. `requirePermission`'s own per-request registry lookup remains the live,
* defense-in-depth enforcement regardless.
*/
export function mountModuleHttpRoutes(
app: Hono,
routes: readonly RegisteredModuleHttpRoute[],
options: ModuleHttpRoutesOptions,
): void {
const middlewareOptions: AuthorizationMiddlewareOptions = {
evaluator: options.evaluator,
registry: options.registry,
runtime: options.runtime,
};
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
for (const route of routes) {
if (!options.registry.has(route.permission)) {
throw new ModuleRouteConfigurationError();
}
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({
moduleId: route.moduleId,
params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c),
});
let response: Response;
try {
response = await route.handler(c.req.raw, context);
} catch {
return sanitizedModuleHandlerFailure(c);
}
if (!(response instanceof Response)) {
return sanitizedModuleHandlerFailure(c);
}
return sanitizeModuleResponse(response);
};
// Hono's `on()` overloads resolve by literal call arity, not by the runtime length of a spread
// array — each branch below is therefore a distinct, fixed-arity call rather than one call
// spreading a variable-length handlers array.
if (STATE_CHANGING_METHODS.has(route.method)) {
app.on(route.method, path, requireOrigin, requireRoutePermission, finalHandler);
} else {
app.on(route.method, path, requireRoutePermission, finalHandler);
}
}
}
+49 -12
View File
@@ -54,7 +54,7 @@ async function readPackageVersion(packagePath: string): Promise<string> {
return packageData.version; return packageData.version;
} }
export interface ComposeStartupPermissionRegistryOptions { export interface PrepareStartupModuleInventoryOptions {
/** Test-only escape hatch: overrides the directory module discovery scans for installed /** Test-only escape hatch: overrides the directory module discovery scans for installed
* modules. Never set by the executable entry point — production always discovers the real * modules. Never set by the executable entry point — production always discovers the real
* repository's `modules/` directory. Platform and Module SDK versions are always read from the * repository's `modules/` directory. Platform and Module SDK versions are always read from the
@@ -62,7 +62,8 @@ export interface ComposeStartupPermissionRegistryOptions {
readonly modulesDirectoryOverride?: string; readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the /** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry` * filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
* on it. Exists because a genuine exact permission-id collision between two modules is * on it — and, if startup progresses that far, driving module runtime startup with this exact
* list too. Exists because a genuine exact permission-id collision between two modules is
* unreachable through real discovery: module ids are unique (discovery itself rejects a * unreachable through real discovery: module ids are unique (discovery itself rejects a
* duplicate) and every permission id is namespaced by its own module's id, so no two * duplicate) and every permission id is namespaced by its own module's id, so no two
* really-discovered modules can ever contribute the same permission id. Proving * really-discovered modules can ever contribute the same permission id. Proving
@@ -76,18 +77,35 @@ export interface ComposeStartupPermissionRegistryOptions {
readonly discoveredModulesOverride?: readonly DiscoveredModule[]; readonly discoveredModulesOverride?: readonly DiscoveredModule[];
} }
export type ComposeStartupPermissionRegistryOptions = PrepareStartupModuleInventoryOptions;
export interface StartupModuleInventory {
/** The exact discovered-module list — in discovery order — permission composition ran against.
* Production startup (`apps/api/src/server.ts`) passes this same list to
* `startDiscoveredModuleRuntime` rather than letting the module runtime rediscover modules a
* second time. */
readonly modules: readonly DiscoveredModule[];
readonly permissionRegistry: PermissionRegistry;
}
/** /**
* Discovers every installed module's manifest and composes the one immutable platform permission * Discovers every installed module's manifest exactly once and composes the one immutable platform
* registry (Core's own declarations plus every module's) — the production source of truth * permission registry (Core's own declarations plus every module's) from that exact discovered
* `apps/api` enforces every request against. Only manifests are read; no module server entrypoint * list — the production source of truth `apps/api` enforces every request against, and the exact
* is imported or executed, and no module lifecycle hook runs. Read the platform and Module SDK * module inventory `startDiscoveredModuleRuntime` starts from. Only manifests are read here; no
* versions only when this function actually executes, never at import time. * module server entrypoint is imported or executed, and no module lifecycle hook runs. Read the
* platform and Module SDK versions only when this function actually executes, never at import
* time.
*/ */
export async function composeStartupPermissionRegistry( export async function prepareStartupModuleInventory(
options: ComposeStartupPermissionRegistryOptions = {}, options: PrepareStartupModuleInventoryOptions = {},
): Promise<PermissionRegistry> { ): Promise<StartupModuleInventory> {
if (options.discoveredModulesOverride) { if (options.discoveredModulesOverride) {
return composePlatformPermissionRegistry(options.discoveredModulesOverride); const modules = options.discoveredModulesOverride;
return Object.freeze({
modules,
permissionRegistry: composePlatformPermissionRegistry(modules),
});
} }
const repositoryRoot = resolveRepositoryRoot(); const repositoryRoot = resolveRepositoryRoot();
@@ -102,5 +120,24 @@ export async function composeStartupPermissionRegistry(
platformVersion, platformVersion,
}); });
return composePlatformPermissionRegistry(modules); return Object.freeze({
modules,
permissionRegistry: composePlatformPermissionRegistry(modules),
});
}
/**
* Compatible wrapper for callers — currently only existing tests — that need this package's
* composed registry without the discovered module list itself. (Doctor and `modules:validate` are
* Core-owned and never call into `apps/api` at all; they call Core's own
* `composePlatformPermissionRegistry` directly over their own already-discovered module list.)
* Production `startApi` does not call this wrapper: it calls `prepareStartupModuleInventory`
* directly so it can hand the exact same discovered list to `startDiscoveredModuleRuntime`, rather
* than discarding it here and rediscovering modules a second time for runtime startup.
*/
export async function composeStartupPermissionRegistry(
options: ComposeStartupPermissionRegistryOptions = {},
): Promise<PermissionRegistry> {
const inventory = await prepareStartupModuleInventory(options);
return inventory.permissionRegistry;
} }
+87 -22
View File
@@ -5,13 +5,17 @@ import {
createAuthorizationStore, createAuthorizationStore,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
import type { DiscoveredModule } from "@ekklesia/core/modules"; import {
type DiscoveredModule,
type StartedModuleRuntime,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
import { serve, type ServerType } from "@hono/node-server"; import { serve, type ServerType } from "@hono/node-server";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { createApiApp } from "./app.js"; import { createApiApp } from "./app.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { composeStartupPermissionRegistry } from "./platform-permissions.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000; const connectionTimeoutMs = 10_000;
const operationTimeoutMs = 10_000; const operationTimeoutMs = 10_000;
@@ -26,12 +30,13 @@ export interface StartApiOptions {
/** Test-only escape hatch: never set by the executable entry point. See /** Test-only escape hatch: never set by the executable entry point. See
* `IdentityRateLimitOverride` for why production must leave this unset. */ * `IdentityRateLimitOverride` for why production must leave this unset. */
readonly rateLimitOverride?: IdentityRateLimitOverride; readonly rateLimitOverride?: IdentityRateLimitOverride;
/** Test-only escape hatch: overrides the directory permission-composition module discovery /** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
* scans. Never set by the executable entry point. See `composeStartupPermissionRegistry`. */ * executable entry point. See `prepareStartupModuleInventory`. */
readonly modulesDirectoryOverride?: string; readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly to permission /** Test-only escape hatch: supplies an already-discovered module list directly, skipping
* composition, skipping filesystem discovery entirely. Never set by the executable entry * filesystem discovery entirely — this exact list drives both permission composition and module
* point. See `composeStartupPermissionRegistry`. */ * runtime startup. Never set by the executable entry point. See
* `prepareStartupModuleInventory`. */
readonly discoveredModulesOverride?: readonly DiscoveredModule[]; readonly discoveredModulesOverride?: readonly DiscoveredModule[];
} }
@@ -44,6 +49,17 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
} }
} }
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, used only while unwinding a startup
* failure that already occurred after the module runtime started — never during normal shutdown,
* where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */
async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promise<void> {
try {
await moduleRuntime.stop();
} catch {
// See safeCloseDatabase: never mask the startup failure already propagating.
}
}
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> { function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let settled = false; let settled = false;
@@ -63,25 +79,36 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
} }
/** /**
* Explicit runtime composition, in a fixed order: discover every installed module's manifest and * Explicit runtime composition, in a fixed order:
* compose the one immutable platform permission registry from Core's own declarations plus every *
* module's (`./platform-permissions.js`); only then construct the one shared PostgreSQL pool * 1. Discover every installed module's manifest exactly once and compose the one immutable
* (identity and authorization each get a typed Drizzle view over it — see `./database.js`), the * platform permission registry from Core's own declarations plus every module's
* Core identity runtime, the Core authorization store and evaluator, and the Hono application; * (`prepareStartupModuleInventory`, `./platform-permissions.js`).
* finally start the Node HTTP server. Permission composition happens before any pool, Better * 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
* Auth instance, or HTTP server exists, so a discovery or composition failure — an invalid * Drizzle view over it — see `./database.js`).
* 3. Construct the Core identity runtime, authorization store, and evaluator.
* 4. Start the module runtime from that exact same discovered inventory
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time.
* 5. Create the Hono application, mounting the started module runtime's published routes.
* 6. Bind the Node HTTP server.
*
* Discovery and permission composition happen before any pool, Better Auth instance, module
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
* 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. Returns a handle with an * directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
* idempotent `shutdown()` so both the executable entry point and tests can stop the server and * identity, authorization) exists before any executable module lifecycle code runs, and every
* close the shared pool deterministically. Never applies migrations or generates schema — the * module route is published only after the entire module runtime has registered and started
* database must already be migrated through `pnpm migrations:apply`. * successfully, so no request can ever reach a module before every module has started. Returns a
* handle with an idempotent `shutdown()` so both the executable entry point and tests can stop the
* server, the module runtime, and the shared pool deterministically. Never applies migrations or
* generates schema — the database must already be migrated through `pnpm migrations:apply`.
*/ */
export async function startApi( export async function startApi(
config: ApiRuntimeConfig, config: ApiRuntimeConfig,
options: StartApiOptions = {}, options: StartApiOptions = {},
): Promise<RunningApi> { ): Promise<RunningApi> {
const permissionRegistry = await composeStartupPermissionRegistry({ const inventory = await prepareStartupModuleInventory({
...(options.modulesDirectoryOverride === undefined ...(options.modulesDirectoryOverride === undefined
? {} ? {}
: { modulesDirectoryOverride: options.modulesDirectoryOverride }), : { modulesDirectoryOverride: options.modulesDirectoryOverride }),
@@ -114,16 +141,40 @@ export async function startApi(
platformOwners: authorizationStore, platformOwners: authorizationStore,
}); });
const app = createApiApp({ // Module load/register/start failures use Core's own existing rollback (already-started
authorization: { evaluator, registry: permissionRegistry, store: authorizationStore }, // modules are stopped, in reverse order, before this rejects) — there is no separate runtime
// handle to stop here, only the pool this failure leaves open.
let moduleRuntime: StartedModuleRuntime;
try {
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules);
} catch (error) {
await safeCloseDatabase(databaseHandle);
throw error;
}
let app: Hono;
try {
app = createApiApp({
authorization: {
evaluator,
registry: inventory.permissionRegistry,
store: authorizationStore,
},
moduleRoutes: moduleRuntime.routes,
runtime, runtime,
trustedOrigins: config.identity.trustedOrigins, trustedOrigins: config.identity.trustedOrigins,
}); });
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseDatabase(databaseHandle);
throw error;
}
let server: ServerType; let server: ServerType;
try { try {
server = await bindServer(app, config.host, config.port); server = await bindServer(app, config.host, config.port);
} catch (error) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -133,7 +184,12 @@ 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. // observes it complete (or fail) exactly once. Order matters: the HTTP listener is drained and
// closed first so no new or in-flight request can reach a module handler while the module
// runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the
// startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine
// shutdown result a caller should observe — but database cleanup still always runs first, so a
// stop failure never leaves the pool open.
let shutdownPromise: Promise<void> | undefined; let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => { const shutdown = (): Promise<void> => {
if (!shutdownPromise) { if (!shutdownPromise) {
@@ -141,7 +197,16 @@ export async function startApi(
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
server.close(() => resolve()); server.close(() => resolve());
}); });
let moduleStopError: unknown;
try {
await moduleRuntime.stop();
} catch (error) {
moduleStopError = error;
}
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
if (moduleStopError) {
throw moduleStopError;
}
})(); })();
} }
return shutdownPromise; return shutdownPromise;
+133 -8
View File
@@ -200,6 +200,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
} }
const trackedModulesDirectories: string[] = []; const trackedModulesDirectories: string[] = [];
const eventSymbols: symbol[] = [];
async function createFixtureModulesDirectory(): Promise<string> { async function createFixtureModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-")); const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-"));
@@ -207,13 +208,31 @@ async function createFixtureModulesDirectory(): Promise<string> {
return directory; return directory;
} }
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery and /** A dynamically imported module fixture cannot close over this test file's local variables, so
* composition run against it, nothing hand-built. The server entrypoint throws immediately if * lifecycle markers cross that boundary through a `globalThis` symbol instead (same technique as
* ever imported/executed, so a scenario that completes without that throw firing is direct * `core/tests/module-runtime.test.ts`). */
* evidence permission composition and Doctor never ran module lifecycle code. */ function createEventRecorder(): { events: string[]; expression: string } {
const key = `ekklesia.api.integration.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)})]` };
}
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery,
* composition, and (where a scenario actually starts the API) real module runtime execution all
* run against it, nothing hand-built. Register/start/stop push markers onto `recorder.events`
* instead of throwing, so both "Doctor/composition never executed this" (an empty recorder) and
* "startApi actually imported, registered, and started this" (a populated recorder) can be proven
* against the exact same fixture. When declared with the `calendar.events.read` permission, the
* module also registers a restricted `GET /events/:eventId` route and a restricted, state-changing
* `POST /events` route against that same permission, so the full HTTP mounting/enforcement chain
* can be exercised live. */
async function createFixtureModule( async function createFixtureModule(
modulesDirectory: string, modulesDirectory: string,
id: string, id: string,
recorder: { expression: string },
manifestOverrides: Record<string, unknown> = {}, manifestOverrides: Record<string, unknown> = {},
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
@@ -227,6 +246,9 @@ async function createFixtureModule(
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides, ...manifestOverrides,
}; };
const declaresCalendarRead = (manifest.permissions as ReadonlyArray<{ id: string }>).some(
(declaration) => declaration.id === "calendar.events.read",
);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([ await Promise.all([
writeFile( writeFile(
@@ -241,7 +263,39 @@ async function createFixtureModule(
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)), writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
writeFile( writeFile(
path.join(moduleDirectory, "dist/server/index.js"), path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("permission composition and Doctor must never execute a module server entrypoint");', `export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
register({ http }) {
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
${
declaresCalendarRead
? `http.route({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
async handler(request, context) {
return Response.json({ eventId: context.params.eventId, principal: context.principal });
},
});
http.route({
method: "POST",
path: "/events",
permission: "calendar.events.read",
async handler(request, context) {
return Response.json({ created: true, principal: context.principal }, { status: 201 });
},
});`
: ""
}
},
start() {
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
},
stop() {
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
},
};`,
), ),
]); ]);
} }
@@ -253,6 +307,10 @@ afterAll(async () => {
for (const directory of trackedModulesDirectories.splice(0)) { for (const directory of trackedModulesDirectories.splice(0)) {
await rm(directory, { recursive: true, force: true }).catch(() => undefined); await rm(directory, { recursive: true, force: true }).catch(() => undefined);
} }
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
for (const symbol of eventSymbols.splice(0)) {
delete globalValues[symbol];
}
}); });
const identityBaseUrl = "https://identity.example.test"; const identityBaseUrl = "https://identity.example.test";
@@ -1222,10 +1280,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
const password = "correct horse battery staple"; const password = "correct horse battery staple";
const modulePermission = "calendar.events.read"; const modulePermission = "calendar.events.read";
const absentPermission = "scheduler.jobs.read"; const absentPermission = "scheduler.jobs.read";
const recorder = createEventRecorder();
it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => { it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => {
modulesDirectory = await createFixtureModulesDirectory(); modulesDirectory = await createFixtureModulesDirectory();
await createFixtureModule(modulesDirectory, "calendar", { await createFixtureModule(modulesDirectory, "calendar", recorder, {
permissions: [ permissions: [
{ {
baseline: "restricted", baseline: "restricted",
@@ -1257,14 +1316,21 @@ describe.sequential("live API — installed module permission end-to-end", () =>
details: ["2 permissions registered"], details: ["2 permissions registered"],
status: "pass", status: "pass",
}); });
// Doctor and permission composition read only the manifest — the fixture's register/start
// markers must remain entirely unset.
expect(recorder.events).toEqual([]);
}); });
it("starts a freshly migrated API that discovers the fixture module and signs up its eventual owner", async () => { it("starts a freshly migrated API that discovers the fixture module, imports and starts its runtime exactly once, and mounts its route", async () => {
database = await createDisposableDatabase("authz_module"); database = await createDisposableDatabase("authz_module");
await applyCoreMigrations(database.url); await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url, modulesDirectory); running = await startMigratedApi(database.url, modulesDirectory);
origin = apiOrigin(running); origin = apiOrigin(running);
// Unlike Doctor above, a successful production startup does deliberately import, register,
// and start the module's runtime — exactly once each.
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
const ownerSignUp = await signUp(origin, ownerEmail, password); const ownerSignUp = await signUp(origin, ownerEmail, password);
ownerUserId = ownerSignUp.body.user.id; ownerUserId = ownerSignUp.body.user.id;
const ownerSignIn = await signIn(origin, ownerEmail, password); const ownerSignIn = await signIn(origin, ownerEmail, password);
@@ -1316,6 +1382,56 @@ describe.sequential("live API — installed module permission end-to-end", () =>
} }
}); });
it("denies the module's own mounted route to an anonymous caller, then serves it to the newly granted owner with the decoded path parameter and authenticated principal", async () => {
const anonymousResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`);
expect(anonymousResponse.status).toBe(403);
expect(await anonymousResponse.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
const grantedResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`, {
headers: { cookie: ownerCookie },
});
expect(grantedResponse.status).toBe(200);
const body = (await grantedResponse.json()) as {
eventId: string;
principal: { kind: string; userId: string };
};
expect(body.eventId).toBe("evt-42");
expect(body.principal).toEqual({ kind: "authenticated", userId: ownerUserId });
});
it("requires a trusted Origin for the module's own state-changing route, exactly like the platform's own mutation routes", async () => {
const noOriginResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
body: JSON.stringify({}),
headers: { "content-type": "application/json", cookie: ownerCookie },
method: "POST",
});
expect(noOriginResponse.status).toBe(403);
expect(await noOriginResponse.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
const untrustedResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
body: JSON.stringify({}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: untrustedOrigin,
},
method: "POST",
});
expect(untrustedResponse.status).toBe(403);
const trustedResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
body: JSON.stringify({}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(trustedResponse.status).toBe(201);
expect(await trustedResponse.json()).toMatchObject({ created: true });
});
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => { it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url); const admin = await openAdministrativeClient(database.url);
try { try {
@@ -1355,15 +1471,24 @@ describe.sequential("live API — installed module permission end-to-end", () =>
} }
}); });
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission while the historical grant remains listable", async () => { it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
await running.shutdown(); await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name); const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
// Exactly one stop, matching the exactly-one register/start already asserted above.
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
const emptyModulesDirectory = await createFixtureModulesDirectory(); const emptyModulesDirectory = await createFixtureModulesDirectory();
running = await startMigratedApi(database.url, emptyModulesDirectory); running = await startMigratedApi(database.url, emptyModulesDirectory);
origin = apiOrigin(running); origin = apiOrigin(running);
// The module's own route is unmounted along with the module itself.
const removedRouteResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`, {
headers: { cookie: ownerCookie },
});
expect(removedRouteResponse.status).toBe(404);
expect(await removedRouteResponse.json()).toEqual({ code: "NOT_FOUND" });
// The owner's existing session survives the restart (sessions live in the database, not the // The owner's existing session survives the restart (sessions live in the database, not the
// process), so the same cookie proves ordinary identity behavior is unaffected here too. // process), so the same cookie proves ordinary identity behavior is unaffected here too.
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
+36
View File
@@ -52,6 +52,7 @@ describe("Ekklesia API Hono application", () => {
it("mounts Better Auth exactly under /api/auth/*", async () => { it("mounts Better Auth exactly under /api/auth/*", async () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -67,6 +68,7 @@ describe("Ekklesia API Hono application", () => {
it("does not route requests outside /api/auth/* to Better Auth", async () => { it("does not route requests outside /api/auth/* to Better Auth", async () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -83,6 +85,7 @@ describe("Ekklesia API Hono application", () => {
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -96,11 +99,33 @@ describe("Ekklesia API Hono application", () => {
expect(response.headers.get("vary")).toContain("Cookie"); expect(response.headers.get("vary")).toContain("Cookie");
}); });
it("never duplicates Origin in Vary even though the route sets it directly and Hono's own CORS middleware also appends it", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: { origin: trustedOrigin },
});
const vary = response.headers.get("vary") ?? "";
const tokens = vary.split(",").map((token) => token.trim().toLowerCase());
expect(tokens.filter((token) => token === "origin")).toHaveLength(1);
expect(tokens).toContain("cookie");
});
it("returns the authenticated principal shape containing only kind and userId", async () => { it("returns the authenticated principal shape containing only kind and userId", async () => {
const runtime = fakeRuntime({ const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")), resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -121,6 +146,7 @@ describe("Ekklesia API Hono application", () => {
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")), .mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -138,6 +164,7 @@ describe("Ekklesia API Hono application", () => {
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")), handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -154,6 +181,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([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -167,6 +195,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([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -182,6 +211,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([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -196,6 +226,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([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -208,6 +239,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([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -227,6 +259,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([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -249,6 +282,7 @@ describe("Ekklesia API Hono application", () => {
it("mounts the v1 grants routes under /api/v1/grants", async () => { it("mounts the v1 grants routes under /api/v1/grants", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false }); const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(), runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -274,6 +308,7 @@ describe("Ekklesia API Hono application", () => {
has: () => false, has: () => false,
}; };
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({ authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize }, evaluator: { authorize: evaluatorAuthorize },
registry: emptyRegistry, registry: emptyRegistry,
@@ -290,6 +325,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([]),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
+327
View File
@@ -13,6 +13,13 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock })); 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 { parseModuleManifest } from "@ekklesia/contracts";
import { import {
@@ -21,6 +28,7 @@ import {
ModulePermissionCompositionError, ModulePermissionCompositionError,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { PermissionRegistryError } from "@ekklesia/core/authorization"; import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.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 /** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used
* only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is * 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 * 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(); 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();
});
});
File diff suppressed because it is too large Load Diff
+147 -21
View File
@@ -4,12 +4,17 @@ import {
type CapabilityRegistrar, type CapabilityRegistrar,
type CapabilityResolver, type CapabilityResolver,
capabilityIdSchema, capabilityIdSchema,
type ModuleHttpRouteRegistrar,
type ModuleId, type ModuleId,
type ModuleRegistrationContext, type ModuleRegistrationContext,
type ModuleRouteDefinitionInput,
type ModuleRuntimeContext, type ModuleRuntimeContext,
type ModuleRuntimeDefinition, type ModuleRuntimeDefinition,
moduleRouteShapeKey,
parseModuleRuntimeDefinition, parseModuleRuntimeDefinition,
type RegisteredCapability, type RegisteredCapability,
type RegisteredModuleHttpRoute,
safeParseModuleRouteDefinition,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
type DiscoveredModule, type DiscoveredModule,
@@ -27,6 +32,12 @@ export type ModuleRuntimeIssueCode =
| "MODULE_CAPABILITY_UNREGISTERED" | "MODULE_CAPABILITY_UNREGISTERED"
| "MODULE_REGISTER_FAILED" | "MODULE_REGISTER_FAILED"
| "MODULE_ROLLBACK_FAILED" | "MODULE_ROLLBACK_FAILED"
| "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_DUPLICATE"
| "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
| "MODULE_RUNTIME_EXPORT_INVALID" | "MODULE_RUNTIME_EXPORT_INVALID"
| "MODULE_RUNTIME_ID_MISMATCH" | "MODULE_RUNTIME_ID_MISMATCH"
| "MODULE_RUNTIME_IMPORT_FAILED" | "MODULE_RUNTIME_IMPORT_FAILED"
@@ -53,6 +64,9 @@ export interface RunningModule {
export interface StartedModuleRuntime { export interface StartedModuleRuntime {
readonly capabilities: CapabilityRegistry; readonly capabilities: CapabilityRegistry;
readonly modules: readonly RunningModule[]; readonly modules: readonly RunningModule[];
/** One immutable startup snapshot: discovered-module order, then registration order within each
* module. Never mutated after startup — there is no dynamic route addition or removal. */
readonly routes: readonly RegisteredModuleHttpRoute[];
stop(): Promise<void>; stop(): Promise<void>;
} }
@@ -63,11 +77,16 @@ interface LoadedModule {
readonly discovered: DiscoveredModule; readonly discovered: DiscoveredModule;
} }
interface RegistrationStage { interface CapabilityRegistrationStage {
readonly context: ModuleRegistrationContext; readonly capabilities: CapabilityRegistrar;
commit(): readonly ModuleRuntimeIssue[]; commit(): readonly ModuleRuntimeIssue[];
} }
interface HttpRouteRegistrationStage {
readonly http: ModuleHttpRouteRegistrar;
commit(): void;
}
class ModuleRuntimeSignal extends Error { class ModuleRuntimeSignal extends Error {
readonly issue: ModuleRuntimeIssue; readonly issue: ModuleRuntimeIssue;
@@ -109,7 +128,7 @@ function defaultExport(moduleNamespace: unknown): unknown {
class StagedCapabilityRegistry { class StagedCapabilityRegistry {
readonly #registered = new Map<CapabilityId, RegisteredCapability>(); readonly #registered = new Map<CapabilityId, RegisteredCapability>();
createRegistrationStage(module: DiscoveredModule): RegistrationStage { createRegistrationStage(module: DiscoveredModule): CapabilityRegistrationStage {
const local = new Map<CapabilityId, RegisteredCapability>(); const local = new Map<CapabilityId, RegisteredCapability>();
const declarations = new Map( const declarations = new Map(
module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const), module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const),
@@ -154,13 +173,9 @@ class StagedCapabilityRegistry {
}, },
resolve, resolve,
}); });
const context: ModuleRegistrationContext = Object.freeze({
capabilities,
moduleId: module.manifest.id,
});
return { return {
context, capabilities,
commit: (): readonly ModuleRuntimeIssue[] => { commit: (): readonly ModuleRuntimeIssue[] => {
const missing = [...declarations.keys()] const missing = [...declarations.keys()]
.filter((capabilityId) => !local.has(capabilityId)) .filter((capabilityId) => !local.has(capabilityId))
@@ -252,6 +267,88 @@ class StagedCapabilityRegistry {
} }
} }
/**
* Stages a module's HTTP route registrations during `register`, exactly like
* `StagedCapabilityRegistry` stages capability provision: routes accumulate in a per-module local
* list, validated and rejected immediately (`ModuleRuntimeSignal`) at the `http.route(...)` call
* site, and are only merged into the shared, eventually-published list by `commit()` — called only
* after the module's `register` hook returns without throwing. Unlike capabilities, there is no
* "declared but never provided" concept for routes (a manifest never declares routes), so `commit`
* has nothing to validate; it only publishes what already passed validation.
*/
class StagedModuleHttpRoutes {
readonly #routes: RegisteredModuleHttpRoute[] = [];
createRegistrationStage(module: DiscoveredModule): HttpRouteRegistrationStage {
const local: RegisteredModuleHttpRoute[] = [];
const shapes = new Set<string>();
const ownedPermissionIds = new Set(
module.manifest.permissions.map((declaration) => declaration.id),
);
const http: ModuleHttpRouteRegistrar = Object.freeze({
route: (definition: ModuleRouteDefinitionInput): void => {
const parsed = safeParseModuleRouteDefinition(definition);
if (!parsed.success) {
throw new ModuleRuntimeSignal(
runtimeIssue(
parsed.code,
"register",
module.manifest.id,
`Module ${module.manifest.id} registered an invalid HTTP route.`,
),
);
}
// Ownership is checked against this module's own manifest-declared permissions only, never
// the composed platform registry — a module can therefore never gate a route with Core's or
// another module's permission, structurally, regardless of what that other permission is.
if (!ownedPermissionIds.has(parsed.data.permission)) {
throw new ModuleRuntimeSignal(
runtimeIssue(
"MODULE_ROUTE_PERMISSION_UNDECLARED",
"register",
module.manifest.id,
`Module ${module.manifest.id} registered an HTTP route with a permission its own manifest does not declare.`,
),
);
}
const shapeKey = moduleRouteShapeKey(parsed.data.method, parsed.data.path);
if (shapes.has(shapeKey)) {
throw new ModuleRuntimeSignal(
runtimeIssue(
"MODULE_ROUTE_DUPLICATE",
"register",
module.manifest.id,
`Module ${module.manifest.id} registered a duplicate HTTP route.`,
),
);
}
shapes.add(shapeKey);
local.push(
Object.freeze({
handler: parsed.data.handler,
method: parsed.data.method,
moduleId: module.manifest.id,
path: parsed.data.path,
permission: parsed.data.permission,
}),
);
},
});
return {
http,
commit: (): void => {
this.#routes.push(...local);
},
};
}
publish(): readonly RegisteredModuleHttpRoute[] {
return Object.freeze([...this.#routes]);
}
}
async function loadRuntimeDefinitions( async function loadRuntimeDefinitions(
discoveredModules: readonly DiscoveredModule[], discoveredModules: readonly DiscoveredModule[],
): Promise<readonly LoadedModule[]> { ): Promise<readonly LoadedModule[]> {
@@ -327,12 +424,19 @@ async function loadRuntimeDefinitions(
async function registerModules( async function registerModules(
modules: readonly LoadedModule[], modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry, capabilityRegistry: StagedCapabilityRegistry,
httpRoutes: StagedModuleHttpRoutes,
): Promise<void> { ): Promise<void> {
for (const module of modules) { for (const module of modules) {
const stage = registry.createRegistrationStage(module.discovered); const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
const routeStage = httpRoutes.createRegistrationStage(module.discovered);
const context: ModuleRegistrationContext = Object.freeze({
capabilities: capabilityStage.capabilities,
http: routeStage.http,
moduleId: module.discovered.manifest.id,
});
try { try {
await module.definition.register?.(stage.context); await module.definition.register?.(context);
} catch (error) { } catch (error) {
if (error instanceof ModuleRuntimeSignal) { if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]); throw new ModuleRuntimeError([error.issue]);
@@ -346,10 +450,15 @@ async function registerModules(
), ),
]); ]);
} }
const issues = stage.commit(); const issues = capabilityStage.commit();
if (issues.length > 0) { if (issues.length > 0) {
throw new ModuleRuntimeError(issues); throw new ModuleRuntimeError(issues);
} }
// Routes are only merged into the shared published list once this module's registration has
// fully succeeded (register hook did not throw, and every declared capability was provided) —
// a failed registration therefore publishes no routes for this module, matching the same
// all-or-nothing guarantee capabilities already have.
routeStage.commit();
} }
} }
@@ -405,16 +514,25 @@ async function startModules(
} }
} }
export async function startModuleRuntime( /**
options: StartModuleRuntimeOptions, * Starts the module runtime from a module list the caller already discovered — performs no
* module-directory scan or manifest read of its own, and preserves the given list's order exactly.
* `startModuleRuntime` below is the convenient discovery-plus-start wrapper most callers want;
* `apps/api`'s production startup calls this form directly (`prepareStartupModuleInventory`,
* `apps/api/src/platform-permissions.ts`) so permission composition and module HTTP/runtime
* startup share the exact same one-time discovery rather than discovering modules twice.
*/
export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[],
): Promise<StartedModuleRuntime> { ): Promise<StartedModuleRuntime> {
const discovered = await discoverModules(options); const modules = await loadRuntimeDefinitions(discoveredModules);
const modules = await loadRuntimeDefinitions(discovered); const capabilityRegistry = new StagedCapabilityRegistry();
const stagedRegistry = new StagedCapabilityRegistry(); const httpRoutes = new StagedModuleHttpRoutes();
await registerModules(modules, stagedRegistry); await registerModules(modules, capabilityRegistry, httpRoutes);
await startModules(modules, stagedRegistry); await startModules(modules, capabilityRegistry);
const capabilities = stagedRegistry.publish(); const capabilities = capabilityRegistry.publish();
const routes = httpRoutes.publish();
const runningModules = Object.freeze( const runningModules = Object.freeze(
modules.map((module) => modules.map((module) =>
Object.freeze({ Object.freeze({
@@ -428,9 +546,10 @@ export async function startModuleRuntime(
return Object.freeze({ return Object.freeze({
capabilities, capabilities,
modules: runningModules, modules: runningModules,
routes,
stop: (): Promise<void> => { stop: (): Promise<void> => {
stopPromise ??= (async () => { stopPromise ??= (async () => {
const issues = await stopModules(modules, stagedRegistry, "stop"); const issues = await stopModules(modules, capabilityRegistry, "stop");
if (issues.length > 0) { if (issues.length > 0) {
throw new ModuleRuntimeError(issues); throw new ModuleRuntimeError(issues);
} }
@@ -439,3 +558,10 @@ export async function startModuleRuntime(
}, },
}); });
} }
export async function startModuleRuntime(
options: StartModuleRuntimeOptions,
): Promise<StartedModuleRuntime> {
const discovered = await discoverModules(options);
return startDiscoveredModuleRuntime(discovered);
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from "vitest";
const readdir = vi.fn();
const readFile = vi.fn();
const realpath = vi.fn();
const stat = vi.fn();
vi.mock("node:fs/promises", async (importOriginal) => ({
...(await importOriginal<typeof import("node:fs/promises")>()),
readdir,
readFile,
realpath,
stat,
}));
describe("module HTTP contract and runtime import safety", () => {
it("performs no filesystem I/O merely by importing the module runtime or the module-http contract", async () => {
await import("../src/modules/index.js");
await import("@ekklesia/contracts");
expect(readdir).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(realpath).not.toHaveBeenCalled();
expect(stat).not.toHaveBeenCalled();
});
});
+489 -2
View File
@@ -1,14 +1,21 @@
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 { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverModules } from "../src/modules/discovery.js";
import { import {
ModuleDiscoveryError, ModuleDiscoveryError,
ModuleRuntimeError, ModuleRuntimeError,
type ModuleRuntimeIssueCode, type ModuleRuntimeIssueCode,
startDiscoveredModuleRuntime,
startModuleRuntime, startModuleRuntime,
} from "../src/modules/index.js"; } from "../src/modules/index.js";
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
return { ...actual, discoverModules: vi.fn(actual.discoverModules) };
});
interface RuntimeModuleFixture { interface RuntimeModuleFixture {
readonly directory?: string; readonly directory?: string;
readonly manifest?: Record<string, unknown>; readonly manifest?: Record<string, unknown>;
@@ -40,6 +47,7 @@ function manifestFor(
options: { options: {
capabilities?: Array<{ id: string; version: string }>; capabilities?: Array<{ id: string; version: string }>;
dependencies?: Record<string, unknown>; dependencies?: Record<string, unknown>;
permissions?: Array<{ id: string; baseline: string; description: string }>;
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
@@ -52,7 +60,7 @@ function manifestFor(
moduleSdk: "^0.1.0", moduleSdk: "^0.1.0",
}, },
capabilities: options.capabilities ?? [], capabilities: options.capabilities ?? [],
permissions: [], permissions: options.permissions ?? [],
...(options.dependencies ? { dependencies: options.dependencies } : {}), ...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: { entrypoints: {
server: "./dist/server/index.js", server: "./dist/server/index.js",
@@ -370,3 +378,482 @@ describe("module runtime", () => {
expect(JSON.stringify(error.issues)).not.toContain(modulesDirectory); expect(JSON.stringify(error.issues)).not.toContain(modulesDirectory);
}); });
}); });
function noopHandlerSource(): string {
return "() => new Response(null, { status: 204 })";
}
function permissionDeclaration(
id: string,
baseline: "authenticated" | "public" | "restricted" = "restricted",
): { id: string; baseline: string; description: string } {
return { id, baseline, description: `Test permission ${id}.` };
}
describe("module HTTP routes", () => {
it("registers one route and publishes it on the started runtime", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "single", {
manifest: manifestFor("single", {
permissions: [permissionDeclaration("single.items.read")],
}),
serverSource: `export default {
id: "single", version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/items",
permission: "single.items.read",
handler: ${noopHandlerSource()},
});
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(runtime.routes).toHaveLength(1);
expect(runtime.routes[0]).toMatchObject({
moduleId: "single",
method: "GET",
path: "/items",
permission: "single.items.read",
});
await runtime.stop();
});
it("retains registration order within a module and discovery order across modules", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "alpha", {
manifest: manifestFor("alpha", {
permissions: [
permissionDeclaration("alpha.first.read"),
permissionDeclaration("alpha.second.read"),
],
}),
serverSource: `export default {
id: "alpha", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/first", permission: "alpha.first.read", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/second", permission: "alpha.second.read", handler: ${noopHandlerSource()} });
}
};`,
});
await createRuntimeModule(modulesDirectory, "beta", {
manifest: manifestFor("beta", {
permissions: [permissionDeclaration("beta.third.read")],
}),
serverSource: `export default {
id: "beta", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/third", permission: "beta.third.read", handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(
runtime.routes.map((route) => `${route.moduleId}:${route.method}:${route.path}`),
).toEqual(["alpha:GET:/first", "alpha:GET:/second", "beta:GET:/third"]);
await runtime.stop();
});
it("allows the same path under different methods", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "same-path", {
manifest: manifestFor("same-path", {
permissions: [
permissionDeclaration("same-path.items.read"),
permissionDeclaration("same-path.items.write"),
],
}),
serverSource: `export default {
id: "same-path", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "same-path.items.read", handler: ${noopHandlerSource()} });
http.route({ method: "POST", path: "/items", permission: "same-path.items.write", handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(runtime.routes.map((route) => `${route.method} ${route.path}`)).toEqual([
"GET /items",
"POST /items",
]);
await runtime.stop();
});
it("rejects a duplicate method/path route registered by the same module", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "dup", {
manifest: manifestFor("dup", {
permissions: [permissionDeclaration("dup.items.read")],
}),
serverSource: `export default {
id: "dup", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "dup.items.read", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/items", permission: "dup.items.read", handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
});
it("collides parameter-name-only path variants under the same method", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "param-shape", {
manifest: manifestFor("param-shape", {
permissions: [permissionDeclaration("param-shape.events.read")],
}),
serverSource: `export default {
id: "param-shape", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/events/:eventId", permission: "param-shape.events.read", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/:id", permission: "param-shape.events.read", handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
});
it("fails a malformed route with its specific structured issue code", async () => {
const badMethodDirectory = await createModulesDirectory();
await createRuntimeModule(badMethodDirectory, "bad-method", {
manifest: manifestFor("bad-method", {
permissions: [permissionDeclaration("bad-method.items.read")],
}),
serverSource: `export default {
id: "bad-method", version: "0.1.0",
register({ http }) {
http.route({ method: "get", path: "/items", permission: "bad-method.items.read", handler: ${noopHandlerSource()} });
}
};`,
});
expect(runtimeIssueCodes(await captureRuntimeError(badMethodDirectory))).toEqual([
"MODULE_ROUTE_METHOD_INVALID",
]);
const badPathDirectory = await createModulesDirectory();
await createRuntimeModule(badPathDirectory, "bad-path", {
manifest: manifestFor("bad-path", {
permissions: [permissionDeclaration("bad-path.items.read")],
}),
serverSource: `export default {
id: "bad-path", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "items", permission: "bad-path.items.read", handler: ${noopHandlerSource()} });
}
};`,
});
expect(runtimeIssueCodes(await captureRuntimeError(badPathDirectory))).toEqual([
"MODULE_ROUTE_PATH_INVALID",
]);
const badDefinitionDirectory = await createModulesDirectory();
await createRuntimeModule(badDefinitionDirectory, "bad-shape", {
manifest: manifestFor("bad-shape", {
permissions: [permissionDeclaration("bad-shape.items.read")],
}),
serverSource: `export default {
id: "bad-shape", version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/items",
permission: "bad-shape.items.read",
baseline: "public",
handler: ${noopHandlerSource()},
});
}
};`,
});
expect(runtimeIssueCodes(await captureRuntimeError(badDefinitionDirectory))).toEqual([
"MODULE_ROUTE_DEFINITION_INVALID",
]);
});
it("rejects a route permission the registering module does not itself declare", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "undeclared", {
manifest: manifestFor("undeclared"),
serverSource: `export default {
id: "undeclared", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "undeclared.items.read", handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
});
it("rejects a module gating a route with a Core-owned permission it cannot declare", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "coreattempt", {
manifest: manifestFor("coreattempt"),
serverSource: `export default {
id: "coreattempt", version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/items",
permission: "core.authorization.grants.manage",
handler: ${noopHandlerSource()},
});
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
});
it("rejects a module gating a route with another module's permission", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "alpha", {
manifest: manifestFor("alpha", {
permissions: [permissionDeclaration("alpha.items.read")],
}),
serverSource: `export default { id: "alpha", version: "0.1.0" };`,
});
await createRuntimeModule(modulesDirectory, "beta", {
manifest: manifestFor("beta"),
serverSource: `export default {
id: "beta", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "alpha.items.read", handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
});
it("accepts a declared public, authenticated, or restricted permission without interpreting its baseline", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "baselines", {
manifest: manifestFor("baselines", {
permissions: [
permissionDeclaration("baselines.public.read", "public"),
permissionDeclaration("baselines.authenticated.read", "authenticated"),
permissionDeclaration("baselines.restricted.read", "restricted"),
],
}),
serverSource: `export default {
id: "baselines", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/public", permission: "baselines.public.read", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/authenticated", permission: "baselines.authenticated.read", handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/restricted", permission: "baselines.restricted.read", handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(runtime.routes).toHaveLength(3);
for (const route of runtime.routes) {
expect(Object.keys(route).sort()).toEqual([
"handler",
"method",
"moduleId",
"path",
"permission",
]);
}
await runtime.stop();
});
it("never invokes a route handler while registering or starting", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "never-invoked", {
manifest: manifestFor("never-invoked", {
permissions: [permissionDeclaration("never-invoked.items.read")],
}),
serverSource: `export default {
id: "never-invoked", version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/items",
permission: "never-invoked.items.read",
handler: () => { ${recorder.expression}.push("handler:called"); return new Response(null, { status: 204 }); },
});
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(recorder.events).toEqual([]);
await runtime.stop();
expect(recorder.events).toEqual([]);
});
it("publishes frozen, immutable routes as a frozen route list", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "frozen", {
manifest: manifestFor("frozen", {
permissions: [permissionDeclaration("frozen.items.read")],
}),
serverSource: `export default {
id: "frozen", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "frozen.items.read", handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(Object.isFrozen(runtime.routes)).toBe(true);
expect(Object.isFrozen(runtime.routes[0])).toBe(true);
await runtime.stop();
});
it("does not let a module mutate the published route by mutating its own definition afterward", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "mutate", {
manifest: manifestFor("mutate", {
permissions: [
permissionDeclaration("mutate.items.read"),
permissionDeclaration("mutate.other.read"),
],
}),
serverSource: `export default {
id: "mutate", version: "0.1.0",
register({ http }) {
const definition = {
method: "GET",
path: "/items",
permission: "mutate.items.read",
handler: ${noopHandlerSource()},
};
http.route(definition);
definition.method = "POST";
definition.path = "/mutated";
definition.permission = "mutate.other.read";
}
};`,
});
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(runtime.routes[0]).toMatchObject({
method: "GET",
path: "/items",
permission: "mutate.items.read",
});
await runtime.stop();
});
it("publishes no runtime when a later module's route registration fails", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "first-ok", {
manifest: manifestFor("first-ok", {
permissions: [permissionDeclaration("first-ok.items.read")],
}),
serverSource: `export default {
id: "first-ok", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "first-ok.items.read", handler: ${noopHandlerSource()} });
}
};`,
});
await createRuntimeModule(modulesDirectory, "second-bad", {
manifest: manifestFor("second-bad"),
serverSource: `export default {
id: "second-bad", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "second-bad.items.read", handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
expect(error.issues[0]?.moduleId).toBe("second-bad");
});
it("preserves existing rollback behavior when a registered route's module later fails to start", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "routed", {
manifest: manifestFor("routed", {
permissions: [permissionDeclaration("routed.items.read")],
}),
serverSource: `export default {
id: "routed", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "routed.items.read", handler: ${noopHandlerSource()} });
},
start() { ${recorder.expression}.push("routed:start"); },
stop() { ${recorder.expression}.push("routed:stop"); }
};`,
});
await createRuntimeModule(modulesDirectory, "failing", {
manifest: manifestFor("failing", {
dependencies: { modules: { required: { routed: "^0.1.0" } } },
}),
serverSource: `export default {
id: "failing", version: "0.1.0",
start() { throw new Error("boom"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED"]);
expect(recorder.events).toEqual(["routed:start", "routed:stop"]);
});
});
describe("already-discovered module runtime", () => {
it("starts from an already-discovered inventory without a second directory scan", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "already-discovered", {
serverSource: `export default { id: "already-discovered", version: "0.1.0" };`,
});
vi.mocked(discoverModules).mockClear();
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
expect(discoverModules).toHaveBeenCalledTimes(1);
const runtime = await startDiscoveredModuleRuntime(discovered);
expect(discoverModules).toHaveBeenCalledTimes(1);
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
await runtime.stop();
});
it("preserves the given discovered-module list order exactly, without re-sorting", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "first", {
serverSource: `export default { id: "first", version: "0.1.0" };`,
});
await createRuntimeModule(modulesDirectory, "second", {
serverSource: `export default { id: "second", version: "0.1.0" };`,
});
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
const runtime = await startDiscoveredModuleRuntime([...discovered].reverse());
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
await runtime.stop();
});
it("startModuleRuntime remains backward-compatible and discovers exactly once", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "compat", {
serverSource: `export default { id: "compat", version: "0.1.0" };`,
});
vi.mocked(discoverModules).mockClear();
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
expect(discoverModules).toHaveBeenCalledTimes(1);
await runtime.stop();
});
});
@@ -28,6 +28,15 @@ registry, which conflicts with install-by-cloning discovery.
> ordering — remains accepted and unchanged. This section is left as originally accepted, as the > ordering — remains accepted and unchanged. This section is left as originally accepted, as the
> historical record of the manifest's prior shape. > historical record of the manifest's prior shape.
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).**
> "HTTP routing... remain[s] outside this slice" (Consequences, below) and "The injected contexts
> contain only the module ID and a capability registrar or resolver" (this section, below) described
> this ADR's own scope accurately at the time. ADR 0015 implements module HTTP routing: it extends
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar, while
> `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes) remains exactly as
> limited to module ID and capability access as originally decided here. Every other statement in
> this ADR remains accepted and unchanged.
`ekklesia.module.json` remains the one canonical static manifest and moves to `ekklesia.module.json` remains the one canonical static manifest and moves to
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact `manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
semantic version. Required and optional capability dependencies map identifiers to semantic-version semantic version. Required and optional capability dependencies map identifiers to semantic-version
@@ -223,6 +223,10 @@ current deployment model but not the final word on it.
## Deferred ## Deferred
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).**
> Module HTTP route-registration contracts and their production mounting, listed immediately below
> as deferred, are now implemented. Every other item in this list remains deferred and unimplemented.
Module HTTP route-registration contracts (a module mounting its own endpoints); a Module HTTP route-registration contracts (a module mounting its own endpoints); a
`ModuleRuntimeContext` authorization service; relationship-derived grant creation (a roster or `ModuleRuntimeContext` authorization service; relationship-derived grant creation (a roster or
similar module operation using `sourceKind: "relationship"`, not the generic explicit-grant API); similar module operation using `sourceKind: "relationship"`, not the generic explicit-grant API);
@@ -0,0 +1,286 @@
# 0015: Static module HTTP routing and production runtime lifecycle
- Status: Accepted
- Date: 2026-07-24
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) and
[0014](0014-module-permission-declarations-and-platform-registry.md)
## Context
ADR 0006 established the compiled module runtime — deterministic discovery, a staged capability
registry, dependency-ordered registration and lifecycle, and the injected context's deliberate
limit to module ID and capability access. ADR 0014 wired module-declared permissions into Core's
authorization system and explicitly deferred two things: a module mounting its own HTTP routes,
and `apps/api`'s permission-composition startup retaining the discovered module list for anything
beyond composition itself. This ADR records the architecture that closes both gaps: a
framework-neutral route contract a module declares during its existing `register` hook, Core's
atomic collection of those routes into the same immutable startup snapshot as capabilities, and
`apps/api`'s production mounting of that snapshot — enforced, origin-checked, and
response-sanitized — under one platform-owned path prefix.
## Decision
### One contracts-layer, Fetch-based route definition
`packages/contracts/src/module-http.ts` is the sole route contract, consumed identically by Core's
module runtime and `apps/api`'s mounting adapter — there is no second, parallel definition in
either layer. It depends only on standard Fetch primitives (`Request`, `Response`), `EkklesiaPrincipal`,
`ModuleId`, and `PermissionId`; it never imports Hono, Node HTTP types, Better Auth, a database
client, or any other framework or Core implementation type. A route definition contains exactly
four fields — `method`, `path`, `permission`, `handler` — validated as an exact-shape object with
no unknown properties; a module cannot smuggle a baseline, a mount prefix, an absolute path, CORS
or origin policy, authentication behavior, grant/platform-owner state, middleware, audit metadata,
or another module's namespace through this contract, because the type has no field for any of
them. `permission` is accepted as a plain string at the authoring boundary (so a module writes a
literal identifier with no parse call) and validated and branded to `PermissionId` by the
contract's own parser, mirroring how `PermissionDeclarationInput`/`PermissionDeclaration` already
split authoring input from validated output (ADR 0014).
Exactly `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` are authorable, always uppercase; there is no
`OPTIONS` (the platform owns CORS preflight) and no wildcard or all-methods registration. A path is
always local to the module and canonical: it begins with `/`, a non-root path never ends with `/`,
and every segment is either a lowercase kebab-case static segment or a single `:parameterName`
segment — no empty or repeated slashes, no query string or fragment, no backslash or
percent-encoding, no dot segment, and no wildcard/catch-all segment, because none of those
characters can ever satisfy either segment grammar. Malformed input is rejected outright, never
normalized, so a module can never register a path Core silently reinterpreted.
### Permission ownership is structural, not a runtime policy choice
Every route — including one meant to be publicly reachable — names exactly one permission the
owning module's own manifest declares (ADR 0014's `manifestVersion: 3` declarations); there is no
way to construct an unprotected, no-permission route through this contract. A route can never
supply a baseline: the trusted `public`/`authenticated`/`restricted` baseline comes exclusively
from the composed platform permission registry at enforcement time, exactly like every other
permission check in the platform. Public access is expressed by referencing a permission whose own
manifest declaration has `baseline: "public"`, never by omitting a permission. Core's module
runtime checks route ownership against only the registering module's own manifest-declared
permissions — never the composed platform registry, never another module's manifest — so a module
can never gate a route with Core's or another module's permission; this is a structural
consequence of what the ownership check is allowed to consult, not a policy a caller could
misconfigure.
### Atomic validation and publication alongside capabilities
Core's staged registration (ADR 0006) now stages a module's HTTP routes exactly as it already
stages capability provision: a route is validated and, on failure, rejected immediately at the
`http.route(...)` call site during `register`, using structured `ModuleRuntimeIssue` codes
distinguishing invalid shape/handler, invalid method, invalid path, invalid permission identifier,
an undeclared (unowned) permission, and a duplicate route — never a caught exception's message,
handler source, or a machine path. A module's routes are merged into the shared, eventually
published list only after that module's `register` hook returns without throwing *and* every
capability it declared was actually provided — the same all-or-nothing gate capabilities already
enforce. `StartedModuleRuntime.routes` is populated, and becomes non-empty, only after every
discovered module has registered and started successfully; a failed registration or a failed
startup (with Core's existing reverse-order rollback) publishes no route snapshot at all, exactly
as no capability registry is published in that case either.
Duplicate detection uses route *shape* — method plus path with every parameter segment collapsed
to one placeholder — not the parameter's chosen name, so `/events/:eventId` and `/events/:id`
collide under the same method exactly like the identical route they are. Detection is scoped to
one module's own routes; two different modules may register the identical local method and path
without conflict, because the production mount prefix (below) namespaces them apart regardless.
### An immutable, ordered startup snapshot
`StartedModuleRuntime.routes` is a single frozen array of frozen route records, published once at
startup and never mutated afterward — there is no dynamic route addition or removal while the
process runs. Its order is deterministic: discovered-module order, then registration order within
each module. Because a caller-owned route-definition object could otherwise be mutated after
`http.route(...)` returns, Core never retains the caller's own object — it copies each validated
field into a freshly frozen record, so mutating the original definition afterward cannot alter the
published snapshot. Core never invokes a handler during registration or startup; a handler is only
ever a stored function reference until a route is actually dispatched (a concern entirely owned by
`apps/api`, below).
### One discovered inventory drives both composition and runtime startup
`prepareStartupModuleInventory` (`apps/api/src/platform-permissions.ts`) discovers the installed
module inventory exactly once and returns both the discovered module list and the platform
permission registry composed from it, together. `apps/api`'s production startup
(`startApi`, `apps/api/src/server.ts`) passes that same discovered list directly to
`startDiscoveredModuleRuntime` — a Core entry point that performs no discovery of its own and
preserves the given list's order exactly — so permission composition and executable module
startup are provably looking at the same installed-module set, never two independently discovered
ones. `startModuleRuntime` (discover-then-start) remains available as a convenience wrapper for
callers that do not already have a discovered list; production never calls it. The test-only
`discoveredModulesOverride` drives both composition and runtime startup identically, for the same
reason: it stands in for the one real discovered list, never a second, competing one.
Production startup composes in one fixed order: discover the module inventory and compose the
platform permission registry from it; construct the shared PostgreSQL pool; construct Identity,
the authorization store, and the evaluator; start the module runtime from that same discovered
inventory; create the Hono application and mount the started runtime's published routes; bind the
HTTP server. A discovery or composition failure therefore occurs before any pool, Better Auth
instance, or executable module code exists; a database/identity construction failure occurs before
any module runtime starts; and a module runtime or app-construction failure binds no server. Every
cleanup path preserves the original failure if cleanup itself also fails, matching the sanitized,
non-masking failure discipline the rest of the platform already follows.
### The platform-owned `/api/v1/modules/<module-id>` namespace, enforced identically to Core's own routes
`apps/api/src/module-http-routes.ts` — the one place in the repository below `apps/api` importing
Hono for this purpose — mounts a started runtime's published route snapshot under
`/api/v1/modules/<module-id>`, in publication order, never sorted or otherwise rewritten. A local
`"/"` maps to the bare module prefix; every other local path appends directly; the platform alone
decides this mapping, and a module has no way to choose or influence its own mount prefix. This
ordering is load-bearing, not cosmetic: Hono's router resolves two routes whose patterns overlap at
the same depth (a static segment and a same-position `:parameter`) by registration order, not by
static-path specificity, so preserving the runtime's exact publication order is what lets a module
author's more-specific-first declaration continue to take precedence after mounting. Mounting uses
one fixed-arity `app.on(method, path, ...middleware, handler)` call per route rather than spreading
a variable-length handler array — Hono's overload resolution is arity-sensitive, and a spread call
silently resolves to an unrelated, incompatible overload.
Every mounted route is protected by the identical `requirePermission` middleware Core's own routes
use (ADR 0013), consulting `route.permission` against the exact same composed platform registry —
there is no module-specific enforcement path, no wildcard, prefix, namespace, or implication
matching, and a route's baseline always comes from the registry, never the route. A public
permission still resolves a principal and traverses the same authorization check as any other
baseline; only the evaluator's own decision differs. Because the mounted routes and the injected
registry both derive from the same discovered inventory, every mounted route's permission should
already be registered; `apps/api` still checks this invariant explicitly at app-construction time
and fails construction with a fixed, sanitized configuration error if it ever does not hold, rather
than silently mounting an unenforceable route — `requirePermission`'s own per-request registry
lookup remains the live, defense-in-depth enforcement regardless of that check.
### Trusted origin, and platform-owned `OPTIONS`/`HEAD`
A state-changing method (`POST`, `PUT`, `PATCH`, `DELETE`) additionally requires an exact trusted
`Origin`, checked before authorization or the handler ever run — the identical `requireTrustedOrigin`
mechanism the explicit-grant mutation routes already use (ADR 0013), for the identical reason: CORS
response headers alone do not stop a browser from *sending* a state-changing cross-origin request,
only from reading its response. `GET` does not require an `Origin`. `OPTIONS` is never authorable by
a module and remains entirely platform-owned: the shared CORS middleware intercepts every preflight
request before any route-specific middleware or handler runs, so a preflight never reaches
authorization or module code. `HEAD` is not separately authorable either; Hono's own router
transparently re-dispatches a `HEAD` request through the identical registered `GET` chain and
strips the response body afterward, so a `HEAD` request is authorized exactly like the `GET` it
mirrors and cannot bypass anything a direct `GET` would not also bypass. The platform's CORS policy
advertises `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`, and reflects only an exact
configured trusted origin — never a wildcard, and never anything module-influenced.
### A frozen, minimal handler context and sanitized failure boundary
A handler receives the unmodified Fetch `Request` (`c.req.raw`, so `Request.url`, headers, method,
body, and a streaming request all behave normally, and the full query string remains available
without a competing query abstraction) and a frozen context of exactly `moduleId`, the
already-authorized `EkklesiaPrincipal`, and an immutable, Hono-decoded parameter record — an empty
frozen object for a root or fully static route. A handler never receives a Hono `Context`, a
database connection, the authorization store or evaluator, PostgreSQL, configuration, secrets, or
any other middleware-internal state. Module code is executable, trusted backend plugin code, not a
sandboxed boundary — the same trust model ADR 0006 already established for capability registration
and lifecycle hooks — but its HTTP-facing boundary is still sanitized: a handler that throws,
rejects, or returns anything other than a `Response` becomes a fixed `500
{"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store`, and nothing about the caught
value, a stack, the module ID, the route's path, or its permission ID is ever included. A
`Response` a handler deliberately returns — including a 4xx or 5xx one — is never remapped, and a
streaming body is passed through unread rather than consumed or buffered while headers are
sanitized.
### Platform-controlled caching and CORS on every module response
The platform remains the sole owner of caching and CORS for a module response: any
`Access-Control-*` header a handler set is stripped (case-insensitively, since header names are
themselves case-insensitive) before the response leaves the process, and every module response is
forced to `Cache-Control: no-store` with a `Vary` that always includes `Origin` and `Cookie` —
merged case-insensitively so a differently-cased duplicate never appears, and collapsed to a bare
`*` rather than an invalid combination whenever an existing `Vary` already contains one (RFC 9110
§12.5.5 forbids combining `*` with any other value) — while any of a handler's own legitimate
`Vary` values are preserved alongside the forced ones. This is deliberately conservative: even a
public-baseline handler receives an authorized principal and may personalize its response, so
until a dedicated module-declared cache-policy contract exists, no module response is ever treated
as cacheable or origin-independent by default.
### Discovery, Doctor, and `modules:validate` remain manifest-only; production runtime startup does not
Module discovery, Doctor's checks, `modules:validate`, and permission composition continue to read
only manifests — none of them imports or executes a module's server entrypoint, register hook, or
any other lifecycle code, exactly as ADR 0006 and ADR 0014 established. A *successful* production
runtime startup is the one deliberate exception: it imports each installed module's compiled server
entrypoint and runs its `register` and `start` hooks, because that is the only way a module's
declared routes and capabilities become real, dispatchable behavior. Entrypoint modules remain
trusted backend plugins, not a sandbox boundary (ADR 0006); their top-level ESM evaluation must
remain side-effect-free, because ESM evaluation cannot be rolled back or unloaded once it has run —
this was already a hard requirement for capability registration and remains exactly as hard a
requirement now that routes are declared the same way, during the same hook.
### Restart-based static installation, and ordered runtime shutdown
Installing, removing, or editing a module's routes takes effect only on the next API restart,
exactly like permission composition (ADR 0014) — there is no hot installation, removal, or dynamic
route mutation while the process runs. Removing a module makes its former routes return the
platform's ordinary `404 {"code":"NOT_FOUND"}`, the same fallback every unmatched path already
receives. `RunningApi`'s shutdown stops accepting new HTTP work and drains in-flight requests
first, then stops the module runtime, then closes the shared PostgreSQL pool — in that order, so no
module resource stops while a route handler might still be executing. One memoized shutdown promise
is shared by every concurrent or later caller; each of the three steps runs at most once regardless
of how many times `shutdown()` is called. Unlike a startup-failure cleanup path (which always
swallows a secondary cleanup failure so it never masks the original error), a module-stop failure
during *normal* shutdown is not swallowed — it is a genuine result a caller should observe — but
database cleanup still always completes first, so a stop failure never leaves the pool open.
## Alternatives
- **Hono or another framework's own request/response/routing types in the contracts layer** was
rejected: it would leak an implementation-layer framework choice into the one public contract
every module and Core depend on, and would make swapping the HTTP transport a breaking contract
change instead of an internal one.
- **Declaring routes in the static module manifest** (alongside capabilities and permissions) was
rejected: a route's `handler` is executable behavior, not inert data a manifest can express
without executing module code, and duplicating the same route between a manifest and the runtime
definition would create two sources of truth that could silently drift.
- **Permissionless routes, or a route/handler that selects its own baseline** were rejected outright
by the contract's shape: there is no field for either, so no caller can construct a route that
omits a permission or asserts its own trust level.
- **An arbitrary or module-chosen mount prefix** was rejected in favor of a single platform-owned
`/api/v1/modules/<module-id>` namespace: a module choosing its own path risks colliding with
Core's own routes or another module's, and removes the platform's ability to reason about the
complete route surface from one place.
- **A second module discovery pass, or a second route/runtime registry, for HTTP routes
specifically** was rejected in favor of reusing the exact discovery Phase 5A already performs and
staging routes inside the same `StartedModuleRuntime` capabilities already publish through —
avoiding two independently discovered inventories that could disagree, and two registry
implementations that could drift.
- **Module-owned authorization, CORS, or origin-checking middleware** was rejected: every module
route is enforced through the identical, already-audited `requirePermission`/`requireTrustedOrigin`
mechanism Core's own routes use, so there is exactly one enforcement mechanism to reason about and
audit, not one per module.
- **`OPTIONS` or `HEAD` as module-authorable methods** were rejected: `OPTIONS` is inseparable from
the platform's own CORS policy, and `HEAD` is fully and correctly served by Hono's own transparent
`GET`-chain re-dispatch; making either authorable would only create a second, competing code path
for behavior the platform already provides correctly.
- **Dynamic or hot route mutation** (adding, removing, or replacing a route while the process runs)
was rejected for this phase: it would reopen the same concurrent-mutation and partial-update
hazards ADR 0014 already rejected for permission composition, for no benefit the current
deployment model needs.
- **A module-declared cache policy** (letting a route specify its own `Cache-Control`/`Vary`
behavior) was rejected for this phase: the conservative default (`no-store`, vary on `Origin` and
`Cookie`) is always correct, while a wrong module-declared policy could leak one user's
personalized response to another; a real cache-policy contract is deferred, deliberately, rather
than guessed at now.
## Consequences
A module gains a real, dispatchable HTTP surface the moment its `register` hook declares routes
against permissions its own manifest already declares — no second registration step, no
module-specific transport code to write. The integration boundary is safe by construction: the
contracts-layer shape structurally prevents a permissionless route or a module-asserted baseline,
Core's ownership check structurally prevents borrowing another owner's permission, and `apps/api`'s
mounting reuses exactly the enforcement, origin-checking, and response-sanitization machinery
already audited for Core's own routes. The operational cost mirrors ADR 0014's: every module
inventory or route change requires an API restart to take effect, and — because production startup
now deliberately imports and executes trusted module entrypoints, not merely their manifests — a
module's compiled server code must be as trustworthy as any other backend plugin code Core already
runs, since nothing about this design sandboxes it.
## Deferred
Module database or Drizzle access; module storage access; jobs and workflow/event automation;
module-owned configuration and secrets services; a `ModuleRuntimeContext` authorization service;
relationship-derived grant operations; reinstall/reactivation semantics for a permission or route
whose owning module was previously removed; roles, groups, teams, and tenants; an owner-management
HTTP endpoint; service accounts and API tokens for non-browser automation; a native-client
trusted-origin exemption policy; a module-declared cache-policy contract; hot module
installation, removal, or reloading; Surfaces and UI; and real product-module endpoints. None of
these are implemented by, or required for, the architecture this ADR records.
+1
View File
@@ -23,3 +23,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted - [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted - [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted - [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted
- [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted
+87 -21
View File
@@ -57,6 +57,7 @@ No real values are shown here — configure per environment and never commit the
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). | | `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
deterministic `404 {"code":"NOT_FOUND"}`. deterministic `404 {"code":"NOT_FOUND"}`.
@@ -206,17 +207,74 @@ trusted-proxy IP policy is configured, Better Auth may fall back to a single sha
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
abuse-control design. abuse-control design.
## Module permission discovery at startup ## Module discovery, permission composition, and module runtime startup
Before any database pool, Better Auth instance, or HTTP server exists, `startApi()` discovers `startApi()` composes production in one fixed order:
every installed module's manifest and composes the one immutable platform permission registry
(Core's own declarations plus every module's) that the rest of startup — and every subsequent 1. Discover every installed module's manifest exactly once and compose the one immutable platform
request — enforces against. A discovery or composition failure (an invalid manifest, a module permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
claiming the reserved `core.*` permission namespace, a duplicate permission declaration, ...) fails `apps/api/src/platform-permissions.ts`.
startup outright, before there is anything to close; startup never falls back to a Core-only 2. Construct the one shared PostgreSQL pool.
registry. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement) 3. Construct the Core identity runtime, authorization store, and evaluator.
for the full flow, and [Module development](module-development/README.md) for the manifest shape 4. Start the module runtime from that exact same discovered inventory
that produces these declarations. (`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step.
5. Create the Hono application, mounting the started module runtime's published routes.
6. Bind the HTTP server.
Discovery and permission composition happen before any pool, Better Auth instance, module
entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid
manifest, a module claiming the reserved `core.*` permission namespace, a duplicate permission
declaration, ...) fails startup outright, before there is anything to close; startup never falls
back to a Core-only registry. Core infrastructure exists before any executable module lifecycle
code runs, and every module route is published only after the *entire* module runtime has
registered and started successfully, so no request can ever reach a module before every module has
started. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
for the permission-composition detail, and [Module development](module-development/README.md) for
the manifest shape that produces these declarations.
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
successful production startup does import each module's compiled server entrypoint and run its
`register`/`start` hooks — this is the one place in the platform that deliberately executes module
code, and only after every other startup step already listed has succeeded.
## Module HTTP routes
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
[Module development](module-development/README.md#http-routes) for the module-authoring contract)
are mounted under `/api/v1/modules/<module-id>`, in the module runtime's published order, by
`apps/api/src/module-http-routes.ts`. Mounting never invokes a handler.
- **Enforcement.** Every route runs through the identical `requirePermission` middleware Core's own
routes use, with the route's own declared permission id as the only permission ever consulted —
its trusted baseline always comes from the composed platform registry, never from the route or
the request. A `public`, `authenticated`, and `restricted` module permission are enforced through
the exact same path; a public-baseline route still resolves and receives an authorized principal.
- **Trusted origin.** `POST`, `PUT`, `PATCH`, and `DELETE` module routes additionally require an
exact trusted `Origin` (see [Origin, CORS, and cookies](#origin-cors-and-cookies)) before
authorization ever runs, exactly like the existing grants-mutation routes. `GET` does not require
one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight
never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling
(same permission check, response body stripped); a module cannot separately author `HEAD`.
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
body all behave normally, and the full query string remains available) plus a frozen context of
exactly `moduleId`, the authorized `EkklesiaPrincipal`, and frozen, Hono-decoded path parameters
(an empty frozen object for a route with none) — never a Hono `Context`, a database connection,
the authorization store, storage, jobs, configuration, or secrets.
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
handler deliberately returns — including a 4xx/5xx one — passes through unchanged.
- **CORS and caching.** The platform remains the sole owner of CORS for module routes exactly like
every other `/api/v1/*` route (see [Origin, CORS, and cookies](#origin-cors-and-cookies), now
additionally advertising `PUT`/`PATCH`/`DELETE`); any CORS-control header a handler sets on its
own response is stripped before the response leaves the process. Every module response is forced
to `Cache-Control: no-store` and a `Vary` that always includes `Origin` and `Cookie` (a handler's
own legitimate `Vary` values are preserved alongside those) — deliberately conservative until a
dedicated cache-policy contract exists, since even a public-baseline handler receives an
authorized principal and may personalize its response.
- **Static snapshot.** Routes are part of the same static startup snapshot as the permission
registry: installing, removing, or editing a module's routes takes effect only on the next API
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
## Database connection limits ## Database connection limits
@@ -239,17 +297,25 @@ not two.
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the `startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one
shared promise the first time it is called: every caller, concurrent or sequential, awaits the shared promise the first time it is called: every caller, concurrent or sequential, awaits the
exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight
lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and requests finish; then stops the module runtime (so no module resource stops while a route handler
authorization both read from it — see might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization
[Authorization](authorization.md#shared-database-composition)). The executable entry point's both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a
same function production composition uses, but independently testable with a fake `running`/`exit` genuine result a caller should observe — but pool cleanup always still runs first, so a stop
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is
`process.exit`. A startup failure after the database pool was already created (identity extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
construction failure, or the server failing to bind its listen address) always closes that pool composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
before the failure propagates. signal arrived" without sending a real process signal or invoking the real `process.exit`.
A startup failure after the database pool was already created always closes that pool before the
failure propagates: identity/authorization construction failing (no module runtime was started
yet); the server failing to bind its listen address; or an app-construction failure, both of which
also stop the module runtime first, since it may have already started successfully. A module
load/register/start failure uses Core's own existing rollback (already-started modules stop, in
reverse order, before the failure propagates) and then closes the pool — there is no separate
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
## A real signup → sign-in → principal → sign-out transcript ## A real signup → sign-in → principal → sign-out transcript
+84 -42
View File
@@ -16,14 +16,15 @@ for the durable architectural decisions this page describes in more implementati
An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone
they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any
registered permission, Core-owned or module-owned; and every enforced route fails closed on registered permission, Core-owned or module-owned; a module can register its own HTTP routes and
infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage have them mounted, enforced, and reachable in production (see
roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or [API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble
re-granting platform-owner status is not exposed at all); there are no service accounts or API instead of silently allowing or denying. It is **not** possible to manage roles, groups, teams, or
tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot tenants; there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status
yet mount its own HTTP routes; and relationship-derived grants (for example, automatic access from is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated
a roster assignment) cannot yet be created by anything in this repository — the generic grants API browser callers can reach mutation routes, Core-owned or module-owned; and relationship-derived
structurally cannot create or revoke them either. grants (for example, automatic access from a roster assignment) cannot yet be created by anything
in this repository — the generic grants API structurally cannot create or revoke them either.
## Core authorization schema and store ## Core authorization schema and store
@@ -105,27 +106,37 @@ integration layer depends on authorization).
## API startup: discovery, composition, and enforcement ## API startup: discovery, composition, and enforcement
`apps/api` builds its production permission registry from the complete installed-module inventory `apps/api` builds its production permission registry, and starts the module runtime, from the
every time it starts, in a fixed order, before anything else: exact same installed-module inventory every time it starts, in a fixed order, before anything
else:
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the 1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
platform/Module SDK versions from their own `package.json` files). platform/Module SDK versions from their own `package.json` files).
2. Discover installed modules (`discoverModules`). 2. Discover installed modules exactly once (`discoverModules`).
3. Compose the platform permission registry (`composePlatformPermissionRegistry`). 3. Compose the platform permission registry from that exact discovered list
(`composePlatformPermissionRegistry`).
4. Construct the shared PostgreSQL pool. 4. Construct the shared PostgreSQL pool.
5. Construct Identity and Authorization. 5. Construct Identity, the authorization store, and the evaluator.
6. Create the Hono application, injecting the composed registry. 6. Start the module runtime from that same discovered list (`startDiscoveredModuleRuntime`) —
7. Bind the HTTP server. modules are never rediscovered for this step.
7. Create the Hono application, injecting the composed registry and mounting the started module
runtime's published routes (see [API](api.md#module-http-routes)).
8. Bind the HTTP server.
`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3. `apps/api/src/platform-permissions.ts`'s `prepareStartupModuleInventory` performs steps 1–3,
Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server returning both the discovered module list and the composed registry so step 6 can start the module
exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the runtime from that identical list rather than rediscovering modules a second time.
reserved `core.*` namespace, or a duplicate permission declaration — leaves nothing to close: it (`composeStartupPermissionRegistry` remains available as a registry-only convenience wrapper
propagates directly out of `startApi()`, and startup never falls back to `CORE_PERMISSION_REGISTRY` around it, for callers — Doctor, `modules:validate`, existing tests — that only need the
or any Core-only registry. The repository root is resolved from this compiled/source file's own registry.) Discovery and composition happen strictly before any pool, Better Auth instance, module
location (two directories up from `apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at entrypoint import, or HTTP server exists, so a failure there — an invalid manifest, a broken
identical depth), never `process.cwd()` — a package-scoped test run has `apps/api` as its working dependency graph, a module claiming the reserved `core.*` namespace, or a duplicate permission
directory, which would otherwise point discovery at the wrong `modules/` directory. declaration — leaves nothing to close: it propagates directly out of `startApi()`, and startup
never falls back to `CORE_PERMISSION_REGISTRY` or any Core-only registry. The repository root is
resolved from this compiled/source file's own location (two directories up from
`apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at identical depth), never `process.cwd()`
— a package-scoped test run has `apps/api` as its working directory, which would otherwise point
discovery at the wrong `modules/` directory.
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
installation, removal, or dynamic mutation of installed modules or their permissions while the installation, removal, or dynamic mutation of installed modules or their permissions while the
@@ -148,10 +159,11 @@ consults the registry for a grant that already exists — revocation is keyed to
and source kind, not to whether its permission is currently registered — so a manager can still and source kind, not to whether its permission is currently registered — so a manager can still
revoke a historical grant for a now-unregistered permission. revoke a historical grant for a now-unregistered permission.
Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived A module's own mounted HTTP routes (`/api/v1/modules/<module-id>/*`) are enforced through this
grant creation both remain deferred, unrelated slices — this work only makes a module's *declared* identical registry and `requirePermission` middleware — see [API](api.md#module-http-routes) for
permission usable through Core's existing, generic explicit-grant HTTP API and enforcement the route-mounting contract. Relationship-derived grant creation remains a deferred, unrelated
middleware. slice — this work only makes a module's *declared* permission usable through Core's existing,
generic explicit-grant HTTP API, enforcement middleware, and now its own mounted routes.
## Doctor and modules:validate ## Doctor and modules:validate
@@ -446,12 +458,34 @@ there are or whether the failure happened during startup.
`403` and `store.createGrant` never called, exact server-derived-argument assertions, `403` and `store.createGrant` never called, exact server-derived-argument assertions,
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
store-error mapping. store-error mapping.
- `apps/api/tests/module-http-routes.test.ts` — the production route-mounting contract: exact
prefix/path joining (including the non-trailing-slash root case), every supported method,
independent same-path routes across two modules, deterministic static-versus-parameterized
resolution by published order, request/handler adaptation (raw `Request`, frozen context, decoded
and frozen path parameters, empty frozen params for static/root routes), the registry's exact
baseline for `public`/`authenticated`/`restricted` module permissions, denial/infrastructure-
failure paths never invoking the handler, `ModuleRouteConfigurationError` for an inconsistent
route/registry snapshot, trusted-origin enforcement for state-changing methods, CORS (advertised
methods, no reflection for an untrusted origin, no authorization or handler invocation on
preflight), fixed sanitized `500` for a throwing/rejecting/non-`Response`-returning handler with
no raw leakage, a valid module-owned 4xx/5xx response passing through unchanged, forced
`Cache-Control`/`Vary` with a handler's own `Vary` preserved, stripped module-supplied CORS
headers, `HEAD` traversing the same permission middleware as `GET` with no body, and unmatched
paths/zero-route/no-handler-during-construction behavior.
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above, - `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above,
plus module-permission composition at startup: discovery/composition run before any pool, plus module-permission composition at startup: discovery/composition run before any pool,
Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace
failure, and a generic duplicate-declaration failure each create zero pools/Better Auth failure, and a generic duplicate-declaration failure each create zero pools/Better Auth
instances/servers and remain distinguishable from one another; zero installed modules still instances/servers and remain distinguishable from one another; zero installed modules still
succeeds; and import safety for `platform-permissions.ts`. succeeds; and import safety for `platform-permissions.ts`. A dedicated block covers module
*runtime* startup and shutdown against real, working (non-throwing) fixture modules: permission
composition and module runtime startup share the exact same discovered inventory, route mounting
order matches discovery order, a load/register/start failure each close the pool exactly once and
bind no server (preserving Core's own rollback for a start failure), an app-construction failure
and a bind failure both stop the module runtime before closing the pool, normal shutdown stops the
server/module-runtime/pool in that exact order, a module stop failure still runs pool cleanup
before the shutdown rejection surfaces, and a direct `discoveredModulesOverride` drives both
composition and runtime startup identically.
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under - `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
consulting the grant or platform-owner ports for `public`/`authenticated`. consulting the grant or platform-owner ports for `public`/`authenticated`.
@@ -466,16 +500,23 @@ there are or whether the failure happened during startup.
invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database
dropped out from under a live session) asserting a `503` rather than a false `403` or `200`. dropped out from under a live session) asserting a `503` rather than a false `403` or `200`.
- `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live - `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live
PostgreSQL) — a real temporary module fixture (structurally valid manifest, a server entrypoint PostgreSQL) — a real temporary module fixture (structurally valid manifest; a real, working
that throws if ever imported) proves the complete chain against a real API process and real server entrypoint recording register/start/stop markers instead of throwing, and declaring a
Doctor run: Doctor recognizes the installed module and passes permission composition without restricted `GET /events/:eventId` route plus a restricted, state-changing `POST /events` route
executing its entrypoint; API startup discovers the fixture and makes its declared permission against its own manifest permission) proves the complete chain against a real API process and
enforceable; a bootstrapped platform owner creates a real explicit grant for the module's real Doctor run: Doctor recognizes the installed module and passes permission composition
permission with an atomic audit row recorded under its exact declared id; a well-formed without executing its entrypoint (an empty marker set); a successful `startApi()` discovers the
permission absent from the composed registry is rejected with no grant or audit write; and, same fixture, imports/registers/starts its runtime exactly once, and makes its declared
after a clean shutdown and restart with the module absent, the same permission can no longer be permission enforceable; a bootstrapped platform owner creates a real explicit grant for the
newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity module's permission with an atomic audit row recorded under its exact declared id; the module's
HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout. own mounted route denies an anonymous caller and then serves the newly granted owner with the
decoded path parameter and authenticated principal; the module's state-changing route requires a
trusted `Origin` exactly like the platform's own mutation routes; a well-formed permission absent
from the composed registry is rejected with no grant or audit write; and, after a clean shutdown
(module stop invoked exactly once) and restart with the module absent, its former route returns
the ordinary `404`, the same permission can no longer be newly granted, and the earlier,
still-unrevoked grant remains listed — with ordinary identity HTTP behavior (sign-up, sign-in,
principal resolution) unaffected throughout.
Run the focused suites: Run the focused suites:
@@ -487,10 +528,11 @@ pnpm test:migrations:integration # requires Docker
## Next phase ## Next phase
Deliberately deferred, unrelated slices — see Module HTTP route registration and production mounting (see
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred) [ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
for the complete list: module HTTP route-registration contracts (a module mounting its own for the deferral this closes) is now implemented — see [API](api.md#module-http-routes) and
endpoints), a module runtime-context authorization service, relationship-derived grant creation [Module development](module-development/README.md#http-routes). Deliberately deferred, unrelated
slices remain: a module runtime-context authorization service, relationship-derived grant creation
itself (a roster or similar module operation, not the generic grants API), reinstall/reactivation itself (a roster or similar module operation, not the generic grants API), reinstall/reactivation
policy for a permission whose owning module was previously removed, roles/groups/teams/tenants, an policy for a permission whose owning module was previously removed, roles/groups/teams/tenants, an
owner-management HTTP endpoint, and service accounts/API tokens for non-browser automation. owner-management HTTP endpoint, and service accounts/API tokens for non-browser automation.
+12 -7
View File
@@ -22,13 +22,18 @@ error-only sanitized logger that strips Better Auth's own message and arguments
they contain, pinned 8–128 password-length bounds, sanitized failures, and that a database outage they contain, pinned 8–128 password-length bounds, sanitized failures, and that a database outage
during principal resolution is never silently reported as anonymous), and Core migration discovery during principal resolution is never silently reported as anonymous), and Core migration discovery
(deterministic ordering, the reserved `core` identity, and collision rejection) — all without a (deterministic ordering, the reserved `core` identity, and collision rejection) — all without a
live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, CORS live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, module
allow/deny/preflight, deterministic 404 and sanitized error responses), its runtime configuration, route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404
its idempotent shutdown lifecycle (concurrent callers share one server-close-then-pool-close and sanitized error responses), its runtime configuration, its idempotent shutdown lifecycle
promise; a startup failure after the pool was already created still closes it; `SIGINT`/`SIGTERM` (concurrent callers share one server-close-then-module-runtime-stop-then-pool-close promise; a
wiring is exercised through an injectable exit function rather than the real `process.exit`), and module-stop failure never prevents pool cleanup; a startup failure after the pool was already
that importing any of its modules — including the executable server module without calling its created still closes it, stopping the module runtime first if it had already started;
exported `main` — never reads the environment, connects to PostgreSQL, or starts a server. `SIGINT`/`SIGTERM` wiring is exercised through an injectable exit function rather than the real
`process.exit`), and that importing any of its modules — including the executable server module
without calling its exported `main` — never reads the environment, connects to PostgreSQL, imports
a module entrypoint, or starts a server. Real, working (non-throwing) module fixtures prove
production module-runtime startup/shutdown ordering and cleanup against a mocked pool/Better
Auth/server — see [Authorization](authorization.md#tests).
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live `pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database. database.
+84 -4
View File
@@ -177,6 +177,84 @@ Entrypoint imports must have no top-level side effects. Modules are trusted back
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
close them in `stop`. close them in `stop`.
## HTTP routes
A module registers plain Fetch (`Request` in, `Response` out) route handlers during `register`,
through the `http` registrar `ModuleRegistrationContext` provides alongside `capabilities`:
```ts
import { defineModuleRuntime } from "@ekklesia/module-sdk";
export default defineModuleRuntime({
id: "calendar",
version: "0.1.0",
register({ capabilities, http }) {
capabilities.provide("calendar.events", calendarEvents);
http.route({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
async handler(request, context) {
return Response.json({
eventId: context.params.eventId,
principal: context.principal.kind,
});
},
});
},
});
```
A route definition has exactly four fields — `method`, `path`, `permission`, `handler` — and nothing
else; an unknown field (a baseline, a mount prefix, CORS/origin policy, middleware, audit metadata,
authentication behavior, or another module's namespace) fails registration outright rather than
being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, always uppercase; there is
no `OPTIONS` (the platform owns CORS preflight) and no wildcard/all-method registration.
`path` is local to the module, never the path a caller actually sends. The platform alone owns the
production mount prefix, `/api/v1/modules/<module-id>`: `"/"` is the module's own root, `"/events"`
means `/api/v1/modules/calendar/events`, and `"/events/:eventId"` is a parameterized route using the
same `:name` syntax a caller's request is matched against. A canonical path begins with `/`, a
non-root path never ends with `/`, and every segment is either a lowercase kebab-case static segment
or one `:parameterName` segment — no empty or repeated slashes, no query string or fragment, no
backslash or percent-encoding, no dot segments, and no wildcard/catch-all segment. A malformed path
is rejected outright, never silently normalized.
Every route — including a route meant to be publicly reachable — names exactly one `permission` the
owning module's own manifest declares; there is no way to register an unprotected, no-permission
route. A module cannot supply a baseline directly: the permission's trusted `public`, `authenticated`,
or `restricted` baseline always comes from the composed platform registry, exactly like any other
enforcement decision. Public access is therefore expressed by referencing a permission whose manifest
declaration has `baseline: "public"`, never by omitting a permission. A module may only reference a
permission its own manifest declares — never a Core-owned permission, and never another module's —
so it can never gate a route behind authority it does not itself own. The same local path may be
registered under more than one method, but registering the same module twice under the identical
method and path (or two paths that are identical once every parameter is treated as an anonymous
placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route.
The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's
already-authorized `EkklesiaPrincipal`, and the immutable decoded path parameters — never a database
connection, authorization store, grant/platform-owner state, storage, jobs, configuration, secrets,
a framework-specific request context, or a raw Better Auth session. The full query string remains
available through `Request.url`; there is no separate query or body abstraction.
Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production
at startup, under the `/api/v1/modules/<module-id>` prefix described above: every route is enforced
through the identical `requirePermission` middleware Core's own routes use, a state-changing method
(`POST`/`PUT`/`PATCH`/`DELETE`) additionally requires a trusted `Origin` before authorization ever
runs, and a handler that throws, rejects, or returns something other than a `Response` becomes a
fixed, sanitized `500 {"code":"MODULE_HANDLER_FAILED"}` — never the caught value, a stack, the
route's path, its permission id, or the module id. The platform remains the sole owner of CORS and
caching for every module route: a handler-supplied CORS-control header is stripped, and every
module response is forced to `Cache-Control: no-store` with `Vary: Origin, Cookie` (in addition to
any of the handler's own legitimate `Vary` values) until a dedicated cache-policy contract exists —
deliberately conservative, since even a public-baseline handler still receives an authorized
principal and may personalize its response. See [API](../development/api.md#module-http-routes) for
the full production mounting, enforcement, and error-response contract.
## Drizzle schema definition ## Drizzle schema definition
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`: A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`:
@@ -327,10 +405,12 @@ not misreported as a successful check.
Core completes discovery before importing any server entrypoint. It then loads and verifies every Core completes discovery before importing any server entrypoint. It then loads and verifies every
runtime definition before running hooks, registers all modules dependency-first into an unpublished runtime definition before running hooks, registers all modules dependency-first into an unpublished
staged capability registry, and starts them in the same order. Shutdown is reverse-order. If a later staged capability registry and an unpublished staged HTTP route list side by side, and starts them in
module fails to start, Core stops already-started modules in reverse order and reports any cleanup the same order. Neither the capability registry nor the route list is published — visible on
failures without leaking caught exceptions or machine paths. A failing `start` hook must clean up `StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is
its own partial work before rejecting. reverse-order. If a later module fails to start, Core stops already-started modules in reverse order
and reports any cleanup failures without leaking caught exceptions or machine paths. A failing
`start` hook must clean up its own partial work before rejecting.
## Start from the template ## Start from the template
+1
View File
@@ -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-http.js";
export * from "./module-lifecycle.js"; export * from "./module-lifecycle.js";
export * from "./module-manifest.js"; export * from "./module-manifest.js";
export * from "./module-migration.js"; export * from "./module-migration.js";
+258
View File
@@ -0,0 +1,258 @@
import { z } from "zod";
import type { EkklesiaPrincipal } from "./identity.js";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
// it would create a source dependency cycle with that module, which also imports from this one for
// `ModuleHttpRouteRegistrar`. `scripts/check-boundaries.ts` rejects any source cycle, including one
// formed only by `import type`, so this tiny, fixed-shape utility type is kept private here instead.
type Awaitable<Value> = Value | PromiseLike<Value>;
/** The exact, canonical HTTP methods a module route may declare. Uppercase only — a module never
* gets `OPTIONS` (the platform owns CORS preflight) or a wildcard/all-methods registration. */
export const MODULE_HTTP_METHODS = ["DELETE", "GET", "PATCH", "POST", "PUT"] as const;
export type ModuleHttpMethod = (typeof MODULE_HTTP_METHODS)[number];
const moduleHttpMethods: ReadonlySet<string> = new Set(MODULE_HTTP_METHODS);
export function isModuleHttpMethod(value: unknown): value is ModuleHttpMethod {
return typeof value === "string" && moduleHttpMethods.has(value);
}
const MODULE_ROUTE_PATH_MAX_LENGTH = 256;
const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const parameterSegmentPattern = /^:[a-z][a-zA-Z0-9]*$/u;
/**
* A module-local route path — never the production-mounted `/api/v1/modules/<module-id>` path a
* caller actually sends (`apps/api/src/module-http-routes.ts` owns that mapping). Not branded
* (unlike `PermissionId`): like `PackageRelativePath`,
* this is a validated location, not an opaque cross-system identifier. `"/"` is the module root;
* any other canonical path begins with `/`, never ends with `/`, and is composed only of lowercase
* kebab-case static segments or single `:parameterName` segments — no empty or repeated slashes, no
* query string or fragment, no backslash or percent-encoding, no dot segments, and no wildcard or
* catch-all segment, since none of those can ever match either segment grammar. Malformed input is
* rejected outright rather than normalized, so a module can never register a path Core silently
* reinterpreted.
*/
export const moduleRoutePathSchema = z
.string()
.max(MODULE_ROUTE_PATH_MAX_LENGTH)
.superRefine((value, context) => {
if (
!value.startsWith("/") ||
value.includes("\\") ||
value.includes("%") ||
value.includes("?") ||
value.includes("#")
) {
context.addIssue({
code: "custom",
message:
"Module route paths must begin with / and must not contain backslashes, " +
"percent-encoding, query strings, or fragments.",
});
return;
}
if (value === "/") {
return;
}
if (value.endsWith("/")) {
context.addIssue({
code: "custom",
message: "Non-root module route paths must not end with /.",
});
return;
}
const segments = value.slice(1).split("/");
const parameterNames = new Set<string>();
for (const segment of segments) {
const isParameter = segment.startsWith(":");
const matchesGrammar = isParameter
? parameterSegmentPattern.test(segment)
: staticSegmentPattern.test(segment);
if (!matchesGrammar) {
context.addIssue({
code: "custom",
message:
"Module route path segments must be lowercase kebab-case static segments or a " +
"single :parameterName segment.",
});
return;
}
if (isParameter) {
const parameterName = segment.slice(1);
if (parameterNames.has(parameterName)) {
context.addIssue({
code: "custom",
message: "Module route path parameter names must be unique within the path.",
});
return;
}
parameterNames.add(parameterName);
}
}
});
export type ModuleRoutePath = z.output<typeof moduleRoutePathSchema>;
export function parseModuleRoutePath(input: unknown): ModuleRoutePath {
return moduleRoutePathSchema.parse(input);
}
export function safeParseModuleRoutePath(input: unknown): z.ZodSafeParseResult<ModuleRoutePath> {
return moduleRoutePathSchema.safeParse(input);
}
/**
* Produces a deterministic key identifying a route's shape — method plus path with every parameter
* segment collapsed to one placeholder — so `/events/:eventId` and `/events/:id` collide under the
* same method exactly like the identical route they are, regardless of the parameter's chosen name.
* Takes an already-validated `ModuleRoutePath`; never re-validates or echoes the original path.
*/
export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRoutePath): string {
if (path === "/") {
return `${method} /`;
}
const shapeSegments = path
.slice(1)
.split("/")
.map((segment) => (segment.startsWith(":") ? ":param" : segment));
return `${method} /${shapeSegments.join("/")}`;
}
/**
* What a module's route handler receives about the caller and the match — never a database
* connection, authorization-store, grant-management, storage, jobs, configuration, secrets, the
* Hono `Context`, or a raw Better Auth session. `principal` is already authorized by the time a
* handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route before ever
* invoking its handler; this contracts-layer module never invokes a handler itself.
*/
export interface ModuleRouteHandlerContext {
readonly moduleId: ModuleId;
readonly principal: EkklesiaPrincipal;
readonly params: Readonly<Record<string, string>>;
}
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
* query string remains available through `Request.url`; there is no competing query or body
* abstraction. */
export type ModuleRouteHandler = (
request: Request,
context: ModuleRouteHandlerContext,
) => Awaitable<Response>;
/**
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
* (never the branded `PermissionId`) so a module can write a literal permission id without an
* explicit parse call first; `http.route` validates and brands it. The type has exactly these four
* fields — a module cannot specify a baseline, mount prefix, absolute path, CORS/origin policy,
* authentication behavior, grant/platform-owner state, middleware, audit metadata, another module's
* namespace, or an unprotected/no-permission route.
*/
export interface ModuleRouteDefinitionInput {
readonly method: ModuleHttpMethod;
readonly path: string;
readonly permission: string;
readonly handler: ModuleRouteHandler;
}
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
* only after every field passes its own grammar. */
export interface ModuleRouteDefinition {
readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath;
readonly permission: PermissionId;
readonly handler: ModuleRouteHandler;
}
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
export interface ModuleHttpRouteRegistrar {
route(definition: ModuleRouteDefinitionInput): void;
}
/** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated
* fields as `ModuleRouteDefinition` plus the owning module's ID, which the production mount prefix
* (`/api/v1/modules/<module-id>`, `apps/api/src/module-http-routes.ts`) uses to keep two modules'
* routes from ever colliding regardless of their own local paths. */
export interface RegisteredModuleHttpRoute {
readonly moduleId: ModuleId;
readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath;
readonly permission: PermissionId;
readonly handler: ModuleRouteHandler;
}
const moduleRouteDefinitionKeys = new Set(["method", "path", "permission", "handler"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasModuleRouteDefinitionShape(
value: unknown,
): value is { method: unknown; path: unknown; permission: unknown; handler: unknown } {
if (!isRecord(value)) {
return false;
}
const keys = Object.keys(value);
return (
keys.length === moduleRouteDefinitionKeys.size &&
keys.every((key) => moduleRouteDefinitionKeys.has(key)) &&
typeof value.handler === "function"
);
}
export type ModuleRouteDefinitionIssueCode =
| "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID";
export type ModuleRouteDefinitionParseResult =
| { readonly success: true; readonly data: ModuleRouteDefinition }
| { readonly success: false; readonly code: ModuleRouteDefinitionIssueCode };
/**
* Validates every field-level rule a route definition must satisfy on its own — exact shape,
* canonical method, canonical path, well-formed permission id — but never whether the permission is
* actually declared by the registering module. That ownership check needs the module's own manifest
* and belongs to the caller (Core's module runtime), not this framework-neutral contract. Never
* echoes the rejected input in the returned code.
*/
export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefinitionParseResult {
if (!hasModuleRouteDefinitionShape(value)) {
return { success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" };
}
if (!isModuleHttpMethod(value.method)) {
return { success: false, code: "MODULE_ROUTE_METHOD_INVALID" };
}
const parsedPath = moduleRoutePathSchema.safeParse(value.path);
if (!parsedPath.success) {
return { success: false, code: "MODULE_ROUTE_PATH_INVALID" };
}
const parsedPermission = permissionIdSchema.safeParse(value.permission);
if (!parsedPermission.success) {
return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" };
}
return {
success: true,
data: Object.freeze({
method: value.method,
path: parsedPath.data,
permission: parsedPermission.data,
handler: value.handler as ModuleRouteHandler,
}),
};
}
export function parseModuleRouteDefinition(value: unknown): ModuleRouteDefinition {
const result = safeParseModuleRouteDefinition(value);
if (!result.success) {
throw new TypeError("Invalid Ekklesia module route definition.");
}
return result.data;
}
+5
View File
@@ -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 { ModuleHttpRouteRegistrar } from "./module-http.js";
import { semanticVersionSchema } from "./versions.js"; import { semanticVersionSchema } from "./versions.js";
export type Awaitable<Value> = Value | PromiseLike<Value>; export type Awaitable<Value> = Value | PromiseLike<Value>;
@@ -21,6 +22,10 @@ export interface CapabilityRegistrar extends CapabilityResolver {
export interface ModuleRegistrationContext { export interface ModuleRegistrationContext {
readonly capabilities: CapabilityRegistrar; readonly capabilities: CapabilityRegistrar;
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever
* be declared while Core is still building the immutable startup snapshot, never added or removed
* afterward from `start`, `stop`, or any other post-registration code path. */
readonly http: ModuleHttpRouteRegistrar;
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
} }
@@ -0,0 +1,255 @@
import { describe, expect, it } from "vitest";
import {
isModuleHttpMethod,
MODULE_HTTP_METHODS,
type ModuleRouteDefinitionInput,
moduleRoutePathSchema,
moduleRouteShapeKey,
parseModuleRouteDefinition,
parseModuleRoutePath,
safeParseModuleRouteDefinition,
safeParseModuleRoutePath,
} from "../src/index.js";
function noopHandler(): Response {
return new Response(null, { status: 204 });
}
function validDefinition(
overrides: Partial<ModuleRouteDefinitionInput> = {},
): ModuleRouteDefinitionInput {
return {
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
handler: noopHandler,
...overrides,
};
}
describe("ModuleHttpMethod", () => {
it("accepts every supported method", () => {
for (const method of MODULE_HTTP_METHODS) {
expect(isModuleHttpMethod(method)).toBe(true);
}
expect(MODULE_HTTP_METHODS).toEqual(["DELETE", "GET", "PATCH", "POST", "PUT"]);
});
it("rejects lowercase, unknown, OPTIONS, HEAD, and wildcard methods", () => {
for (const method of ["get", "post", "TRACE", "OPTIONS", "HEAD", "*", "ALL", ""]) {
expect(isModuleHttpMethod(method)).toBe(false);
}
expect(isModuleHttpMethod(42)).toBe(false);
expect(isModuleHttpMethod(undefined)).toBe(false);
});
});
describe("moduleRoutePathSchema", () => {
it("accepts the root, static, nested, and parameterized canonical paths", () => {
for (const path of [
"/",
"/events",
"/events/upcoming",
"/events/:eventId",
"/events/:eventId/attendees/:attendeeId",
]) {
expect(parseModuleRoutePath(path)).toBe(path);
}
});
it("rejects paths that do not begin with /", () => {
expect(safeParseModuleRoutePath("events").success).toBe(false);
expect(safeParseModuleRoutePath("").success).toBe(false);
});
it("rejects a non-root path ending with /", () => {
expect(safeParseModuleRoutePath("/events/").success).toBe(false);
});
it("rejects empty and repeated segments", () => {
expect(safeParseModuleRoutePath("/events//upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("//events").success).toBe(false);
});
it("rejects query strings and fragments", () => {
expect(safeParseModuleRoutePath("/events?limit=10").success).toBe(false);
expect(safeParseModuleRoutePath("/events#upcoming").success).toBe(false);
});
it("rejects backslashes and percent-encoded path syntax", () => {
expect(safeParseModuleRoutePath("/events\\upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/events%2Fupcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/%2e%2e/events").success).toBe(false);
});
it("rejects dot segments and traversal syntax", () => {
expect(safeParseModuleRoutePath("/events/..").success).toBe(false);
expect(safeParseModuleRoutePath("/events/.").success).toBe(false);
expect(safeParseModuleRoutePath("/../events").success).toBe(false);
});
it("rejects wildcard or catch-all segments", () => {
expect(safeParseModuleRoutePath("/events/*").success).toBe(false);
expect(safeParseModuleRoutePath("/events/**").success).toBe(false);
expect(safeParseModuleRoutePath("/*").success).toBe(false);
});
it("rejects non-kebab-case static segments", () => {
expect(safeParseModuleRoutePath("/Events").success).toBe(false);
expect(safeParseModuleRoutePath("/events_list").success).toBe(false);
expect(safeParseModuleRoutePath("/events--upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/-events").success).toBe(false);
expect(safeParseModuleRoutePath("/events-").success).toBe(false);
});
it("rejects a malformed parameter segment", () => {
expect(safeParseModuleRoutePath("/events/:").success).toBe(false);
expect(safeParseModuleRoutePath("/events/:Event-Id").success).toBe(false);
expect(safeParseModuleRoutePath("/events/:1id").success).toBe(false);
});
it("rejects duplicate parameter names within one path", () => {
expect(safeParseModuleRoutePath("/events/:id/attendees/:id").success).toBe(false);
});
it("rejects an unreasonably long path", () => {
expect(safeParseModuleRoutePath(`/${"a".repeat(300)}`).success).toBe(false);
});
it("never echoes the rejected raw path in its issue messages", () => {
const secret = "/machine/private/path/traversal-attempt-%2e%2e";
const result = moduleRoutePathSchema.safeParse(secret);
expect(result.success).toBe(false);
expect(JSON.stringify(result.error?.issues)).not.toContain(secret);
});
});
describe("moduleRouteShapeKey", () => {
it("collapses different parameter names into the same shape for the same method", () => {
const eventId = parseModuleRoutePath("/events/:eventId");
const id = parseModuleRoutePath("/events/:id");
expect(moduleRouteShapeKey("GET", eventId)).toBe(moduleRouteShapeKey("GET", id));
});
it("distinguishes shapes by method and by static path structure", () => {
const path = parseModuleRoutePath("/events/:eventId");
expect(moduleRouteShapeKey("GET", path)).not.toBe(moduleRouteShapeKey("POST", path));
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/events"))).not.toBe(
moduleRouteShapeKey("GET", parseModuleRoutePath("/events/:eventId")),
);
});
it("treats the root path as its own shape", () => {
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/"))).toBe("GET /");
});
});
describe("safeParseModuleRouteDefinition", () => {
it("accepts a well-formed definition and brands its path and permission", () => {
const result = safeParseModuleRouteDefinition(validDefinition());
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
handler: noopHandler,
});
expect(Object.isFrozen(result.data)).toBe(true);
}
});
it("rejects a non-object, null, or array definition", () => {
for (const value of [null, undefined, "route", 42, []]) {
expect(safeParseModuleRouteDefinition(value)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
}
});
it("rejects an unknown route-definition field", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), baseline: "public" })).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), mountPrefix: "/custom" }),
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
});
it("rejects a missing required field", () => {
const { handler: _handler, ...withoutHandler } = validDefinition();
expect(safeParseModuleRouteDefinition(withoutHandler)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
});
it("rejects a non-function handler", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), handler: "not-a-function" }),
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
});
it("rejects an invalid method", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), method: "get" })).toEqual({
success: false,
code: "MODULE_ROUTE_METHOD_INVALID",
});
});
it("rejects an invalid path", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), path: "events" })).toEqual({
success: false,
code: "MODULE_ROUTE_PATH_INVALID",
});
});
it("rejects an invalid permission id", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), permission: "not namespaced" }),
).toEqual({ success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" });
expect(safeParseModuleRouteDefinition({ ...validDefinition(), permission: "*" })).toEqual({
success: false,
code: "MODULE_ROUTE_PERMISSION_INVALID",
});
});
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
const source = { ...validDefinition() };
const result = safeParseModuleRouteDefinition(source);
expect(result.success).toBe(true);
source.method = "POST";
source.path = "/mutated";
source.permission = "mutated.permission.id";
if (result.success) {
expect(result.data.method).toBe("GET");
expect(result.data.path).toBe("/events/:eventId");
expect(result.data.permission).toBe("calendar.events.read");
}
});
});
describe("parseModuleRouteDefinition", () => {
it("returns the parsed definition for valid input", () => {
expect(parseModuleRouteDefinition(validDefinition())).toMatchObject({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
});
});
it("throws without echoing the rejected input", () => {
const secret = "secret-token-value";
expect(() => parseModuleRouteDefinition({ ...validDefinition(), permission: secret })).toThrow(
"Invalid Ekklesia module route definition.",
);
try {
parseModuleRouteDefinition({ ...validDefinition(), permission: secret });
throw new Error("expected parseModuleRouteDefinition to throw");
} catch (error) {
expect(String(error)).not.toContain(secret);
}
});
});
@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { defineModuleRuntime } from "../src/index.js"; import {
defineModuleRuntime,
isModuleHttpMethod,
MODULE_HTTP_METHODS,
type ModuleHttpRouteRegistrar,
type ModuleRegistrationContext,
} from "../src/index.js";
describe("defineModuleRuntime", () => { describe("defineModuleRuntime", () => {
it("defines a frozen server runtime without duplicating the static manifest", async () => { it("defines a frozen server runtime without duplicating the static manifest", async () => {
@@ -22,4 +28,42 @@ describe("defineModuleRuntime", () => {
}), }),
).toThrow("Invalid Ekklesia module runtime definition."); ).toThrow("Invalid Ekklesia module runtime definition.");
}); });
it("re-exports the module-http contract so a module's register hook can declare routes", async () => {
expect(MODULE_HTTP_METHODS).toContain("GET");
expect(isModuleHttpMethod("GET")).toBe(true);
expect(isModuleHttpMethod("get")).toBe(false);
const registeredRoutes: unknown[] = [];
const http: ModuleHttpRouteRegistrar = {
route: (definition) => {
registeredRoutes.push(definition);
},
};
const runtime = defineModuleRuntime({
id: "example",
version: "0.1.0",
register({ http: registrar }) {
registrar.route({
method: "GET",
path: "/records",
permission: "example.records.read",
handler: () => new Response(null, { status: 204 }),
});
},
});
const context: ModuleRegistrationContext = {
capabilities: {
provide: () => undefined,
resolve: () => undefined,
},
http,
moduleId: "example",
};
await runtime.register?.(context);
expect(registeredRoutes).toHaveLength(1);
expect(registeredRoutes[0]).toMatchObject({ method: "GET", path: "/records" });
});
}); });
+5
View File
@@ -70,6 +70,11 @@ describe("module template", () => {
return undefined; return undefined;
}, },
}, },
http: {
route() {
throw new Error("The template does not register any HTTP route.");
},
},
moduleId: "example", moduleId: "example",
}); });