fix: give Doctor the host configuration the runtime receives
`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:
co-authored by
Claude Opus 5
parent
a835215eb8
commit
2b4f0afb72
@@ -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
@@ -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);
|
||||
|
||||
@@ -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: "✓",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type HostConfiguration,
|
||||
type HostEnvironment,
|
||||
type HostEnvironmentName,
|
||||
type HostIdentityEnvironmentInput,
|
||||
loadHostConfiguration,
|
||||
} from "./configuration.js";
|
||||
Reference in New Issue
Block a user