import dotenv from 'dotenv'; import { z } from 'zod'; dotenv.config(); const envSchema = z.object({ // Server NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.coerce.number().default(4000), API_URL: z.string().default('http://localhost:4000'), ADMIN_URL: z.string().default('http://localhost:3000'), DOMAIN: z.string().default('cmlite.org'), // Logging LOG_DIR: z.string().default('/app/logs'), // Security CSP_ENABLED: z.string().default('false'), // Bunker Ops (Fleet Management) INSTANCE_LABEL: z.string().default(''), BUNKER_OPS_ENABLED: z.string().default('false'), BUNKER_OPS_REMOTE_WRITE_URL: z.string().default(''), // Database DATABASE_URL: z.string(), // Redis REDIS_URL: z.string().default('redis://redis-changemaker:6379'), // JWT JWT_ACCESS_SECRET: z.string().min(32), JWT_REFRESH_SECRET: z.string().min(32), JWT_INVITE_SECRET: z.string().min(32), JWT_ACCESS_EXPIRY: z.string().default('15m'), // Reduced 2026-04-12 from 7d → 24h. Stolen refresh tokens have a much tighter // exploitation window now; combined with device-fingerprint binding in // auth.service.ts, theft is materially harder to monetize. JWT_REFRESH_EXPIRY: z.string().default('24h'), // Encryption (for DB-stored secrets like SMTP password — required for all environments) ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'), // Gitea SSO cookie signing secret — MUST be distinct from JWT secrets. // Breaking change 2026-04-12: previously fell back to JWT_ACCESS_SECRET, which // meant a JWT leak compromised SSO cookies too. Now required (min 32 chars). GITEA_SSO_SECRET: z.string().min(32, 'GITEA_SSO_SECRET must be ≥32 chars; generate with: openssl rand -hex 32'), // Salt for deriving deterministic service passwords (Gitea, Rocket.Chat). // Breaking change 2026-04-12: previously fell back to JWT_ACCESS_SECRET. Now required. SERVICE_PASSWORD_SALT: z.string().min(32, 'SERVICE_PASSWORD_SALT must be ≥32 chars; generate with: openssl rand -hex 32'), // Initial Super Admin (auto-created during database seeding) INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'), INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS') .refine( (val) => val !== 'REQUIRED_STRONG_PASSWORD_CHANGE_THIS', { message: 'INITIAL_ADMIN_PASSWORD must be changed from the default placeholder value' }, ), // SMTP SMTP_HOST: z.string().default('mailhog-changemaker'), SMTP_PORT: z.coerce.number().default(1025), SMTP_USER: z.string().default(''), SMTP_PASS: z.string().default(''), SMTP_FROM: z.string().default('noreply@cmlite.org'), SMTP_FROM_NAME: z.string().default('Changemaker Lite'), EMAIL_TEST_MODE: z.string().default('true'), TEST_EMAIL_RECIPIENT: z.string().default('admin@cmlite.org'), // Listmonk LISTMONK_URL: z.string().default('http://listmonk-app:9000'), LISTMONK_ADMIN_USER: z.string().default('admin'), LISTMONK_ADMIN_PASSWORD: z.string().default(''), LISTMONK_SYNC_ENABLED: z.string().default('false'), LISTMONK_WEBHOOK_SECRET: z.string().default(''), LISTMONK_PROXY_PORT: z.coerce.number().default(9002), // Represent API (Canadian electoral data) REPRESENT_API_URL: z.string().default('https://represent.opennorth.ca'), // CORS CORS_ORIGINS: z.string().default('http://localhost:3000'), // Rate Limiting RATE_LIMIT_WINDOW_MS: z.coerce.number().default(15 * 60 * 1000), RATE_LIMIT_MAX: z.coerce.number().default(500), // Geocoding MAPBOX_API_KEY: z.string().optional(), GEOCODING_RATE_LIMIT_MS: z.coerce.number().default(1100), GEOCODING_CACHE_ENABLED: z.string().default('true'), GEOCODING_CACHE_TTL_HOURS: z.coerce.number().default(24), // Phase 2: Performance & Accuracy GOOGLE_MAPS_API_KEY: z.string().optional(), GOOGLE_MAPS_ENABLED: z.string().default('false'), GEOCODING_PARALLEL_ENABLED: z.string().default('true'), GEOCODING_BATCH_SIZE: z.coerce.number().default(10), // Bulk Re-Geocoding (Phase 3) BULK_GEOCODE_ENABLED: z.string().default('true'), BULK_GEOCODE_MAX_BATCH: z.coerce.number().default(5000), // Platform Services (NocoDB, n8n, Gitea) NOCODB_URL: z.string().default('http://changemaker-v2-nocodb:8080'), NOCODB_PORT: z.coerce.number().default(8091), NOCODB_EMBED_PORT: z.coerce.number().default(8881), N8N_URL: z.string().default('http://n8n-changemaker:5678'), N8N_PORT: z.coerce.number().default(5678), N8N_EMBED_PORT: z.coerce.number().default(8882), GITEA_URL: z.string().default('http://gitea-changemaker:3000'), GITEA_PORT: z.coerce.number().default(3030), GITEA_EMBED_PORT: z.coerce.number().default(8883), // MailHog (email testing UI) MAILHOG_URL: z.string().default('http://mailhog-changemaker:8025'), MAILHOG_EMBED_PORT: z.coerce.number().default(8884), // Mini QR (QR code generator) MINI_QR_URL: z.string().default('http://mini-qr:8080'), MINI_QR_PORT: z.coerce.number().default(8089), MINI_QR_EMBED_PORT: z.coerce.number().default(8885), // Excalidraw (collaborative whiteboard) EXCALIDRAW_URL: z.string().default('http://excalidraw-changemaker:80'), EXCALIDRAW_PORT: z.coerce.number().default(8090), EXCALIDRAW_EMBED_PORT: z.coerce.number().default(8886), // Homepage (service dashboard) HOMEPAGE_URL: z.string().default('http://homepage-changemaker:3000'), HOMEPAGE_EMBED_PORT: z.coerce.number().default(8887), // Vaultwarden (password manager) VAULTWARDEN_URL: z.string().default('http://vaultwarden-changemaker:80'), VAULTWARDEN_ADMIN_TOKEN: z.string().default(''), VAULTWARDEN_EMBED_PORT: z.coerce.number().default(8890), // Rocket.Chat (team chat) ROCKETCHAT_URL: z.string().default('http://rocketchat-changemaker:3000'), ROCKETCHAT_ADMIN_USER: z.string().default(''), ROCKETCHAT_ADMIN_PASSWORD: z.string().default(''), ROCKETCHAT_EMBED_PORT: z.coerce.number().default(8891), ENABLE_CHAT: z.string().default('false'), // Gancio (event management) GANCIO_URL: z.string().default('http://gancio-changemaker:13120'), GANCIO_PORT: z.coerce.number().default(8092), GANCIO_EMBED_PORT: z.coerce.number().default(8892), GANCIO_ADMIN_USER: z.string().default('admin'), GANCIO_ADMIN_PASSWORD: z.string().default(''), GANCIO_SYNC_ENABLED: z.string().default('false'), // Jitsi Meet (video conferencing) ENABLE_MEET: z.string().default('false'), JITSI_APP_ID: z.string().default('changemaker'), JITSI_APP_SECRET: z.string().default(''), JITSI_URL: z.string().default('http://jitsi-web-changemaker:80'), JITSI_EMBED_PORT: z.coerce.number().default(8893), // Pangolin (tunnel / reverse proxy) PANGOLIN_API_URL: z.string() .default('') .refine( (url) => !url || url.startsWith('https://'), { message: 'PANGOLIN_API_URL must use HTTPS for secure credential transmission' } ), PANGOLIN_API_KEY: z.string().default(''), PANGOLIN_ORG_ID: z.string().default(''), PANGOLIN_SITE_ID: z.string().default(''), PANGOLIN_ENDPOINT: z.string().default(''), PANGOLIN_NEWT_ID: z.string().default(''), PANGOLIN_NEWT_SECRET: z.string().default(''), // NAR (National Address Register) NAR_DATA_DIR: z.string().default('/data'), // Overpass / Area Import OVERPASS_API_URL: z.string().default('https://overpass-api.de/api/interpreter'), OVERPASS_MIN_DELAY_MS: z.coerce.number().default(30000), AREA_IMPORT_MAX_GRID_POINTS: z.coerce.number().default(500), // Payments (Stripe) ENABLE_PAYMENTS: z.string().default('false'), // Media Management ENABLE_MEDIA_FEATURES: z.string().default('false'), MEDIA_API_PORT: z.coerce.number().default(4100), MEDIA_API_PUBLIC_URL: z.string().default('http://media-api:4100'), MEDIA_ROOT: z.string().default('/media/library'), MEDIA_UPLOADS: z.string().default('/media/uploads'), MAX_UPLOAD_SIZE_GB: z.coerce.number().default(10), // HLS adaptive bitrate transcoding. When false, uploads are not enqueued // for transcoding (the worker stays registered so PENDING jobs from a // previous run still process if the flag is flipped back on). MP4 range- // request streaming continues to work as a fallback for un-transcoded // videos regardless of this flag. ENABLE_HLS_TRANSCODE: z.string().default('false'), // Container Registry (remote — gitea.bnkops.com) GITEA_REGISTRY: z.string().default('gitea.bnkops.com/admin'), GITEA_REGISTRY_USER: z.string().default(''), GITEA_REGISTRY_PASS: z.string().default(''), GITEA_REGISTRY_API_TOKEN: z.string().default(''), // For release uploads (build-release.sh) // Gitea Docs Comments (local platform instance) GITEA_COMMENTS_ENABLED: z.string().default('false'), GITEA_API_TOKEN: z.string().default(''), // Local Gitea — NOT the remote registry GITEA_COMMENTS_REPO_OWNER: z.string().default(''), GITEA_COMMENTS_REPO_NAME: z.string().default('docs-comments'), GITEA_OAUTH_CLIENT_ID: z.string().default(''), GITEA_OAUTH_CLIENT_SECRET: z.string().default(''), // Gitea Docs Version History GITEA_DOCS_REPO: z.string().default('admin/changemaker.lite'), GITEA_DOCS_PREFIX: z.string().default('mkdocs/docs'), GITEA_DOCS_BRANCH: z.string().default('v2'), // Gitea Auto-Setup (password used once to create API token, then cleared) GITEA_ADMIN_PASSWORD: z.string().default(''), // SMS Campaigns (Termux Android bridge) ENABLE_SMS: z.string().default('false'), // Social, People, Analytics (initial defaults; DB authoritative once admin saves) ENABLE_SOCIAL: z.string().default('false'), ENABLE_PEOPLE: z.string().default('false'), ENABLE_ANALYTICS: z.string().default('false'), // CCP Agent (remote management) ENABLE_CCP_AGENT: z.string().default('false'), CCP_URL: z.string().default(''), CCP_AGENT_URL: z.string().default(''), COMPOSE_PROFILES: z.string().default(''), TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'), TERMUX_API_KEY: z.string().default(''), SMS_DELAY_BETWEEN_MS: z.coerce.number().default(3000), SMS_MAX_RETRIES: z.coerce.number().default(3), SMS_RESPONSE_SYNC_INTERVAL_MS: z.coerce.number().default(30000), SMS_DEVICE_MONITOR_INTERVAL_MS: z.coerce.number().default(30000), // Docs / Code Server CODE_SERVER_URL: z.string().default('http://code-server-changemaker:8443'), CODE_SERVER_PORT: z.coerce.number().default(8888), MKDOCS_PREVIEW_URL: z.string().default('http://mkdocs-changemaker:8000'), MKDOCS_PORT: z.coerce.number().default(4003), MKDOCS_DOCS_PATH: z.string().default('/mkdocs/docs'), MKDOCS_CONFIG_PATH: z.string().default('/mkdocs/mkdocs.yml'), MKDOCS_CONTAINER_NAME: z.string().default('mkdocs-changemaker'), MKDOCS_SITE_SERVER_URL: z.string().default('http://mkdocs-site-server-changemaker:80'), MKDOCS_SITE_SERVER_PORT: z.coerce.number().default(4004), // Docker (container status dashboard + service management) DOCKER_PROXY_URL: z.string().default('http://docker-socket-proxy:2375'), DOCKER_NETWORK_NAME: z.string().default('changemaker-lite'), NEWT_CONTAINER_NAME: z.string().default('newt-changemaker'), NEWT_COMPOSE_SERVICE: z.string().default('newt'), // Monitoring Services (behind 'monitoring' profile) PROMETHEUS_URL: z.string().default('http://prometheus-changemaker:9090'), PROMETHEUS_PORT: z.coerce.number().default(9090), GRAFANA_URL: z.string().default('http://grafana-changemaker:3000'), GRAFANA_PORT: z.coerce.number().default(3005), GRAFANA_EMBED_PORT: z.coerce.number().default(8894), ALERTMANAGER_URL: z.string().default('http://alertmanager-changemaker:9093'), ALERTMANAGER_PORT: z.coerce.number().default(9093), ALERTMANAGER_EMBED_PORT: z.coerce.number().default(8895), CADVISOR_URL: z.string().default('http://cadvisor-changemaker:8080'), CADVISOR_PORT: z.coerce.number().default(8086), NODE_EXPORTER_URL: z.string().default('http://node-exporter-changemaker:9100'), NODE_EXPORTER_PORT: z.coerce.number().default(9100), REDIS_EXPORTER_URL: z.string().default('http://redis-exporter-changemaker:9121'), REDIS_EXPORTER_PORT: z.coerce.number().default(9121), GOTIFY_URL: z.string().default('http://gotify-changemaker:80'), GOTIFY_PORT: z.coerce.number().default(8889), // GeoIP (MaxMind GeoLite2) MAXMIND_ACCOUNT_ID: z.string().default(''), MAXMIND_LICENSE_KEY: z.string().default(''), GEOIP_DB_PATH: z.string().default('/data/geoip/GeoLite2-City.mmdb'), // JSN -> cmlite bridge shared secret. Must match JSN's CMLITE_BRIDGE_SECRET. // Required for /api/auth/bridge/users and other /api/*/bridge/* endpoints to // accept calls. Generate with: openssl rand -hex 32 JSN_BRIDGE_SECRET: z.string().min(32).optional(), }); export type Env = z.infer; function validateEnv(): Env { const result = envSchema.safeParse(process.env); if (!result.success) { console.error('Invalid environment variables:'); console.error(result.error.flatten().fieldErrors); process.exit(1); } // GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT are now validated as required // via .min(32) above — no more silent JWT_ACCESS_SECRET fallback. If either is // missing, the schema check above exits with a clear error. return result.data; } export const env = validateEnv();