282 lines
13 KiB
TypeScript
282 lines
13 KiB
TypeScript
import {
|
|
EMPTY_MODULE_CONFIGURATION,
|
|
EMPTY_MODULE_STORAGE,
|
|
type ModuleId,
|
|
type ModuleRouteHandlerContext,
|
|
type ModuleRuntimeServices,
|
|
type RegisteredModuleHttpRoute,
|
|
} from "@ekklesia/contracts";
|
|
import type {
|
|
ModuleConfigurationLookup,
|
|
ModuleRealtimeRegistry,
|
|
ModuleStorageLookup,
|
|
} from "@ekklesia/core/modules";
|
|
import type { Context, Hono } from "hono";
|
|
import {
|
|
type AuthorizationMiddlewareOptions,
|
|
getAuthorizedPrincipal,
|
|
requirePermission,
|
|
requireTrustedOrigin,
|
|
} from "./authorization-middleware.js";
|
|
import { platformBodyLimit } from "./body-limits.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>;
|
|
/** The same frozen services object the started module runtime's `start`/`stop` hooks already
|
|
* received for this API runtime — never a fresh one built per request, and never touched for a
|
|
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
|
* after authorization succeeds. */
|
|
readonly services: ModuleRuntimeServices;
|
|
/**
|
|
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
|
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
|
* rather than two independently resolved values. A module with no entry receives
|
|
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
|
|
*
|
|
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
|
|
* ever reach its own module's configuration, because that is the only entry looked up for it.
|
|
*/
|
|
readonly configurations: ModuleConfigurationLookup;
|
|
/**
|
|
* Per-module scoped object storage — the exact map the module runtime started from (ADR 0030), so
|
|
* a handler and that module's lifecycle hooks share one facade rather than two independently
|
|
* resolved ones.
|
|
*
|
|
* Keyed by module id, like `configurations` and unlike `services`: a handler can only ever reach
|
|
* its own module's storage, because that is the only entry looked up for it.
|
|
*/
|
|
readonly storages: ModuleStorageLookup;
|
|
/** The same started registry whose scoped publishers lifecycle hooks receive. */
|
|
readonly realtime: ModuleRealtimeRegistry;
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The root a module's own HTTP API is mounted at — the one place that URL shape is constructed.
|
|
*
|
|
* Exported because a browser host has to resolve `context.api.request("records")`
|
|
* (`ModuleUiApiClient`, `@ekklesia/module-ui-sdk`) to a real Ekklesia URL, and a module UI must
|
|
* never build that prefix itself: it is Ekklesia's private deployment detail, and a module that
|
|
* hardcoded it could not be hosted anywhere else (ADR 0022). The server hands the resolved base to
|
|
* the host through the module UI inventory (`./module-ui-inventory.js`) rather than letting the
|
|
* browser re-spell this template, so this prefix can change without touching either.
|
|
*
|
|
* Percent-encoded for the same reason `moduleUiArtifactUrl` is: a value that somehow did not come
|
|
* from a validated manifest can only ever become one segment's worth of text, never a new segment.
|
|
* A real module id (lowercase kebab-case, `moduleIdSchema`) is unaffected by the encoding.
|
|
*/
|
|
export function moduleApiBaseUrl(moduleId: string): string {
|
|
return `${MODULE_ROUTE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
|
}
|
|
|
|
/** "/" 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 {
|
|
const base = moduleApiBaseUrl(moduleId);
|
|
return routePath === "/" ? base : `${base}${routePath}`;
|
|
}
|
|
|
|
function sanitizedModuleHandlerFailure(c: Context): Response {
|
|
c.header("Cache-Control", "no-store");
|
|
return c.json({ code: "MODULE_HANDLER_FAILED" }, 500);
|
|
}
|
|
|
|
/**
|
|
* The one shared `Vary` parsing/canonicalization primitive: splits and trims comma-separated
|
|
* tokens and de-duplicates them case-insensitively — `Vary` lists header *names*, which are
|
|
* themselves case-insensitive, so a module's own `origin` must collapse with a later `Origin`
|
|
* rather than appear as a separate, redundant entry — while preserving first-seen casing and
|
|
* relative order. Never returns early on `*`; callers decide what a `*` token means for their own
|
|
* policy (see `mergedVaryHeader` and `canonicalizeVaryHeader` below, the two callers that build on
|
|
* this instead of re-implementing parsing/dedup independently).
|
|
*/
|
|
function parseVaryTokens(existingVary: string | null): Map<string, string> {
|
|
const canonicalByLowercase = new Map<string, string>();
|
|
for (const token of (existingVary ?? "").split(",")) {
|
|
const trimmed = token.trim();
|
|
if (trimmed.length === 0) {
|
|
continue;
|
|
}
|
|
const key = trimmed.toLowerCase();
|
|
if (!canonicalByLowercase.has(key)) {
|
|
canonicalByLowercase.set(key, trimmed);
|
|
}
|
|
}
|
|
return canonicalByLowercase;
|
|
}
|
|
|
|
/** A literal `*` (RFC 9110 §12.5.5) must never be combined with any other value; since `*` already
|
|
* means "may vary on anything," a strictly broader signal than any explicit list, its presence
|
|
* collapses the result to `*` alone rather than producing an invalid combination. */
|
|
function serializeVaryTokens(tokens: ReadonlyMap<string, string>): string {
|
|
if (tokens.has("*")) {
|
|
return "*";
|
|
}
|
|
return [...tokens.values()].join(", ");
|
|
}
|
|
|
|
/**
|
|
* Used only for a module's own response: always emits the canonical `Origin`/`Cookie` casing
|
|
* regardless of how (or whether) a module already listed them, since every module response is
|
|
* forced to vary on both (see `sanitizeModuleResponse`) — this is a deliberate product policy for
|
|
* module responses specifically, not a generic property every response through the platform's CORS
|
|
* boundary should have forced onto it (an `OPTIONS` preflight, for one, has no `Cookie` concept at
|
|
* all).
|
|
*/
|
|
export function mergedVaryHeader(existingVary: string | null): string {
|
|
const tokens = parseVaryTokens(existingVary);
|
|
if (tokens.has("*")) {
|
|
return "*";
|
|
}
|
|
tokens.set("origin", "Origin");
|
|
tokens.set("cookie", "Cookie");
|
|
return serializeVaryTokens(tokens);
|
|
}
|
|
|
|
/**
|
|
* Used at the app-level CORS boundary (`apps/api/src/app.ts`), which wraps every response —
|
|
* `OPTIONS` preflights and Core's own routes included, not only module ones — after Hono's own
|
|
* `cors()` has already appended its own `Vary: Origin`. Unlike `mergedVaryHeader`, this never
|
|
* injects a token that was not already present; it only de-duplicates and collapses `*`, since a
|
|
* generic boundary must not assume every response wants `Origin`/`Cookie` forced onto it.
|
|
*/
|
|
export function canonicalizeVaryHeader(existingVary: string): string {
|
|
return serializeVaryTokens(parseVaryTokens(existingVary));
|
|
}
|
|
|
|
/**
|
|
* The platform owns CORS and caching for every module route: strips any CORS-control header a
|
|
* handler set (defense in depth — the outer `cors()` middleware already computes the authoritative
|
|
* values for the actual response) and forces `Cache-Control: no-store` plus a `Vary` that always
|
|
* includes `Origin` and `Cookie`, since even a public-baseline handler receives an authorized
|
|
* principal and may personalize its response. A separate cache-policy contract is future work;
|
|
* this is the conservative default until then. A module's own legitimate `Vary` values (if any)
|
|
* are preserved alongside the forced ones. Never reads or buffers `response.body` — it is passed
|
|
* through to the new `Response` unread, so a streaming body is neither consumed nor blocked here.
|
|
*/
|
|
function sanitizeModuleResponse(response: Response): Response {
|
|
const headers = new Headers(response.headers);
|
|
for (const headerName of STRIPPED_CORS_RESPONSE_HEADERS) {
|
|
headers.delete(headerName);
|
|
}
|
|
headers.set("Cache-Control", "no-store");
|
|
headers.set("Vary", mergedVaryHeader(headers.get("Vary")));
|
|
return new Response(response.body, {
|
|
headers,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mounts `StartedModuleRuntime`'s published, immutable route snapshot into `app`, in publication
|
|
* order — never sorted or mutated, and never invoking a handler while mounting. Every route is
|
|
* protected by the exact same `requirePermission` middleware Core's own routes use, with
|
|
* `route.permission` as the only permission id ever consulted and its baseline coming exclusively
|
|
* from `options.registry`; a state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`) additionally
|
|
* requires `requireTrustedOrigin` before authorization ever runs. `GET` inherits Hono's own
|
|
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
|
|
* response body afterward — there is no separate `HEAD` registration or permission path.
|
|
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
|
|
* frozen context carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`,
|
|
* `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or
|
|
* deployment-wide configuration. `services.database` is the module's only reachable database
|
|
* surface, and the whole context is unreachable to anything short of a successfully authorized
|
|
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
|
|
* this handler at all).
|
|
*
|
|
* 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);
|
|
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
|
// all) is enforced before permission evaluation ever runs, so an oversized request never
|
|
// reaches authorization work, let alone the handler or the module database.
|
|
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
|
|
const path = moduleRoutePath(route.moduleId, route.path);
|
|
const finalHandler = async (c: Context): Promise<Response> => {
|
|
const context: ModuleRouteHandlerContext = Object.freeze({
|
|
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
|
moduleId: route.moduleId,
|
|
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
|
|
params: Object.freeze({ ...c.req.param() }),
|
|
principal: getAuthorizedPrincipal(c),
|
|
realtime: options.realtime.publisherFor(route.moduleId),
|
|
services: options.services,
|
|
});
|
|
|
|
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,
|
|
routeBodyLimit,
|
|
requireRoutePermission,
|
|
finalHandler,
|
|
);
|
|
} else {
|
|
app.on(route.method, path, routeBodyLimit, requireRoutePermission, finalHandler);
|
|
}
|
|
}
|
|
}
|