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'), JWT_REFRESH_EXPIRY: z.string().default('7d'), // 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'), // 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), // Container Registry GITEA_REGISTRY: z.string().default('gitea.bnkops.com/admin'), GITEA_REGISTRY_USER: z.string().default(''), GITEA_REGISTRY_PASS: z.string().default(''), // Gitea Docs Comments GITEA_COMMENTS_ENABLED: z.string().default('false'), GITEA_API_TOKEN: z.string().default(''), 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(''), // SMS Campaigns (Termux Android bridge) ENABLE_SMS: z.string().default('false'), 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), }); 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); } return result.data; } export const env = validateEnv();