Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView) over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and wires it into startApi's fixed startup order ahead of Identity/Authorization construction. Registration stays database-free; a module reaches its database only through an authorized start, stop, or HTTP handler invocation.
171 lines
7.6 KiB
TypeScript
171 lines
7.6 KiB
TypeScript
import {
|
|
isAuthenticatedPrincipal,
|
|
type ModuleRuntimeServices,
|
|
type RegisteredModuleHttpRoute,
|
|
} from "@ekklesia/contracts";
|
|
import type {
|
|
AuthorizationEvaluator,
|
|
AuthorizationStore,
|
|
PermissionRegistry,
|
|
} from "@ekklesia/core/authorization";
|
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
|
import { Hono } from "hono";
|
|
import type { Context, MiddlewareHandler } from "hono";
|
|
import { cors } from "hono/cors";
|
|
import { createGrantsRoutes } from "./grants-routes.js";
|
|
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
|
|
|
export interface CreateApiAppAuthorizationOptions {
|
|
readonly evaluator: AuthorizationEvaluator;
|
|
/** The complete platform permission registry (Core's own declarations plus every installed
|
|
* module's) — always the caller-composed registry `startApi` produces via
|
|
* `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 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;
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
app.on(["POST", "GET"], "/api/auth/*", async (c) => {
|
|
try {
|
|
return await runtime.handleAuthRequest(c.req.raw);
|
|
} 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,
|
|
}),
|
|
);
|
|
|
|
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
|
evaluator: options.authorization.evaluator,
|
|
registry: options.authorization.registry,
|
|
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;
|
|
}
|