| const fs = require("fs"); |
| const path = require("path"); |
| const dotenv = require("dotenv"); |
|
|
| function loadEnvironment() { |
| const backendEnvPath = path.resolve(__dirname, "../../.env"); |
| const rootEnvPath = path.resolve(__dirname, "../../../.env"); |
|
|
| if (fs.existsSync(backendEnvPath)) { |
| dotenv.config({ path: backendEnvPath }); |
| return; |
| } |
|
|
| if (fs.existsSync(rootEnvPath)) { |
| dotenv.config({ path: rootEnvPath }); |
| return; |
| } |
|
|
| dotenv.config(); |
| } |
|
|
| loadEnvironment(); |
|
|
| function readBool(name, fallback) { |
| const raw = process.env[name]; |
| if (raw === undefined || raw === null || raw === "") { |
| return fallback; |
| } |
| return ["1", "true", "yes", "on"].includes(String(raw).toLowerCase()); |
| } |
|
|
| function readNumber(name, fallback) { |
| const raw = process.env[name]; |
| if (raw === undefined || raw === null || raw === "") { |
| return fallback; |
| } |
|
|
| const parsed = Number(raw); |
| if (!Number.isFinite(parsed)) { |
| throw new Error(`Environment variable ${name} must be a valid number.`); |
| } |
| return parsed; |
| } |
|
|
| function readString(name, fallback = "") { |
| const raw = process.env[name]; |
| if (raw === undefined || raw === null || raw === "") { |
| return fallback; |
| } |
| return String(raw); |
| } |
|
|
| function assertJwtSecret(name, value) { |
| if (!value || value.length < 32) { |
| throw new Error( |
| `Environment variable ${name} must be set and at least 32 characters long for JWT security.` |
| ); |
| } |
| } |
|
|
| function resolveFilePath(value) { |
| if (!value) { |
| return ""; |
| } |
| return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value); |
| } |
|
|
| function readOptionalFile(filePathValue, label) { |
| if (!filePathValue) { |
| return undefined; |
| } |
|
|
| const absolutePath = resolveFilePath(filePathValue); |
| if (!fs.existsSync(absolutePath)) { |
| throw new Error(`${label} file does not exist: ${absolutePath}`); |
| } |
| return fs.readFileSync(absolutePath); |
| } |
|
|
| function parseBrokerUrl() { |
| const brokerUrl = readString("MQTT_BROKER_URL", ""); |
| if (!brokerUrl) { |
| return null; |
| } |
|
|
| const url = new URL(brokerUrl); |
| return { |
| host: url.hostname, |
| port: Number(url.port || 8883), |
| username: decodeURIComponent(url.username || ""), |
| password: decodeURIComponent(url.password || ""), |
| }; |
| } |
|
|
| function buildDatabaseConfig() { |
| const connectionString = readString("DATABASE_URL", ""); |
| const sslEnabled = readBool("DB_SSL", false) || readBool("PGSSL", false); |
| const sslRejectUnauthorized = readBool("DB_SSL_REJECT_UNAUTHORIZED", true); |
| const sslCaPath = readString("DB_SSL_CA_PATH", ""); |
|
|
| const base = { |
| max: readNumber("PGPOOL_MAX", 20), |
| idleTimeoutMillis: readNumber("PG_IDLE_TIMEOUT_MS", 30000), |
| connectionTimeoutMillis: readNumber("PG_CONNECT_TIMEOUT_MS", 15000), |
| }; |
|
|
| if (sslEnabled) { |
| base.ssl = { |
| rejectUnauthorized: sslRejectUnauthorized, |
| ca: readOptionalFile(sslCaPath, "DB SSL CA") || undefined, |
| }; |
| } |
|
|
| if (connectionString) { |
| return { |
| ...base, |
| connectionString, |
| }; |
| } |
|
|
| return { |
| ...base, |
| host: readString("PGHOST", "127.0.0.1"), |
| port: readNumber("PGPORT", 5432), |
| user: readString("PGUSER", "postgres"), |
| password: readString("PGPASSWORD", "postgres"), |
| database: readString("PGDATABASE", "smart_cargo"), |
| }; |
| } |
|
|
| const brokerFromUrl = parseBrokerUrl(); |
| const legacyBrokerHost = readString("MQTT_BROKER", ""); |
| const brokerHost = |
| readString("MQTT_BROKER_HOST", "") || |
| legacyBrokerHost || |
| (brokerFromUrl && brokerFromUrl.host) || |
| ""; |
| const brokerPort = readNumber( |
| "MQTT_BROKER_PORT", |
| readNumber("MQTT_PORT", (brokerFromUrl && brokerFromUrl.port) || 8883) |
| ); |
|
|
| const mqttCaPath = readString("MQTT_CA_PATH", ""); |
| const jwtAccessSecret = readString("JWT_ACCESS_SECRET", ""); |
| const jwtRefreshSecret = readString("JWT_REFRESH_SECRET", ""); |
|
|
| assertJwtSecret("JWT_ACCESS_SECRET", jwtAccessSecret); |
| assertJwtSecret("JWT_REFRESH_SECRET", jwtRefreshSecret); |
|
|
| const config = { |
| nodeEnv: readString("NODE_ENV", "development"), |
| logLevel: readString("LOG_LEVEL", "info"), |
| server: { |
| port: readNumber("PORT", 5000), |
| apiPrefix: readString("API_PREFIX", "/api"), |
| }, |
| cors: { |
| origin: readString("CORS_ORIGIN", "*"), |
| }, |
| auth: { |
| jwtAccessSecret, |
| jwtRefreshSecret, |
| jwtAccessExpiresIn: readString("JWT_ACCESS_EXPIRES_IN", "15m"), |
| jwtRefreshExpiresIn: readString("JWT_REFRESH_EXPIRES_IN", "7d"), |
| jwtIssuer: readString("JWT_ISSUER", "smart-cargo-backend"), |
| jwtAudience: readString("JWT_AUDIENCE", "smart-cargo-api"), |
| }, |
| mqtt: { |
| enabled: Boolean(brokerHost), |
| host: brokerHost, |
| port: brokerPort, |
| username: |
| readString("MQTT_USERNAME", "") || |
| readString("MQTT_USER", "") || |
| (brokerFromUrl && brokerFromUrl.username) || |
| "", |
| password: readString("MQTT_PASSWORD", "") || (brokerFromUrl && brokerFromUrl.password) || "", |
| topicFilter: readString( |
| "MQTT_TOPIC_FILTER", |
| "tenant/+/truck/+/container/+/telemetry" |
| ), |
| clientIdPrefix: readString("MQTT_CLIENT_ID_PREFIX", "smart-cargo-backend"), |
| connectTimeoutMs: readNumber("MQTT_CONNECT_TIMEOUT_MS", 30000), |
| reconnectPeriodMs: readNumber("MQTT_RECONNECT_PERIOD_MS", 5000), |
| rejectUnauthorized: readBool("MQTT_REJECT_UNAUTHORIZED", true), |
| caPath: mqttCaPath, |
| ca: readOptionalFile(mqttCaPath, "MQTT CA"), |
| }, |
| db: { |
| runMigrationsOnBoot: readBool("RUN_MIGRATIONS_ON_BOOT", false), |
| runSeedsOnBoot: readBool("RUN_SEEDS_ON_BOOT", false), |
| pg: buildDatabaseConfig(), |
| }, |
| alerts: { |
| highTemperatureThresholdC: readNumber("ALERT_HIGH_TEMPERATURE_THRESHOLD_C", 35), |
| gasSpikeThresholdRaw: readNumber("ALERT_GAS_SPIKE_THRESHOLD_RAW", 1500), |
| autoResolveShock: readBool("ALERT_AUTO_RESOLVE_SHOCK", false), |
| offlineThresholdMs: readNumber("OFFLINE_THRESHOLD_MS", 30000), |
| }, |
| jobs: { |
| offlineScanIntervalMs: readNumber("OFFLINE_SCAN_INTERVAL_MS", 5000), |
| }, |
| ingest: { |
| queueConcurrency: readNumber("INGEST_QUEUE_CONCURRENCY", 6), |
| queueMaxBacklog: readNumber("INGEST_QUEUE_MAX_BACKLOG", 5000), |
| }, |
| query: { |
| historyDefaultLimit: readNumber("HISTORY_DEFAULT_LIMIT", 240), |
| historyMaxLimit: readNumber("HISTORY_MAX_LIMIT", 2000), |
| }, |
| ai: { |
| tripSummaryEnabled: readBool("AI_TRIP_SUMMARY_ENABLED", true), |
| geminiModel: readString("GEMINI_MODEL", "gemini-flash-lite-latest"), |
| geminiApiBaseUrl: readString( |
| "GEMINI_API_BASE_URL", |
| "https://generativelanguage.googleapis.com/v1beta" |
| ), |
| geminiApiKeyBase64: readString("GEMINI_API_KEY_BASE64", ""), |
| tripSummaryTimeoutMs: readNumber("AI_TRIP_SUMMARY_TIMEOUT_MS", 15000), |
| }, |
| }; |
|
|
| module.exports = { config }; |
|
|