CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s
An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty <meta name="ekklesia-api-origin">, which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
219 lines
8.4 KiB
TypeScript
219 lines
8.4 KiB
TypeScript
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<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 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<IdentityConfig, "baseUrl" | "trustedOrigins">,
|
|
): readonly string[] {
|
|
return Object.freeze([...new Set([...identity.trustedOrigins, identity.baseUrl])]);
|
|
}
|