// Package config loads and validates process configuration. package config import ( "fmt" "net/url" "os" "strconv" "strings" "time" "unicode" ) const ( // HTTPAddressEnv is the environment variable used to configure the listen address. HTTPAddressEnv = "FIRE_SAFETY_HTTP_ADDR" SuperAgentEnabledEnv = "FIRE_SAFETY_SUPERAGENT_ENABLED" SuperAgentBaseURLEnv = "FIRE_SAFETY_SUPERAGENT_BASE_URL" SuperAgentOpenAPIKeyEnv = "FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY" SuperAgentConnectTimeoutEnv = "FIRE_SAFETY_SUPERAGENT_CONNECT_TIMEOUT" SuperAgentRecoveryMaxAttemptsEnv = "FIRE_SAFETY_SUPERAGENT_RECOVERY_MAX_ATTEMPTS" SuperAgentRecoveryInitialBackoffEnv = "FIRE_SAFETY_SUPERAGENT_RECOVERY_INITIAL_BACKOFF" SuperAgentMaxMessageBytesEnv = "FIRE_SAFETY_SUPERAGENT_MAX_MESSAGE_BYTES" SuperAgentProbeSubjectIDEnv = "FIRE_SAFETY_SUPERAGENT_PROBE_SUBJECT_ID" SuperAgentProbeTimeoutEnv = "FIRE_SAFETY_SUPERAGENT_PROBE_TIMEOUT" ChatEnabledEnv = "FIRE_SAFETY_CHAT_ENABLED" ChatPageEnabledEnv = "FIRE_SAFETY_CHAT_PAGE_ENABLED" ChatAuthTokenEnv = "FIRE_SAFETY_CHAT_AUTH_TOKEN" ChatAllowLegacyShortTokenEnv = "FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN" ChatSubjectIDEnv = "FIRE_SAFETY_CHAT_SUBJECT_ID" ChatAllowedOriginsEnv = "FIRE_SAFETY_CHAT_ALLOWED_ORIGINS" ChatMaxBodyBytesEnv = "FIRE_SAFETY_CHAT_MAX_BODY_BYTES" ChatRunTimeoutEnv = "FIRE_SAFETY_CHAT_RUN_TIMEOUT" ChatSessionTTLEnv = "FIRE_SAFETY_CHAT_SESSION_TTL" ChatMaxSessionsEnv = "FIRE_SAFETY_CHAT_MAX_SESSIONS" ChatCompatAppIDEnv = "FIRE_SAFETY_CHAT_COMPAT_APP_ID" MCPEnabledEnv = "FIRE_SAFETY_MCP_ENABLED" MCPAuthTokenEnv = "FIRE_SAFETY_MCP_AUTH_TOKEN" MCPScopeModeEnv = "FIRE_SAFETY_MCP_SCOPE_MODE" MCPAllowedTownsEnv = "FIRE_SAFETY_MCP_ALLOWED_TOWNS" MCPMaxBodyBytesEnv = "FIRE_SAFETY_MCP_MAX_BODY_BYTES" MCPToolTimeoutEnv = "FIRE_SAFETY_MCP_TOOL_TIMEOUT" PostGISEnabledEnv = "FIRE_SAFETY_POSTGIS_ENABLED" PostGISDSNEnv = "FIRE_SAFETY_POSTGIS_DSN" PostGISExpectedSRIDEnv = "FIRE_SAFETY_POSTGIS_EXPECTED_SRID" PostGISConnectTimeoutEnv = "FIRE_SAFETY_POSTGIS_CONNECT_TIMEOUT" PostGISQueryTimeoutEnv = "FIRE_SAFETY_POSTGIS_QUERY_TIMEOUT" PostGISMaxConnsEnv = "FIRE_SAFETY_POSTGIS_MAX_CONNS" defaultHTTPAddress = ":8080" defaultSuperAgentConnectTimeout = 15 * time.Second defaultSuperAgentRecoveryMaxAttempts = 5 maximumSuperAgentRecoveryAttempts = 20 defaultSuperAgentRecoveryBackoff = 250 * time.Millisecond defaultSuperAgentMaxMessageBytes int64 = 64 * 1024 maximumSuperAgentMessageBytes int64 = 16 * 1024 * 1024 defaultSuperAgentProbeSubjectID = "fire-safety-ymd-connectivity-probe" defaultSuperAgentProbeTimeout = 10 * time.Minute defaultChatSubjectID = "fire-safety-ymd-chat-test-subject" defaultChatMaxBodyBytes int64 = 128 * 1024 maximumChatBodyBytes int64 = 1024 * 1024 defaultChatRunTimeout = 10 * time.Minute maximumChatRunTimeout = 30 * time.Minute defaultChatSessionTTL = 30 * time.Minute maximumChatSessionTTL = 24 * time.Hour defaultChatMaxSessions int64 = 1000 maximumChatMaxSessions int64 = 10_000 maximumChatAllowedOrigins = 50 defaultMCPMaxBodyBytes int64 = 256 * 1024 maximumMCPBodyBytes int64 = 1024 * 1024 defaultMCPToolTimeout = 5 * time.Second maximumMCPToolTimeout = 30 * time.Second defaultMCPScopeMode = MCPScopeModeTownAllowlist defaultPostGISConnectTimeout = 5 * time.Second defaultPostGISQueryTimeout = 3 * time.Second maximumPostGISTimeout = 30 * time.Second defaultPostGISMaxConns int64 = 4 maximumPostGISMaxConns int64 = 20 requiredMCPSpatialSRID = 4326 ) // MCPScopeMode defines the trusted server-side data scope applied to every MCP query. type MCPScopeMode string const ( // MCPScopeModeTownAllowlist restricts results to configured town names. MCPScopeModeTownAllowlist MCPScopeMode = "town_allowlist" // MCPScopeModeAll permits every town in the MCP's fixed query tables. MCPScopeModeAll MCPScopeMode = "all" ) // Config contains immutable application configuration. type Config struct { HTTPAddress string SuperAgent SuperAgentConfig Chat ChatConfig MCP MCPConfig PostGIS PostGISConfig } // SuperAgentConfig contains outbound SuperAgent Open API settings. type SuperAgentConfig struct { Enabled bool BaseURL string OpenAPIKey string ConnectTimeout time.Duration RecoveryMaxAttempts int RecoveryInitialBackoff time.Duration MaxMessageBytes int64 ProbeSubjectID string ProbeTimeout time.Duration } // ChatConfig contains the protected user-facing chat transport and in-memory // conversation settings. The static credential is a test-stage boundary, not // final end-user authentication. type ChatConfig struct { Enabled bool PageEnabled bool AuthToken string AllowLegacyShortToken bool SubjectID string CompatAppID string AllowedOrigins []string MaxBodyBytes int64 RunTimeout time.Duration SessionTTL time.Duration MaxSessions int } // MCPConfig contains inbound SuperAgent MCP settings. type MCPConfig struct { Enabled bool AuthToken string ScopeMode MCPScopeMode AllowedTowns []string MaxBodyBytes int64 ToolTimeout time.Duration } // PostGISConfig contains PostgreSQL/PostGIS connection and query settings. type PostGISConfig struct { Enabled bool DSN string ExpectedSRID int ConnectTimeout time.Duration QueryTimeout time.Duration MaxConns int32 } // Load reads configuration from the environment, applies safe defaults, and validates it. func Load() (Config, error) { address := strings.TrimSpace(os.Getenv(HTTPAddressEnv)) if address == "" { address = defaultHTTPAddress } enabled, err := parseBool(SuperAgentEnabledEnv, false) if err != nil { return Config{}, err } connectTimeout, err := parseDuration(SuperAgentConnectTimeoutEnv, defaultSuperAgentConnectTimeout) if err != nil { return Config{}, err } recoveryMaxAttempts, err := parseInt(SuperAgentRecoveryMaxAttemptsEnv, defaultSuperAgentRecoveryMaxAttempts) if err != nil { return Config{}, err } recoveryInitialBackoff, err := parseDuration(SuperAgentRecoveryInitialBackoffEnv, defaultSuperAgentRecoveryBackoff) if err != nil { return Config{}, err } maxMessageBytes, err := parseInt64(SuperAgentMaxMessageBytesEnv, defaultSuperAgentMaxMessageBytes) if err != nil { return Config{}, err } probeTimeout, err := parseDuration(SuperAgentProbeTimeoutEnv, defaultSuperAgentProbeTimeout) if err != nil { return Config{}, err } superAgent := SuperAgentConfig{ Enabled: enabled, BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv(SuperAgentBaseURLEnv)), "/"), OpenAPIKey: strings.TrimSpace(os.Getenv(SuperAgentOpenAPIKeyEnv)), ConnectTimeout: connectTimeout, RecoveryMaxAttempts: recoveryMaxAttempts, RecoveryInitialBackoff: recoveryInitialBackoff, MaxMessageBytes: maxMessageBytes, ProbeSubjectID: valueOrDefault(SuperAgentProbeSubjectIDEnv, defaultSuperAgentProbeSubjectID), ProbeTimeout: probeTimeout, } if err := validateSuperAgent(superAgent); err != nil { return Config{}, err } chat, err := loadChatConfig() if err != nil { return Config{}, err } mcp, err := loadMCPConfig() if err != nil { return Config{}, err } postGIS, err := loadPostGISConfig() if err != nil { return Config{}, err } if err := validateMCPDependencies(mcp, postGIS, superAgent); err != nil { return Config{}, err } if err := validateChatDependencies(chat, superAgent, mcp); err != nil { return Config{}, err } return Config{ HTTPAddress: address, SuperAgent: superAgent, Chat: chat, MCP: mcp, PostGIS: postGIS, }, nil } func loadChatConfig() (ChatConfig, error) { enabled, err := parseBool(ChatEnabledEnv, false) if err != nil { return ChatConfig{}, err } pageEnabled, err := parseBool(ChatPageEnabledEnv, false) if err != nil { return ChatConfig{}, err } allowLegacyShortToken, err := parseBool(ChatAllowLegacyShortTokenEnv, false) if err != nil { return ChatConfig{}, err } maxBodyBytes, err := parseInt64(ChatMaxBodyBytesEnv, defaultChatMaxBodyBytes) if err != nil { return ChatConfig{}, err } runTimeout, err := parseDuration(ChatRunTimeoutEnv, defaultChatRunTimeout) if err != nil { return ChatConfig{}, err } sessionTTL, err := parseDuration(ChatSessionTTLEnv, defaultChatSessionTTL) if err != nil { return ChatConfig{}, err } maxSessions, err := parseInt64(ChatMaxSessionsEnv, defaultChatMaxSessions) if err != nil { return ChatConfig{}, err } allowedOrigins, err := normalizeChatOrigins(parseList(os.Getenv(ChatAllowedOriginsEnv))) if err != nil { return ChatConfig{}, err } cfg := ChatConfig{ Enabled: enabled, PageEnabled: pageEnabled, AuthToken: os.Getenv(ChatAuthTokenEnv), AllowLegacyShortToken: allowLegacyShortToken, SubjectID: valueOrDefault(ChatSubjectIDEnv, defaultChatSubjectID), CompatAppID: strings.TrimSpace(os.Getenv(ChatCompatAppIDEnv)), AllowedOrigins: allowedOrigins, MaxBodyBytes: maxBodyBytes, RunTimeout: runTimeout, SessionTTL: sessionTTL, MaxSessions: int(maxSessions), } if cfg.MaxBodyBytes <= 0 || cfg.MaxBodyBytes > maximumChatBodyBytes { return ChatConfig{}, fmt.Errorf("%s must be between 1 and %d", ChatMaxBodyBytesEnv, maximumChatBodyBytes) } if cfg.RunTimeout <= 0 || cfg.RunTimeout > maximumChatRunTimeout { return ChatConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", ChatRunTimeoutEnv, maximumChatRunTimeout) } if cfg.SessionTTL <= 0 || cfg.SessionTTL > maximumChatSessionTTL { return ChatConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", ChatSessionTTLEnv, maximumChatSessionTTL) } if maxSessions <= 0 || maxSessions > maximumChatMaxSessions { return ChatConfig{}, fmt.Errorf("%s must be between 1 and %d", ChatMaxSessionsEnv, maximumChatMaxSessions) } if strings.TrimSpace(cfg.SubjectID) == "" || len(cfg.SubjectID) > 512 || containsControlCharacter(cfg.SubjectID) { return ChatConfig{}, fmt.Errorf("%s must be a non-empty value of at most 512 bytes without control characters", ChatSubjectIDEnv) } if cfg.CompatAppID != "" && !validChatCompatAppID(cfg.CompatAppID) { return ChatConfig{}, fmt.Errorf("%s must contain 1 to 128 ASCII letters, digits, underscores, or hyphens", ChatCompatAppIDEnv) } if cfg.Enabled && !validChatAuthToken(cfg.AuthToken, cfg.AllowLegacyShortToken) { if cfg.AllowLegacyShortToken { return ChatConfig{}, fmt.Errorf("%s must contain 1 to 4096 printable ASCII characters when %s is true", ChatAuthTokenEnv, ChatAllowLegacyShortTokenEnv) } return ChatConfig{}, fmt.Errorf("%s must contain 32 to 4096 printable ASCII characters", ChatAuthTokenEnv) } return cfg, nil } func validChatAuthToken(value string, allowLegacyShortToken bool) bool { if !validSecretHeaderValue(value) { return false } return allowLegacyShortToken || len(value) >= 32 } func validChatCompatAppID(value string) bool { if value == "" || len(value) > 128 { return false } for _, character := range value { if !(character >= 'a' && character <= 'z') && !(character >= 'A' && character <= 'Z') && !(character >= '0' && character <= '9') && character != '_' && character != '-' { return false } } return true } func normalizeChatOrigins(values []string) ([]string, error) { if len(values) > maximumChatAllowedOrigins { return nil, fmt.Errorf("%s must contain no more than %d values", ChatAllowedOriginsEnv, maximumChatAllowedOrigins) } normalized := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { parsed, err := url.Parse(value) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") || value == "*" { return nil, fmt.Errorf("%s must contain only exact HTTP(S) origins", ChatAllowedOriginsEnv) } origin := parsed.Scheme + "://" + parsed.Host if _, exists := seen[origin]; exists { continue } seen[origin] = struct{}{} normalized = append(normalized, origin) } return normalized, nil } func loadMCPConfig() (MCPConfig, error) { enabled, err := parseBool(MCPEnabledEnv, false) if err != nil { return MCPConfig{}, err } scopeMode, err := parseMCPScopeMode(os.Getenv(MCPScopeModeEnv)) if err != nil { return MCPConfig{}, err } maxBodyBytes, err := parseInt64(MCPMaxBodyBytesEnv, defaultMCPMaxBodyBytes) if err != nil { return MCPConfig{}, err } toolTimeout, err := parseDuration(MCPToolTimeoutEnv, defaultMCPToolTimeout) if err != nil { return MCPConfig{}, err } cfg := MCPConfig{ Enabled: enabled, AuthToken: strings.TrimSpace(os.Getenv(MCPAuthTokenEnv)), ScopeMode: scopeMode, AllowedTowns: parseList(os.Getenv(MCPAllowedTownsEnv)), MaxBodyBytes: maxBodyBytes, ToolTimeout: toolTimeout, } if cfg.MaxBodyBytes <= 0 || cfg.MaxBodyBytes > maximumMCPBodyBytes { return MCPConfig{}, fmt.Errorf("%s must be between 1 and %d", MCPMaxBodyBytesEnv, maximumMCPBodyBytes) } if cfg.ToolTimeout <= 0 || cfg.ToolTimeout > maximumMCPToolTimeout { return MCPConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", MCPToolTimeoutEnv, maximumMCPToolTimeout) } if !cfg.Enabled { return cfg, nil } if len(cfg.AuthToken) < 32 || !validSecretHeaderValue(cfg.AuthToken) { return MCPConfig{}, fmt.Errorf("%s must contain at least 32 printable ASCII characters", MCPAuthTokenEnv) } switch cfg.ScopeMode { case MCPScopeModeAll: if len(cfg.AllowedTowns) > 0 { return MCPConfig{}, fmt.Errorf("%s must be empty when %s is %s", MCPAllowedTownsEnv, MCPScopeModeEnv, MCPScopeModeAll) } case MCPScopeModeTownAllowlist: if len(cfg.AllowedTowns) == 0 { return MCPConfig{}, fmt.Errorf("%s is required when %s is %s", MCPAllowedTownsEnv, MCPScopeModeEnv, MCPScopeModeTownAllowlist) } if len(cfg.AllowedTowns) > 100 { return MCPConfig{}, fmt.Errorf("%s must contain no more than 100 values", MCPAllowedTownsEnv) } for _, town := range cfg.AllowedTowns { if len([]rune(town)) > 100 || containsControlCharacter(town) { return MCPConfig{}, fmt.Errorf("%s contains an invalid town value", MCPAllowedTownsEnv) } } } return cfg, nil } func parseMCPScopeMode(raw string) (MCPScopeMode, error) { value := MCPScopeMode(strings.ToLower(strings.TrimSpace(raw))) if value == "" { return defaultMCPScopeMode, nil } switch value { case MCPScopeModeTownAllowlist, MCPScopeModeAll: return value, nil default: return "", fmt.Errorf("%s must be %q or %q", MCPScopeModeEnv, MCPScopeModeTownAllowlist, MCPScopeModeAll) } } func containsControlCharacter(value string) bool { for _, character := range value { if unicode.IsControl(character) { return true } } return false } func loadPostGISConfig() (PostGISConfig, error) { enabled, err := parseBool(PostGISEnabledEnv, false) if err != nil { return PostGISConfig{}, err } expectedSRID, err := parseInt(PostGISExpectedSRIDEnv, 0) if err != nil { return PostGISConfig{}, err } connectTimeout, err := parseDuration(PostGISConnectTimeoutEnv, defaultPostGISConnectTimeout) if err != nil { return PostGISConfig{}, err } queryTimeout, err := parseDuration(PostGISQueryTimeoutEnv, defaultPostGISQueryTimeout) if err != nil { return PostGISConfig{}, err } maxConns, err := parseInt64(PostGISMaxConnsEnv, defaultPostGISMaxConns) if err != nil { return PostGISConfig{}, err } cfg := PostGISConfig{ Enabled: enabled, DSN: strings.TrimSpace(os.Getenv(PostGISDSNEnv)), ExpectedSRID: expectedSRID, ConnectTimeout: connectTimeout, QueryTimeout: queryTimeout, MaxConns: int32(maxConns), } if cfg.ConnectTimeout <= 0 || cfg.ConnectTimeout > maximumPostGISTimeout { return PostGISConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", PostGISConnectTimeoutEnv, maximumPostGISTimeout) } if cfg.QueryTimeout <= 0 || cfg.QueryTimeout > maximumPostGISTimeout { return PostGISConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", PostGISQueryTimeoutEnv, maximumPostGISTimeout) } if maxConns <= 0 || maxConns > maximumPostGISMaxConns { return PostGISConfig{}, fmt.Errorf("%s must be between 1 and %d", PostGISMaxConnsEnv, maximumPostGISMaxConns) } if cfg.ExpectedSRID < 0 || cfg.ExpectedSRID > 999999 { return PostGISConfig{}, fmt.Errorf("%s must be between 0 and 999999", PostGISExpectedSRIDEnv) } if !cfg.Enabled { return cfg, nil } if cfg.DSN == "" { return PostGISConfig{}, fmt.Errorf("%s is required when %s is true", PostGISDSNEnv, PostGISEnabledEnv) } if !validPostGISDSN(cfg.DSN) { return PostGISConfig{}, fmt.Errorf("%s must be an absolute postgresql:// or postgres:// URL", PostGISDSNEnv) } return cfg, nil } func validateMCPDependencies(mcp MCPConfig, postGIS PostGISConfig, superAgent SuperAgentConfig) error { if !mcp.Enabled { return nil } if !postGIS.Enabled { return fmt.Errorf("%s must be true when %s is true", PostGISEnabledEnv, MCPEnabledEnv) } if postGIS.ExpectedSRID != requiredMCPSpatialSRID { return fmt.Errorf("%s must be explicitly set to %d when %s is true", PostGISExpectedSRIDEnv, requiredMCPSpatialSRID, MCPEnabledEnv) } if superAgent.OpenAPIKey != "" && mcp.AuthToken == superAgent.OpenAPIKey { return fmt.Errorf("%s must not reuse %s", MCPAuthTokenEnv, SuperAgentOpenAPIKeyEnv) } return nil } func validateChatDependencies(chat ChatConfig, superAgent SuperAgentConfig, mcp MCPConfig) error { if chat.PageEnabled { if !chat.Enabled { return fmt.Errorf("%s must be true when %s is true", ChatEnabledEnv, ChatPageEnabledEnv) } if chat.CompatAppID == "" { return fmt.Errorf("%s is required when %s is true", ChatCompatAppIDEnv, ChatPageEnabledEnv) } if len(chat.AllowedOrigins) == 0 { return fmt.Errorf("%s must contain at least one exact HTTP(S) origin when %s is true", ChatAllowedOriginsEnv, ChatPageEnabledEnv) } } if !chat.Enabled { return nil } if !superAgent.Enabled { return fmt.Errorf("%s must be true when %s is true", SuperAgentEnabledEnv, ChatEnabledEnv) } if chat.AuthToken == superAgent.OpenAPIKey { return fmt.Errorf("%s must not reuse %s", ChatAuthTokenEnv, SuperAgentOpenAPIKeyEnv) } if mcp.AuthToken != "" && chat.AuthToken == mcp.AuthToken { return fmt.Errorf("%s must not reuse %s", ChatAuthTokenEnv, MCPAuthTokenEnv) } return nil } func validateSuperAgent(cfg SuperAgentConfig) error { if cfg.ConnectTimeout <= 0 { return fmt.Errorf("%s must be greater than zero", SuperAgentConnectTimeoutEnv) } if cfg.RecoveryMaxAttempts < 0 { return fmt.Errorf("%s must not be negative", SuperAgentRecoveryMaxAttemptsEnv) } if cfg.RecoveryMaxAttempts > maximumSuperAgentRecoveryAttempts { return fmt.Errorf("%s must not exceed %d", SuperAgentRecoveryMaxAttemptsEnv, maximumSuperAgentRecoveryAttempts) } if cfg.RecoveryInitialBackoff <= 0 { return fmt.Errorf("%s must be greater than zero", SuperAgentRecoveryInitialBackoffEnv) } if cfg.MaxMessageBytes <= 0 { return fmt.Errorf("%s must be greater than zero", SuperAgentMaxMessageBytesEnv) } if cfg.MaxMessageBytes > maximumSuperAgentMessageBytes { return fmt.Errorf("%s must not exceed %d", SuperAgentMaxMessageBytesEnv, maximumSuperAgentMessageBytes) } if cfg.ProbeTimeout <= 0 { return fmt.Errorf("%s must be greater than zero", SuperAgentProbeTimeoutEnv) } if !cfg.Enabled { return nil } if cfg.BaseURL == "" { return fmt.Errorf("%s is required when %s is true", SuperAgentBaseURLEnv, SuperAgentEnabledEnv) } if cfg.OpenAPIKey == "" { return fmt.Errorf("%s is required when %s is true", SuperAgentOpenAPIKeyEnv, SuperAgentEnabledEnv) } if !validSecretHeaderValue(cfg.OpenAPIKey) { return fmt.Errorf("%s must be a valid header token", SuperAgentOpenAPIKeyEnv) } if strings.TrimSpace(cfg.ProbeSubjectID) == "" || len(cfg.ProbeSubjectID) > 512 { return fmt.Errorf("%s is required when %s is true", SuperAgentProbeSubjectIDEnv, SuperAgentEnabledEnv) } parsed, err := url.Parse(cfg.BaseURL) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return fmt.Errorf("%s must be an absolute HTTP(S) URL", SuperAgentBaseURLEnv) } if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return fmt.Errorf("%s must not include user information, query, or fragment", SuperAgentBaseURLEnv) } return nil } func validSecretHeaderValue(value string) bool { if value == "" || len(value) > 4096 { return false } for _, character := range value { if character <= 0x20 || character >= 0x7f { return false } } return true } func validPostGISDSN(value string) bool { parsed, err := url.Parse(value) if err != nil || parsed.Host == "" { return false } return parsed.Scheme == "postgres" || parsed.Scheme == "postgresql" } func parseList(raw string) []string { seen := make(map[string]struct{}) values := make([]string, 0) for _, item := range strings.Split(raw, ",") { value := strings.TrimSpace(item) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} values = append(values, value) } return values } func parseBool(key string, fallback bool) (bool, error) { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback, nil } value, err := strconv.ParseBool(raw) if err != nil { return false, fmt.Errorf("%s must be a boolean", key) } return value, nil } func parseDuration(key string, fallback time.Duration) (time.Duration, error) { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback, nil } value, err := time.ParseDuration(raw) if err != nil { return 0, fmt.Errorf("%s must be a valid duration", key) } return value, nil } func parseInt(key string, fallback int) (int, error) { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback, nil } value, err := strconv.Atoi(raw) if err != nil { return 0, fmt.Errorf("%s must be an integer", key) } return value, nil } func parseInt64(key string, fallback int64) (int64, error) { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback, nil } value, err := strconv.ParseInt(raw, 10, 64) if err != nil { return 0, fmt.Errorf("%s must be an integer", key) } return value, nil } func valueOrDefault(key, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value }