package config import ( "fmt" "os" "strconv" "strings" ) type Config struct { HTTP HTTPConfig Postgres PostgresConfig Webhook WebhookConfig PlatformAdapters PlatformAdaptersConfig Runtime RuntimeConfig } type RuntimeConfig struct { Env string } type HTTPConfig struct { Addr string ReadHeaderTimeout int ReadTimeout int WriteTimeout int IdleTimeout int MaxHeaderBytes int MaxBodyBytes int64 } type PostgresConfig struct { Enabled bool DSN string MigrationDir string MaxOpenConns int MaxIdleConns int ConnMaxLifetime int } type WebhookConfig struct { Secret string TimestampHeader string SignatureHeader string MaxSkewSeconds int } type PlatformAdaptersConfig struct { Enabled bool Sub2API PlatformAdapterProfileConfig NewAPI PlatformAdapterProfileConfig } type PlatformAdapterProfileConfig struct { Enabled bool IngressSecret string CallbackBaseURL string CallbackSecret string CallbackTimeoutMS int CallbackMaxRetries int CallbackPollIntervalMS int CallbackBatchSize int CallbackRetrySchedule []int } func Load() (*Config, error) { cfg := &Config{ HTTP: HTTPConfig{ Addr: getEnv("AI_CS_ADDR", ":8080"), ReadHeaderTimeout: getEnvInt("AI_CS_READ_HEADER_TIMEOUT_SEC", 5), ReadTimeout: getEnvInt("AI_CS_READ_TIMEOUT_SEC", 10), WriteTimeout: getEnvInt("AI_CS_WRITE_TIMEOUT_SEC", 15), IdleTimeout: getEnvInt("AI_CS_IDLE_TIMEOUT_SEC", 60), MaxHeaderBytes: getEnvInt("AI_CS_MAX_HEADER_BYTES", 1<<20), MaxBodyBytes: getEnvInt64("AI_CS_MAX_BODY_BYTES", 1<<20), }, Postgres: PostgresConfig{ Enabled: getEnvBool("AI_CS_POSTGRES_ENABLED", false), DSN: getEnv("AI_CS_POSTGRES_DSN", ""), MigrationDir: getEnv("AI_CS_POSTGRES_MIGRATION_DIR", "db/migration"), MaxOpenConns: getEnvInt("AI_CS_POSTGRES_MAX_OPEN_CONNS", 20), MaxIdleConns: getEnvInt("AI_CS_POSTGRES_MAX_IDLE_CONNS", 5), ConnMaxLifetime: getEnvInt("AI_CS_POSTGRES_CONN_MAX_LIFETIME_SEC", 300), }, Webhook: WebhookConfig{ Secret: getEnv("AI_CS_WEBHOOK_SECRET", ""), TimestampHeader: getEnv("AI_CS_WEBHOOK_TIMESTAMP_HEADER", "X-CS-Timestamp"), SignatureHeader: getEnv("AI_CS_WEBHOOK_SIGNATURE_HEADER", "X-CS-Signature"), MaxSkewSeconds: getEnvInt("AI_CS_WEBHOOK_MAX_SKEW_SECONDS", 300), }, PlatformAdapters: PlatformAdaptersConfig{ Enabled: getEnvBool("AI_CS_PLATFORM_ADAPTERS_ENABLED", false), Sub2API: PlatformAdapterProfileConfig{ Enabled: getEnvBool("AI_CS_PLATFORM_SUB2API_ENABLED", false), IngressSecret: getEnv("AI_CS_PLATFORM_SUB2API_INGRESS_SECRET", ""), CallbackBaseURL: getEnv("AI_CS_PLATFORM_SUB2API_CALLBACK_BASE_URL", ""), CallbackSecret: getEnv("AI_CS_PLATFORM_SUB2API_CALLBACK_SECRET", ""), CallbackTimeoutMS: getEnvInt("AI_CS_PLATFORM_SUB2API_CALLBACK_TIMEOUT_MS", 3000), CallbackMaxRetries: getEnvInt("AI_CS_PLATFORM_SUB2API_CALLBACK_MAX_RETRIES", 5), CallbackPollIntervalMS: getEnvInt("AI_CS_PLATFORM_SUB2API_CALLBACK_POLL_INTERVAL_MS", 5000), CallbackBatchSize: getEnvInt("AI_CS_PLATFORM_SUB2API_CALLBACK_BATCH_SIZE", 20), CallbackRetrySchedule: getEnvIntList("AI_CS_PLATFORM_SUB2API_CALLBACK_RETRY_SCHEDULE_SEC", []int{10, 30, 60, 300, 900}), }, NewAPI: PlatformAdapterProfileConfig{ Enabled: getEnvBool("AI_CS_PLATFORM_NEWAPI_ENABLED", false), IngressSecret: getEnv("AI_CS_PLATFORM_NEWAPI_INGRESS_SECRET", ""), CallbackBaseURL: getEnv("AI_CS_PLATFORM_NEWAPI_CALLBACK_BASE_URL", ""), CallbackSecret: getEnv("AI_CS_PLATFORM_NEWAPI_CALLBACK_SECRET", ""), CallbackTimeoutMS: getEnvInt("AI_CS_PLATFORM_NEWAPI_CALLBACK_TIMEOUT_MS", 3000), CallbackMaxRetries: getEnvInt("AI_CS_PLATFORM_NEWAPI_CALLBACK_MAX_RETRIES", 5), CallbackPollIntervalMS: getEnvInt("AI_CS_PLATFORM_NEWAPI_CALLBACK_POLL_INTERVAL_MS", 5000), CallbackBatchSize: getEnvInt("AI_CS_PLATFORM_NEWAPI_CALLBACK_BATCH_SIZE", 20), CallbackRetrySchedule: getEnvIntList("AI_CS_PLATFORM_NEWAPI_CALLBACK_RETRY_SCHEDULE_SEC", []int{10, 30, 60, 300, 900}), }, }, Runtime: RuntimeConfig{ Env: normalizeRuntimeEnv(getEnv("AI_CS_RUNTIME_ENV", getEnv("AI_CS_ENV", "development"))), }, } if strings.TrimSpace(cfg.HTTP.Addr) == "" { return nil, fmt.Errorf("AI_CS_ADDR must not be empty") } if cfg.HTTP.MaxBodyBytes <= 0 { return nil, fmt.Errorf("AI_CS_MAX_BODY_BYTES must be positive") } if cfg.Postgres.Enabled && strings.TrimSpace(cfg.Postgres.DSN) == "" { return nil, fmt.Errorf("AI_CS_POSTGRES_DSN must not be empty when postgres is enabled") } if cfg.Webhook.MaxSkewSeconds <= 0 { return nil, fmt.Errorf("AI_CS_WEBHOOK_MAX_SKEW_SECONDS must be positive") } if err := validatePlatformProfile("sub2api", cfg.PlatformAdapters.Enabled, cfg.PlatformAdapters.Sub2API); err != nil { return nil, err } if err := validatePlatformProfile("newapi", cfg.PlatformAdapters.Enabled, cfg.PlatformAdapters.NewAPI); err != nil { return nil, err } if cfg.Runtime.Env != "production" && cfg.Runtime.Env != "development" && cfg.Runtime.Env != "test" { return nil, fmt.Errorf("AI_CS_RUNTIME_ENV must be one of production/development/test, got: %s", cfg.Runtime.Env) } if cfg.Runtime.Env == "production" && !cfg.Postgres.Enabled { return nil, fmt.Errorf("AI_CS_RUNTIME_ENV=production requires AI_CS_POSTGRES_ENABLED=true, but it is false (memory fallback is not allowed in production)") } if cfg.Runtime.Env == "production" && strings.TrimSpace(cfg.Webhook.Secret) == "" { return nil, fmt.Errorf("AI_CS_WEBHOOK_SECRET must not be empty in production") } return cfg, nil } func validatePlatformProfile(platform string, adaptersEnabled bool, profile PlatformAdapterProfileConfig) error { if !adaptersEnabled || !profile.Enabled { return nil } upperPlatform := strings.ToUpper(platform) if strings.TrimSpace(profile.IngressSecret) == "" { return fmt.Errorf("AI_CS_PLATFORM_%s_INGRESS_SECRET must not be empty when platform ingress is enabled", upperPlatform) } if profile.CallbackTimeoutMS <= 0 { return fmt.Errorf("AI_CS_PLATFORM_%s_CALLBACK_TIMEOUT_MS must be positive", upperPlatform) } if profile.CallbackMaxRetries < 0 { return fmt.Errorf("AI_CS_PLATFORM_%s_CALLBACK_MAX_RETRIES must not be negative", upperPlatform) } if profile.CallbackPollIntervalMS <= 0 { return fmt.Errorf("AI_CS_PLATFORM_%s_CALLBACK_POLL_INTERVAL_MS must be positive", upperPlatform) } if profile.CallbackBatchSize <= 0 { return fmt.Errorf("AI_CS_PLATFORM_%s_CALLBACK_BATCH_SIZE must be positive", upperPlatform) } if len(profile.CallbackRetrySchedule) == 0 { return fmt.Errorf("AI_CS_PLATFORM_%s_CALLBACK_RETRY_SCHEDULE_SEC must not be empty", upperPlatform) } for _, seconds := range profile.CallbackRetrySchedule { if seconds <= 0 { return fmt.Errorf("AI_CS_PLATFORM_%s_CALLBACK_RETRY_SCHEDULE_SEC must contain only positive integers", upperPlatform) } } return nil } func normalizeRuntimeEnv(value string) string { switch strings.TrimSpace(strings.ToLower(value)) { case "", "dev", "development": return "development" case "prod", "production": return "production" case "test": return "test" default: return strings.TrimSpace(strings.ToLower(value)) } } func getEnv(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func getEnvInt(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func getEnvInt64(key string, fallback int64) int64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil { return fallback } return parsed } func getEnvBool(key string, fallback bool) bool { value := strings.TrimSpace(strings.ToLower(os.Getenv(key))) if value == "" { return fallback } switch value { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: return fallback } } func getEnvIntList(key string, fallback []int) []int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return append([]int(nil), fallback...) } parts := strings.Split(value, ",") result := make([]int, 0, len(parts)) for _, part := range parts { parsed, err := strconv.Atoi(strings.TrimSpace(part)) if err != nil { return append([]int(nil), fallback...) } result = append(result, parsed) } return result }