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>
229 lines
8.7 KiB
TypeScript
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)]
|
|
};
|
|
}
|