import type { ModuleSecretSource } from "@ekklesia/contracts"; import type { DatabaseTransportPolicyInput } from "../database/index.js"; import type { ClientAddressConfigInput } from "../identity/index.js"; import type { MailProviderConfigInput } from "../mail/index.js"; import { createEnvironmentModuleSecretSource } from "../modules/index.js"; import type { StorageProviderConfigInput } from "../storage/index.js"; /** * A deployment's environment, passed **as an argument**. * * `process.env` satisfies this, but nothing here reads it: the repository rule that only * executables touch the process environment is preserved exactly as * `createEnvironmentModuleSecretSource` preserves it, and it is what makes every variable this * module understands testable without mutating global state. */ export type HostEnvironment = Readonly>; export type HostEnvironmentName = "development" | "production"; /** * The identity settings as they appear in the environment — strings, unvalidated. * * Typed as `string | undefined` rather than `unknown` so this one object can be handed to both * `createIdentityConfig` (which validates it) and Doctor's `identityConfiguration` (which validates * it and reports the sanitized code) without either side re-deriving it. */ export interface HostIdentityEnvironmentInput { readonly baseUrl: string | undefined; /** Where an emailed verification or reset link sends a browser. Unset means the Dashboard is * served from `baseUrl`, which is the ordinary same-origin production shape (ADR 0038). */ readonly dashboardOrigin: string | undefined; readonly publicSignupEnabled: string | undefined; readonly secret: string | undefined; readonly trustedOrigins: readonly string[] | undefined; } /** * Every host infrastructure input an Ekklesia executable resolves from its environment. * * Deliberately **unvalidated**: each consumer runs the same Core validator over these values, but * they do so at different moments and want different failure behaviour. `startApi` wants a * malformed value to abort startup; Doctor wants it reported as a sanitized code in a check. If this * type were pre-validated, Doctor could never report a configuration error — the load would have * thrown before a report existed. * * The API listener's own `host`/`port` are deliberately absent: they describe the API process, not * the host infrastructure a diagnostic evaluates, and Doctor has no use for them. */ export interface HostConfiguration { /** `"development"` only when `NODE_ENV` is exactly that string, so an unset or misspelled value * validates as production and never silently relaxes a security rule. */ readonly environment: HostEnvironmentName; /** `undefined` when no mode is set at all, which selects direct mode. */ readonly clientAddress: ClientAddressConfigInput | undefined; readonly databaseTransport: DatabaseTransportPolicyInput; readonly databaseUrl: string | undefined; readonly identity: HostIdentityEnvironmentInput; readonly mail: MailProviderConfigInput; /** The deployment's module secrets, resolvable only under names Core derives from a module's own * declared keys — there is no arbitrary variable lookup. */ readonly moduleSecrets: ModuleSecretSource; /** `EKKLESIA_MODULES_DIR`, raw and unvalidated: `undefined` only when the variable is unset. Every * executable passes it to `resolveModulesDirectory`, the one place it is interpreted (ADR 0048), * so a relative or blank value is refused there rather than repaired here. */ readonly modulesDirectory: string | undefined; readonly storage: StorageProviderConfigInput; } function splitList(value: string | undefined): readonly string[] | undefined { if (value === undefined) { return undefined; } return value .split(",") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); } /** Omitted entirely when no mode is set: reading `header`/`trustedProxies` only when `mode` is * actually present keeps an operator from half-configuring proxy mode by setting one of three * variables. */ function readClientAddress(environment: HostEnvironment): ClientAddressConfigInput | undefined { const mode = environment.EKKLESIA_CLIENT_ADDRESS_MODE; if (mode === undefined) { return undefined; } return { header: environment.EKKLESIA_CLIENT_ADDRESS_HEADER, mode, trustedProxies: splitList(environment.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES), }; } /** * The one place any Ekklesia executable turns a deployment environment into host infrastructure * configuration. * * It exists because the alternative was demonstrably wrong: `apps/api` and `pnpm doctor` each read * their own subset of the same variables, and the subsets drifted. Doctor never read `S3_*` at all, * so a correctly configured installation was told its object storage was "not configured" and every * module requiring a bucket was reported blocked — while `startApi`, from the identical environment, * bound those buckets and ran the modules. A diagnostic that disagrees with the runtime it * diagnoses is worse than no diagnostic. * * So there is now exactly one mapping from variable names to typed inputs, and both executables call * it. Validation stays where it already lives — `createIdentityConfig`, * `createDatabaseTransportPolicy`, `createStorageProviderConfig`, `createMailProviderConfig` — and * is deliberately *not* performed here, so each caller keeps its own failure behaviour. * * Nothing is read from `process.env`, and nothing here logs, prints, or serializes: the returned * object carries the identity secret, both storage credentials, and the SMTP password. */ export function loadHostConfiguration(environment: HostEnvironment): HostConfiguration { return Object.freeze({ clientAddress: readClientAddress(environment), databaseTransport: { ...(environment.EKKLESIA_DATABASE_CA === undefined ? {} : { certificateAuthority: environment.EKKLESIA_DATABASE_CA }), ...(environment.EKKLESIA_DATABASE_TLS_MODE === undefined ? {} : { mode: environment.EKKLESIA_DATABASE_TLS_MODE }), }, databaseUrl: environment.DATABASE_URL, environment: environment.NODE_ENV === "development" ? "development" : "production", identity: { baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL, dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN, publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED, secret: environment.EKKLESIA_IDENTITY_SECRET, trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS), }, // Names follow the platform's own `EKKLESIA__` convention rather than a // provider's. Absent host means this installation sends no outbound email, which is a complete // deployment (ADR 0037); username and password are optional as a pair, because a relay // authorized by source IP has neither. mail: { fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS, fromName: environment.EKKLESIA_SMTP_FROM_NAME, // The name this client announces in `EHLO`. Optional, and unset is the ordinary case: it // matters only where the machine's own hostname is not a fully qualified name the relay will // accept. heloName: environment.EKKLESIA_SMTP_HELO_NAME, host: environment.EKKLESIA_SMTP_HOST, password: environment.EKKLESIA_SMTP_PASSWORD, port: environment.EKKLESIA_SMTP_PORT, security: environment.EKKLESIA_SMTP_SECURITY, username: environment.EKKLESIA_SMTP_USERNAME, }, moduleSecrets: createEnvironmentModuleSecretSource(environment), modulesDirectory: environment.EKKLESIA_MODULES_DIR, // The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent // endpoint means this installation has no object storage, which is a complete deployment // (ADR 0030). storage: { accessKey: environment.S3_ACCESS_KEY, bucketPrefix: environment.S3_BUCKET_PREFIX, // Optional, and unset is the ordinary case: it matters only where the name Core reaches // storage by is not a name a browser could resolve. deliveryEndpoint: environment.S3_DELIVERY_ENDPOINT, endpoint: environment.S3_ENDPOINT, forcePathStyle: environment.S3_FORCE_PATH_STYLE, region: environment.S3_REGION, secretKey: environment.S3_SECRET_KEY, }, }); }