初始化第一版
This commit is contained in:
commit
8a6c31c14d
83 files changed
+14302
No files matched your search
@@ -0,0 +1,631 @@
|
||||
// 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"
|
||||
ChatAuthTokenEnv = "FIRE_SAFETY_CHAT_AUTH_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
|
||||
AuthToken string
|
||||
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
|
||||
}
|
||||
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,
|
||||
AuthToken: strings.TrimSpace(os.Getenv(ChatAuthTokenEnv)),
|
||||
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 && (len(cfg.AuthToken) < 32 || !validSecretHeaderValue(cfg.AuthToken)) {
|
||||
return ChatConfig{}, fmt.Errorf("%s must contain at least 32 printable ASCII characters", ChatAuthTokenEnv)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
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.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
|
||||
}
|
||||
Reference in new issue
Block a user