fix: give Doctor the host configuration the runtime receives
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m24s

`pnpm doctor` and `startApi` each read their own subset of the same
environment variables, and the subsets had drifted. Doctor never read
`S3_*` or module secrets at all, so a correctly configured installation
was told its object storage was "not configured" and every module
requiring a bucket or a secret was reported blocked -- while the runtime,
from the identical environment, bound those buckets and ran the modules.

Both executables now map the environment through one
`loadHostConfiguration(env)` in Core, which takes the environment as an
argument and never reads `process.env` itself. Validation stays with the
existing per-domain validators, so each caller keeps its own failure
behaviour: startup refuses a rejected value, Doctor reports it as a
sanitized code.

Doctor's storage option becomes raw input like its mail option, so a
half-configured provider is reported as a blocking configuration failure
instead of throwing before a report exists. The outbound-email check --
computed, counted toward the result, but absent from every rendered
section -- is now printed, and the rendered set is asserted against a
real report so a future check cannot go missing the same way.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-24 19:15:49 -05:00
co-authored by Claude Opus 5
parent a835215eb8
commit 2b4f0afb72
23 changed files with 1411 additions and 209 deletions
+14 -21
View File
@@ -4,35 +4,28 @@ import {
type DatabaseTransportPolicy,
createDatabaseTransportPolicy,
} from "../database/transport.js";
import { loadHostConfiguration } from "../host/index.js";
/**
* The one place every Core-owned executable reads its database transport policy from the process
* environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and
* `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`.
* The database transport policy for the small Core-owned executables — `pnpm migrations:plan`,
* `pnpm migrations:apply`, `pnpm modules:enable`, and the owner-bootstrap commands — resolved from
* the process environment.
*
* Environment reading lives here, in the executable layer, deliberately: the reusable Core
* database/migration constructors keep taking explicit typed inputs and never touch `process.env`.
* `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string
* `"development"` relaxes anything, so an unset or misspelled value validates as production.
* The mapping itself belongs to `loadHostConfiguration`, which `startApi` and `pnpm doctor` also
* call, so `NODE_ENV` and the two TLS variables have exactly one interpretation across every entry
* point. Environment *reading* still happens here, in the executable layer: the reusable Core
* database and migration constructors keep taking explicit typed inputs and never touch
* `process.env`.
*/
export function readDatabaseTransportFromEnvironment(): {
readonly environment: DatabaseTransportEnvironment;
readonly transport: DatabaseTransportPolicy;
} {
const environment: DatabaseTransportEnvironment =
process.env.NODE_ENV === "development" ? "development" : "production";
const transport = createDatabaseTransportPolicy(
{
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
{ environment },
);
return { environment, transport };
const host = loadHostConfiguration(process.env);
const transport = createDatabaseTransportPolicy(host.databaseTransport, {
environment: host.environment,
});
return { environment: host.environment, transport };
}
/**
+52 -64
View File
@@ -7,6 +7,7 @@ import {
doctorExitCode,
formatDoctorReport,
} from "../diagnostics/index.js";
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
interface ModuleSdkPackageDocument {
readonly version?: unknown;
@@ -18,6 +19,18 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
};
}
/**
* Everything Doctor needs that comes from the repository itself rather than the deployment
* environment, resolved by the caller so the environment mapping below stays pure and testable.
*/
export interface DoctorCommandMetadata {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
readonly platformVersion: string;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
async function readJson(pathname: string): Promise<unknown> {
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
}
@@ -36,40 +49,40 @@ function requiredString(value: unknown): string {
return value;
}
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
const secret = process.env.EKKLESIA_IDENTITY_SECRET;
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL;
const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED;
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
const trustedOrigins = trustedOriginsValue
? trustedOriginsValue
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0)
: undefined;
/**
* Turns one deployment environment into Doctor's inputs.
*
* Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls
* with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this
* deployment is configured to use. Doctor previously read its own subset here and silently omitted
* object storage and module secrets entirely, which made it report a correctly configured
* installation as having neither.
*
* Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a
* malformed value appears in the report — which is the whole reason an operator runs this command —
* instead of throwing before a report exists.
*/
export function createDoctorOptions(
environment: HostEnvironment,
metadata: DoctorCommandMetadata,
): DoctorOptions {
const host = loadHostConfiguration(environment);
return {
...(baseUrl ? { baseUrl } : {}),
environment: process.env.NODE_ENV === "development" ? "development" : "production",
...(publicSignupEnabled ? { publicSignupEnabled } : {}),
...(secret ? { secret } : {}),
...(trustedOrigins ? { trustedOrigins } : {}),
};
}
function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] {
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
if (mode === undefined) {
return undefined;
}
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
return {
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: trustedProxiesValue
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
clientAddress: host.clientAddress,
databaseEnvironment: host.environment,
databaseTransport: host.databaseTransport,
databaseUrl: host.databaseUrl,
identityConfiguration: { ...host.identity, environment: host.environment },
mailConfiguration: host.mail,
moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion,
modulesDirectory: metadata.modulesDirectory,
platformVersion: metadata.platformVersion,
},
moduleSecrets: host.moduleSecrets,
runtimeRange: metadata.runtimeRange,
runtimeVersion: metadata.runtimeVersion,
storageConfiguration: host.storage,
};
}
@@ -81,40 +94,15 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const rootPackage = rootPackageDocument(rootValue);
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
const databaseEnvironment =
process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const);
const report = await diagnoseEkklesia({
clientAddress: readClientAddressFromEnvironment(),
databaseEnvironment,
databaseTransport: {
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
databaseUrl: process.env.DATABASE_URL,
identityConfiguration: readIdentityConfigurationFromEnvironment(),
// Raw, exactly like the identity settings above: Doctor validates them and reports a sanitized
// code, so a malformed SMTP value appears in the report instead of crashing the command.
mailConfiguration: {
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
host: process.env.EKKLESIA_SMTP_HOST,
password: process.env.EKKLESIA_SMTP_PASSWORD,
port: process.env.EKKLESIA_SMTP_PORT,
security: process.env.EKKLESIA_SMTP_SECURITY,
username: process.env.EKKLESIA_SMTP_USERNAME,
},
moduleDiscovery: {
const report = await diagnoseEkklesia(
createDoctorOptions(process.env, {
moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion: requiredString(rootPackage.version),
},
runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node,
});
runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node,
}),
);
console.log(formatDoctorReport(report).join("\n"));
return doctorExitCode(report);
+14
View File
@@ -22,8 +22,22 @@ const sections: readonly DoctorSection[] = Object.freeze([
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration", "identity.client-address"]),
section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
section("Outbound email", ["outbound-email.configuration"]),
]);
/**
* Every check id, exactly once.
*
* A check absent from `sections` is still computed and still counted toward the overall result, but
* is never shown — so a configuration failure would flip an operator's report to NOT READY with
* nothing on screen explaining why. `outbound-email.configuration` was in precisely that state, and
* `doctor-report.test.ts` now compares this list against the ids of a real report, so a new check
* cannot be added without being rendered.
*/
export const RENDERED_DOCTOR_CHECK_IDS: readonly DoctorCheckId[] = Object.freeze(
sections.flatMap((entry) => [...entry.checkIds]),
);
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
failure: "✗",
pass: "✓",
+58 -17
View File
@@ -73,9 +73,12 @@ import {
} from "../modules/index.js";
import {
createS3StorageProvider,
createStorageProviderConfig,
describeStorageProvider,
physicalBucketName,
StorageConfigError,
type StorageProviderConfig,
type StorageProviderConfigInput,
} from "../storage/index.js";
export type DoctorCheckId =
@@ -129,19 +132,27 @@ export interface DoctorOptions {
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
readonly moduleDiscovery: ModuleDiscoveryOptions;
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required
* secret as missing, which is the honest answer for a deployment that has not set it. */
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports every
* required secret as missing, which is only the honest answer for a deployment that has actually
* set none, so an executable must pass the same source it would give the runtime. */
readonly moduleSecrets?: ModuleSecretSource;
/** The validated host storage provider, or `undefined` when this installation has none. Doctor
* never receives raw environment values and never prints a credential. */
readonly storage?: StorageProviderConfig | undefined;
/**
* The host's object-storage settings as the executable read them, validated here.
*
* **Raw input**, for the same reason `mailConfiguration` below is raw: a half-configured provider
* is precisely the thing an operator runs Doctor to find, so Doctor has to be the one that
* validates it and reports the sanitized code. A pre-validated option meant a bad value threw
* before a report existed, and — because the executable then had to validate it itself — meant
* `pnpm doctor` simply never passed one at all, reporting configured storage as absent.
*/
readonly storageConfiguration?: StorageProviderConfigInput | undefined;
/**
* The host's outbound mail settings as the executable read them, validated here.
*
* Deliberately **raw input** rather than a validated config, exactly like `identityConfiguration`:
* a malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has
* to be the one that validates it and reports the sanitized code. A pre-validated option would
* mean a bad value crashed the command instead of appearing in its report.
* Deliberately **raw input**, exactly like `identityConfiguration` and `storageConfiguration`: a
* malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has to
* be the one that validates it and reports the sanitized code. A pre-validated option would mean
* a bad value crashed the command instead of appearing in its report.
*/
readonly mailConfiguration?: MailProviderConfigInput | undefined;
readonly runtimeRange: string;
@@ -851,6 +862,23 @@ export async function diagnoseEkklesia(
options: DoctorOptions,
dependencies: DoctorDependencies = productionDependencies,
): Promise<DoctorReport> {
// Resolved first, before module eligibility is decided: whether a provider is configured is an
// input to whether a module that declares a bucket can run, so validating it later would mean
// deciding eligibility against a question that had not been answered yet.
const hostEnvironment =
options.databaseEnvironment === "development" ? "development" : "production";
let storageConfig: StorageProviderConfig | undefined;
let storageConfigurationError: string | undefined;
try {
storageConfig = createStorageProviderConfig(options.storageConfiguration, {
environment: hostEnvironment,
});
} catch (error) {
// The sanitized code only — never the endpoint, the access key, or the secret key.
storageConfigurationError =
error instanceof StorageConfigError ? error.code : "STORAGE_CONFIGURATION_INVALID";
}
let runtimeCheck: DoctorCheck;
try {
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
@@ -960,7 +988,7 @@ export async function diagnoseEkklesia(
discoveredModules,
configuration,
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
{ providerConfigured: options.storage !== undefined },
{ providerConfigured: storageConfig !== undefined },
);
eligibilityEntries = resolution.entries;
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
@@ -1075,7 +1103,20 @@ export async function diagnoseEkklesia(
// whether modules run. Conflating them would let a momentary network blip read as a module
// problem (ADR 0030).
let storageCheck: DoctorCheck;
if (!options.storage) {
if (storageConfigurationError !== undefined) {
// A failure, unlike an unreachable provider: this is not a network condition an operator can
// wait out. `startApi` refuses to start on exactly this input, so a deployment carrying it is
// genuinely not ready, and saying so is what stops the operator from discovering it at boot.
storageCheck = freezeCheck(
"object-storage.integration",
"failure",
"Object storage configuration invalid",
[
`The object-storage settings were rejected: ${storageConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the S3_* values and run Doctor again.",
],
);
} else if (!storageConfig) {
storageCheck = freezeCheck(
"object-storage.integration",
"skipped",
@@ -1084,14 +1125,14 @@ export async function diagnoseEkklesia(
);
} else {
try {
await dependencies.probeStorage(options.storage);
await dependencies.probeStorage(storageConfig);
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
describeStorageProvider(options.storage),
describeStorageProvider(storageConfig),
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a
// separate browser-facing origin is by definition something a browser reaches and this
// process may not. Reporting "reachable" without that caveat would let an operator conclude
// direct delivery works when only half of it was tested.
...(options.storage.deliveryEndpoint === options.storage.endpoint
...(storageConfig.deliveryEndpoint === storageConfig.endpoint
? []
: [
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
@@ -1106,7 +1147,7 @@ export async function diagnoseEkklesia(
"warning",
"Object storage unreachable",
[
describeStorageProvider(options.storage),
describeStorageProvider(storageConfig),
"The configured storage service did not respond. Modules already running are unaffected.",
],
);
@@ -1114,7 +1155,7 @@ export async function diagnoseEkklesia(
}
const storageBindings = discoveredModules
? storageBindingsCheck(discoveredModules, eligibilityEntries, options.storage)
? storageBindingsCheck(discoveredModules, eligibilityEntries, storageConfig)
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
"Complete module discovery is required.",
]);
@@ -1128,7 +1169,7 @@ export async function diagnoseEkklesia(
let mailConfigurationError: string | undefined;
try {
mailConfig = createMailProviderConfig(options.mailConfiguration, {
environment: options.databaseEnvironment === "development" ? "development" : "production",
environment: hostEnvironment,
});
} catch (error) {
// The sanitized code only — never the host, the username, or the password.
+154
View File
@@ -0,0 +1,154 @@
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;
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,
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,
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,
},
});
}
+7
View File
@@ -0,0 +1,7 @@
export {
type HostConfiguration,
type HostEnvironment,
type HostEnvironmentName,
type HostIdentityEnvironmentInput,
loadHostConfiguration,
} from "./configuration.js";