feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement, and module-runtime lifecycle integration. apps/api now discovers the installed module inventory exactly once (prepareStartupModuleInventory), composes the platform permission registry from it, starts the module runtime from that same discovered list (startDiscoveredModuleRuntime, never rediscovering), and mounts StartedModuleRuntime's published routes under /api/v1/modules/<module-id> via a new apps/api-owned Hono adapter (module-http-routes.ts). Every module route is protected by the existing requirePermission middleware against the composed registry, state-changing methods additionally require a trusted Origin, OPTIONS remains entirely platform-owned, and a handler that throws, rejects, or returns a non-Response becomes a fixed, sanitized 500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped and every module response is forced to Cache-Control: no-store with Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime between draining the HTTP server and closing the shared pool, and every startup failure path stops the runtime and/or closes the pool before propagating, without masking the original failure. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e296e083ec
commit
dfaf324a65
@@ -61,12 +61,25 @@ enforceable and grantable through the exact same `requirePermission` middleware
|
|||||||
claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission
|
claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission
|
||||||
composition. Installing, removing, or editing a module currently requires an API restart; removing
|
composition. Installing, removing, or editing a module currently requires an API restart; removing
|
||||||
one makes its permission unavailable to new checks and new grants without deleting or revoking any
|
one makes its permission unavailable to new checks and new grants without deleting or revoking any
|
||||||
historical grant or audit row. Module HTTP route registration and relationship-derived grant
|
historical grant or audit row. See
|
||||||
creation remain later work. See
|
|
||||||
[Authorization](docs/development/authorization.md#module-permission-composition),
|
[Authorization](docs/development/authorization.md#module-permission-composition),
|
||||||
[Module development](docs/module-development/README.md), and
|
[Module development](docs/module-development/README.md), and
|
||||||
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md).
|
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md).
|
||||||
|
|
||||||
|
Phase 5B mounts a module's own HTTP routes in production. A module registers framework-neutral
|
||||||
|
Fetch (`Request`/`Response`) route handlers during its existing `register` hook; Core collects them
|
||||||
|
into an immutable snapshot alongside its capabilities, and `apps/api` discovers modules exactly
|
||||||
|
once, starts the module runtime from that same discovered inventory, and mounts its published
|
||||||
|
routes under `/api/v1/modules/<module-id>` — after every module has registered and started
|
||||||
|
successfully, so no request can reach a module before startup completes. Every route is enforced
|
||||||
|
through the identical `requirePermission` middleware and composed permission registry Core's own
|
||||||
|
routes use (a module can never supply its own baseline), state-changing methods require a trusted
|
||||||
|
`Origin`, and a handler failure or non-`Response` return becomes a fixed, sanitized `500` — never a
|
||||||
|
raw error, stack, path, or permission id. Relationship-derived grant creation remains later work.
|
||||||
|
See [API](docs/development/api.md#module-http-routes),
|
||||||
|
[Module development](docs/module-development/README.md#http-routes), and
|
||||||
|
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred).
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
|
|||||||
+27
-8
@@ -1,4 +1,4 @@
|
|||||||
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
|
import { isAuthenticatedPrincipal, type RegisteredModuleHttpRoute } from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
AuthorizationEvaluator,
|
AuthorizationEvaluator,
|
||||||
AuthorizationStore,
|
AuthorizationStore,
|
||||||
@@ -9,23 +9,30 @@ import { Hono } from "hono";
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
|
import { mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||||
|
|
||||||
export interface CreateApiAppAuthorizationOptions {
|
export interface CreateApiAppAuthorizationOptions {
|
||||||
readonly evaluator: AuthorizationEvaluator;
|
readonly evaluator: AuthorizationEvaluator;
|
||||||
/** The complete platform permission registry (Core's own declarations plus every installed
|
/** The complete platform permission registry (Core's own declarations plus every installed
|
||||||
* module's) — always the caller-composed registry `startApi` produces via
|
* module's) — always the caller-composed registry `startApi` produces via
|
||||||
* `composeStartupPermissionRegistry`, never a Core-only fallback constructed here. */
|
* `prepareStartupModuleInventory`, never a Core-only fallback constructed here. */
|
||||||
readonly registry: PermissionRegistry;
|
readonly registry: PermissionRegistry;
|
||||||
readonly store: AuthorizationStore;
|
readonly store: AuthorizationStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateApiAppOptions {
|
export interface CreateApiAppOptions {
|
||||||
readonly authorization: CreateApiAppAuthorizationOptions;
|
readonly authorization: CreateApiAppAuthorizationOptions;
|
||||||
|
/** The started module runtime's published, immutable route snapshot — always the caller-started
|
||||||
|
* runtime's exact `routes` (`startDiscoveredModuleRuntime`, `apps/api/src/server.ts`), mounted in
|
||||||
|
* publication order under `/api/v1/modules/<module-id>`. Required rather than defaulted so a
|
||||||
|
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
||||||
|
* implicit fallback. */
|
||||||
|
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
||||||
readonly runtime: EkklesiaIdentityRuntime;
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||||
* `requireTrustedOrigin` checks for grant-mutation routes — there is no second, competing origin
|
* `requireTrustedOrigin` checks for grant- and module-route-mutation routes — there is no second,
|
||||||
* configuration. */
|
* competing origin configuration. */
|
||||||
readonly trustedOrigins: readonly string[];
|
readonly trustedOrigins: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +43,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
|
* Pure Hono application factory: no I/O, no environment reads, no process side effects, and it
|
||||||
* for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime
|
* never invokes a module route handler while composing routes. Suitable for unit and integration
|
||||||
* and authorization evaluator/store standing in for real Better Auth/PostgreSQL where appropriate.
|
* tests via `app.fetch` directly, with a fully injected identity runtime and authorization
|
||||||
|
* evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. Synchronously
|
||||||
|
* throws `ModuleRouteConfigurationError` (`./module-http-routes.js`) if `moduleRoutes` and
|
||||||
|
* `authorization.registry` are inconsistent — a route naming a permission the registry does not
|
||||||
|
* register, which should never happen since both derive from the same discovered inventory, but is
|
||||||
|
* checked explicitly rather than silently mounting an unenforceable route.
|
||||||
*/
|
*/
|
||||||
export function createApiApp(options: CreateApiAppOptions): Hono {
|
export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||||
const { runtime } = options;
|
const { runtime } = options;
|
||||||
@@ -48,7 +60,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
|
|
||||||
const corsMiddleware = cors({
|
const corsMiddleware = cors({
|
||||||
allowHeaders: ["Content-Type"],
|
allowHeaders: ["Content-Type"],
|
||||||
allowMethods: ["GET", "POST", "OPTIONS"],
|
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
maxAge: 600,
|
maxAge: 600,
|
||||||
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
|
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
|
||||||
@@ -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) => {
|
app.notFound((c) => {
|
||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
return c.json({ code: "NOT_FOUND" }, 404);
|
return c.json({ code: "NOT_FOUND" }, 404);
|
||||||
|
|||||||
@@ -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;
|
return packageData.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComposeStartupPermissionRegistryOptions {
|
export interface PrepareStartupModuleInventoryOptions {
|
||||||
/** Test-only escape hatch: overrides the directory module discovery scans for installed
|
/** Test-only escape hatch: overrides the directory module discovery scans for installed
|
||||||
* modules. Never set by the executable entry point — production always discovers the real
|
* modules. Never set by the executable entry point — production always discovers the real
|
||||||
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
|
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
|
||||||
@@ -62,7 +62,8 @@ export interface ComposeStartupPermissionRegistryOptions {
|
|||||||
readonly modulesDirectoryOverride?: string;
|
readonly modulesDirectoryOverride?: string;
|
||||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
||||||
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
|
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
|
||||||
* on it. Exists because a genuine exact permission-id collision between two modules is
|
* on it — and, if startup progresses that far, driving module runtime startup with this exact
|
||||||
|
* list too. Exists because a genuine exact permission-id collision between two modules is
|
||||||
* unreachable through real discovery: module ids are unique (discovery itself rejects a
|
* unreachable through real discovery: module ids are unique (discovery itself rejects a
|
||||||
* duplicate) and every permission id is namespaced by its own module's id, so no two
|
* duplicate) and every permission id is namespaced by its own module's id, so no two
|
||||||
* really-discovered modules can ever contribute the same permission id. Proving
|
* really-discovered modules can ever contribute the same permission id. Proving
|
||||||
@@ -76,18 +77,35 @@ export interface ComposeStartupPermissionRegistryOptions {
|
|||||||
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ComposeStartupPermissionRegistryOptions = PrepareStartupModuleInventoryOptions;
|
||||||
|
|
||||||
|
export interface StartupModuleInventory {
|
||||||
|
/** The exact discovered-module list — in discovery order — permission composition ran against.
|
||||||
|
* Production startup (`apps/api/src/server.ts`) passes this same list to
|
||||||
|
* `startDiscoveredModuleRuntime` rather than letting the module runtime rediscover modules a
|
||||||
|
* second time. */
|
||||||
|
readonly modules: readonly DiscoveredModule[];
|
||||||
|
readonly permissionRegistry: PermissionRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discovers every installed module's manifest and composes the one immutable platform permission
|
* Discovers every installed module's manifest exactly once and composes the one immutable platform
|
||||||
* registry (Core's own declarations plus every module's) — the production source of truth
|
* permission registry (Core's own declarations plus every module's) from that exact discovered
|
||||||
* `apps/api` enforces every request against. Only manifests are read; no module server entrypoint
|
* list — the production source of truth `apps/api` enforces every request against, and the exact
|
||||||
* is imported or executed, and no module lifecycle hook runs. Read the platform and Module SDK
|
* module inventory `startDiscoveredModuleRuntime` starts from. Only manifests are read here; no
|
||||||
* versions only when this function actually executes, never at import time.
|
* module server entrypoint is imported or executed, and no module lifecycle hook runs. Read the
|
||||||
|
* platform and Module SDK versions only when this function actually executes, never at import
|
||||||
|
* time.
|
||||||
*/
|
*/
|
||||||
export async function composeStartupPermissionRegistry(
|
export async function prepareStartupModuleInventory(
|
||||||
options: ComposeStartupPermissionRegistryOptions = {},
|
options: PrepareStartupModuleInventoryOptions = {},
|
||||||
): Promise<PermissionRegistry> {
|
): Promise<StartupModuleInventory> {
|
||||||
if (options.discoveredModulesOverride) {
|
if (options.discoveredModulesOverride) {
|
||||||
return composePlatformPermissionRegistry(options.discoveredModulesOverride);
|
const modules = options.discoveredModulesOverride;
|
||||||
|
return Object.freeze({
|
||||||
|
modules,
|
||||||
|
permissionRegistry: composePlatformPermissionRegistry(modules),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const repositoryRoot = resolveRepositoryRoot();
|
const repositoryRoot = resolveRepositoryRoot();
|
||||||
@@ -102,5 +120,24 @@ export async function composeStartupPermissionRegistry(
|
|||||||
platformVersion,
|
platformVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
return composePlatformPermissionRegistry(modules);
|
return Object.freeze({
|
||||||
|
modules,
|
||||||
|
permissionRegistry: composePlatformPermissionRegistry(modules),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatible wrapper for callers — currently only existing tests — that need this package's
|
||||||
|
* composed registry without the discovered module list itself. (Doctor and `modules:validate` are
|
||||||
|
* Core-owned and never call into `apps/api` at all; they call Core's own
|
||||||
|
* `composePlatformPermissionRegistry` directly over their own already-discovered module list.)
|
||||||
|
* Production `startApi` does not call this wrapper: it calls `prepareStartupModuleInventory`
|
||||||
|
* directly so it can hand the exact same discovered list to `startDiscoveredModuleRuntime`, rather
|
||||||
|
* than discarding it here and rediscovering modules a second time for runtime startup.
|
||||||
|
*/
|
||||||
|
export async function composeStartupPermissionRegistry(
|
||||||
|
options: ComposeStartupPermissionRegistryOptions = {},
|
||||||
|
): Promise<PermissionRegistry> {
|
||||||
|
const inventory = await prepareStartupModuleInventory(options);
|
||||||
|
return inventory.permissionRegistry;
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-25
@@ -5,13 +5,17 @@ import {
|
|||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||||
import type { DiscoveredModule } from "@ekklesia/core/modules";
|
import {
|
||||||
|
type DiscoveredModule,
|
||||||
|
type StartedModuleRuntime,
|
||||||
|
startDiscoveredModuleRuntime,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
import { serve, type ServerType } from "@hono/node-server";
|
import { serve, type ServerType } from "@hono/node-server";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { createApiApp } from "./app.js";
|
import { createApiApp } from "./app.js";
|
||||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||||
import { composeStartupPermissionRegistry } from "./platform-permissions.js";
|
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||||
|
|
||||||
const connectionTimeoutMs = 10_000;
|
const connectionTimeoutMs = 10_000;
|
||||||
const operationTimeoutMs = 10_000;
|
const operationTimeoutMs = 10_000;
|
||||||
@@ -26,12 +30,13 @@ export interface StartApiOptions {
|
|||||||
/** Test-only escape hatch: never set by the executable entry point. See
|
/** Test-only escape hatch: never set by the executable entry point. See
|
||||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||||
/** Test-only escape hatch: overrides the directory permission-composition module discovery
|
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
||||||
* scans. Never set by the executable entry point. See `composeStartupPermissionRegistry`. */
|
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||||
readonly modulesDirectoryOverride?: string;
|
readonly modulesDirectoryOverride?: string;
|
||||||
/** Test-only escape hatch: supplies an already-discovered module list directly to permission
|
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
|
||||||
* composition, skipping filesystem discovery entirely. Never set by the executable entry
|
* filesystem discovery entirely — this exact list drives both permission composition and module
|
||||||
* point. See `composeStartupPermissionRegistry`. */
|
* runtime startup. Never set by the executable entry point. See
|
||||||
|
* `prepareStartupModuleInventory`. */
|
||||||
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +49,17 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, used only while unwinding a startup
|
||||||
|
* failure that already occurred after the module runtime started — never during normal shutdown,
|
||||||
|
* where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */
|
||||||
|
async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promise<void> {
|
||||||
|
try {
|
||||||
|
await moduleRuntime.stop();
|
||||||
|
} catch {
|
||||||
|
// See safeCloseDatabase: never mask the startup failure already propagating.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
|
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -63,25 +79,36 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicit runtime composition, in a fixed order: discover every installed module's manifest and
|
* Explicit runtime composition, in a fixed order:
|
||||||
* compose the one immutable platform permission registry from Core's own declarations plus every
|
*
|
||||||
* module's (`./platform-permissions.js`); only then construct the one shared PostgreSQL pool
|
* 1. Discover every installed module's manifest exactly once and compose the one immutable
|
||||||
* (identity and authorization each get a typed Drizzle view over it — see `./database.js`), the
|
* platform permission registry from Core's own declarations plus every module's
|
||||||
* Core identity runtime, the Core authorization store and evaluator, and the Hono application;
|
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||||
* finally start the Node HTTP server. Permission composition happens before any pool, Better
|
* 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
|
||||||
* Auth instance, or HTTP server exists, so a discovery or composition failure — an invalid
|
* Drizzle view over it — see `./database.js`).
|
||||||
|
* 3. Construct the Core identity runtime, authorization store, and evaluator.
|
||||||
|
* 4. Start the module runtime from that exact same discovered inventory
|
||||||
|
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time.
|
||||||
|
* 5. Create the Hono application, mounting the started module runtime's published routes.
|
||||||
|
* 6. Bind the Node HTTP server.
|
||||||
|
*
|
||||||
|
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||||
|
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
|
||||||
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
||||||
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
||||||
* directly, and startup never falls back to a Core-only registry. Returns a handle with an
|
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
|
||||||
* idempotent `shutdown()` so both the executable entry point and tests can stop the server and
|
* identity, authorization) exists before any executable module lifecycle code runs, and every
|
||||||
* close the shared pool deterministically. Never applies migrations or generates schema — the
|
* module route is published only after the entire module runtime has registered and started
|
||||||
* database must already be migrated through `pnpm migrations:apply`.
|
* successfully, so no request can ever reach a module before every module has started. Returns a
|
||||||
|
* handle with an idempotent `shutdown()` so both the executable entry point and tests can stop the
|
||||||
|
* server, the module runtime, and the shared pool deterministically. Never applies migrations or
|
||||||
|
* generates schema — the database must already be migrated through `pnpm migrations:apply`.
|
||||||
*/
|
*/
|
||||||
export async function startApi(
|
export async function startApi(
|
||||||
config: ApiRuntimeConfig,
|
config: ApiRuntimeConfig,
|
||||||
options: StartApiOptions = {},
|
options: StartApiOptions = {},
|
||||||
): Promise<RunningApi> {
|
): Promise<RunningApi> {
|
||||||
const permissionRegistry = await composeStartupPermissionRegistry({
|
const inventory = await prepareStartupModuleInventory({
|
||||||
...(options.modulesDirectoryOverride === undefined
|
...(options.modulesDirectoryOverride === undefined
|
||||||
? {}
|
? {}
|
||||||
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||||
@@ -114,16 +141,40 @@ export async function startApi(
|
|||||||
platformOwners: authorizationStore,
|
platformOwners: authorizationStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = createApiApp({
|
// Module load/register/start failures use Core's own existing rollback (already-started
|
||||||
authorization: { evaluator, registry: permissionRegistry, store: authorizationStore },
|
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
||||||
runtime,
|
// handle to stop here, only the pool this failure leaves open.
|
||||||
trustedOrigins: config.identity.trustedOrigins,
|
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;
|
let server: ServerType;
|
||||||
try {
|
try {
|
||||||
server = await bindServer(app, config.host, config.port);
|
server = await bindServer(app, config.host, config.port);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await safeStopModuleRuntime(moduleRuntime);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -133,7 +184,12 @@ export async function startApi(
|
|||||||
// would previously get an already-resolved `undefined` immediately, before the listener had
|
// would previously get an already-resolved `undefined` immediately, before the listener had
|
||||||
// actually drained or the pool had actually closed. Storing the promise itself means every
|
// actually drained or the pool had actually closed. Storing the promise itself means every
|
||||||
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
|
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
|
||||||
// observes it complete (or fail) exactly once.
|
// observes it complete (or fail) exactly once. Order matters: the HTTP listener is drained and
|
||||||
|
// closed first so no new or in-flight request can reach a module handler while the module
|
||||||
|
// runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the
|
||||||
|
// startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine
|
||||||
|
// shutdown result a caller should observe — but database cleanup still always runs first, so a
|
||||||
|
// stop failure never leaves the pool open.
|
||||||
let shutdownPromise: Promise<void> | undefined;
|
let shutdownPromise: Promise<void> | undefined;
|
||||||
const shutdown = (): Promise<void> => {
|
const shutdown = (): Promise<void> => {
|
||||||
if (!shutdownPromise) {
|
if (!shutdownPromise) {
|
||||||
@@ -141,7 +197,16 @@ export async function startApi(
|
|||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
server.close(() => resolve());
|
server.close(() => resolve());
|
||||||
});
|
});
|
||||||
|
let moduleStopError: unknown;
|
||||||
|
try {
|
||||||
|
await moduleRuntime.stop();
|
||||||
|
} catch (error) {
|
||||||
|
moduleStopError = error;
|
||||||
|
}
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
|
if (moduleStopError) {
|
||||||
|
throw moduleStopError;
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
return shutdownPromise;
|
return shutdownPromise;
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trackedModulesDirectories: string[] = [];
|
const trackedModulesDirectories: string[] = [];
|
||||||
|
const eventSymbols: symbol[] = [];
|
||||||
|
|
||||||
async function createFixtureModulesDirectory(): Promise<string> {
|
async function createFixtureModulesDirectory(): Promise<string> {
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-"));
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-"));
|
||||||
@@ -207,13 +208,31 @@ async function createFixtureModulesDirectory(): Promise<string> {
|
|||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery and
|
/** A dynamically imported module fixture cannot close over this test file's local variables, so
|
||||||
* composition run against it, nothing hand-built. The server entrypoint throws immediately if
|
* lifecycle markers cross that boundary through a `globalThis` symbol instead (same technique as
|
||||||
* ever imported/executed, so a scenario that completes without that throw firing is direct
|
* `core/tests/module-runtime.test.ts`). */
|
||||||
* evidence permission composition and Doctor never ran module lifecycle code. */
|
function createEventRecorder(): { events: string[]; expression: string } {
|
||||||
|
const key = `ekklesia.api.integration.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||||
|
const symbol = Symbol.for(key);
|
||||||
|
const events: string[] = [];
|
||||||
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
|
||||||
|
eventSymbols.push(symbol);
|
||||||
|
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery,
|
||||||
|
* composition, and (where a scenario actually starts the API) real module runtime execution all
|
||||||
|
* run against it, nothing hand-built. Register/start/stop push markers onto `recorder.events`
|
||||||
|
* instead of throwing, so both "Doctor/composition never executed this" (an empty recorder) and
|
||||||
|
* "startApi actually imported, registered, and started this" (a populated recorder) can be proven
|
||||||
|
* against the exact same fixture. When declared with the `calendar.events.read` permission, the
|
||||||
|
* module also registers a restricted `GET /events/:eventId` route and a restricted, state-changing
|
||||||
|
* `POST /events` route against that same permission, so the full HTTP mounting/enforcement chain
|
||||||
|
* can be exercised live. */
|
||||||
async function createFixtureModule(
|
async function createFixtureModule(
|
||||||
modulesDirectory: string,
|
modulesDirectory: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
recorder: { expression: string },
|
||||||
manifestOverrides: Record<string, unknown> = {},
|
manifestOverrides: Record<string, unknown> = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
@@ -227,6 +246,9 @@ async function createFixtureModule(
|
|||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
...manifestOverrides,
|
...manifestOverrides,
|
||||||
};
|
};
|
||||||
|
const declaresCalendarRead = (manifest.permissions as ReadonlyArray<{ id: string }>).some(
|
||||||
|
(declaration) => declaration.id === "calendar.events.read",
|
||||||
|
);
|
||||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writeFile(
|
writeFile(
|
||||||
@@ -241,7 +263,39 @@ async function createFixtureModule(
|
|||||||
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
|
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "dist/server/index.js"),
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
'throw new Error("permission composition and Doctor must never execute a module server entrypoint");',
|
`export default {
|
||||||
|
id: ${JSON.stringify(id)},
|
||||||
|
version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
|
||||||
|
${
|
||||||
|
declaresCalendarRead
|
||||||
|
? `http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/events/:eventId",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
async handler(request, context) {
|
||||||
|
return Response.json({ eventId: context.params.eventId, principal: context.principal });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/events",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
async handler(request, context) {
|
||||||
|
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
||||||
|
},
|
||||||
|
});`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
start() {
|
||||||
|
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
||||||
|
},
|
||||||
|
};`,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -253,6 +307,10 @@ afterAll(async () => {
|
|||||||
for (const directory of trackedModulesDirectories.splice(0)) {
|
for (const directory of trackedModulesDirectories.splice(0)) {
|
||||||
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
|
||||||
|
for (const symbol of eventSymbols.splice(0)) {
|
||||||
|
delete globalValues[symbol];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const identityBaseUrl = "https://identity.example.test";
|
const identityBaseUrl = "https://identity.example.test";
|
||||||
@@ -1222,10 +1280,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
const password = "correct horse battery staple";
|
const password = "correct horse battery staple";
|
||||||
const modulePermission = "calendar.events.read";
|
const modulePermission = "calendar.events.read";
|
||||||
const absentPermission = "scheduler.jobs.read";
|
const absentPermission = "scheduler.jobs.read";
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
|
||||||
it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => {
|
it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => {
|
||||||
modulesDirectory = await createFixtureModulesDirectory();
|
modulesDirectory = await createFixtureModulesDirectory();
|
||||||
await createFixtureModule(modulesDirectory, "calendar", {
|
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
baseline: "restricted",
|
baseline: "restricted",
|
||||||
@@ -1257,14 +1316,21 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
details: ["2 permissions registered"],
|
details: ["2 permissions registered"],
|
||||||
status: "pass",
|
status: "pass",
|
||||||
});
|
});
|
||||||
|
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||||
|
// markers must remain entirely unset.
|
||||||
|
expect(recorder.events).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts a freshly migrated API that discovers the fixture module and signs up its eventual owner", async () => {
|
it("starts a freshly migrated API that discovers the fixture module, imports and starts its runtime exactly once, and mounts its route", async () => {
|
||||||
database = await createDisposableDatabase("authz_module");
|
database = await createDisposableDatabase("authz_module");
|
||||||
await applyCoreMigrations(database.url);
|
await applyCoreMigrations(database.url);
|
||||||
running = await startMigratedApi(database.url, modulesDirectory);
|
running = await startMigratedApi(database.url, modulesDirectory);
|
||||||
origin = apiOrigin(running);
|
origin = apiOrigin(running);
|
||||||
|
|
||||||
|
// Unlike Doctor above, a successful production startup does deliberately import, register,
|
||||||
|
// and start the module's runtime — exactly once each.
|
||||||
|
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
|
||||||
|
|
||||||
const ownerSignUp = await signUp(origin, ownerEmail, password);
|
const ownerSignUp = await signUp(origin, ownerEmail, password);
|
||||||
ownerUserId = ownerSignUp.body.user.id;
|
ownerUserId = ownerSignUp.body.user.id;
|
||||||
const ownerSignIn = await signIn(origin, ownerEmail, password);
|
const ownerSignIn = await signIn(origin, ownerEmail, password);
|
||||||
@@ -1316,6 +1382,56 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("denies the module's own mounted route to an anonymous caller, then serves it to the newly granted owner with the decoded path parameter and authenticated principal", async () => {
|
||||||
|
const anonymousResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`);
|
||||||
|
expect(anonymousResponse.status).toBe(403);
|
||||||
|
expect(await anonymousResponse.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
|
||||||
|
const grantedResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`, {
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
expect(grantedResponse.status).toBe(200);
|
||||||
|
const body = (await grantedResponse.json()) as {
|
||||||
|
eventId: string;
|
||||||
|
principal: { kind: string; userId: string };
|
||||||
|
};
|
||||||
|
expect(body.eventId).toBe("evt-42");
|
||||||
|
expect(body.principal).toEqual({ kind: "authenticated", userId: ownerUserId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a trusted Origin for the module's own state-changing route, exactly like the platform's own mutation routes", async () => {
|
||||||
|
const noOriginResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
headers: { "content-type": "application/json", cookie: ownerCookie },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(noOriginResponse.status).toBe(403);
|
||||||
|
expect(await noOriginResponse.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
|
||||||
|
const untrustedResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: untrustedOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(untrustedResponse.status).toBe(403);
|
||||||
|
|
||||||
|
const trustedResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(trustedResponse.status).toBe(201);
|
||||||
|
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
||||||
const admin = await openAdministrativeClient(database.url);
|
const admin = await openAdministrativeClient(database.url);
|
||||||
try {
|
try {
|
||||||
@@ -1355,15 +1471,24 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission while the historical grant remains listable", async () => {
|
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
|
||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
const remainingConnections = await activeConnectionCount(database.name);
|
const remainingConnections = await activeConnectionCount(database.name);
|
||||||
expect(remainingConnections).toBe(0);
|
expect(remainingConnections).toBe(0);
|
||||||
|
// Exactly one stop, matching the exactly-one register/start already asserted above.
|
||||||
|
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
|
||||||
|
|
||||||
const emptyModulesDirectory = await createFixtureModulesDirectory();
|
const emptyModulesDirectory = await createFixtureModulesDirectory();
|
||||||
running = await startMigratedApi(database.url, emptyModulesDirectory);
|
running = await startMigratedApi(database.url, emptyModulesDirectory);
|
||||||
origin = apiOrigin(running);
|
origin = apiOrigin(running);
|
||||||
|
|
||||||
|
// The module's own route is unmounted along with the module itself.
|
||||||
|
const removedRouteResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`, {
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
expect(removedRouteResponse.status).toBe(404);
|
||||||
|
expect(await removedRouteResponse.json()).toEqual({ code: "NOT_FOUND" });
|
||||||
|
|
||||||
// The owner's existing session survives the restart (sessions live in the database, not the
|
// The owner's existing session survives the restart (sessions live in the database, not the
|
||||||
// process), so the same cookie proves ordinary identity behavior is unaffected here too.
|
// process), so the same cookie proves ordinary identity behavior is unaffected here too.
|
||||||
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
|
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("mounts Better Auth exactly under /api/auth/*", async () => {
|
it("mounts Better Auth exactly under /api/auth/*", async () => {
|
||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -67,6 +68,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("does not route requests outside /api/auth/* to Better Auth", async () => {
|
it("does not route requests outside /api/auth/* to Better Auth", async () => {
|
||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -83,6 +85,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -101,6 +104,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
|
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
|
||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -121,6 +125,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
|
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
|
||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -138,6 +143,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
|
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
|
||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -154,6 +160,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
|
|
||||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -167,6 +174,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
|
|
||||||
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -182,6 +190,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
|
|
||||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -196,6 +205,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
|
|
||||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -208,6 +218,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
|
|
||||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -227,6 +238,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
|
|
||||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -249,6 +261,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("mounts the v1 grants routes under /api/v1/grants", async () => {
|
it("mounts the v1 grants routes under /api/v1/grants", async () => {
|
||||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -274,6 +287,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
has: () => false,
|
has: () => false,
|
||||||
};
|
};
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
evaluator: { authorize: evaluatorAuthorize },
|
evaluator: { authorize: evaluatorAuthorize },
|
||||||
registry: emptyRegistry,
|
registry: emptyRegistry,
|
||||||
@@ -290,6 +304,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
|
|
||||||
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
|||||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||||
|
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
|
||||||
|
// gets the real Hono application; only the one test that deliberately forces an app-construction
|
||||||
|
// failure overrides this with `mockImplementationOnce`.
|
||||||
|
vi.mock("../src/app.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../src/app.js")>();
|
||||||
|
return { ...actual, createApiApp: vi.fn(actual.createApiApp) };
|
||||||
|
});
|
||||||
|
|
||||||
import { parseModuleManifest } from "@ekklesia/contracts";
|
import { parseModuleManifest } from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +28,7 @@ import {
|
|||||||
ModulePermissionCompositionError,
|
ModulePermissionCompositionError,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||||
|
|
||||||
@@ -87,6 +95,76 @@ async function createFixtureModule(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventSymbols: symbol[] = [];
|
||||||
|
|
||||||
|
/** Same technique as `core/tests/module-runtime.test.ts`: a dynamically imported module fixture
|
||||||
|
* cannot close over this test file's local variables, so lifecycle events cross that boundary
|
||||||
|
* through a `globalThis` symbol instead. Returns the same `events` array the fixture's generated
|
||||||
|
* source pushes into, so test code can both read it directly and push its own markers (pool
|
||||||
|
* close, server close) onto the identical array to observe cross-cutting shutdown order. */
|
||||||
|
function createEventRecorder(): { events: string[]; expression: string } {
|
||||||
|
const key = `ekklesia.api.lifecycle.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||||
|
const symbol = Symbol.for(key);
|
||||||
|
const events: string[] = [];
|
||||||
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
|
||||||
|
eventSymbols.push(symbol);
|
||||||
|
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkingFixtureModuleOptions {
|
||||||
|
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
||||||
|
readonly withRoute?: boolean;
|
||||||
|
readonly registerThrows?: boolean;
|
||||||
|
readonly startThrows?: boolean;
|
||||||
|
readonly stopThrows?: boolean;
|
||||||
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
|
||||||
|
* throw on import; it pushes lifecycle markers onto `recorder.events` and can be told to fail at a
|
||||||
|
* specific hook, so tests can prove exactly what a successful (or a specifically-failing) module
|
||||||
|
* runtime startup actually executes and in what order, relative to pool/server construction. */
|
||||||
|
async function createWorkingFixtureModule(
|
||||||
|
modulesDirectory: string,
|
||||||
|
id: string,
|
||||||
|
recorder: { expression: string },
|
||||||
|
options: WorkingFixtureModuleOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const permissionId = `${id}.ping.read`;
|
||||||
|
await createFixtureModule(modulesDirectory, id, {
|
||||||
|
permissions: options.withRoute
|
||||||
|
? [{ baseline: "public", description: "Ping.", id: permissionId }]
|
||||||
|
: [],
|
||||||
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
|
});
|
||||||
|
const routeSource = options.withRoute
|
||||||
|
? `http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/ping",
|
||||||
|
permission: ${JSON.stringify(permissionId)},
|
||||||
|
handler: async () => new Response("pong", { status: 200 }),
|
||||||
|
});`
|
||||||
|
: "";
|
||||||
|
const serverSource = `export default {
|
||||||
|
id: ${JSON.stringify(id)},
|
||||||
|
version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
|
||||||
|
${routeSource}
|
||||||
|
${options.registerThrows ? 'throw new Error("register failed");' : ""}
|
||||||
|
},
|
||||||
|
start() {
|
||||||
|
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
|
||||||
|
${options.startThrows ? 'throw new Error("start failed");' : ""}
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
||||||
|
${options.stopThrows ? 'throw new Error("stop failed");' : ""}
|
||||||
|
},
|
||||||
|
};`;
|
||||||
|
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
|
||||||
|
}
|
||||||
|
|
||||||
/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used
|
/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used
|
||||||
* only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is
|
* only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is
|
||||||
* unreachable through real filesystem discovery (module ids are unique and every permission id is
|
* unreachable through real filesystem discovery (module ids are unique and every permission id is
|
||||||
@@ -518,3 +596,252 @@ describe("Ekklesia API module permission composition at startup", () => {
|
|||||||
expect(poolConstructor).not.toHaveBeenCalled();
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
|
||||||
|
for (const symbol of eventSymbols.splice(0)) {
|
||||||
|
delete globalValues[symbol];
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
poolConstructor.mockReset();
|
||||||
|
betterAuthMock.mockReset();
|
||||||
|
drizzleAdapterMock.mockClear();
|
||||||
|
serveMock.mockReset();
|
||||||
|
vi.mocked(createApiApp).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
||||||
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: poolEnd, on: vi.fn() };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
return { poolEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "alpha", recorder, { withRoute: true });
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "beta", recorder, { withRoute: true });
|
||||||
|
mockPoolAndAuth();
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||||
|
|
||||||
|
expect(recorder.events).toEqual([
|
||||||
|
"alpha:register",
|
||||||
|
"beta:register",
|
||||||
|
"alpha:start",
|
||||||
|
"beta:start",
|
||||||
|
]);
|
||||||
|
// Hono records one internal route entry per handler in a route's middleware chain, not one
|
||||||
|
// per logical route, so entries for the same method/path are deduplicated (preserving first-
|
||||||
|
// seen order) before asserting mounting order.
|
||||||
|
const moduleRoutePaths = [
|
||||||
|
...new Set(
|
||||||
|
running.app.routes
|
||||||
|
.filter((route) => route.path.startsWith("/api/v1/modules/"))
|
||||||
|
.map((route) => `${route.method} ${route.path}`),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(moduleRoutePaths).toEqual([
|
||||||
|
"GET /api/v1/modules/alpha/ping",
|
||||||
|
"GET /api/v1/modules/beta/ping",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await running.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
await createFixtureModule(modulesDirectory, "broken-export");
|
||||||
|
await writeFile(
|
||||||
|
path.join(modulesDirectory, "broken-export", "dist/server/index.js"),
|
||||||
|
"export default { thisIsNotAValidRuntimeDefinition: true };",
|
||||||
|
);
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
issues: [expect.objectContaining({ code: "MODULE_RUNTIME_EXPORT_INVALID" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the pool exactly once and binds no server when a module's register hook fails", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "register-fails", recorder, {
|
||||||
|
registerThrows: true,
|
||||||
|
});
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
issues: [expect.objectContaining({ code: "MODULE_REGISTER_FAILED" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recorder.events).toEqual(["register-fails:register"]);
|
||||||
|
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the pool exactly once and binds no server when a module's start hook fails, preserving Core's rollback of already-started modules", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
|
||||||
|
dependencies: { modules: { required: { one: "^0.1.0" } } },
|
||||||
|
startThrows: true,
|
||||||
|
});
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recorder.events).toEqual([
|
||||||
|
"one:register",
|
||||||
|
"two:register",
|
||||||
|
"one:start",
|
||||||
|
"two:start",
|
||||||
|
"one:stop",
|
||||||
|
]);
|
||||||
|
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the module runtime and closes the pool, binding no server, when app construction fails", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
vi.mocked(createApiApp).mockImplementationOnce(() => {
|
||||||
|
throw new Error("forced app construction failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||||
|
).rejects.toThrow("forced app construction failure");
|
||||||
|
|
||||||
|
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
|
||||||
|
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the module runtime and closes the pool when the server fails to bind", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
mockFailingServe(new Error("EADDRINUSE"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
|
||||||
|
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drains the HTTP server, then stops the module runtime, then closes the pool, in that exact order", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "ordered", recorder);
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return {
|
||||||
|
end: vi.fn(async () => {
|
||||||
|
recorder.events.push("pool:end");
|
||||||
|
}),
|
||||||
|
on: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
const { server } = mockSuccessfulServe();
|
||||||
|
const originalClose = server.close;
|
||||||
|
server.close = vi.fn((callback?: () => void) => {
|
||||||
|
recorder.events.push("server:close");
|
||||||
|
return originalClose(callback);
|
||||||
|
});
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||||
|
recorder.events.length = 0;
|
||||||
|
await running.shutdown();
|
||||||
|
|
||||||
|
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "stop-fails", recorder, {
|
||||||
|
stopThrows: true,
|
||||||
|
});
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||||
|
|
||||||
|
await expect(running.shutdown()).rejects.toMatchObject({
|
||||||
|
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
|
||||||
|
});
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a direct discoveredModulesOverride driving both permission composition and module runtime startup", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||||
|
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
mockPoolAndAuth();
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), { discoveredModulesOverride: discovered });
|
||||||
|
|
||||||
|
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
|
||||||
|
// Hono records one internal route entry per handler in a route's middleware chain, not one
|
||||||
|
// per logical route, so entries for the same method/path are deduplicated (preserving first-
|
||||||
|
// seen order) before asserting mounting order.
|
||||||
|
const moduleRoutePaths = [
|
||||||
|
...new Set(
|
||||||
|
running.app.routes
|
||||||
|
.filter((route) => route.path.startsWith("/api/v1/modules/"))
|
||||||
|
.map((route) => `${route.method} ${route.path}`),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(moduleRoutePaths).toEqual(["GET /api/v1/modules/calendar/ping"]);
|
||||||
|
|
||||||
|
await running.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs no filesystem I/O, module import, pool construction, Better Auth construction, or server binding merely by importing the module-route adapter", async () => {
|
||||||
|
const module = await import("../src/module-http-routes.js");
|
||||||
|
|
||||||
|
expect(typeof module.mountModuleHttpRoutes).toBe("function");
|
||||||
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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`, ...). |
|
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
|
||||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
|
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||||
|
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
|
||||||
|
|
||||||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||||
deterministic `404 {"code":"NOT_FOUND"}`.
|
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||||
@@ -206,17 +207,74 @@ trusted-proxy IP policy is configured, Better Auth may fall back to a single sha
|
|||||||
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
|
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
|
||||||
abuse-control design.
|
abuse-control design.
|
||||||
|
|
||||||
## Module permission discovery at startup
|
## Module discovery, permission composition, and module runtime startup
|
||||||
|
|
||||||
Before any database pool, Better Auth instance, or HTTP server exists, `startApi()` discovers
|
`startApi()` composes production in one fixed order:
|
||||||
every installed module's manifest and composes the one immutable platform permission registry
|
|
||||||
(Core's own declarations plus every module's) that the rest of startup — and every subsequent
|
1. Discover every installed module's manifest exactly once and compose the one immutable platform
|
||||||
request — enforces against. A discovery or composition failure (an invalid manifest, a module
|
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
|
||||||
claiming the reserved `core.*` permission namespace, a duplicate permission declaration, ...) fails
|
`apps/api/src/platform-permissions.ts`.
|
||||||
startup outright, before there is anything to close; startup never falls back to a Core-only
|
2. Construct the one shared PostgreSQL pool.
|
||||||
registry. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
|
3. Construct the Core identity runtime, authorization store, and evaluator.
|
||||||
for the full flow, and [Module development](module-development/README.md) for the manifest shape
|
4. Start the module runtime from that exact same discovered inventory
|
||||||
that produces these declarations.
|
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step.
|
||||||
|
5. Create the Hono application, mounting the started module runtime's published routes.
|
||||||
|
6. Bind the HTTP server.
|
||||||
|
|
||||||
|
Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||||
|
entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid
|
||||||
|
manifest, a module claiming the reserved `core.*` permission namespace, a duplicate permission
|
||||||
|
declaration, ...) fails startup outright, before there is anything to close; startup never falls
|
||||||
|
back to a Core-only registry. Core infrastructure exists before any executable module lifecycle
|
||||||
|
code runs, and every module route is published only after the *entire* module runtime has
|
||||||
|
registered and started successfully, so no request can ever reach a module before every module has
|
||||||
|
started. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
|
||||||
|
for the permission-composition detail, and [Module development](module-development/README.md) for
|
||||||
|
the manifest shape that produces these declarations.
|
||||||
|
|
||||||
|
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
|
||||||
|
successful production startup does import each module's compiled server entrypoint and run its
|
||||||
|
`register`/`start` hooks — this is the one place in the platform that deliberately executes module
|
||||||
|
code, and only after every other startup step already listed has succeeded.
|
||||||
|
|
||||||
|
## Module HTTP routes
|
||||||
|
|
||||||
|
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||||||
|
[Module development](module-development/README.md#http-routes) for the module-authoring contract)
|
||||||
|
are mounted under `/api/v1/modules/<module-id>`, in the module runtime's published order, by
|
||||||
|
`apps/api/src/module-http-routes.ts`. Mounting never invokes a handler.
|
||||||
|
|
||||||
|
- **Enforcement.** Every route runs through the identical `requirePermission` middleware Core's own
|
||||||
|
routes use, with the route's own declared permission id as the only permission ever consulted —
|
||||||
|
its trusted baseline always comes from the composed platform registry, never from the route or
|
||||||
|
the request. A `public`, `authenticated`, and `restricted` module permission are enforced through
|
||||||
|
the exact same path; a public-baseline route still resolves and receives an authorized principal.
|
||||||
|
- **Trusted origin.** `POST`, `PUT`, `PATCH`, and `DELETE` module routes additionally require an
|
||||||
|
exact trusted `Origin` (see [Origin, CORS, and cookies](#origin-cors-and-cookies)) before
|
||||||
|
authorization ever runs, exactly like the existing grants-mutation routes. `GET` does not require
|
||||||
|
one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight
|
||||||
|
never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling
|
||||||
|
(same permission check, response body stripped); a module cannot separately author `HEAD`.
|
||||||
|
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
|
||||||
|
body all behave normally, and the full query string remains available) plus a frozen context of
|
||||||
|
exactly `moduleId`, the authorized `EkklesiaPrincipal`, and frozen, Hono-decoded path parameters
|
||||||
|
(an empty frozen object for a route with none) — never a Hono `Context`, a database connection,
|
||||||
|
the authorization store, storage, jobs, configuration, or secrets.
|
||||||
|
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
|
||||||
|
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
|
||||||
|
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
|
||||||
|
handler deliberately returns — including a 4xx/5xx one — passes through unchanged.
|
||||||
|
- **CORS and caching.** The platform remains the sole owner of CORS for module routes exactly like
|
||||||
|
every other `/api/v1/*` route (see [Origin, CORS, and cookies](#origin-cors-and-cookies), now
|
||||||
|
additionally advertising `PUT`/`PATCH`/`DELETE`); any CORS-control header a handler sets on its
|
||||||
|
own response is stripped before the response leaves the process. Every module response is forced
|
||||||
|
to `Cache-Control: no-store` and a `Vary` that always includes `Origin` and `Cookie` (a handler's
|
||||||
|
own legitimate `Vary` values are preserved alongside those) — deliberately conservative until a
|
||||||
|
dedicated cache-policy contract exists, since even a public-baseline handler receives an
|
||||||
|
authorized principal and may personalize its response.
|
||||||
|
- **Static snapshot.** Routes are part of the same static startup snapshot as the permission
|
||||||
|
registry: installing, removing, or editing a module's routes takes effect only on the next API
|
||||||
|
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
|
||||||
|
|
||||||
## Database connection limits
|
## Database connection limits
|
||||||
|
|
||||||
@@ -239,17 +297,25 @@ not two.
|
|||||||
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
||||||
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one
|
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one
|
||||||
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
|
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
|
||||||
exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter
|
exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
|
||||||
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and
|
is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight
|
||||||
lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and
|
requests finish; then stops the module runtime (so no module resource stops while a route handler
|
||||||
authorization both read from it — see
|
might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization
|
||||||
[Authorization](authorization.md#shared-database-composition)). The executable entry point's
|
both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a
|
||||||
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the
|
startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a
|
||||||
same function production composition uses, but independently testable with a fake `running`/`exit`
|
genuine result a caller should observe — but pool cleanup always still runs first, so a stop
|
||||||
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real
|
failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is
|
||||||
`process.exit`. A startup failure after the database pool was already created (identity
|
extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
|
||||||
construction failure, or the server failing to bind its listen address) always closes that pool
|
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
|
||||||
before the failure propagates.
|
signal arrived" without sending a real process signal or invoking the real `process.exit`.
|
||||||
|
|
||||||
|
A startup failure after the database pool was already created always closes that pool before the
|
||||||
|
failure propagates: identity/authorization construction failing (no module runtime was started
|
||||||
|
yet); the server failing to bind its listen address; or an app-construction failure, both of which
|
||||||
|
also stop the module runtime first, since it may have already started successfully. A module
|
||||||
|
load/register/start failure uses Core's own existing rollback (already-started modules stop, in
|
||||||
|
reverse order, before the failure propagates) and then closes the pool — there is no separate
|
||||||
|
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
|
||||||
|
|
||||||
## A real signup → sign-in → principal → sign-out transcript
|
## A real signup → sign-in → principal → sign-out transcript
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,15 @@ for the durable architectural decisions this page describes in more implementati
|
|||||||
|
|
||||||
An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone
|
An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone
|
||||||
they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any
|
they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any
|
||||||
registered permission, Core-owned or module-owned; and every enforced route fails closed on
|
registered permission, Core-owned or module-owned; a module can register its own HTTP routes and
|
||||||
infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage
|
have them mounted, enforced, and reachable in production (see
|
||||||
roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or
|
[API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble
|
||||||
re-granting platform-owner status is not exposed at all); there are no service accounts or API
|
instead of silently allowing or denying. It is **not** possible to manage roles, groups, teams, or
|
||||||
tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot
|
tenants; there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status
|
||||||
yet mount its own HTTP routes; and relationship-derived grants (for example, automatic access from
|
is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated
|
||||||
a roster assignment) cannot yet be created by anything in this repository — the generic grants API
|
browser callers can reach mutation routes, Core-owned or module-owned; and relationship-derived
|
||||||
structurally cannot create or revoke them either.
|
grants (for example, automatic access from a roster assignment) cannot yet be created by anything
|
||||||
|
in this repository — the generic grants API structurally cannot create or revoke them either.
|
||||||
|
|
||||||
## Core authorization schema and store
|
## Core authorization schema and store
|
||||||
|
|
||||||
@@ -105,27 +106,37 @@ integration layer depends on authorization).
|
|||||||
|
|
||||||
## API startup: discovery, composition, and enforcement
|
## API startup: discovery, composition, and enforcement
|
||||||
|
|
||||||
`apps/api` builds its production permission registry from the complete installed-module inventory
|
`apps/api` builds its production permission registry, and starts the module runtime, from the
|
||||||
every time it starts, in a fixed order, before anything else:
|
exact same installed-module inventory every time it starts, in a fixed order, before anything
|
||||||
|
else:
|
||||||
|
|
||||||
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
|
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
|
||||||
platform/Module SDK versions from their own `package.json` files).
|
platform/Module SDK versions from their own `package.json` files).
|
||||||
2. Discover installed modules (`discoverModules`).
|
2. Discover installed modules exactly once (`discoverModules`).
|
||||||
3. Compose the platform permission registry (`composePlatformPermissionRegistry`).
|
3. Compose the platform permission registry from that exact discovered list
|
||||||
|
(`composePlatformPermissionRegistry`).
|
||||||
4. Construct the shared PostgreSQL pool.
|
4. Construct the shared PostgreSQL pool.
|
||||||
5. Construct Identity and Authorization.
|
5. Construct Identity, the authorization store, and the evaluator.
|
||||||
6. Create the Hono application, injecting the composed registry.
|
6. Start the module runtime from that same discovered list (`startDiscoveredModuleRuntime`) —
|
||||||
7. Bind the HTTP server.
|
modules are never rediscovered for this step.
|
||||||
|
7. Create the Hono application, injecting the composed registry and mounting the started module
|
||||||
|
runtime's published routes (see [API](api.md#module-http-routes)).
|
||||||
|
8. Bind the HTTP server.
|
||||||
|
|
||||||
`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3.
|
`apps/api/src/platform-permissions.ts`'s `prepareStartupModuleInventory` performs steps 1–3,
|
||||||
Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server
|
returning both the discovered module list and the composed registry so step 6 can start the module
|
||||||
exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the
|
runtime from that identical list rather than rediscovering modules a second time.
|
||||||
reserved `core.*` namespace, or a duplicate permission declaration — leaves nothing to close: it
|
(`composeStartupPermissionRegistry` remains available as a registry-only convenience wrapper
|
||||||
propagates directly out of `startApi()`, and startup never falls back to `CORE_PERMISSION_REGISTRY`
|
around it, for callers — Doctor, `modules:validate`, existing tests — that only need the
|
||||||
or any Core-only registry. The repository root is resolved from this compiled/source file's own
|
registry.) Discovery and composition happen strictly before any pool, Better Auth instance, module
|
||||||
location (two directories up from `apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at
|
entrypoint import, or HTTP server exists, so a failure there — an invalid manifest, a broken
|
||||||
identical depth), never `process.cwd()` — a package-scoped test run has `apps/api` as its working
|
dependency graph, a module claiming the reserved `core.*` namespace, or a duplicate permission
|
||||||
directory, which would otherwise point discovery at the wrong `modules/` directory.
|
declaration — leaves nothing to close: it propagates directly out of `startApi()`, and startup
|
||||||
|
never falls back to `CORE_PERMISSION_REGISTRY` or any Core-only registry. The repository root is
|
||||||
|
resolved from this compiled/source file's own location (two directories up from
|
||||||
|
`apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at identical depth), never `process.cwd()`
|
||||||
|
— a package-scoped test run has `apps/api` as its working directory, which would otherwise point
|
||||||
|
discovery at the wrong `modules/` directory.
|
||||||
|
|
||||||
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
|
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
|
||||||
installation, removal, or dynamic mutation of installed modules or their permissions while the
|
installation, removal, or dynamic mutation of installed modules or their permissions while the
|
||||||
@@ -148,10 +159,11 @@ consults the registry for a grant that already exists — revocation is keyed to
|
|||||||
and source kind, not to whether its permission is currently registered — so a manager can still
|
and source kind, not to whether its permission is currently registered — so a manager can still
|
||||||
revoke a historical grant for a now-unregistered permission.
|
revoke a historical grant for a now-unregistered permission.
|
||||||
|
|
||||||
Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived
|
A module's own mounted HTTP routes (`/api/v1/modules/<module-id>/*`) are enforced through this
|
||||||
grant creation both remain deferred, unrelated slices — this work only makes a module's *declared*
|
identical registry and `requirePermission` middleware — see [API](api.md#module-http-routes) for
|
||||||
permission usable through Core's existing, generic explicit-grant HTTP API and enforcement
|
the route-mounting contract. Relationship-derived grant creation remains a deferred, unrelated
|
||||||
middleware.
|
slice — this work only makes a module's *declared* permission usable through Core's existing,
|
||||||
|
generic explicit-grant HTTP API, enforcement middleware, and now its own mounted routes.
|
||||||
|
|
||||||
## Doctor and modules:validate
|
## Doctor and modules:validate
|
||||||
|
|
||||||
@@ -446,12 +458,34 @@ there are or whether the failure happened during startup.
|
|||||||
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
||||||
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
||||||
store-error mapping.
|
store-error mapping.
|
||||||
|
- `apps/api/tests/module-http-routes.test.ts` — the production route-mounting contract: exact
|
||||||
|
prefix/path joining (including the non-trailing-slash root case), every supported method,
|
||||||
|
independent same-path routes across two modules, deterministic static-versus-parameterized
|
||||||
|
resolution by published order, request/handler adaptation (raw `Request`, frozen context, decoded
|
||||||
|
and frozen path parameters, empty frozen params for static/root routes), the registry's exact
|
||||||
|
baseline for `public`/`authenticated`/`restricted` module permissions, denial/infrastructure-
|
||||||
|
failure paths never invoking the handler, `ModuleRouteConfigurationError` for an inconsistent
|
||||||
|
route/registry snapshot, trusted-origin enforcement for state-changing methods, CORS (advertised
|
||||||
|
methods, no reflection for an untrusted origin, no authorization or handler invocation on
|
||||||
|
preflight), fixed sanitized `500` for a throwing/rejecting/non-`Response`-returning handler with
|
||||||
|
no raw leakage, a valid module-owned 4xx/5xx response passing through unchanged, forced
|
||||||
|
`Cache-Control`/`Vary` with a handler's own `Vary` preserved, stripped module-supplied CORS
|
||||||
|
headers, `HEAD` traversing the same permission middleware as `GET` with no body, and unmatched
|
||||||
|
paths/zero-route/no-handler-during-construction behavior.
|
||||||
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above,
|
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above,
|
||||||
plus module-permission composition at startup: discovery/composition run before any pool,
|
plus module-permission composition at startup: discovery/composition run before any pool,
|
||||||
Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace
|
Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace
|
||||||
failure, and a generic duplicate-declaration failure each create zero pools/Better Auth
|
failure, and a generic duplicate-declaration failure each create zero pools/Better Auth
|
||||||
instances/servers and remain distinguishable from one another; zero installed modules still
|
instances/servers and remain distinguishable from one another; zero installed modules still
|
||||||
succeeds; and import safety for `platform-permissions.ts`.
|
succeeds; and import safety for `platform-permissions.ts`. A dedicated block covers module
|
||||||
|
*runtime* startup and shutdown against real, working (non-throwing) fixture modules: permission
|
||||||
|
composition and module runtime startup share the exact same discovered inventory, route mounting
|
||||||
|
order matches discovery order, a load/register/start failure each close the pool exactly once and
|
||||||
|
bind no server (preserving Core's own rollback for a start failure), an app-construction failure
|
||||||
|
and a bind failure both stop the module runtime before closing the pool, normal shutdown stops the
|
||||||
|
server/module-runtime/pool in that exact order, a module stop failure still runs pool cleanup
|
||||||
|
before the shutdown rejection surfaces, and a direct `discoveredModulesOverride` drives both
|
||||||
|
composition and runtime startup identically.
|
||||||
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
|
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
|
||||||
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
||||||
consulting the grant or platform-owner ports for `public`/`authenticated`.
|
consulting the grant or platform-owner ports for `public`/`authenticated`.
|
||||||
@@ -466,16 +500,23 @@ there are or whether the failure happened during startup.
|
|||||||
invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database
|
invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database
|
||||||
dropped out from under a live session) asserting a `503` rather than a false `403` or `200`.
|
dropped out from under a live session) asserting a `503` rather than a false `403` or `200`.
|
||||||
- `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live
|
- `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live
|
||||||
PostgreSQL) — a real temporary module fixture (structurally valid manifest, a server entrypoint
|
PostgreSQL) — a real temporary module fixture (structurally valid manifest; a real, working
|
||||||
that throws if ever imported) proves the complete chain against a real API process and real
|
server entrypoint recording register/start/stop markers instead of throwing, and declaring a
|
||||||
Doctor run: Doctor recognizes the installed module and passes permission composition without
|
restricted `GET /events/:eventId` route plus a restricted, state-changing `POST /events` route
|
||||||
executing its entrypoint; API startup discovers the fixture and makes its declared permission
|
against its own manifest permission) proves the complete chain against a real API process and
|
||||||
enforceable; a bootstrapped platform owner creates a real explicit grant for the module's
|
real Doctor run: Doctor recognizes the installed module and passes permission composition
|
||||||
permission with an atomic audit row recorded under its exact declared id; a well-formed
|
without executing its entrypoint (an empty marker set); a successful `startApi()` discovers the
|
||||||
permission absent from the composed registry is rejected with no grant or audit write; and,
|
same fixture, imports/registers/starts its runtime exactly once, and makes its declared
|
||||||
after a clean shutdown and restart with the module absent, the same permission can no longer be
|
permission enforceable; a bootstrapped platform owner creates a real explicit grant for the
|
||||||
newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity
|
module's permission with an atomic audit row recorded under its exact declared id; the module's
|
||||||
HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout.
|
own mounted route denies an anonymous caller and then serves the newly granted owner with the
|
||||||
|
decoded path parameter and authenticated principal; the module's state-changing route requires a
|
||||||
|
trusted `Origin` exactly like the platform's own mutation routes; a well-formed permission absent
|
||||||
|
from the composed registry is rejected with no grant or audit write; and, after a clean shutdown
|
||||||
|
(module stop invoked exactly once) and restart with the module absent, its former route returns
|
||||||
|
the ordinary `404`, the same permission can no longer be newly granted, and the earlier,
|
||||||
|
still-unrevoked grant remains listed — with ordinary identity HTTP behavior (sign-up, sign-in,
|
||||||
|
principal resolution) unaffected throughout.
|
||||||
|
|
||||||
Run the focused suites:
|
Run the focused suites:
|
||||||
|
|
||||||
@@ -487,10 +528,11 @@ pnpm test:migrations:integration # requires Docker
|
|||||||
|
|
||||||
## Next phase
|
## Next phase
|
||||||
|
|
||||||
Deliberately deferred, unrelated slices — see
|
Module HTTP route registration and production mounting (see
|
||||||
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
|
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
|
||||||
for the complete list: module HTTP route-registration contracts (a module mounting its own
|
for the deferral this closes) is now implemented — see [API](api.md#module-http-routes) and
|
||||||
endpoints), a module runtime-context authorization service, relationship-derived grant creation
|
[Module development](module-development/README.md#http-routes). Deliberately deferred, unrelated
|
||||||
|
slices remain: a module runtime-context authorization service, relationship-derived grant creation
|
||||||
itself (a roster or similar module operation, not the generic grants API), reinstall/reactivation
|
itself (a roster or similar module operation, not the generic grants API), reinstall/reactivation
|
||||||
policy for a permission whose owning module was previously removed, roles/groups/teams/tenants, an
|
policy for a permission whose owning module was previously removed, roles/groups/teams/tenants, an
|
||||||
owner-management HTTP endpoint, and service accounts/API tokens for non-browser automation.
|
owner-management HTTP endpoint, and service accounts/API tokens for non-browser automation.
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
Core 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
|
snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production
|
||||||
application, enforcing each route's permission through `requirePermission`, applying
|
at startup, under the `/api/v1/modules/<module-id>` prefix described above: every route is enforced
|
||||||
`requireTrustedOrigin`, mapping handler errors to sanitized responses, and wiring this into
|
through the identical `requirePermission` middleware Core's own routes use, a state-changing method
|
||||||
`startApi`'s production lifecycle are later work.
|
(`POST`/`PUT`/`PATCH`/`DELETE`) additionally requires a trusted `Origin` before authorization ever
|
||||||
|
runs, and a handler that throws, rejects, or returns something other than a `Response` becomes a
|
||||||
|
fixed, sanitized `500 {"code":"MODULE_HANDLER_FAILED"}` — never the caught value, a stack, the
|
||||||
|
route's path, its permission id, or the module id. The platform remains the sole owner of CORS and
|
||||||
|
caching for every module route: a handler-supplied CORS-control header is stripped, and every
|
||||||
|
module response is forced to `Cache-Control: no-store` with `Vary: Origin, Cookie` (in addition to
|
||||||
|
any of the handler's own legitimate `Vary` values) until a dedicated cache-policy contract exists —
|
||||||
|
deliberately conservative, since even a public-baseline handler still receives an authorized
|
||||||
|
principal and may personalize its response. See [API](../development/api.md#module-http-routes) for
|
||||||
|
the full production mounting, enforcement, and error-response contract.
|
||||||
|
|
||||||
## Drizzle schema definition
|
## Drizzle schema definition
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user