package config import ( "fmt" "os" "strconv" "time" "github.com/joho/godotenv" ) // Config holds all application configuration. type Config struct { Port string DatabaseURL string DBDriver string // "postgres" (default) or "sqlite" JWTSecret string Environment string BasePath string // URL path prefix (e.g. "/dev", "/test", or "" for root) // Admin bootstrap (optional — set via K8s secret) AdminUsername string AdminPassword string AdminEmail string // Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2" SeedUsers string // API key encryption (required+) // Used to derive AES-256 key for global/team provider API keys. // Personal keys use per-user UEK (derived from password). EncryptionKey string // File storage // STORAGE_BACKEND: "pvc" or "s3". Empty = auto-detect (pvc if path writable). // STORAGE_PATH: mount point for PVC backend (default /data/storage). StorageBackend string StoragePath string // S3-compatible storage // Works with AWS S3, MinIO, Ceph RGW, or any S3-compatible API. S3Endpoint string // custom endpoint URL (required for MinIO/Ceph, optional for AWS) S3Bucket string // bucket name (required when STORAGE_BACKEND=s3) S3Region string // AWS region (default "us-east-1") S3AccessKey string // access key ID S3SecretKey string // secret access key S3Prefix string // optional key prefix within bucket (e.g. "switchboard/") S3ForcePathStyle bool // use path-style URLs (required for MinIO, most self-hosted) // Structured logging // LOG_FORMAT: "text" (default, backward-compatible) or "json" (structured). // LOG_LEVEL: "debug", "info" (default), "warn", "error". LogFormat string LogLevel string // Auth mode: "builtin" (default) | "mtls" | "oidc" AuthMode string // mTLS // Headers injected by TLS-terminating reverse proxy. MTLSHeaderDN string // default "X-SSL-Client-DN" MTLSHeaderVerify string // default "X-SSL-Client-Verify" MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint" MTLSAutoActivate bool // auto-activate new users (default true) MTLSDefaultTeam string // team ID for auto-provisioned users (optional) // Bundled packages // SKIP_BUNDLED_PACKAGES: set true to disable auto-install of bundled packages on first run. // BUNDLED_PACKAGES_DIR: directory containing pre-built .pkg archives (default /app/bundled-packages). // BUNDLED_PACKAGES: controls which bundled packages are auto-installed: // empty → curated default set (notes, chat-core, workflow-chat, dashboard, demo workflows) // "*" → install all .pkg archives found in the directory // "a,b" → comma-separated allowlist of specific package IDs SkipBundledPackages bool BundledPackagesDir string BundledPackages string // OIDC OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard" OIDCExternalIssuerURL string // OIDC_EXTERNAL_ISSUER_URL OIDCClientID string OIDCClientSecret string OIDCRedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback" OIDCAutoActivate bool // auto-activate new users (default true) OIDCDefaultTeam string // team ID for auto-provisioned users (optional) OIDCRolesClaim string // JWT claim for role mapping (default "realm_access.roles") OIDCGroupsClaim string // JWT claim for group sync (default "groups") OIDCAdminRole string // IdP role value that maps to admin (default "admin") // Cluster registry // PG-backed node registry for horizontal scaling. No-op on SQLite. // CLUSTER_NODE_ID: override for deterministic identity (default: hostname-PID). // CLUSTER_HEARTBEAT_INTERVAL: tick frequency (default "10s"). // CLUSTER_STALE_THRESHOLD: 3x heartbeat = stale (default "30s"). // CLUSTER_ENDPOINT: advertised address for peer mesh (Phase 2, auto-detect). ClusterNodeID string ClusterHeartbeatInterval time.Duration ClusterStaleThreshold time.Duration ClusterEndpoint string } // Load reads configuration from environment variables. // A .env file is loaded if present but not required. func Load() *Config { // Best-effort .env load (not required in production) _ = godotenv.Load() return &Config{ Port: getEnv("PORT", "8080"), DatabaseURL: resolveDatabaseURL(), DBDriver: getEnv("DB_DRIVER", ""), JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"), Environment: getEnv("ENVIRONMENT", "development"), BasePath: sanitizeBasePath(getEnv("BASE_PATH", "")), AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""), AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""), AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""), SeedUsers: getEnv("SEED_USERS", ""), EncryptionKey: getEnv("ENCRYPTION_KEY", ""), StorageBackend: getEnv("STORAGE_BACKEND", ""), StoragePath: getEnv("STORAGE_PATH", "/data/storage"), S3Endpoint: getEnv("S3_ENDPOINT", ""), S3Bucket: getEnv("S3_BUCKET", ""), S3Region: getEnv("S3_REGION", "us-east-1"), S3AccessKey: getEnv("S3_ACCESS_KEY", ""), S3SecretKey: getEnv("S3_SECRET_KEY", ""), S3Prefix: getEnv("S3_PREFIX", ""), S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true", SkipBundledPackages: getEnvBool("SKIP_BUNDLED_PACKAGES", false), BundledPackagesDir: getEnv("BUNDLED_PACKAGES_DIR", "/app/bundled-packages"), BundledPackages: getEnv("BUNDLED_PACKAGES", ""), LogFormat: getEnv("LOG_FORMAT", "text"), LogLevel: getEnv("LOG_LEVEL", "info"), AuthMode: getEnv("AUTH_MODE", "builtin"), // mTLS MTLSHeaderDN: getEnv("MTLS_HEADER_DN", "X-SSL-Client-DN"), MTLSHeaderVerify: getEnv("MTLS_HEADER_VERIFY", "X-SSL-Client-Verify"), MTLSHeaderFingerprint: getEnv("MTLS_HEADER_FINGERPRINT", "X-SSL-Client-Fingerprint"), MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true), MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""), // OIDC OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""), OIDCExternalIssuerURL: getEnv("OIDC_EXTERNAL_ISSUER_URL", ""), OIDCClientID: getEnv("OIDC_CLIENT_ID", ""), OIDCClientSecret: getEnv("OIDC_CLIENT_SECRET", ""), OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""), OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true), OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""), OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"), OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"), OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"), // Cluster ClusterNodeID: getEnv("CLUSTER_NODE_ID", ""), ClusterHeartbeatInterval: getEnvDuration("CLUSTER_HEARTBEAT_INTERVAL", 10*time.Second), ClusterStaleThreshold: getEnvDuration("CLUSTER_STALE_THRESHOLD", 30*time.Second), ClusterEndpoint: getEnv("CLUSTER_ENDPOINT", ""), } } // resolveDatabaseURL returns the database connection string. // // Resolution order: // 1. DATABASE_URL — explicit DSN (K8s, .env, manual) // 2. POSTGRES_* vars — assembled from individual env vars: // POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB // (set by K8s manifest and docker-compose) // 3. Empty string — backend runs without database func resolveDatabaseURL() string { if dsn := os.Getenv("DATABASE_URL"); dsn != "" { return dsn } // Assemble from individual vars (K8s sets POSTGRES_*, docker-compose may too) host := getEnv("POSTGRES_HOST", "") if host == "" { return "" } port := getEnv("POSTGRES_PORT", "5432") user := getEnv("POSTGRES_USER", "") pass := getEnv("POSTGRES_PASSWORD", "") db := getEnv("POSTGRES_DB", "switchboard_core") ssl := getEnv("POSTGRES_SSLMODE", "disable") return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", user, pass, host, port, db, ssl) } // sanitizeBasePath ensures the path starts with / and doesn't end with /. // Empty string means root (no prefix). func sanitizeBasePath(p string) string { if p == "" || p == "/" { return "" } // Ensure leading slash if p[0] != '/' { p = "/" + p } // Strip trailing slash for len(p) > 1 && p[len(p)-1] == '/' { p = p[:len(p)-1] } return p } func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func getEnvInt(key string, fallback int) int { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil { return n } } return fallback } func getEnvDuration(key string, fallback time.Duration) time.Duration { if v := os.Getenv(key); v != "" { if d, err := time.ParseDuration(v); err == nil { return d } } return fallback } func getEnvBool(key string, fallback bool) bool { if v := os.Getenv(key); v != "" { b, err := strconv.ParseBool(v) if err == nil { return b } } return fallback }