From 26d976e272dcfdd7272692c887b3440d940ec1c5 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 6 Jul 2026 14:51:04 -0500 Subject: [PATCH] =?UTF-8?q?feat(config):=20boot-time=20env=20validation=20?= =?UTF-8?q?=E2=80=94=20fail-fast=20+=20prod=20key=20warning=20(Pillar=20D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utils/env.cts `validateEnv()` runs first in main(): exits 1 on clearly invalid config (non-integer PORT / SESSION_CLEANUP_INTERVAL_MS, too-short TOKEN_ENCRYPTION_KEY), and warns loudly in production when TOKEN_ENCRYPTION_KEY is unset (secrets fall back to a DB-stored key). Resolves the standing prod-hardening TODO. Verified: bad PORT → exit 1; prod without key → warns but boots (health 200). Co-Authored-By: Claude Opus 4.8 --- server.cts | 3 +++ utils/env.cts | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 utils/env.cts diff --git a/server.cts b/server.cts index eb64e08..91c43c6 100644 --- a/server.cts +++ b/server.cts @@ -364,6 +364,9 @@ function gracefulShutdown(signal, server) { // ── Bootstrap ───────────────────────────────────────────────────────────────── async function main() { + // Fail fast on invalid configuration; warn on security-relevant gaps. + require('./utils/env.cts').validateEnv(); + const db = getDb(); // Migrate DB-key-encrypted secrets to env key when TOKEN_ENCRYPTION_KEY is set diff --git a/utils/env.cts b/utils/env.cts new file mode 100644 index 0000000..5c2a903 --- /dev/null +++ b/utils/env.cts @@ -0,0 +1,56 @@ +// Boot-time environment validation. Fails fast on clearly-invalid values so a +// misconfigured deployment stops loudly instead of misbehaving later, and warns +// on security-relevant gaps in production. Keep this the single place that +// asserts config invariants at startup. + +// Leading import so Node's type-stripping activates for `node --check`. +import type { Db } from '../types/db'; + +const isProd = () => process.env.NODE_ENV === 'production'; + +function isIntString(v: string | undefined): boolean { + return v === undefined || /^\d+$/.test(v.trim()); +} + +/** + * Validate environment configuration. Throws (exit 1 in the caller) on fatal + * misconfiguration; logs warnings for security-relevant gaps. + */ +function validateEnv(): void { + const fatal: string[] = []; + + if (!isIntString(process.env.PORT)) { + fatal.push(`PORT must be an integer (got "${process.env.PORT}")`); + } + if (!isIntString(process.env.SESSION_CLEANUP_INTERVAL_MS)) { + fatal.push( + `SESSION_CLEANUP_INTERVAL_MS must be an integer (got "${process.env.SESSION_CLEANUP_INTERVAL_MS}")`, + ); + } + + const key = process.env.TOKEN_ENCRYPTION_KEY?.trim(); + if (key && key.length < 16) { + fatal.push( + 'TOKEN_ENCRYPTION_KEY is set but too short (use a long random string, e.g. 48 random bytes hex)', + ); + } + + if (fatal.length) { + console.error('[config] FATAL — invalid configuration:'); + for (const f of fatal) console.error(` ✗ ${f}`); + throw new Error('Invalid environment configuration'); + } + + // Security-relevant warnings (do NOT fail — existing deployments intentionally + // run on the DB-stored auto-key). + if (isProd() && !key) { + console.warn( + '[config] WARNING: TOKEN_ENCRYPTION_KEY is not set in production. Encrypted secrets ' + + '(SimpleFIN token, SMTP/OIDC secrets) fall back to a key stored inside the database — ' + + 'anyone with DB/backup read access can decrypt them. Set TOKEN_ENCRYPTION_KEY (outside ' + + 'the DB) to a long random string.', + ); + } +} + +module.exports = { validateEnv };