import { assertProductionDatabaseTransport, createDatabaseTransportPolicy, type DatabaseTransportPolicy, type DatabaseTransportPolicyInput, } from "@ekklesia/core/database"; import { type CreateIdentityConfigOptions, createIdentityConfig, type IdentityConfig, type IdentityConfigInput, } from "@ekklesia/core/identity"; import { createMailProviderConfig, type MailProviderConfig, type MailProviderConfigInput, } from "@ekklesia/core/mail"; import { createStorageProviderConfig, type StorageProviderConfig, type StorageProviderConfigInput, } from "@ekklesia/core/storage"; import { type ClientAddressConfig, type ClientAddressConfigInput, createClientAddressConfig, } from "./client-address.js"; 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> = 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 clientAddress: ClientAddressConfig; readonly databaseUrl: string; /** The one validated PostgreSQL transport policy this runtime's single pool uses. */ readonly databaseTransport: DatabaseTransportPolicy; readonly host: string; readonly identity: IdentityConfig; readonly port: number; /** * The validated host object-storage provider, or `undefined` when this installation has none * (ADR 0030). * * `undefined` is a complete, valid deployment: modules that declare no logical buckets are * unaffected, and a module that does declare one is reported blocked rather than crashing * anything. These values reach Core's provider and nowhere else — no module, module UI, browser * bundle, diagnostic, or API response ever sees the endpoint or the credentials. */ readonly storage: StorageProviderConfig | undefined; /** * The validated host outbound-email provider, or `undefined` when this installation has none * (ADR 0037). * * `undefined` is a valid deployment: the platform starts, every existing feature works, and the * initial platform owner — created already verified by an operator command — can still sign in * (ADR 0038). What it cannot do is verify a new account or deliver a password reset; each attempt * reports a sanitized `MAIL_NOT_CONFIGURED` out of band rather than changing any HTTP response. * These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or * API response ever sees the host or the password. */ readonly mail: MailProviderConfig | undefined; } export interface ApiRuntimeConfigInput { /** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy * trust unless explicitly configured. See `./client-address.js`. */ readonly clientAddress?: ClientAddressConfigInput | undefined; readonly databaseUrl?: unknown; /** Optional. Defaults to certificate-verified TLS in production and plaintext in development; * production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */ readonly databaseTransport?: DatabaseTransportPolicyInput; readonly host?: unknown; readonly identity: IdentityConfigInput; readonly port?: unknown; /** Optional. Omitted, or with no endpoint, means this installation has no object storage. */ readonly storage?: StorageProviderConfigInput; /** Optional. Omitted, or with no host, means this installation sends no outbound email. */ readonly mail?: MailProviderConfigInput; } 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 }, ); const clientAddress = createClientAddressConfig(input.clientAddress); const environment = options.environment ?? "production"; const databaseTransport = createDatabaseTransportPolicy(input.databaseTransport, { environment, }); // Fails here — before `startApi` constructs a pool, Better Auth, the module runtime, or a // listener — if a production deployment would connect without certificate-verified TLS, or if // the URL carries TLS parameters that could override the policy. assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment); // Validated in the same pass as every other runtime value, before `startApi` constructs a pool, // a provider client, or a listener — so a malformed endpoint or a half-supplied credential pair // fails startup with a sanitized code rather than surfacing later as a confusing storage error. const storage = createStorageProviderConfig(input.storage, { environment }); // Validated in the same pass as everything else, so a malformed port, a half-supplied credential // pair, or a production deployment that asked for plaintext SMTP fails startup with a sanitized // code — rather than surfacing later as a confusing failure the first time something tries to // send a password-reset email. const mail = createMailProviderConfig(input.mail, { environment }); return Object.freeze({ clientAddress, databaseTransport, databaseUrl, host, identity, mail, port, storage, }); } /** * The exact browser origins the API trusts: CORS on `/api/auth/*` and `/api/v1/*`, and every * mutation route's origin check (ADR 0045). * * The configured trusted origins plus the identity base URL's own origin, which is where the API * lives. Better Auth already trusts its base URL implicitly; this makes the API's own checks agree, * so a page the API itself serves (the display host, and the Dashboard in the same-origin topology) * passes without an operator listing the API's origin. Order is preserved and duplicates removed; * every entry is already a canonical origin. */ export function apiTrustedOrigins( identity: Pick, ): readonly string[] { return Object.freeze([...new Set([...identity.trustedOrigins, identity.baseUrl])]); }