Production readiness hardening and ops tooling
This commit is contained in:
141
scripts/check-production-env.mjs
Normal file
141
scripts/check-production-env.mjs
Normal file
@ -0,0 +1,141 @@
|
||||
import "dotenv/config";
|
||||
import path from "node:path";
|
||||
|
||||
const required = [
|
||||
"ADMIN_SESSION_SECRET",
|
||||
"MERCHANT_SESSION_SECRET",
|
||||
"INTEGRATION_WEBHOOK_SECRET",
|
||||
"DATABASE_URL",
|
||||
"MQTT_BROKER_URL",
|
||||
"MQTT_USERNAME",
|
||||
"MQTT_PASSWORD",
|
||||
"MQTT_CLIENT_ID",
|
||||
"EXPORT_STORAGE_DIR"
|
||||
];
|
||||
|
||||
const insecureDefaults = new Map([
|
||||
["ADMIN_TOKEN", "admin-dev-token"],
|
||||
["MERCHANT_TOKEN", "merchant-dev-token"],
|
||||
["DEVICE_TOKEN", "device-dev-token"],
|
||||
["MERCHANT_PORTAL_PASSWORD", "merchant"],
|
||||
["INTEGRATION_WEBHOOK_SECRET", "dev-callback-secret"],
|
||||
["MQTT_PASSWORD", "change-me"],
|
||||
["ADMIN_SESSION_SECRET", "change-me-long-random-admin-session-secret"],
|
||||
["MERCHANT_SESSION_SECRET", "change-me-long-random-merchant-session-secret"]
|
||||
]);
|
||||
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
|
||||
for (const name of required) {
|
||||
if (!process.env[name]) {
|
||||
errors.push(`${name} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, value] of insecureDefaults.entries()) {
|
||||
if (name === "ADMIN_TOKEN" && process.env.ADMIN_AUTH_ALLOW_LEGACY_TOKEN === "false") {
|
||||
continue;
|
||||
}
|
||||
if (name === "DEVICE_TOKEN" && process.env.DEVICE_AUTH_ALLOW_LEGACY_TOKEN === "false") {
|
||||
continue;
|
||||
}
|
||||
if (name === "MERCHANT_TOKEN" && process.env.MERCHANT_AUTH_ALLOW_LEGACY_TOKEN === "false") {
|
||||
continue;
|
||||
}
|
||||
if (name === "MERCHANT_PORTAL_PASSWORD" && process.env.MERCHANT_DEV_LOGIN_ENABLED === "false") {
|
||||
continue;
|
||||
}
|
||||
if (process.env[name] === value) {
|
||||
errors.push(`${name} still uses the development default`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireFalse(name) {
|
||||
if (process.env[name] !== "false") {
|
||||
errors.push(`${name} must be false in production`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireTrue(name, message) {
|
||||
if (process.env[name] !== "true") {
|
||||
errors.push(message || `${name} must be true in production`);
|
||||
}
|
||||
}
|
||||
|
||||
requireFalse("ADMIN_AUTH_ALLOW_LEGACY_TOKEN");
|
||||
requireFalse("DEVICE_AUTH_ALLOW_LEGACY_TOKEN");
|
||||
requireFalse("MERCHANT_AUTH_ALLOW_LEGACY_TOKEN");
|
||||
|
||||
if (process.env.ADMIN_DEV_LOGIN_ENABLED !== "false") {
|
||||
errors.push("ADMIN_DEV_LOGIN_ENABLED must be false in production");
|
||||
}
|
||||
|
||||
if (process.env.MERCHANT_DEV_LOGIN_ENABLED !== "false") {
|
||||
errors.push("MERCHANT_DEV_LOGIN_ENABLED must be false in production");
|
||||
}
|
||||
|
||||
if (process.env.MQTT_PUBLISH_MODE !== "broker") {
|
||||
errors.push("MQTT_PUBLISH_MODE must be broker in production");
|
||||
}
|
||||
|
||||
if (process.env.MQTT_SUBSCRIBE_ENABLED !== "true") {
|
||||
warnings.push("MQTT_SUBSCRIBE_ENABLED should be true when device uplink observability is required");
|
||||
}
|
||||
|
||||
requireTrue("SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL", "SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL must be true for production finance control");
|
||||
|
||||
if (process.env.DATABASE_URL && !/^postgres(ql)?:\/\//.test(process.env.DATABASE_URL)) {
|
||||
errors.push("DATABASE_URL must be a postgres connection string");
|
||||
}
|
||||
|
||||
if (process.env.MQTT_BROKER_URL && !/^mqtts:\/\//.test(process.env.MQTT_BROKER_URL)) {
|
||||
warnings.push("MQTT_BROKER_URL should use mqtts:// for production");
|
||||
}
|
||||
|
||||
for (const name of ["ADMIN_SESSION_SECRET", "MERCHANT_SESSION_SECRET", "INTEGRATION_WEBHOOK_SECRET", "MQTT_PASSWORD"]) {
|
||||
if ((process.env[name] || "").length < 24) {
|
||||
errors.push(`${name} must be at least 24 characters`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.LOG_FORMAT !== "json") {
|
||||
warnings.push("LOG_FORMAT should be json in production");
|
||||
}
|
||||
|
||||
if (process.env.EXPORT_WORKER_ENABLED !== "true") {
|
||||
errors.push("EXPORT_WORKER_ENABLED must be true in production");
|
||||
}
|
||||
|
||||
if (Number(process.env.EXPORT_RETENTION_DAYS || 0) <= 0) {
|
||||
errors.push("EXPORT_RETENTION_DAYS must be a positive number");
|
||||
}
|
||||
|
||||
if (process.env.EXPORT_STORAGE_DIR && !path.isAbsolute(process.env.EXPORT_STORAGE_DIR)) {
|
||||
errors.push("EXPORT_STORAGE_DIR must be an absolute path in production");
|
||||
}
|
||||
|
||||
if (process.env.RATE_LIMIT_ENABLED !== "true") {
|
||||
errors.push("RATE_LIMIT_ENABLED must be true in production");
|
||||
}
|
||||
|
||||
if (process.env.TRUST_PROXY !== "true") {
|
||||
warnings.push("TRUST_PROXY should be true when running behind a reverse proxy/load balancer");
|
||||
}
|
||||
|
||||
if (!process.env.JSON_BODY_LIMIT) {
|
||||
errors.push("JSON_BODY_LIMIT is required");
|
||||
}
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.warn(`WARN ${warning}`);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
for (const error of errors) {
|
||||
console.error(`ERROR ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Production environment preflight passed");
|
||||
Reference in New Issue
Block a user