export const REQUIRED_ENV_VARS = [ "MONGODB_URI", "SESSION_SECRET", "NAS_ROOT_PATH", ]; export const ALLOWED_NODE_ENVS = new Set(["development", "test", "production"]); export const MIN_SESSION_SECRET_LENGTH = 32; function isBlank(value) { return value === undefined || value === null || String(value).trim() === ""; } function normalizeString(value) { return String(value).trim(); } function normalizeUnixPath(value) { let p = normalizeString(value); // Remove trailing slashes (but keep "/" as-is) if (p.length > 1) p = p.replace(/\/+$/, ""); return p; } function containsDotDotSegment(p) { // Reject "/../", "/..", "../", etc. return /(^|\/)\.\.(\/|$)/.test(p); } function looksLikePlaceholderSecret(value) { const s = normalizeString(value).toLowerCase(); return ( s.includes("change-me") || s.includes("changeme") || s.includes("replace-me") || s.includes("replace_this") || s === "secret" || s === "password" ); } function validateMongoUri(uri) { return uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://"); } function parsePort(value) { const raw = normalizeString(value); if (!/^\d+$/.test(raw)) return { ok: false, value: null }; const n = Number(raw); if (!Number.isInteger(n) || n < 1 || n > 65535) return { ok: false, value: null }; return { ok: true, value: n }; } function buildEnvError(missing, invalid) { const lines = []; lines.push("Invalid environment configuration."); if (missing.length > 0) { lines.push(""); lines.push("Missing required environment variables:"); for (const key of missing) lines.push(`- ${key}`); } if (invalid.length > 0) { lines.push(""); lines.push("Invalid environment variables:"); for (const item of invalid) lines.push(`- ${item.key}: ${item.message}`); } lines.push(""); lines.push( "Tip: Copy and adjust the example env files (.env.local.example / .env.docker.example)." ); const err = new Error(lines.join("\n")); err.code = "ENV_INVALID"; err.missing = missing; err.invalid = invalid; return err; } /** * Validates and normalizes environment variables. * This function is pure: pass in an env object (e.g. process.env) and it returns config or throws. * * @param {Record} env * @returns {{ * mongodbUri: string, * sessionSecret: string, * nasRootPath: string, * nodeEnv: "development" | "test" | "production", * port?: number * }} */ export function validateEnv(env) { const e = env ?? {}; const missing = []; const invalid = []; for (const key of REQUIRED_ENV_VARS) { if (isBlank(e[key])) missing.push(key); } const mongodbUri = !isBlank(e.MONGODB_URI) ? normalizeString(e.MONGODB_URI) : ""; if (mongodbUri && !validateMongoUri(mongodbUri)) { invalid.push({ key: "MONGODB_URI", message: 'must start with "mongodb://" or "mongodb+srv://"', }); } const sessionSecret = !isBlank(e.SESSION_SECRET) ? normalizeString(e.SESSION_SECRET) : ""; if (sessionSecret) { if (sessionSecret.length < MIN_SESSION_SECRET_LENGTH) { invalid.push({ key: "SESSION_SECRET", message: `must be at least ${MIN_SESSION_SECRET_LENGTH} characters long`, }); } if (looksLikePlaceholderSecret(sessionSecret)) { invalid.push({ key: "SESSION_SECRET", message: "looks like a placeholder (replace it with a strong random secret)", }); } } const nasRootPath = !isBlank(e.NAS_ROOT_PATH) ? normalizeUnixPath(e.NAS_ROOT_PATH) : ""; if (nasRootPath) { if (!nasRootPath.startsWith("/")) { invalid.push({ key: "NAS_ROOT_PATH", message: 'must be an absolute Unix path (starts with "/")', }); } if (containsDotDotSegment(nasRootPath)) { invalid.push({ key: "NAS_ROOT_PATH", message: 'must not contain ".." path segments', }); } } const nodeEnvRaw = !isBlank(e.NODE_ENV) ? normalizeString(e.NODE_ENV) : "development"; if (nodeEnvRaw && !ALLOWED_NODE_ENVS.has(nodeEnvRaw)) { invalid.push({ key: "NODE_ENV", message: 'must be one of "development", "test", "production"', }); } let port; if (!isBlank(e.PORT)) { const parsed = parsePort(e.PORT); if (!parsed.ok) { invalid.push({ key: "PORT", message: "must be an integer between 1 and 65535", }); } else { port = parsed.value; } } if (missing.length > 0 || invalid.length > 0) { throw buildEnvError(missing, invalid); } /** @type {any} */ const cfg = { mongodbUri, sessionSecret, nasRootPath, nodeEnv: nodeEnvRaw, }; if (port !== undefined) cfg.port = port; return cfg; }