451 lines
21 KiB
TypeScript
451 lines
21 KiB
TypeScript
import {
|
|
isAuthenticatedPrincipal,
|
|
type ModuleRuntimeServices,
|
|
type ModuleSecretSource,
|
|
type RegisteredModuleHttpRoute,
|
|
} from "@ekklesia/contracts";
|
|
import type {
|
|
AuthorizationEvaluator,
|
|
AuthorizationStore,
|
|
PermissionRegistry,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
|
import type {
|
|
DiscoveredModule,
|
|
ModuleConfigurationLookup,
|
|
ModuleConfigurationSettings,
|
|
ModuleConfigurationStore,
|
|
ModuleLifecycleSnapshot,
|
|
ModuleLifecycleStore,
|
|
ModuleRealtimeRegistry,
|
|
ModuleStorageLookup,
|
|
ModuleUiPublicationRegistry,
|
|
} from "@ekklesia/core/modules";
|
|
import type { Context, MiddlewareHandler } from "hono";
|
|
import { Hono } from "hono";
|
|
import { cors } from "hono/cors";
|
|
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
|
import { createGrantsRoutes } from "./grants-routes.js";
|
|
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
|
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
|
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
|
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
|
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
|
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
|
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
|
|
|
export interface CreateApiAppAuthorizationOptions {
|
|
readonly evaluator: AuthorizationEvaluator;
|
|
/** The complete platform permission registry (Core's own declarations plus every installed
|
|
* module's) — always the caller-composed registry `startApi` produces via
|
|
* `prepareStartupModuleInventory`, never a Core-only fallback constructed here. */
|
|
readonly registry: PermissionRegistry;
|
|
readonly store: AuthorizationStore;
|
|
}
|
|
|
|
export interface CreateApiAppOptions {
|
|
readonly authorization: CreateApiAppAuthorizationOptions;
|
|
/** The started module runtime's published, immutable route snapshot — always the caller-started
|
|
* runtime's exact `routes` (`startDiscoveredModuleRuntime`, `apps/api/src/server.ts`), mounted in
|
|
* publication order under `/api/v1/modules/<module-id>`. Required rather than defaulted so a
|
|
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
|
* implicit fallback. */
|
|
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
|
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
|
|
* empty, for the same reason as module routes: application composition never guesses what ran. */
|
|
readonly moduleRealtime: ModuleRealtimeRegistry;
|
|
/** The same frozen services object the caller's started module runtime already passed to every
|
|
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
|
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
|
* `ModuleDatabase` for the lifetime of this API runtime. */
|
|
readonly moduleServices: ModuleRuntimeServices;
|
|
/** The platform's module UI publication policy — always the one Core builds from the exact
|
|
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
|
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
|
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
|
|
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
|
/** Surface-owned display host assets, read once by production startup. Optional only when the
|
|
* eligible publication registry contains no display entrypoint; route composition rejects an
|
|
* inconsistent display-bearing registry synchronously. */
|
|
readonly displayHostAssets?: DisplayHostAssets;
|
|
/**
|
|
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
|
|
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
|
|
* Required rather than defaulted, like every other module option here — a caller exercising no
|
|
* modules says so with an empty snapshot, never falls through to an implicit one.
|
|
*
|
|
* The snapshot must come from the same boot as `moduleRoutes` and `moduleUi`; that is what makes
|
|
* "running" an observation of this process rather than a prediction. See
|
|
* `./module-lifecycle-routes.js`.
|
|
*/
|
|
readonly moduleLifecycle: {
|
|
readonly snapshot: ModuleLifecycleSnapshot;
|
|
readonly store: ModuleLifecycleStore;
|
|
};
|
|
/**
|
|
* The platform's module configuration resource (ADR 0029): the discovered inventory whose
|
|
* manifests declare what may be configured, the settings this process resolved at boot (used only
|
|
* to compute `restartRequired`), the deployment's secret source, and the store.
|
|
*
|
|
* Required rather than defaulted, like every other module option here. `secrets` is explicit for
|
|
* the same reason: a caller that supplies no module secrets says so, and a module requiring one is
|
|
* then reported `secret_missing` rather than silently starting without it.
|
|
*/
|
|
readonly moduleConfiguration: {
|
|
readonly discoveredModules: readonly DiscoveredModule[];
|
|
readonly bootSettings: ModuleConfigurationSettings;
|
|
readonly secrets: ModuleSecretSource;
|
|
readonly store: ModuleConfigurationStore;
|
|
/** The per-module frozen snapshots the started runtime is actually using — the identical map
|
|
* `startDiscoveredModuleRuntime` received, so a module's HTTP handler and its lifecycle hooks
|
|
* read the same object rather than two independently resolved values. */
|
|
readonly snapshots: ModuleConfigurationLookup;
|
|
};
|
|
/**
|
|
* The platform's module object storage (ADR 0030): the per-module scoped facades the runtime
|
|
* started with, and whether a host provider is configured at all.
|
|
*
|
|
* Required rather than defaulted, like every other module option here — a caller exercising no
|
|
* storage says so with an empty map and `providerConfigured: false`, never falls through to an
|
|
* implicit one. Nothing here carries an endpoint, a region, or a credential: those never leave
|
|
* Core's provider.
|
|
*/
|
|
readonly moduleStorage: {
|
|
readonly snapshots: ModuleStorageLookup;
|
|
readonly providerConfigured: boolean;
|
|
};
|
|
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
|
|
* rather than defaulted, like every other module option here: a caller that exercises no
|
|
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
|
|
* presentation only — nothing reachable through this store influences discovery, dependency
|
|
* ordering, module startup, or any authorization decision. */
|
|
readonly dashboard: DashboardStore;
|
|
readonly runtime: EkklesiaIdentityRuntime;
|
|
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
|
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
|
* `requireTrustedOrigin` checks for grant- and module-route-mutation routes — there is no second,
|
|
* competing origin configuration. */
|
|
readonly trustedOrigins: readonly string[];
|
|
}
|
|
|
|
function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
|
|
const code = error instanceof IdentityRuntimeError ? error.code : "INTERNAL_ERROR";
|
|
c.header("Cache-Control", "no-store");
|
|
return c.json({ code }, status);
|
|
}
|
|
|
|
const EMAIL_SIGN_IN_PATH = "/api/auth/sign-in/email";
|
|
const EMAIL_SIGN_IN_SUCCESS_BODY = JSON.stringify({ ok: true });
|
|
|
|
/**
|
|
* Better Auth publishes general-purpose session-management endpoints even when an application has
|
|
* no client for them. These four can return the raw bearer-equivalent session token with the
|
|
* pinned configuration: get/list/update return a session record; change-password returns a newly
|
|
* minted token when asked to revoke other sessions. Ekklesia deliberately exposes only its narrow
|
|
* `/api/v1/principal` projection and has no native session/password-management browser UI, so the
|
|
* supported method/path pairs are closed at the outer HTTP boundary before Better Auth executes.
|
|
*/
|
|
const TOKEN_BEARING_AUTH_ENDPOINT_METHODS: Readonly<Record<string, readonly string[]>> =
|
|
Object.freeze({
|
|
"/api/auth/change-password": Object.freeze(["POST"]),
|
|
"/api/auth/get-session": Object.freeze(["GET", "POST"]),
|
|
"/api/auth/list-sessions": Object.freeze(["GET"]),
|
|
"/api/auth/update-session": Object.freeze(["POST"]),
|
|
});
|
|
|
|
function decodedRequestPathname(request: Request): string | undefined {
|
|
const encodedPathname = new URL(request.url).pathname;
|
|
try {
|
|
// URL.pathname intentionally preserves percent escapes. Decode exactly once before comparing,
|
|
// matching the maximum decoding a downstream HTTP router may safely perform; malformed
|
|
// encodings are denied rather than delegated through an ambiguous parser boundary.
|
|
return decodeURIComponent(encodedPathname);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function isDisabledTokenBearingAuthRequest(request: Request): boolean {
|
|
const pathname = decodedRequestPathname(request);
|
|
if (pathname === undefined) {
|
|
return true;
|
|
}
|
|
// Better Auth is configured not to skip trailing slashes, but treating trailing-slash spellings
|
|
// as the same denied resource prevents a router-option change from opening a bypass. This does
|
|
// not collapse interior duplicate slashes or otherwise broaden path normalization.
|
|
const canonicalPath = pathname.replace(/\/+$/u, "") || "/";
|
|
return TOKEN_BEARING_AUTH_ENDPOINT_METHODS[canonicalPath]?.includes(request.method) ?? false;
|
|
}
|
|
|
|
/**
|
|
* Better Auth's native successful email sign-in response includes the newly created session token
|
|
* and the complete user object in its JSON body even though the browser already received the
|
|
* session through an HttpOnly cookie. The browser boundary must never make either value visible to
|
|
* application JavaScript. Keep this deliberately narrower than a general Better Auth response
|
|
* adapter: only the exact supported POST endpoint and a body-bearing successful response are
|
|
* normalized. Every sign-in failure and every other delegated auth endpoint remains Better Auth's
|
|
* native contract; the explicitly denied token-bearing endpoints above are never delegated.
|
|
*/
|
|
function browserSafeAuthResponse(request: Request, response: Response): Response {
|
|
const headers = new Headers(response.headers);
|
|
headers.set("Cache-Control", "no-store");
|
|
|
|
if (
|
|
request.method === "POST" &&
|
|
decodedRequestPathname(request) === EMAIL_SIGN_IN_PATH &&
|
|
response.ok &&
|
|
response.status !== 204 &&
|
|
response.status !== 205
|
|
) {
|
|
// The upstream length describes the deliberately discarded token/user JSON. Retaining it could
|
|
// truncate or stall the replacement payload once an HTTP adapter serializes this response.
|
|
headers.delete("Content-Length");
|
|
headers.set("Content-Type", "application/json; charset=UTF-8");
|
|
return new Response(EMAIL_SIGN_IN_SUCCESS_BODY, {
|
|
headers,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
});
|
|
}
|
|
|
|
return new Response(response.body, {
|
|
headers,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a
|
|
* dynamic-function `origin` — including one that already has its own `Vary` (module responses
|
|
* force `Origin`/`Cookie`; `/api/v1/principal` sets `Origin, Cookie` directly; an `OPTIONS`
|
|
* preflight sets its own) — so the header `cors()` itself leaves behind can still be an invalid
|
|
* combined "*, Origin" or contain a case-different duplicate. This wraps `middleware` and
|
|
* canonicalizes whatever `Vary` remains only *after* it has fully finished, using
|
|
* `canonicalizeVaryHeader` — the same shared parsing/dedup primitive `mergedVaryHeader` (module
|
|
* responses) builds on, so there is one implementation to keep in sync, not two that could diverge
|
|
* — but never `mergedVaryHeader` itself: this boundary wraps *every* response, `OPTIONS` preflights
|
|
* included, and must never inject `Origin`/`Cookie` onto a response that never had them, only
|
|
* de-duplicate what is already there. Two return shapes have to be handled: a normal request lets
|
|
* `middleware` call `next()` and mutate `c.res` in place, so the fix-up goes through `c.header()`
|
|
* (reassigning `c.res` directly triggers Hono's own header-preserving merge and would silently
|
|
* restore the uncanonicalized value); an `OPTIONS` preflight makes `cors()` return a new `Response`
|
|
* directly without ever touching `c.res`, so the fix-up mutates that returned response's headers
|
|
* instead. Neither path reads or clones the response body, so a streaming body is untouched either
|
|
* way. Status, body, content type, ordinary headers, authorization, trusted-origin behavior, and
|
|
* route ordering are all unaffected — only the final `Vary` value changes.
|
|
*/
|
|
function withCanonicalVary(middleware: MiddlewareHandler): MiddlewareHandler {
|
|
return async (c, next) => {
|
|
const result = await middleware(c, next);
|
|
if (result instanceof Response) {
|
|
const existingVary = result.headers.get("Vary");
|
|
if (existingVary !== null) {
|
|
result.headers.set("Vary", canonicalizeVaryHeader(existingVary));
|
|
}
|
|
return result;
|
|
}
|
|
const existingVary = c.res.headers.get("Vary");
|
|
if (existingVary !== null) {
|
|
c.header("Vary", canonicalizeVaryHeader(existingVary));
|
|
}
|
|
return result;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pure Hono application factory: no I/O, no environment reads, no process side effects, and it
|
|
* never invokes a module route handler while composing routes. Suitable for unit and integration
|
|
* tests via `app.fetch` directly, with a fully injected identity runtime and authorization
|
|
* evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. Synchronously
|
|
* throws `ModuleRouteConfigurationError` (`./module-http-routes.js`) if `moduleRoutes` and
|
|
* `authorization.registry` are inconsistent — a route naming a permission the registry does not
|
|
* register, which should never happen since both derive from the same discovered inventory, but is
|
|
* checked explicitly rather than silently mounting an unenforceable route.
|
|
*/
|
|
export function createApiApp(options: CreateApiAppOptions): Hono {
|
|
const { runtime } = options;
|
|
const allowedOrigins = new Set(options.trustedOrigins);
|
|
|
|
const app = new Hono();
|
|
|
|
const corsMiddleware = withCanonicalVary(
|
|
cors({
|
|
allowHeaders: ["Content-Type"],
|
|
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
credentials: true,
|
|
maxAge: 600,
|
|
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
|
|
}),
|
|
);
|
|
app.use("/api/auth/*", corsMiddleware);
|
|
app.use("/api/v1/*", corsMiddleware);
|
|
|
|
// This gate intentionally precedes the body limiter and the generic Better Auth delegate. A
|
|
// denied POST neither reads an attacker-controlled body nor executes any identity-runtime code;
|
|
// it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the
|
|
// pathname comparison, and the CORS middleware above still applies the exact trusted-origin
|
|
// policy to the response.
|
|
app.on(["POST", "GET"], "/api/auth/*", async (c, next) => {
|
|
if (!isDisabledTokenBearingAuthRequest(c.req.raw)) {
|
|
return next();
|
|
}
|
|
c.header("Cache-Control", "no-store");
|
|
return c.json({ code: "NOT_FOUND" }, 404);
|
|
});
|
|
|
|
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
|
|
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
|
|
try {
|
|
const response = await runtime.handleAuthRequest(c.req.raw);
|
|
// Every Better Auth response — including a session cookie — must never be cached by an
|
|
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
|
|
// only affects `c.res`) guarantees this regardless of how the handler produced its result,
|
|
// and never reads or buffers the body.
|
|
return browserSafeAuthResponse(c.req.raw, response);
|
|
} catch (error) {
|
|
return sanitizedErrorResponse(c, error, 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/v1/principal", async (c) => {
|
|
c.header("Cache-Control", "no-store");
|
|
c.header("Vary", "Origin, Cookie");
|
|
try {
|
|
const principal = await runtime.resolvePrincipal(c.req.raw.headers);
|
|
return c.json(
|
|
isAuthenticatedPrincipal(principal)
|
|
? { kind: "authenticated" as const, userId: principal.userId }
|
|
: { kind: "anonymous" as const },
|
|
200,
|
|
);
|
|
} catch (error) {
|
|
return sanitizedErrorResponse(c, error, 503);
|
|
}
|
|
});
|
|
|
|
app.route(
|
|
"/",
|
|
createGrantsRoutes({
|
|
evaluator: options.authorization.evaluator,
|
|
registry: options.authorization.registry,
|
|
runtime,
|
|
store: options.authorization.store,
|
|
trustedOrigins: allowedOrigins,
|
|
}),
|
|
);
|
|
|
|
// A platform-owned route, mounted exactly like Core's own: it serves only artifacts the injected
|
|
// publication registry already approved, and it can never collide with a module's own routes,
|
|
// which all live under `/api/v1/modules/`. See `./module-ui-routes.js` for why it is a sibling of
|
|
// that prefix rather than nested inside it.
|
|
app.route("/", createModuleUiRoutes({ registry: options.moduleUi }));
|
|
|
|
// The browser host's bootstrap query, at the collection root of that same prefix. Unlike the
|
|
// artifact route above it is authenticated (`core.modules.ui.read`) and per-principal, because it
|
|
// is the module enumeration ADR 0025 deliberately withheld from the anonymous artifact route.
|
|
app.route(
|
|
"/",
|
|
createModuleUiInventoryRoutes({
|
|
evaluator: options.authorization.evaluator,
|
|
moduleUi: options.moduleUi,
|
|
registry: options.authorization.registry,
|
|
runtime,
|
|
}),
|
|
);
|
|
|
|
// A separate, chrome-free browser host. Exact page and descriptor routes are derived from the
|
|
// same eligible/running publication registry as artifact bytes; a display-only module never
|
|
// enters Dashboard navigation, and no request value can select a file or permission.
|
|
mountModuleDisplayRoutes(app, {
|
|
evaluator: options.authorization.evaluator,
|
|
moduleUi: options.moduleUi,
|
|
registry: options.authorization.registry,
|
|
runtime,
|
|
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
|
});
|
|
|
|
// Layered deliberately on top of the generic inventory above rather than merged into it: the
|
|
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
|
|
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
|
|
app.route(
|
|
"/",
|
|
createDashboardRoutes({
|
|
evaluator: options.authorization.evaluator,
|
|
moduleUi: options.moduleUi,
|
|
registry: options.authorization.registry,
|
|
runtime,
|
|
store: options.dashboard,
|
|
trustedOrigins: allowedOrigins,
|
|
}),
|
|
);
|
|
|
|
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
|
|
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
|
// rather than inside it. See `./module-lifecycle-routes.js`.
|
|
app.route(
|
|
"/",
|
|
createModuleLifecycleRoutes({
|
|
evaluator: options.authorization.evaluator,
|
|
registry: options.authorization.registry,
|
|
runtime,
|
|
configuration: {
|
|
secrets: options.moduleConfiguration.secrets,
|
|
store: options.moduleConfiguration.store,
|
|
},
|
|
storage: { providerConfigured: options.moduleStorage.providerConfigured },
|
|
snapshot: options.moduleLifecycle.snapshot,
|
|
store: options.moduleLifecycle.store,
|
|
trustedOrigins: allowedOrigins,
|
|
}),
|
|
);
|
|
|
|
// Same sibling-prefix rule as the lifecycle resource above: `/api/v1/modules/` belongs entirely
|
|
// to installed modules. See `./module-configuration-routes.js`.
|
|
app.route(
|
|
"/",
|
|
createModuleConfigurationRoutes({
|
|
bootSettings: options.moduleConfiguration.bootSettings,
|
|
discoveredModules: options.moduleConfiguration.discoveredModules,
|
|
evaluator: options.authorization.evaluator,
|
|
registry: options.authorization.registry,
|
|
runtime,
|
|
secrets: options.moduleConfiguration.secrets,
|
|
store: options.moduleConfiguration.store,
|
|
trustedOrigins: allowedOrigins,
|
|
}),
|
|
);
|
|
|
|
// A sibling platform-owned prefix, never nested under `/api/v1/modules/` where installed modules
|
|
// own every path. One exact route per runtime-published channel, authorized before subscription.
|
|
mountModuleRealtimeRoutes(app, {
|
|
evaluator: options.authorization.evaluator,
|
|
realtime: options.moduleRealtime,
|
|
registry: options.authorization.registry,
|
|
runtime,
|
|
});
|
|
|
|
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
|
configurations: options.moduleConfiguration.snapshots,
|
|
storages: options.moduleStorage.snapshots,
|
|
evaluator: options.authorization.evaluator,
|
|
registry: options.authorization.registry,
|
|
realtime: options.moduleRealtime,
|
|
runtime,
|
|
services: options.moduleServices,
|
|
trustedOrigins: allowedOrigins,
|
|
});
|
|
|
|
app.notFound((c) => {
|
|
c.header("Cache-Control", "no-store");
|
|
return c.json({ code: "NOT_FOUND" }, 404);
|
|
});
|
|
app.onError((_error, c) => sanitizedErrorResponse(c, _error, 500));
|
|
|
|
return app;
|
|
}
|