// Package config loads runtime configuration from environment variables. // // Conventions: // - 12-factor: every config knob is an env var; no config files. // - Sensible dev defaults for local docker-compose; prod sets via env. // - Sensitive values (DB password, system api key) are *required* in // prod; LoadFromEnv() fails fast if absent and ENV_MODE != "dev". package config import ( "fmt" "os" "strings" "time" ) type Config struct { // "dev" | "prod". Dev relaxes required-field checks and enables a // dev-mode auth bypass token. Prod requires every sensitive field. Mode string // HTTP server bind. e.g. "0.0.0.0:8090". HTTPAddr string // CORS allowed origins (comma-separated; "*" allowed only in dev). CORSAllowOrigins []string // MySQL DSN parts. DBHost string DBPort string DBName string DBUser string DBPassword string // Auth. // // SystemAPIKey: Phase-1 system key for posting to announce channels // in Fabric. Mirrored here so Dialectic backend itself can post topic // announcements via Fabric's POST /channels/:id/messages with // x-fabric-system-key header. // // AgentAPIKeyPepper: HMAC pepper for hashing agent API keys at rest // (we store sha256(pepper || raw) not the raw key). Rotating the // pepper invalidates all keys — that's intentional, an emergency // kill switch. // // OIDCDevBypassToken: dev-mode only. If set AND Mode == "dev", a // browser request with header `x-dev-bypass: ` bypasses OIDC // and is treated as user "dev-operator" with role "dialectic-admin". // Prod ignores this even if set. SystemAPIKey string AgentAPIKeyPepper string OIDCDevBypassToken string // DialecticAdminAPIKey gates POST /api/admin/agent-keys (raw key // minting). Held on the operator side only — kept on the openclaw // host at /root/.openclaw/system-secrets/dialectic-admin-key for // `dialectic-ctrl` script to read. Empty in env → admin endpoint // fully closed. DialecticAdminAPIKey string // OIDC issuer URL (Keycloak realm endpoint). e.g. // https://auth.hangman-lab.top/realms/hangman-lab // Phase 2C ships this as configured-but-not-verified; Phase 4 wires // real JWKS validation. OIDCIssuer string OIDCClientID string // Fabric announce coupling (Phase 2D). All four required to enable; // any empty → announcer becomes a no-op (logs intent, skips post). // This lets the orchestrator run in environments where the Fabric // coupling hasn't been wired yet. FabricGuildBaseURL string // e.g. https://fabric-api.hangman-lab.top FabricAnnounceChannelID string FabricSystemAPIKey string // x-fabric-system-key value (env: FABRIC_SYSTEM_API_KEY) FabricBotBearerToken string // Authorization Bearer for the dialectic-system Fabric user // Orchestrator tick interval. 0 / unset → default 15s. OrchestratorTickInterval time.Duration } func LoadFromEnv() (*Config, error) { c := &Config{ Mode: getenv("ENV_MODE", "dev"), HTTPAddr: getenv("HTTP_ADDR", "0.0.0.0:8090"), CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")), DBHost: getenv("DB_HOST", "127.0.0.1"), DBPort: getenv("DB_PORT", "3306"), DBName: getenv("DB_NAME", "dialectic"), DBUser: getenv("DB_USER", "dialectic"), DBPassword: os.Getenv("DB_PASSWORD"), SystemAPIKey: os.Getenv("SYSTEM_API_KEY"), AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"), OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"), DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"), OIDCIssuer: os.Getenv("OIDC_ISSUER"), OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), FabricGuildBaseURL: os.Getenv("FABRIC_GUILD_BASE_URL"), FabricAnnounceChannelID: os.Getenv("FABRIC_ANNOUNCE_CHANNEL_ID"), FabricSystemAPIKey: os.Getenv("FABRIC_SYSTEM_API_KEY"), FabricBotBearerToken: os.Getenv("FABRIC_BOT_BEARER_TOKEN"), } if d := os.Getenv("ORCHESTRATOR_TICK_INTERVAL"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { c.OrchestratorTickInterval = parsed } } if c.Mode != "dev" && c.Mode != "prod" { return nil, fmt.Errorf("ENV_MODE must be dev|prod, got %q", c.Mode) } if c.Mode == "prod" { var missing []string if c.DBPassword == "" { missing = append(missing, "DB_PASSWORD") } if c.AgentAPIKeyPepper == "" { missing = append(missing, "AGENT_API_KEY_PEPPER") } if c.OIDCIssuer == "" { missing = append(missing, "OIDC_ISSUER") } if c.OIDCClientID == "" { missing = append(missing, "OIDC_CLIENT_ID") } if len(missing) > 0 { return nil, fmt.Errorf("prod mode requires env: %s", strings.Join(missing, ", ")) } // In prod, "*" CORS is never accepted. for _, o := range c.CORSAllowOrigins { if o == "*" { return nil, fmt.Errorf("prod mode forbids CORS_ALLOW_ORIGINS='*'") } } } return c, nil } func (c *Config) IsDev() bool { return c.Mode == "dev" } func (c *Config) DSN() string { // MySQL DSN: user:pass@tcp(host:port)/dbname?params return fmt.Sprintf( "%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci", c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName, ) } func getenv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func splitCSV(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out }