667 lines
23 KiB
Go
667 lines
23 KiB
Go
// 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
|
|
}
|