feat: expose core identity transport
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
export interface CreateApiAppOptions {
|
||||
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`. */
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure Hono application factory: no I/O, no environment reads, no process side effects. Suitable
|
||||
* for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime
|
||||
* standing in for real Better Auth/PostgreSQL where appropriate.
|
||||
*/
|
||||
export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
const { runtime } = options;
|
||||
const allowedOrigins = new Set(options.trustedOrigins);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const corsMiddleware = cors({
|
||||
allowHeaders: ["Content-Type"],
|
||||
allowMethods: ["GET", "POST", "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.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;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
type CreateIdentityConfigOptions,
|
||||
createIdentityConfig,
|
||||
type IdentityConfig,
|
||||
type IdentityConfigInput,
|
||||
} from "@ekklesia/core/identity";
|
||||
|
||||
export type ApiConfigErrorCode =
|
||||
| "DATABASE_URL_INVALID"
|
||||
| "DATABASE_URL_MISSING"
|
||||
| "HOST_INVALID"
|
||||
| "PORT_INVALID";
|
||||
|
||||
/** Never carries the offending database URL, host, port, or any other caller-supplied value. */
|
||||
export class ApiConfigError extends Error {
|
||||
readonly code: ApiConfigErrorCode;
|
||||
|
||||
constructor(code: ApiConfigErrorCode) {
|
||||
super(apiConfigErrorMessages[code]);
|
||||
this.name = "ApiConfigError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
const apiConfigErrorMessages: Readonly<Record<ApiConfigErrorCode, string>> = Object.freeze({
|
||||
DATABASE_URL_INVALID:
|
||||
"The API database URL must be an absolute postgres:// or postgresql:// URL.",
|
||||
DATABASE_URL_MISSING: "The API database URL is required.",
|
||||
HOST_INVALID: "The API listen address must be a non-empty host.",
|
||||
PORT_INVALID:
|
||||
"The API listen port must be an integer between 0 and 65535 (0 requests an OS-assigned port).",
|
||||
});
|
||||
|
||||
const defaultHost = "127.0.0.1";
|
||||
const defaultPort = 3000;
|
||||
|
||||
export interface ApiRuntimeConfig {
|
||||
readonly databaseUrl: string;
|
||||
readonly host: string;
|
||||
readonly identity: IdentityConfig;
|
||||
readonly port: number;
|
||||
}
|
||||
|
||||
export interface ApiRuntimeConfigInput {
|
||||
readonly databaseUrl?: unknown;
|
||||
readonly host?: unknown;
|
||||
readonly identity: IdentityConfigInput;
|
||||
readonly port?: unknown;
|
||||
}
|
||||
|
||||
export interface CreateApiRuntimeConfigOptions {
|
||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||
}
|
||||
|
||||
function validateDatabaseUrl(databaseUrl: unknown): string {
|
||||
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||
throw new ApiConfigError("DATABASE_URL_MISSING");
|
||||
}
|
||||
let protocol: string;
|
||||
try {
|
||||
protocol = new URL(databaseUrl).protocol;
|
||||
} catch {
|
||||
throw new ApiConfigError("DATABASE_URL_INVALID");
|
||||
}
|
||||
if (protocol !== "postgres:" && protocol !== "postgresql:") {
|
||||
throw new ApiConfigError("DATABASE_URL_INVALID");
|
||||
}
|
||||
return databaseUrl;
|
||||
}
|
||||
|
||||
function validateHost(host: unknown): string {
|
||||
if (host === undefined) {
|
||||
return defaultHost;
|
||||
}
|
||||
if (typeof host !== "string" || host.trim().length === 0) {
|
||||
throw new ApiConfigError("HOST_INVALID");
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function validatePort(port: unknown): number {
|
||||
if (port === undefined) {
|
||||
return defaultPort;
|
||||
}
|
||||
const numericPort = typeof port === "string" ? Number(port) : port;
|
||||
if (
|
||||
typeof numericPort !== "number" ||
|
||||
!Number.isInteger(numericPort) ||
|
||||
numericPort < 0 ||
|
||||
numericPort > 65_535
|
||||
) {
|
||||
throw new ApiConfigError("PORT_INVALID");
|
||||
}
|
||||
return numericPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates explicit, typed API runtime configuration, delegating identity fields to Core's
|
||||
* existing `createIdentityConfig` rather than defining a competing identity configuration
|
||||
* contract. Performs no environment reads, network calls, or database access.
|
||||
*/
|
||||
export function createApiRuntimeConfig(
|
||||
input: ApiRuntimeConfigInput,
|
||||
options: CreateApiRuntimeConfigOptions = {},
|
||||
): ApiRuntimeConfig {
|
||||
const databaseUrl = validateDatabaseUrl(input.databaseUrl);
|
||||
const host = validateHost(input.host);
|
||||
const port = validatePort(input.port);
|
||||
const identity = createIdentityConfig(
|
||||
input.identity,
|
||||
options.environment === undefined ? {} : { environment: options.environment },
|
||||
);
|
||||
|
||||
return Object.freeze({ databaseUrl, host, identity, port });
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createIdentityDrizzleDatabase,
|
||||
createIdentityRuntime,
|
||||
type IdentityDatabaseHandle,
|
||||
type IdentityRateLimitOverride,
|
||||
} from "@ekklesia/core/identity";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import type { Hono } from "hono";
|
||||
import { createApiApp } from "./app.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
|
||||
const connectionTimeoutMs = 10_000;
|
||||
|
||||
export interface RunningApi {
|
||||
readonly app: Hono;
|
||||
readonly server: ServerType;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartApiOptions {
|
||||
/** Test-only escape hatch: never set by the executable entry point. See
|
||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||
}
|
||||
|
||||
async function safeCloseDatabase(handle: IdentityDatabaseHandle): Promise<void> {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch {
|
||||
// Cleanup failures are swallowed here deliberately: they must never mask a startup failure
|
||||
// that is already propagating, and there is no raw driver detail safe to surface.
|
||||
}
|
||||
}
|
||||
|
||||
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const server = serve({ fetch: app.fetch, hostname: host, port }, () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(server);
|
||||
}
|
||||
});
|
||||
server.once("error", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error("The API server failed to start listening."));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit runtime composition: constructs the PostgreSQL pool and Drizzle database, the Core
|
||||
* identity runtime, and the Hono application, then starts the Node HTTP server. Returns a handle
|
||||
* with an idempotent `shutdown()` so both the executable entry point and tests can stop the
|
||||
* server and close the database pool deterministically. Never applies migrations or generates
|
||||
* schema — the database must already be migrated through `pnpm migrations:apply`.
|
||||
*/
|
||||
export async function startApi(
|
||||
config: ApiRuntimeConfig,
|
||||
options: StartApiOptions = {},
|
||||
): Promise<RunningApi> {
|
||||
const databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, {
|
||||
connectionTimeoutMs,
|
||||
});
|
||||
|
||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||
try {
|
||||
runtime = createIdentityRuntime({
|
||||
config: config.identity,
|
||||
db: databaseHandle.db,
|
||||
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const app = createApiApp({ runtime, trustedOrigins: config.identity.trustedOrigins });
|
||||
|
||||
let server: ServerType;
|
||||
try {
|
||||
server = await bindServer(app, config.host, config.port);
|
||||
} catch (error) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
};
|
||||
|
||||
return Object.freeze({ app, server, shutdown });
|
||||
}
|
||||
|
||||
function readConfigFromEnvironment(): {
|
||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||
readonly environment: "development" | "production";
|
||||
} {
|
||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
||||
const trustedOrigins = trustedOriginsValue
|
||||
?.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
|
||||
return {
|
||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||
input: {
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
host: process.env.EKKLESIA_API_HOST,
|
||||
identity: {
|
||||
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
|
||||
secret: process.env.EKKLESIA_IDENTITY_SECRET,
|
||||
trustedOrigins,
|
||||
},
|
||||
port: process.env.EKKLESIA_API_PORT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
const { environment, input } = readConfigFromEnvironment();
|
||||
const config = createApiRuntimeConfig(input, { environment });
|
||||
const running = await startApi(config);
|
||||
|
||||
const address = running.server.address();
|
||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||
console.log(`Ekklesia API listening on ${config.host}:${boundPort}`);
|
||||
|
||||
let signalReceived = false;
|
||||
const handleSignal = (): void => {
|
||||
if (signalReceived) {
|
||||
return;
|
||||
}
|
||||
signalReceived = true;
|
||||
running
|
||||
.shutdown()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
};
|
||||
process.on("SIGINT", handleSignal);
|
||||
process.on("SIGTERM", handleSignal);
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
main().catch(() => {
|
||||
console.error("Ekklesia API failed to start.");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user