Files
redefined-designs/backend/src/envValidation.ts
T
bermudalambandClaude Opus 5 747ed2d00a build(intake): add the Anthropic SDK and warn when its key is absent (#223)
Both packages go in dependencies rather than devDependencies. The final Docker stage installs with --omit=dev, so the wrong section produces a container that fails on the first submission and nowhere else — which is how sharp went wrong in #226.

ANTHROPIC_API_KEY is a warning, not a requirement. Absent, the container still boots and a submission still arrives, keeps its photos and waits in the queue undrafted. 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 a worse outcome than an item arriving without its description written. Silence would be wrong too: an operator who believes drafting is on and finds every item undrafted has nothing to tell them why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:25:49 -05:00

229 lines
8.7 KiB
TypeScript

/**
* 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.
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)]
};
}