Files
Ekklesia/core/src/host/configuration.ts
T
ekklesiaandClaude Opus 5 3563549e86
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m41s
fix: let a deployment name itself in the SMTP greeting
A real deployment could not send mail through an IP-authorized relay.
Every attempt surfaced as MAIL_PROVIDER_UNAVAILABLE while the relay was
healthy and swaks sent from the same machine.

The cause was the greeting, and it is generic rather than tied to any
provider. An SMTP client identifies itself in EHLO, and with nothing
configured Nodemailer derives that from the machine hostname —
substituting the address literal [127.0.0.1] whenever the hostname is
not fully qualified, which is the normal state of a container. The relay
closed the connection on it with a 421 before any message was offered,
which is indistinguishable from the relay being down.

EKKLESIA_SMTP_HELO_NAME now lets the deployment say what to greet as. It
is validated as a fully qualified domain name — no scheme, port,
brackets, whitespace, or control characters, and at least one dot, since
a single unqualified label is exactly the input Nodemailer already
discards. It is not a credential: it travels in the clear on every
connection, so Doctor prints it where an operator can compare it against
what the relay expects, and Doctor's probe performs the same greeting the
runtime does.

Absent preserves Nodemailer's behaviour exactly. It is deliberately not
defaulted to the sender's domain: a machine whose hostname is already a
valid FQDN may be greeting correctly, and some relays check that against
reverse DNS, so replacing it silently would trade one deployment's bug
for another's.

The regression guard is a raw SMTP server that refuses any greeting but
the expected one, because smtp-server exposes no EHLO hook and its
earliest hook is never reached by a send-nothing probe.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 20:28:30 -05:00

163 lines
7.9 KiB
TypeScript

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<Record<string, string | undefined>>;
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;
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_<DOMAIN>_<FIELD>` 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),
// 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,
},
});
}