feat(backend): check the environment at boot instead of discovering it later (#64)
SonarQube Analysis / sonarqube (pull_request) Failing after 16m53s
Tests / lint (pull_request) Successful in 4m19s
Tests / backend-unit (pull_request) Successful in 1m11s
Tests / frontend-e2e (pull_request) Failing after 27m5s

The backend reads environment variables in a couple of dozen places and validated none of them. A missing or misspelled one was undefined until the first line of code that happened to need it, which could be a long time after the container reported healthy — and several of those failures are silent and customer-visible.

DEMO_MODE is the one that mattered most. It was read as "demo unless the value is exactly the string false", so DEMO_MODE=False, DEMO_MODE=0, or any typo meant demo mode stayed on and the shop quietly stopped charging anyone. It is now required and strict: exactly 'true' or 'false', and anything else refuses to start while quoting the value it was given, so the typo is visible in the message rather than inferred.

Two requirements are conditional, and that is what makes them expressible at all. PayPal credentials are demanded only when DEMO_MODE=false, because QA runs with none of them on purpose and an unconditional rule would be simply wrong there. PUBLIC_URL is demanded only when SMTP is configured, because its only job is building links in email — an environment that cannot send mail does not need it, and requiring it everywhere would break every existing local setup to prevent nothing. UPLOADS_DIR gets no such reprieve: its fallback is correct inside the container and wrong everywhere else, so inheriting it writes uploads somewhere nobody is looking.

Every problem is reported at once rather than one per restart, and the process then exits — the same shape as the container refusing to start on a failed migration rather than serving against a schema it does not match. Warnings are printed but do not stop anything: SMTP absent, the admin gate inactive, or an allowlist missing while mail can be sent. That last one is new and earns its place, since SMTP with no allowlist means the environment can reach real customers, which is what #87 exists to prevent. The admin-gate warning moved here from server.ts, so one place says what this container is and is not configured to do.

validateEnv is a pure function of the environment handed to it rather than a reader of process.env, so it is tested exhaustively without booting anything or mutating global state. It is called from server.ts and deliberately not from app.ts: the integration suite imports app directly and would otherwise become a configuration exercise. Its rules are one small function each at module level, because cognitive complexity counts everything declared inside a function and the first version scored 24 against a limit of 15.

Verified as a real process, not only in tests. A missing DEMO_MODE, a DEMO_MODE of 'False', real payments with no PayPal credentials, and half-configured SMTP each exit 1 with the problems listed; a valid environment starts and serves. Note the exit codes were checked without a pipe, because $? after `| head` reports head rather than node and had first suggested a clean exit.

141 unit, 169 integration and 94 end-to-end passing, lint unchanged at 0 errors and 8 warnings. Both CI workflows already set all six always-required variables plus DEMO_MODE, so the pipeline is unaffected.

Refs #64
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:22:42 -05:00
parent 984d91f00a
commit 9c9e9c3ded
4 changed files with 396 additions and 11 deletions
+171
View File
@@ -0,0 +1,171 @@
/**
* Boot-time configuration checks.
*
* The backend reads environment variables in a couple of dozen places, and a
* missing or misspelled one used to be `undefined` until the first line of code
* that happened to need it — which could be a very long time after the
* container reported healthy. Several of those failures are silent and
* customer-visible: mail containing `undefined` in a link, or a shop that
* quietly stops charging anyone.
*
* The container already refuses to start on a failed migration rather than
* serving against a schema it does not match. This is the same argument applied
* to configuration.
*
* Kept a pure function of the environment it is handed, rather than reading
* `process.env` itself, so it can be tested exhaustively without booting a
* server or mutating global state. `server.ts` calls it; `app.ts` deliberately
* does not, because the integration suite imports `app` directly and would
* otherwise become a configuration exercise.
*/
export interface EnvValidation {
/** Configuration that must be fixed. The process should not start. */
errors: string[];
/** Working, but worth saying out loud — usually a capability that is off. */
warnings: string[];
}
// Without these the process cannot do its job at all.
const ALWAYS_REQUIRED = [
'PGHOST',
'PGPORT',
'PGUSER',
'PGPASSWORD',
'PGDATABASE',
// No reprieve for this one despite having a fallback: '/app/uploads' is
// correct inside the container and wrong everywhere else, so inheriting it
// silently writes uploads somewhere nobody is looking.
'UPLOADS_DIR'
] as const;
// Only meaningful once real payments are switched on. QA runs with none of
// these on purpose, which is why the requirement is conditional rather than
// absolute.
const PAYPAL_REQUIRED = [
'PAYPAL_CLIENT_ID',
'PAYPAL_CLIENT_SECRET',
'PAYPAL_WEBHOOK_ID',
'PAYPAL_ENV'
] as const;
// A variable set to spaces is a configuration mistake, not a value.
function isPresent(env: NodeJS.ProcessEnv, name: string): boolean {
const value = env[name];
return typeof value === 'string' && value.trim() !== '';
}
// One function per rule, at module level rather than nested. Each is small
// enough to read on its own, and cognitive complexity counts everything
// declared inside a function — so keeping these out of validateEnv is what
// keeps the composition below flat.
function checkAlwaysRequired(env: NodeJS.ProcessEnv): string[] {
return ALWAYS_REQUIRED.filter((name) => !isPresent(env, name)).map(
(name) => `${name} is required and is not set.`
);
}
// Strict rather than truthy. This used to be read as "demo unless the value is
// exactly 'false'", so DEMO_MODE=False, 0, or any typo meant demo mode was on —
// a configuration slip that stopped the shop taking money and said nothing.
function checkDemoMode(env: NodeJS.ProcessEnv): string[] {
const demoMode = env.DEMO_MODE;
if (demoMode === undefined || demoMode.trim() === '') {
return [
"DEMO_MODE is required and must be exactly 'true' or 'false'. It decides whether real " +
'payments are taken, so it has to be stated rather than inherited.'
];
}
if (demoMode !== 'true' && demoMode !== 'false') {
return [
`DEMO_MODE must be exactly 'true' or 'false', but is '${demoMode}'. Anything else used to ` +
'be read as demo mode, which meant a typo here quietly stopped the shop charging anyone.'
];
}
return [];
}
// Conditional rather than absolute: QA runs with no PayPal credentials on
// purpose, so requiring them unconditionally would be wrong.
function checkPayPal(env: NodeJS.ProcessEnv): string[] {
if (env.DEMO_MODE !== 'false') {
return [];
}
return PAYPAL_REQUIRED.filter((name) => !isPresent(env, name)).map(
(name) => `${name} is required when DEMO_MODE=false, because real payments are enabled.`
);
}
// SMTP is all or nothing, and two other variables hang off whether it is set.
function checkMail(env: NodeJS.ProcessEnv): EnvValidation {
const errors: string[] = [];
const warnings: string[] = [];
const hasUser = isPresent(env, 'SMTP_USER');
const hasPassword = isPresent(env, 'SMTP_PASSWORD');
// Half-configured is worse than absent: the mailer only skips when both are
// missing, so setting one produces a connection that fails at send time
// instead of a clean "mail is off".
if (hasUser && !hasPassword) {
errors.push('SMTP_PASSWORD is required when SMTP_USER is set — set both or neither.');
}
if (hasPassword && !hasUser) {
errors.push('SMTP_USER is required when SMTP_PASSWORD is set — set both or neither.');
}
if (!hasUser || !hasPassword) {
warnings.push(
'SMTP is not configured — no email will be sent. Verification, password reset, favorite ' +
'alerts and cart reminders will all be skipped with a warning.'
);
return { errors, warnings };
}
// Demanded only alongside SMTP. Its sole job is building links in email, so a
// local environment that cannot send mail does not need it, and requiring it
// there would break every existing local setup to prevent nothing.
if (!isPresent(env, 'PUBLIC_URL')) {
errors.push(
'PUBLIC_URL is required when SMTP is configured, or every link in a verification, ' +
'password-reset, favorite-alert or cart-reminder email reads "undefined".'
);
}
if (!isPresent(env, 'MAIL_ALLOWLIST')) {
warnings.push(
'MAIL_ALLOWLIST is not set while SMTP is configured — this environment can email real ' +
'customers. That is correct for production and a hazard anywhere else.'
);
}
return { errors, warnings };
}
function checkAdminGate(env: NodeJS.ProcessEnv): string[] {
if (isPresent(env, 'ADMIN_GATE_SECRET')) {
return [];
}
return [
'ADMIN_GATE_SECRET is not set — /api/admin is protected only by the reverse proxy. ' +
'Anything able to reach this container directly can administer the store.'
];
}
export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation {
const mail = checkMail(env);
return {
errors: [
...checkAlwaysRequired(env),
...checkDemoMode(env),
...checkPayPal(env),
...mail.errors
],
warnings: [...mail.warnings, ...checkAdminGate(env)]
};
}