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
+24
-83
@@ -12,10 +12,10 @@ import {
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||
import {
|
||||
createEnvironmentModuleSecretSource,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
@@ -35,7 +35,6 @@ import { type ServerType, serve } from "@hono/node-server";
|
||||
import type { Hono } from "hono";
|
||||
import { createApiApp } from "./app.js";
|
||||
import {
|
||||
type ClientAddressConfigInput,
|
||||
type ClientAddressResolver,
|
||||
createClientAddressResolver,
|
||||
wrapFetchWithClientAddress,
|
||||
@@ -719,98 +718,40 @@ export function installShutdownSignalHandlers(
|
||||
process.on("SIGTERM", handleSignal);
|
||||
}
|
||||
|
||||
function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined {
|
||||
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
|
||||
if (mode === undefined) {
|
||||
// Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/
|
||||
// `trustedProxies` only when `mode` is actually set keeps an operator from accidentally
|
||||
// half-configuring proxy mode by setting only one of the three variables.
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function readConfigFromEnvironment(): {
|
||||
/**
|
||||
* Turns one deployment environment into this process's startup inputs.
|
||||
*
|
||||
* Every host infrastructure value comes from `loadHostConfiguration` — the same function
|
||||
* `pnpm doctor` calls with the same environment — so the runtime and the diagnostic that reports on
|
||||
* it can never disagree about what this deployment is configured to use. Only the two values that
|
||||
* describe *this listener* rather than the host's infrastructure are read here.
|
||||
*/
|
||||
export function readConfigFromEnvironment(processEnvironment: HostEnvironment): {
|
||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||
readonly environment: "development" | "production";
|
||||
readonly moduleSecrets: ModuleSecretSource;
|
||||
} {
|
||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
||||
const trustedOrigins = trustedOriginsValue
|
||||
?.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
const clientAddress = readClientAddressInputFromEnvironment();
|
||||
|
||||
const host = loadHostConfiguration(processEnvironment);
|
||||
return {
|
||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||
environment: host.environment,
|
||||
input: {
|
||||
...(clientAddress === undefined ? {} : { clientAddress }),
|
||||
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,
|
||||
host: process.env.EKKLESIA_API_HOST,
|
||||
identity: {
|
||||
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
|
||||
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
||||
secret: process.env.EKKLESIA_IDENTITY_SECRET,
|
||||
trustedOrigins,
|
||||
},
|
||||
port: process.env.EKKLESIA_API_PORT,
|
||||
// The one place this process turns its environment into storage configuration. The names are
|
||||
// the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's
|
||||
// validator never reads `process.env` itself.
|
||||
// The one place this process turns its environment into mail configuration. Names follow the
|
||||
// platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a provider's, and Core's
|
||||
// validator never reads `process.env` itself. Absent host means this installation sends no
|
||||
// outbound email, which is a complete deployment (ADR 0037).
|
||||
mail: {
|
||||
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
|
||||
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
|
||||
host: process.env.EKKLESIA_SMTP_HOST,
|
||||
// Optional as a pair: a relay authorized by source IP has neither.
|
||||
password: process.env.EKKLESIA_SMTP_PASSWORD,
|
||||
port: process.env.EKKLESIA_SMTP_PORT,
|
||||
security: process.env.EKKLESIA_SMTP_SECURITY,
|
||||
username: process.env.EKKLESIA_SMTP_USERNAME,
|
||||
},
|
||||
storage: {
|
||||
accessKey: process.env.S3_ACCESS_KEY,
|
||||
bucketPrefix: process.env.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: process.env.S3_DELIVERY_ENDPOINT,
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE,
|
||||
region: process.env.S3_REGION,
|
||||
secretKey: process.env.S3_SECRET_KEY,
|
||||
},
|
||||
clientAddress: host.clientAddress,
|
||||
databaseTransport: host.databaseTransport,
|
||||
databaseUrl: host.databaseUrl,
|
||||
host: processEnvironment.EKKLESIA_API_HOST,
|
||||
identity: host.identity,
|
||||
mail: host.mail,
|
||||
port: processEnvironment.EKKLESIA_API_PORT,
|
||||
storage: host.storage,
|
||||
},
|
||||
moduleSecrets: host.moduleSecrets,
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
const { environment, input } = readConfigFromEnvironment();
|
||||
const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
|
||||
const config = createApiRuntimeConfig(input, { environment });
|
||||
// The one place this process turns its environment into module secrets. Core's factory takes the
|
||||
// environment as an argument and never touches `process.env` itself, preserving the rule that
|
||||
// only executables read the environment.
|
||||
const running = await startApi(config, {
|
||||
moduleSecrets: createEnvironmentModuleSecretSource(process.env),
|
||||
});
|
||||
const running = await startApi(config, { moduleSecrets });
|
||||
|
||||
const address = running.server.address();
|
||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||
|
||||
Reference in New Issue
Block a user