/** * 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. // // Exported so tests/unit/composeEnvironment.test.ts can assert the deploying // environment actually sets them. #107 happened because this list grew and // docker-compose.qa.yml did not: the check has to read this list rather than a // copy of it, or the next variable added here goes unguarded in exactly the // same way. export 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.' ]; } // Optional on purpose, and unlike the two above, unset costs nothing in // safety. A submission still arrives, keeps its photos and waits in the queue // undrafted (#223). It is a warning rather than an error because the photos are // often the only copy of an item no longer in the sender's hands, so losing a // consignment to an expired key would be far worse than an item arriving // without its description written. Silence would be the wrong answer too: an // operator who believes drafting is on and finds every item undrafted has // nothing to tell them why. // Optional, like the drafting key below. Absent, the notification still sends // with its review link and simply carries no shortcuts — being told an item // arrived matters far more than being able to discard it in one click. function checkIntakeActionSecret(env: NodeJS.ProcessEnv): string[] { if (isPresent(env, 'INTAKE_ACTION_SECRET')) { return []; } return [ 'INTAKE_ACTION_SECRET is not set — intake notifications will link to the review queue ' + 'but carry no regenerate or discard shortcuts.' ]; } function checkDraftingKey(env: NodeJS.ProcessEnv): string[] { if (isPresent(env, 'ANTHROPIC_API_KEY')) { return []; } return [ 'ANTHROPIC_API_KEY is not set — submitted items will arrive undrafted and wait in the ' + 'review queue for someone to write them up by hand.' ]; } // Optional, and the same shape as the admin gate above: unset is a working // configuration with one defence switched off, which is worth saying out loud // rather than leaving to be discovered. Set, it has to be an absolute origin — // a value missing its scheme joins into a relative path and silently breaks // every image on the site, which is a worse outcome than either extreme. function checkUploadsOrigin(env: NodeJS.ProcessEnv): EnvValidation { if (!isPresent(env, 'UPLOADS_BASE_URL')) { return { errors: [], warnings: [ 'UPLOADS_BASE_URL is not set — uploaded files are served from this application on its ' + 'own origin, so anything reaching the uploads directory shares an origin with the site.' ] }; } const value = (env.UPLOADS_BASE_URL ?? '').trim(); if (!value.startsWith('https://') && !value.startsWith('http://')) { return { errors: [ 'UPLOADS_BASE_URL must be an absolute origin including the scheme, such as ' + 'https://uploads.example.com. Without one it joins into a relative path and every ' + 'image on the site breaks.' ], warnings: [] }; } return { errors: [], warnings: [] }; } export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation { const mail = checkMail(env); const uploads = checkUploadsOrigin(env); return { errors: [ ...checkAlwaysRequired(env), ...checkDemoMode(env), ...checkPayPal(env), ...mail.errors, ...uploads.errors ], warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings, ...checkDraftingKey(env), ...checkIntakeActionSecret(env)] }; }