feat(backend): check the environment at boot instead of discovering it later (#64)
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:
+21
-11
@@ -2,6 +2,7 @@ import cron from 'node-cron';
|
||||
import app from './app';
|
||||
import { pool } from './db';
|
||||
import { sendMail } from './mailer';
|
||||
import { validateEnv } from './envValidation';
|
||||
|
||||
// Release cart holds whose expiry has passed.
|
||||
async function sweepExpiredCarts(): Promise<void> {
|
||||
@@ -65,17 +66,26 @@ setInterval(() => void sweepExpiredCarts(), 5 * 60 * 1000);
|
||||
cron.schedule('0 9 * * *', () => void sendCartReminders());
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
// Said at boot rather than left to be discovered. Without the secret the admin
|
||||
// API is protected only by the reverse proxy's auth_request regex, which lives
|
||||
// outside this repository and is bypassed entirely by anything reaching this
|
||||
// container's published port directly. That is a defensible way to run — it is
|
||||
// how this app has always run — but it should be a visible choice rather than a
|
||||
// silent one. See middleware/adminGate.ts and #63.
|
||||
if (!process.env.ADMIN_GATE_SECRET) {
|
||||
console.warn(
|
||||
'[admin-gate] 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.'
|
||||
);
|
||||
|
||||
// Checked at boot rather than left to be discovered by the first request that
|
||||
// happens to need a missing value. Every problem is reported at once — fixing a
|
||||
// fresh environment one restart at a time is miserable — and anything fatal
|
||||
// stops the process, the same way a failed migration does rather than serving
|
||||
// against a schema it does not match. The admin-gate warning lives here too now
|
||||
// (#63), so there is one place that says what this container is and is not
|
||||
// configured to do. See envValidation.ts and #64.
|
||||
const { errors, warnings } = validateEnv(process.env);
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.warn(`[config] ${warning}`);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`[config] refusing to start — ${errors.length} problem(s) with the environment:`);
|
||||
for (const error of errors) {
|
||||
console.error(`[config] - ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`));
|
||||
|
||||
Reference in New Issue
Block a user