feat: add module HTTP transport #12
@@ -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
|
||||
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
|
||||
historical grant or audit row. Module HTTP route registration and relationship-derived grant
|
||||
creation remain later work. See
|
||||
historical grant or audit row. See
|
||||
[Authorization](docs/development/authorization.md#module-permission-composition),
|
||||
[Module development](docs/module-development/README.md), and
|
||||
[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
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
|
||||
+27
-8
@@ -1,4 +1,4 @@
|
||||
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||
import { isAuthenticatedPrincipal, type RegisteredModuleHttpRoute } from "@ekklesia/contracts";
|
||||
import type {
|
||||
AuthorizationEvaluator,
|
||||
AuthorizationStore,
|
||||
@@ -9,23 +9,30 @@ import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
|
||||
export interface CreateApiAppAuthorizationOptions {
|
||||
readonly evaluator: AuthorizationEvaluator;
|
||||
/** The complete platform permission registry (Core's own declarations plus every installed
|
||||
* 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 store: AuthorizationStore;
|
||||
}
|
||||
|
||||
export interface CreateApiAppOptions {
|
||||
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;
|
||||
/** 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
|
||||
* `requireTrustedOrigin` checks for grant-mutation routes — there is no second, competing origin
|
||||
* configuration. */
|
||||
* `requireTrustedOrigin` checks for grant- and module-route-mutation routes — there is no second,
|
||||
* competing origin configuration. */
|
||||
readonly trustedOrigins: readonly string[];
|
||||
}
|
||||
|
||||
@@ -36,9 +43,14 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure Hono application factory: no I/O, no environment reads, no process side effects. 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.
|
||||
* 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 {
|
||||
const { runtime } = options;
|
||||
@@ -48,7 +60,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
|
||||
const corsMiddleware = cors({
|
||||
allowHeaders: ["Content-Type"],
|
||||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
credentials: true,
|
||||
maxAge: 600,
|
||||
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
|
||||
@@ -91,6 +103,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) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "NOT_FOUND" }, 404);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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 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.
|
||||
*/
|
||||
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");
|
||||
const varyValues = new Set(
|
||||
(headers.get("Vary") ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
varyValues.add("Origin");
|
||||
varyValues.add("Cookie");
|
||||
headers.set("Vary", [...varyValues].join(", "));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
return packageData.version;
|
||||
}
|
||||
|
||||
export interface ComposeStartupPermissionRegistryOptions {
|
||||
export interface PrepareStartupModuleInventoryOptions {
|
||||
/** 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
|
||||
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
|
||||
@@ -62,7 +62,8 @@ export interface ComposeStartupPermissionRegistryOptions {
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
||||
* 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
|
||||
* 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
|
||||
@@ -76,18 +77,35 @@ export interface ComposeStartupPermissionRegistryOptions {
|
||||
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
|
||||
* registry (Core's own declarations plus every module's) — the production source of truth
|
||||
* `apps/api` enforces every request against. Only manifests are read; no 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.
|
||||
* Discovers every installed module's manifest exactly once and composes the one immutable platform
|
||||
* permission registry (Core's own declarations plus every module's) from that exact discovered
|
||||
* list — the production source of truth `apps/api` enforces every request against, and the exact
|
||||
* module inventory `startDiscoveredModuleRuntime` starts from. Only manifests are read here; no
|
||||
* 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(
|
||||
options: ComposeStartupPermissionRegistryOptions = {},
|
||||
): Promise<PermissionRegistry> {
|
||||
export async function prepareStartupModuleInventory(
|
||||
options: PrepareStartupModuleInventoryOptions = {},
|
||||
): Promise<StartupModuleInventory> {
|
||||
if (options.discoveredModulesOverride) {
|
||||
return composePlatformPermissionRegistry(options.discoveredModulesOverride);
|
||||
const modules = options.discoveredModulesOverride;
|
||||
return Object.freeze({
|
||||
modules,
|
||||
permissionRegistry: composePlatformPermissionRegistry(modules),
|
||||
});
|
||||
}
|
||||
|
||||
const repositoryRoot = resolveRepositoryRoot();
|
||||
@@ -102,5 +120,24 @@ export async function composeStartupPermissionRegistry(
|
||||
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;
|
||||
}
|
||||
|
||||
+90
-25
@@ -5,13 +5,17 @@ import {
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
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 type { Hono } from "hono";
|
||||
import { createApiApp } from "./app.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.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 operationTimeoutMs = 10_000;
|
||||
@@ -26,12 +30,13 @@ export interface StartApiOptions {
|
||||
/** Test-only escape hatch: never set by the executable entry point. See
|
||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||
/** Test-only escape hatch: overrides the directory permission-composition module discovery
|
||||
* scans. Never set by the executable entry point. See `composeStartupPermissionRegistry`. */
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly to permission
|
||||
* composition, skipping filesystem discovery entirely. Never set by the executable entry
|
||||
* point. See `composeStartupPermissionRegistry`. */
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
|
||||
* filesystem discovery entirely — this exact list drives both permission composition and module
|
||||
* runtime startup. Never set by the executable entry point. See
|
||||
* `prepareStartupModuleInventory`. */
|
||||
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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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
|
||||
* 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
|
||||
* (identity and authorization each get a typed Drizzle view over it — see `./database.js`), the
|
||||
* Core identity runtime, the Core authorization store and evaluator, and the Hono application;
|
||||
* finally start the Node HTTP server. Permission composition happens before any pool, Better
|
||||
* Auth instance, or HTTP server exists, so a discovery or composition failure — an invalid
|
||||
* Explicit runtime composition, in a fixed order:
|
||||
*
|
||||
* 1. Discover every installed module's manifest exactly once and compose the one immutable
|
||||
* platform permission registry from Core's own declarations plus every module's
|
||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||
* 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
|
||||
* 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
|
||||
* 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
|
||||
* idempotent `shutdown()` so both the executable entry point and tests can stop the server and
|
||||
* close the shared pool deterministically. Never applies migrations or generates schema — the
|
||||
* database must already be migrated through `pnpm migrations:apply`.
|
||||
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
|
||||
* identity, authorization) exists before any executable module lifecycle code runs, and every
|
||||
* module 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. 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(
|
||||
config: ApiRuntimeConfig,
|
||||
options: StartApiOptions = {},
|
||||
): Promise<RunningApi> {
|
||||
const permissionRegistry = await composeStartupPermissionRegistry({
|
||||
const inventory = await prepareStartupModuleInventory({
|
||||
...(options.modulesDirectoryOverride === undefined
|
||||
? {}
|
||||
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||
@@ -114,16 +141,40 @@ export async function startApi(
|
||||
platformOwners: authorizationStore,
|
||||
});
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: permissionRegistry, store: authorizationStore },
|
||||
runtime,
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
});
|
||||
// Module load/register/start failures use Core's own existing rollback (already-started
|
||||
// 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,
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
});
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let server: ServerType;
|
||||
try {
|
||||
server = await bindServer(app, config.host, config.port);
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
@@ -133,7 +184,12 @@ export async function startApi(
|
||||
// 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
|
||||
// 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;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
@@ -141,7 +197,16 @@ export async function startApi(
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
let moduleStopError: unknown;
|
||||
try {
|
||||
await moduleRuntime.stop();
|
||||
} catch (error) {
|
||||
moduleStopError = error;
|
||||
}
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
if (moduleStopError) {
|
||||
throw moduleStopError;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return shutdownPromise;
|
||||
|
||||
@@ -200,6 +200,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
}
|
||||
|
||||
const trackedModulesDirectories: string[] = [];
|
||||
const eventSymbols: symbol[] = [];
|
||||
|
||||
async function createFixtureModulesDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-"));
|
||||
@@ -207,13 +208,31 @@ async function createFixtureModulesDirectory(): Promise<string> {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery and
|
||||
* composition run against it, nothing hand-built. The server entrypoint throws immediately if
|
||||
* ever imported/executed, so a scenario that completes without that throw firing is direct
|
||||
* evidence permission composition and Doctor never ran module lifecycle code. */
|
||||
/** A dynamically imported module fixture cannot close over this test file's local variables, so
|
||||
* lifecycle markers cross that boundary through a `globalThis` symbol instead (same technique as
|
||||
* `core/tests/module-runtime.test.ts`). */
|
||||
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(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
recorder: { expression: string },
|
||||
manifestOverrides: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
@@ -227,6 +246,9 @@ async function createFixtureModule(
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
...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 Promise.all([
|
||||
writeFile(
|
||||
@@ -241,7 +263,39 @@ async function createFixtureModule(
|
||||
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
|
||||
writeFile(
|
||||
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)) {
|
||||
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";
|
||||
@@ -1222,10 +1280,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
const password = "correct horse battery staple";
|
||||
const modulePermission = "calendar.events.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 () => {
|
||||
modulesDirectory = await createFixtureModulesDirectory();
|
||||
await createFixtureModule(modulesDirectory, "calendar", {
|
||||
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
||||
permissions: [
|
||||
{
|
||||
baseline: "restricted",
|
||||
@@ -1257,14 +1316,21 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
details: ["2 permissions registered"],
|
||||
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");
|
||||
await applyCoreMigrations(database.url);
|
||||
running = await startMigratedApi(database.url, modulesDirectory);
|
||||
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);
|
||||
ownerUserId = ownerSignUp.body.user.id;
|
||||
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 () => {
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
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();
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
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();
|
||||
running = await startMigratedApi(database.url, emptyModulesDirectory);
|
||||
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
|
||||
// process), so the same cookie proves ordinary identity behavior is unaffected here too.
|
||||
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("mounts Better Auth exactly under /api/auth/*", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -67,6 +68,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("does not route requests outside /api/auth/* to Better Auth", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -83,6 +85,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -101,6 +104,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -121,6 +125,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -138,6 +143,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -154,6 +160,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
|
||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -167,6 +174,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
|
||||
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -182,6 +190,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
|
||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -196,6 +205,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
|
||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -208,6 +218,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
|
||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -227,6 +238,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
|
||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -249,6 +261,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("mounts the v1 grants routes under /api/v1/grants", async () => {
|
||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -274,6 +287,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
has: () => false,
|
||||
};
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: evaluatorAuthorize },
|
||||
registry: emptyRegistry,
|
||||
@@ -290,6 +304,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
|
||||
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -13,6 +13,13 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
|
||||
// gets the real Hono application; only the one test that deliberately forces an app-construction
|
||||
// failure overrides this with `mockImplementationOnce`.
|
||||
vi.mock("../src/app.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/app.js")>();
|
||||
return { ...actual, createApiApp: vi.fn(actual.createApiApp) };
|
||||
});
|
||||
|
||||
import { parseModuleManifest } from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -21,6 +28,7 @@ import {
|
||||
ModulePermissionCompositionError,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||
|
||||
@@ -87,6 +95,76 @@ async function createFixtureModule(
|
||||
]);
|
||||
}
|
||||
|
||||
const eventSymbols: symbol[] = [];
|
||||
|
||||
/** Same technique as `core/tests/module-runtime.test.ts`: a dynamically imported module fixture
|
||||
* cannot close over this test file's local variables, so lifecycle events cross that boundary
|
||||
* through a `globalThis` symbol instead. Returns the same `events` array the fixture's generated
|
||||
* source pushes into, so test code can both read it directly and push its own markers (pool
|
||||
* close, server close) onto the identical array to observe cross-cutting shutdown order. */
|
||||
function createEventRecorder(): { events: string[]; expression: string } {
|
||||
const key = `ekklesia.api.lifecycle.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||
const symbol = Symbol.for(key);
|
||||
const events: string[] = [];
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
|
||||
eventSymbols.push(symbol);
|
||||
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||
}
|
||||
|
||||
interface WorkingFixtureModuleOptions {
|
||||
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
||||
readonly withRoute?: boolean;
|
||||
readonly registerThrows?: boolean;
|
||||
readonly startThrows?: boolean;
|
||||
readonly stopThrows?: boolean;
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
|
||||
* throw on import; it pushes lifecycle markers onto `recorder.events` and can be told to fail at a
|
||||
* specific hook, so tests can prove exactly what a successful (or a specifically-failing) module
|
||||
* runtime startup actually executes and in what order, relative to pool/server construction. */
|
||||
async function createWorkingFixtureModule(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
recorder: { expression: string },
|
||||
options: WorkingFixtureModuleOptions = {},
|
||||
): Promise<void> {
|
||||
const permissionId = `${id}.ping.read`;
|
||||
await createFixtureModule(modulesDirectory, id, {
|
||||
permissions: options.withRoute
|
||||
? [{ baseline: "public", description: "Ping.", id: permissionId }]
|
||||
: [],
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
});
|
||||
const routeSource = options.withRoute
|
||||
? `http.route({
|
||||
method: "GET",
|
||||
path: "/ping",
|
||||
permission: ${JSON.stringify(permissionId)},
|
||||
handler: async () => new Response("pong", { status: 200 }),
|
||||
});`
|
||||
: "";
|
||||
const serverSource = `export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
|
||||
${routeSource}
|
||||
${options.registerThrows ? 'throw new Error("register failed");' : ""}
|
||||
},
|
||||
start() {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
|
||||
${options.startThrows ? 'throw new Error("start failed");' : ""}
|
||||
},
|
||||
stop() {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
||||
${options.stopThrows ? 'throw new Error("stop failed");' : ""}
|
||||
},
|
||||
};`;
|
||||
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
|
||||
}
|
||||
|
||||
/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used
|
||||
* only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is
|
||||
* unreachable through real filesystem discovery (module ids are unique and every permission id is
|
||||
@@ -518,3 +596,252 @@ describe("Ekklesia API module permission composition at startup", () => {
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
afterEach(async () => {
|
||||
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
|
||||
for (const symbol of eventSymbols.splice(0)) {
|
||||
delete globalValues[symbol];
|
||||
}
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
poolConstructor.mockReset();
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
serveMock.mockReset();
|
||||
vi.mocked(createApiApp).mockClear();
|
||||
});
|
||||
|
||||
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
return { poolEnd };
|
||||
}
|
||||
|
||||
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "alpha", recorder, { withRoute: true });
|
||||
await createWorkingFixtureModule(modulesDirectory, "beta", recorder, { withRoute: true });
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
|
||||
expect(recorder.events).toEqual([
|
||||
"alpha:register",
|
||||
"beta:register",
|
||||
"alpha:start",
|
||||
"beta:start",
|
||||
]);
|
||||
// Hono records one internal route entry per handler in a route's middleware chain, not one
|
||||
// per logical route, so entries for the same method/path are deduplicated (preserving first-
|
||||
// seen order) before asserting mounting order.
|
||||
const moduleRoutePaths = [
|
||||
...new Set(
|
||||
running.app.routes
|
||||
.filter((route) => route.path.startsWith("/api/v1/modules/"))
|
||||
.map((route) => `${route.method} ${route.path}`),
|
||||
),
|
||||
];
|
||||
expect(moduleRoutePaths).toEqual([
|
||||
"GET /api/v1/modules/alpha/ping",
|
||||
"GET /api/v1/modules/beta/ping",
|
||||
]);
|
||||
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
await createFixtureModule(modulesDirectory, "broken-export");
|
||||
await writeFile(
|
||||
path.join(modulesDirectory, "broken-export", "dist/server/index.js"),
|
||||
"export default { thisIsNotAValidRuntimeDefinition: true };",
|
||||
);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_RUNTIME_EXPORT_INVALID" })],
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the pool exactly once and binds no server when a module's register hook fails", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "register-fails", recorder, {
|
||||
registerThrows: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_REGISTER_FAILED" })],
|
||||
});
|
||||
|
||||
expect(recorder.events).toEqual(["register-fails:register"]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the pool exactly once and binds no server when a module's start hook fails, preserving Core's rollback of already-started modules", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
|
||||
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
|
||||
dependencies: { modules: { required: { one: "^0.1.0" } } },
|
||||
startThrows: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
|
||||
});
|
||||
|
||||
expect(recorder.events).toEqual([
|
||||
"one:register",
|
||||
"two:register",
|
||||
"one:start",
|
||||
"two:start",
|
||||
"one:stop",
|
||||
]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the module runtime and closes the pool, binding no server, when app construction fails", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
vi.mocked(createApiApp).mockImplementationOnce(() => {
|
||||
throw new Error("forced app construction failure");
|
||||
});
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toThrow("forced app construction failure");
|
||||
|
||||
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the module runtime and closes the pool when the server fails to bind", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
mockFailingServe(new Error("EADDRINUSE"));
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drains the HTTP server, then stops the module runtime, then closes the pool, in that exact order", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "ordered", recorder);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return {
|
||||
end: vi.fn(async () => {
|
||||
recorder.events.push("pool:end");
|
||||
}),
|
||||
on: vi.fn(),
|
||||
};
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { server } = mockSuccessfulServe();
|
||||
const originalClose = server.close;
|
||||
server.close = vi.fn((callback?: () => void) => {
|
||||
recorder.events.push("server:close");
|
||||
return originalClose(callback);
|
||||
});
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
recorder.events.length = 0;
|
||||
await running.shutdown();
|
||||
|
||||
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
|
||||
});
|
||||
|
||||
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "stop-fails", recorder, {
|
||||
stopThrows: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
|
||||
await expect(running.shutdown()).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
|
||||
});
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a direct discoveredModulesOverride driving both permission composition and module runtime startup", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { discoveredModulesOverride: discovered });
|
||||
|
||||
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
|
||||
// Hono records one internal route entry per handler in a route's middleware chain, not one
|
||||
// per logical route, so entries for the same method/path are deduplicated (preserving first-
|
||||
// seen order) before asserting mounting order.
|
||||
const moduleRoutePaths = [
|
||||
...new Set(
|
||||
running.app.routes
|
||||
.filter((route) => route.path.startsWith("/api/v1/modules/"))
|
||||
.map((route) => `${route.method} ${route.path}`),
|
||||
),
|
||||
];
|
||||
expect(moduleRoutePaths).toEqual(["GET /api/v1/modules/calendar/ping"]);
|
||||
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("performs no filesystem I/O, module import, pool construction, Better Auth construction, or server binding merely by importing the module-route adapter", async () => {
|
||||
const module = await import("../src/module-http-routes.js");
|
||||
|
||||
expect(typeof module.mountModuleHttpRoutes).toBe("function");
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRouteHandlerContext,
|
||||
parsePermissionId,
|
||||
type PermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
createPermissionRegistry,
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { ModuleRouteConfigurationError } from "../src/module-http-routes.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const readPermission = parsePermissionId("calendar.events.read");
|
||||
const writePermission = parsePermissionId("calendar.events.write");
|
||||
|
||||
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeStore(): AuthorizationStore {
|
||||
return {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRoute {
|
||||
return Object.freeze({ ...fields });
|
||||
}
|
||||
|
||||
function buildModuleApp(options: {
|
||||
readonly evaluator?: AuthorizationEvaluator;
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly trustedOrigins?: readonly string[];
|
||||
}) {
|
||||
const evaluator = options.evaluator ?? {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const runtime = options.runtime ?? fakeRuntime();
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
||||
moduleRoutes: options.routes,
|
||||
runtime,
|
||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||
});
|
||||
return { app, evaluator, runtime };
|
||||
}
|
||||
|
||||
function permissionRegistry(
|
||||
declarations: ReadonlyArray<{
|
||||
readonly id: PermissionId;
|
||||
readonly baseline: "authenticated" | "public" | "restricted";
|
||||
}>,
|
||||
): PermissionRegistry {
|
||||
return createPermissionRegistry(
|
||||
declarations.map((declaration) => ({ ...declaration, description: "Test permission." })),
|
||||
);
|
||||
}
|
||||
|
||||
describe("module HTTP route mounting", () => {
|
||||
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const rootResponse = await app.request("/api/v1/modules/calendar");
|
||||
expect(rootResponse.status).toBe(200);
|
||||
expect(await rootResponse.text()).toBe("root");
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
const trailingSlashResponse = await app.request("/api/v1/modules/calendar/");
|
||||
expect(trailingSlashResponse.status).toBe(404);
|
||||
});
|
||||
|
||||
it("mounts static and nested paths directly under the module prefix", async () => {
|
||||
const staticHandler = vi.fn<ModuleRouteHandler>(async () => Response.json({ kind: "static" }));
|
||||
const nestedHandler = vi.fn<ModuleRouteHandler>(async () => Response.json({ kind: "nested" }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: staticHandler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
moduleRoute({
|
||||
handler: nestedHandler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events/upcoming/soon",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(await (await app.request("/api/v1/modules/calendar/events")).json()).toEqual({
|
||||
kind: "static",
|
||||
});
|
||||
expect(
|
||||
await (await app.request("/api/v1/modules/calendar/events/upcoming/soon")).json(),
|
||||
).toEqual({ kind: "nested" });
|
||||
});
|
||||
|
||||
it("resolves a same-depth static path and its parameterized sibling by published route order, without silently reordering them", async () => {
|
||||
// Hono's router matches overlapping patterns in registration order, not by specificity —
|
||||
// mounting must therefore preserve `StartedModuleRuntime.routes`' exact publication order
|
||||
// rather than sorting it, or a module author's more-specific-first declaration would silently
|
||||
// stop taking precedence. The static route is declared first here, matching how a module
|
||||
// author would order these two routes to get the static path resolved on its own.
|
||||
const staticHandler = vi.fn<ModuleRouteHandler>(async () => Response.json({ kind: "static" }));
|
||||
const paramHandler = vi.fn<ModuleRouteHandler>(async (_request, context) =>
|
||||
Response.json({ id: context.params.eventId, kind: "param" }),
|
||||
);
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: staticHandler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events/upcoming",
|
||||
permission: readPermission,
|
||||
}),
|
||||
moduleRoute({
|
||||
handler: paramHandler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events/:eventId",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(await (await app.request("/api/v1/modules/calendar/events/upcoming")).json()).toEqual({
|
||||
kind: "static",
|
||||
});
|
||||
expect(staticHandler).toHaveBeenCalledTimes(1);
|
||||
expect(paramHandler).not.toHaveBeenCalled();
|
||||
|
||||
expect(await (await app.request("/api/v1/modules/calendar/events/other-id")).json()).toEqual({
|
||||
id: "other-id",
|
||||
kind: "param",
|
||||
});
|
||||
expect(paramHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("mounts every supported method", async () => {
|
||||
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
const permissions = new Map(
|
||||
methods.map((method) => [method, parsePermissionId(`multi.${method.toLowerCase()}.action`)]),
|
||||
);
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry(
|
||||
methods.map((method) => ({
|
||||
baseline: "public" as const,
|
||||
id: permissions.get(method) as PermissionId,
|
||||
})),
|
||||
),
|
||||
routes: methods.map((method) =>
|
||||
moduleRoute({
|
||||
handler: async () => Response.json({ method }),
|
||||
method,
|
||||
moduleId: "multi",
|
||||
path: "/action",
|
||||
permission: permissions.get(method) as PermissionId,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
for (const method of methods) {
|
||||
const response = await app.request("/api/v1/modules/multi/action", {
|
||||
headers: method === "GET" ? {} : { origin: trustedOrigin },
|
||||
method,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ method });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps two modules' identical local method/path routes independent", async () => {
|
||||
const alphaPermission = parsePermissionId("alpha.items.list");
|
||||
const betaPermission = parsePermissionId("beta.items.list");
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([
|
||||
{ id: alphaPermission, baseline: "public" },
|
||||
{ id: betaPermission, baseline: "public" },
|
||||
]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: async () => Response.json({ moduleId: "alpha" }),
|
||||
method: "GET",
|
||||
moduleId: "alpha",
|
||||
path: "/items",
|
||||
permission: alphaPermission,
|
||||
}),
|
||||
moduleRoute({
|
||||
handler: async () => Response.json({ moduleId: "beta" }),
|
||||
method: "GET",
|
||||
moduleId: "beta",
|
||||
path: "/items",
|
||||
permission: betaPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(await (await app.request("/api/v1/modules/alpha/items")).json()).toEqual({
|
||||
moduleId: "alpha",
|
||||
});
|
||||
expect(await (await app.request("/api/v1/modules/beta/items")).json()).toEqual({
|
||||
moduleId: "beta",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route handler adaptation", () => {
|
||||
it("adapts the request into a raw Fetch Request and a frozen context with decoded parameters", async () => {
|
||||
let capturedRequest: Request | undefined;
|
||||
let capturedContext: ModuleRouteHandlerContext | undefined;
|
||||
const handler: ModuleRouteHandler = async (request, context) => {
|
||||
capturedRequest = request;
|
||||
capturedContext = context;
|
||||
const body = await request.text();
|
||||
return Response.json({ body, url: request.url });
|
||||
};
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events/:eventId/rsvp",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
runtime,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", {
|
||||
body: "hello",
|
||||
headers: { origin: trustedOrigin, "x-custom": "abc" },
|
||||
method: "POST",
|
||||
});
|
||||
const body = (await response.json()) as { body: string; url: string };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.body).toBe("hello");
|
||||
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
||||
expect(capturedRequest).toBeInstanceOf(Request);
|
||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
||||
expect(capturedContext).toEqual({
|
||||
moduleId: "calendar",
|
||||
params: { eventId: "evt-1" },
|
||||
principal: { kind: "authenticated", userId: "user_1" },
|
||||
});
|
||||
expect(Object.isFrozen(capturedContext)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
||||
});
|
||||
|
||||
it("gives static and root routes a frozen, empty params object", async () => {
|
||||
let rootContext: ModuleRouteHandlerContext | undefined;
|
||||
let staticContext: ModuleRouteHandlerContext | undefined;
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: async (_request, context) => {
|
||||
rootContext = context;
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/",
|
||||
permission: readPermission,
|
||||
}),
|
||||
moduleRoute({
|
||||
handler: async (_request, context) => {
|
||||
staticContext = context;
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await app.request("/api/v1/modules/calendar");
|
||||
await app.request("/api/v1/modules/calendar/events");
|
||||
|
||||
expect(rootContext?.params).toEqual({});
|
||||
expect(Object.isFrozen(rootContext?.params)).toBe(true);
|
||||
expect(staticContext?.params).toEqual({});
|
||||
expect(Object.isFrozen(staticContext?.params)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route authorization", () => {
|
||||
it("uses the registry's exact baseline for public, authenticated, and restricted module permissions", async () => {
|
||||
const publicPermission = parsePermissionId("calendar.events.list");
|
||||
const authenticatedPermission = parsePermissionId("calendar.events.subscribe");
|
||||
const restrictedPermission = parsePermissionId("calendar.events.read");
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([
|
||||
{ id: publicPermission, baseline: "public" },
|
||||
{ id: authenticatedPermission, baseline: "authenticated" },
|
||||
{ id: restrictedPermission, baseline: "restricted" },
|
||||
]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/public",
|
||||
permission: publicPermission,
|
||||
}),
|
||||
moduleRoute({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/authenticated",
|
||||
permission: authenticatedPermission,
|
||||
}),
|
||||
moduleRoute({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/restricted",
|
||||
permission: restrictedPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await app.request("/api/v1/modules/calendar/public");
|
||||
await app.request("/api/v1/modules/calendar/authenticated");
|
||||
await app.request("/api/v1/modules/calendar/restricted");
|
||||
|
||||
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ baseline: "public", permission: publicPermission }),
|
||||
);
|
||||
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ baseline: "authenticated", permission: authenticatedPermission }),
|
||||
);
|
||||
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ baseline: "restricted", permission: restrictedPermission }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not invoke the handler when authorization denies", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invoke the handler when principal resolution fails", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
runtime,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.status).toBe(503);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invoke the handler when the evaluator itself fails", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.status).toBe(503);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails app construction safely when a route names a permission the registry does not register", () => {
|
||||
expect(() =>
|
||||
buildModuleApp({
|
||||
registry: permissionRegistry([]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: vi.fn(),
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toThrow(ModuleRouteConfigurationError);
|
||||
});
|
||||
|
||||
it("never leaks the offending permission, route path, or module id in the configuration error", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
buildModuleApp({
|
||||
registry: permissionRegistry([]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: vi.fn(),
|
||||
method: "GET",
|
||||
moduleId: "secret-module",
|
||||
path: "/secret-path",
|
||||
permission: parsePermissionId("secret-module.secret.permission"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(ModuleRouteConfigurationError);
|
||||
const text = `${(captured as Error).message}\n${(captured as Error).stack}`;
|
||||
expect(text).not.toContain("secret-module");
|
||||
expect(text).not.toContain("secret-path");
|
||||
expect(text).not.toContain("secret.permission");
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route trusted-origin enforcement", () => {
|
||||
it("rejects a state-changing module route with no or untrusted Origin before authorization runs", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: methods.map((method) =>
|
||||
moduleRoute({
|
||||
handler,
|
||||
method,
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
for (const method of methods) {
|
||||
const noOriginResponse = await app.request("/api/v1/modules/calendar/events", { method });
|
||||
expect(noOriginResponse.status).toBe(403);
|
||||
expect(await noOriginResponse.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
|
||||
const untrustedResponse = await app.request("/api/v1/modules/calendar/events", {
|
||||
headers: { origin: untrustedOrigin },
|
||||
method,
|
||||
});
|
||||
expect(untrustedResponse.status).toBe(403);
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not require an Origin header for a GET module route", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route CORS", () => {
|
||||
it("advertises GET, POST, PUT, PATCH, and DELETE on preflight", async () => {
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: vi.fn(),
|
||||
method: "PUT",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
headers: { "access-control-request-method": "PUT", origin: trustedOrigin },
|
||||
method: "OPTIONS",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
const allowedMethods = response.headers.get("access-control-allow-methods") ?? "";
|
||||
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
|
||||
expect(allowedMethods).toContain(method);
|
||||
}
|
||||
});
|
||||
|
||||
it("never invokes authorization or the handler for an OPTIONS preflight", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await app.request("/api/v1/modules/calendar/events", {
|
||||
headers: { "access-control-request-method": "POST", origin: trustedOrigin },
|
||||
method: "OPTIONS",
|
||||
});
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never reflects an untrusted origin for a module route", async () => {
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
headers: { origin: untrustedOrigin },
|
||||
});
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route handler failure sanitization", () => {
|
||||
it("returns a fixed sanitized 500 when the handler throws, never leaking the caught message", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => {
|
||||
throw new Error("password=hunter2 host=db.internal.example");
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(JSON.parse(text)).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
||||
expect(text).not.toContain("hunter2");
|
||||
expect(text).not.toContain("db.internal.example");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("returns the same fixed sanitized 500 when the handler rejects", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(() => Promise.reject(new Error("rejected")));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
||||
});
|
||||
|
||||
it("returns the same fixed sanitized 500 when the handler returns a non-Response value", async () => {
|
||||
const handler = vi.fn(async () => ({ ok: true }) as unknown as Response);
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
||||
});
|
||||
|
||||
it("passes through a valid module-owned 4xx/5xx Response unchanged", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () =>
|
||||
Response.json({ code: "MODULE_NOT_FOUND" }, { status: 404 }),
|
||||
);
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route response header policy", () => {
|
||||
it("strips module-supplied CORS-control headers so the platform policy remains authoritative", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(
|
||||
async () =>
|
||||
new Response("body", {
|
||||
headers: {
|
||||
"access-control-allow-credentials": "true",
|
||||
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE",
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers": "X-Secret",
|
||||
},
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
headers: { origin: untrustedOrigin },
|
||||
});
|
||||
expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
|
||||
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
||||
});
|
||||
|
||||
it("forces Cache-Control: no-store and Vary: Origin, Cookie on a successful module response", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(
|
||||
async () => new Response("body", { headers: { "content-type": "text/plain" }, status: 200 }),
|
||||
);
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
const vary = response.headers.get("vary") ?? "";
|
||||
expect(vary).toContain("Origin");
|
||||
expect(vary).toContain("Cookie");
|
||||
});
|
||||
|
||||
it("preserves an additional legitimate Vary value from the handler alongside the forced ones", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(
|
||||
async () => new Response("body", { headers: { Vary: "Accept-Language" }, status: 200 }),
|
||||
);
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
const vary = response.headers.get("vary") ?? "";
|
||||
expect(vary).toContain("Accept-Language");
|
||||
expect(vary).toContain("Origin");
|
||||
expect(vary).toContain("Cookie");
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route HEAD semantics", () => {
|
||||
it("traverses the GET route's permission middleware and returns no body when authorized", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(
|
||||
async () => new Response("body-text", { status: 200 }),
|
||||
);
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("");
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("denies a HEAD request exactly like the GET route would, without invoking the handler", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(
|
||||
async () => new Response("body-text", { status: 200 }),
|
||||
);
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" });
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.text()).toBe("");
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("module routes and the rest of the API", () => {
|
||||
it("does not invoke a handler for an unmatched module path", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/does-not-exist");
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never invokes a handler merely by constructing the app", () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves existing API behavior with zero module routes", async () => {
|
||||
const { app } = buildModuleApp({ registry: permissionRegistry([]), routes: [] });
|
||||
const response = await app.request("/api/v1/principal");
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
+87
-21
@@ -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`, ...). |
|
||||
| `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). |
|
||||
| `/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
|
||||
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
|
||||
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
|
||||
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
|
||||
request — enforces against. 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. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
|
||||
for the full flow, and [Module development](module-development/README.md) for the manifest shape
|
||||
that produces these declarations.
|
||||
`startApi()` composes production in one fixed order:
|
||||
|
||||
1. Discover every installed module's manifest exactly once and compose the one immutable platform
|
||||
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
|
||||
`apps/api/src/platform-permissions.ts`.
|
||||
2. Construct the one shared PostgreSQL pool.
|
||||
3. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
4. Start the module runtime from that exact same discovered inventory
|
||||
(`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
|
||||
|
||||
@@ -239,17 +297,25 @@ not two.
|
||||
`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
|
||||
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
|
||||
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and
|
||||
lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and
|
||||
authorization both read from it — see
|
||||
[Authorization](authorization.md#shared-database-composition)). The executable entry point's
|
||||
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the
|
||||
same function production composition uses, but independently testable with a fake `running`/`exit`
|
||||
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real
|
||||
`process.exit`. A startup failure after the database pool was already created (identity
|
||||
construction failure, or the server failing to bind its listen address) always closes that pool
|
||||
before the failure propagates.
|
||||
exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
|
||||
is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight
|
||||
requests finish; then stops the module runtime (so no module resource stops while a route handler
|
||||
might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization
|
||||
both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a
|
||||
startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a
|
||||
genuine result a caller should observe — but pool cleanup always still runs first, so a stop
|
||||
failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is
|
||||
extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
|
||||
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage
|
||||
roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or
|
||||
re-granting platform-owner status is not exposed at all); there are no service accounts or API
|
||||
tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot
|
||||
yet mount its own HTTP routes; and relationship-derived 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.
|
||||
registered permission, Core-owned or module-owned; a module can register its own HTTP routes and
|
||||
have them mounted, enforced, and reachable in production (see
|
||||
[API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble
|
||||
instead of silently allowing or denying. It is **not** possible to manage roles, groups, teams, or
|
||||
tenants; there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status
|
||||
is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated
|
||||
browser callers can reach mutation routes, Core-owned or module-owned; and relationship-derived
|
||||
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
|
||||
|
||||
@@ -105,27 +106,37 @@ integration layer depends on authorization).
|
||||
|
||||
## API startup: discovery, composition, and enforcement
|
||||
|
||||
`apps/api` builds its production permission registry from the complete installed-module inventory
|
||||
every time it starts, in a fixed order, before anything else:
|
||||
`apps/api` builds its production permission registry, and starts the module runtime, from the
|
||||
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
|
||||
platform/Module SDK versions from their own `package.json` files).
|
||||
2. Discover installed modules (`discoverModules`).
|
||||
3. Compose the platform permission registry (`composePlatformPermissionRegistry`).
|
||||
2. Discover installed modules exactly once (`discoverModules`).
|
||||
3. Compose the platform permission registry from that exact discovered list
|
||||
(`composePlatformPermissionRegistry`).
|
||||
4. Construct the shared PostgreSQL pool.
|
||||
5. Construct Identity and Authorization.
|
||||
6. Create the Hono application, injecting the composed registry.
|
||||
7. Bind the HTTP server.
|
||||
5. Construct Identity, the authorization store, and the evaluator.
|
||||
6. Start the module runtime from that same discovered list (`startDiscoveredModuleRuntime`) —
|
||||
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.
|
||||
Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server
|
||||
exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the
|
||||
reserved `core.*` namespace, or a duplicate permission 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.
|
||||
`apps/api/src/platform-permissions.ts`'s `prepareStartupModuleInventory` performs steps 1–3,
|
||||
returning both the discovered module list and the composed registry so step 6 can start the module
|
||||
runtime from that identical list rather than rediscovering modules a second time.
|
||||
(`composeStartupPermissionRegistry` remains available as a registry-only convenience wrapper
|
||||
around it, for callers — Doctor, `modules:validate`, existing tests — that only need the
|
||||
registry.) Discovery and composition happen strictly before any pool, Better Auth instance, module
|
||||
entrypoint import, or HTTP server exists, so a failure there — an invalid manifest, a broken
|
||||
dependency graph, a module claiming the reserved `core.*` namespace, or a duplicate permission
|
||||
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
|
||||
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
|
||||
revoke a historical grant for a now-unregistered permission.
|
||||
|
||||
Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived
|
||||
grant creation both remain deferred, unrelated slices — this work only makes a module's *declared*
|
||||
permission usable through Core's existing, generic explicit-grant HTTP API and enforcement
|
||||
middleware.
|
||||
A module's own mounted HTTP routes (`/api/v1/modules/<module-id>/*`) are enforced through this
|
||||
identical registry and `requirePermission` middleware — see [API](api.md#module-http-routes) for
|
||||
the route-mounting contract. Relationship-derived grant creation remains a deferred, unrelated
|
||||
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
|
||||
|
||||
@@ -446,12 +458,34 @@ there are or whether the failure happened during startup.
|
||||
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
||||
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
||||
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,
|
||||
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
|
||||
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
|
||||
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
|
||||
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
||||
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
|
||||
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
|
||||
PostgreSQL) — a real temporary module fixture (structurally valid manifest, a server entrypoint
|
||||
that throws if ever imported) proves the complete chain against a real API process and real
|
||||
Doctor run: Doctor recognizes the installed module and passes permission composition without
|
||||
executing its entrypoint; API startup discovers the fixture and makes its declared permission
|
||||
enforceable; a bootstrapped platform owner creates a real explicit grant for the module's
|
||||
permission with an atomic audit row recorded under its exact declared id; a well-formed
|
||||
permission absent from the composed registry is rejected with no grant or audit write; and,
|
||||
after a clean shutdown and restart with the module absent, the same permission can no longer be
|
||||
newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity
|
||||
HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout.
|
||||
PostgreSQL) — a real temporary module fixture (structurally valid manifest; a real, working
|
||||
server entrypoint recording register/start/stop markers instead of throwing, and declaring a
|
||||
restricted `GET /events/:eventId` route plus a restricted, state-changing `POST /events` route
|
||||
against its own manifest permission) proves the complete chain against a real API process and
|
||||
real Doctor run: Doctor recognizes the installed module and passes permission composition
|
||||
without executing its entrypoint (an empty marker set); a successful `startApi()` discovers the
|
||||
same fixture, imports/registers/starts its runtime exactly once, and makes its declared
|
||||
permission enforceable; a bootstrapped platform owner creates a real explicit grant for the
|
||||
module's permission with an atomic audit row recorded under its exact declared id; the module's
|
||||
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:
|
||||
|
||||
@@ -487,10 +528,11 @@ pnpm test:migrations:integration # requires Docker
|
||||
|
||||
## 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)
|
||||
for the complete list: module HTTP route-registration contracts (a module mounting its own
|
||||
endpoints), a module runtime-context authorization service, relationship-derived grant creation
|
||||
for the deferral this closes) is now implemented — see [API](api.md#module-http-routes) and
|
||||
[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
|
||||
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.
|
||||
|
||||
@@ -240,11 +240,20 @@ connection, authorization store, grant/platform-owner state, storage, jobs, conf
|
||||
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.
|
||||
|
||||
This pass only defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
|
||||
snapshot — Core never invokes a handler here. Mounting routes into the production `apps/api` Hono
|
||||
application, enforcing each route's permission through `requirePermission`, applying
|
||||
`requireTrustedOrigin`, mapping handler errors to sanitized responses, and wiring this into
|
||||
`startApi`'s production lifecycle are later work.
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user