// 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. // OIDC env-bootstrap values — used to seed the oidc_config DB row // at first boot if it's empty. Runtime mutation goes through the // dialectic-cli `config oidc ...` subcommand → updates the DB row. // Env-only mode is fine for greenfield deploys; once the DB row is // populated and enabled, env values become advisory. OIDCIssuer string OIDCClientID string // OIDCBearerIssuer + OIDCBearerAudience configure acceptance of // access tokens issued by the external "Tessera" OIDC provider // (Keycloak-compatible) as API bearer tokens. ADDITIVE to the // existing agent-key + browser-session auth paths. A request with // `Authorization: Bearer ` whose JWT verifies against this // issuer's JWKS and carries this audience is treated as a CallerUser. // Env: OIDC_BEARER_ISSUER, OIDC_BEARER_AUDIENCE. OIDCBearerIssuer string OIDCBearerAudience string // OIDC_ONLY: when "true", disables the dev-bypass auth path on // every browser-facing route. Use this in prod once the OIDC // realm + client are configured so a leaked dev token can't // authenticate anyone. Defaults false (dev/sim convenience). OIDCOnly bool // SessionSigningKey: HS256 secret for the session JWT we mint on // /api/auth/oidc/exchange. MUST be stable across restarts (rotating // invalidates every logged-in user — that's the desired side // effect for emergency revocation). Random hex, ≥ 32 bytes. SessionSigningKey string // (Removed Aug 2026: all Fabric coupling — FabricSystemAPIKey, // FabricGuildBaseURL, FabricAnnounceChannelID, FabricBotBearerToken. // Backend no longer broadcasts lifecycle events to Fabric. The // proposing agent posts a single recruitment fabric-send-message // after creating a topic; downstream agents book HF on_call slots // covering the debate window via `hf calendar schedule` and HF // wakes them naturally. The backend stays a pure data + state- // machine service and doesn't know about Fabric.) // 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"), OIDCBearerIssuer: getenv("OIDC_BEARER_ISSUER", "https://login.hangman-lab.top/realms/Hangman-Lab"), OIDCBearerAudience: getenv("OIDC_BEARER_AUDIENCE", "dialectic-prod"), OIDCOnly: os.Getenv("OIDC_ONLY") == "true", SessionSigningKey: os.Getenv("SESSION_SIGNING_KEY"), } 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.SessionSigningKey == "" { missing = append(missing, "SESSION_SIGNING_KEY") } // OIDC_ISSUER + OIDC_CLIENT_ID are no longer required env at // boot — they're optional bootstrap values for the oidc_config // DB row (mutated via cli). If you start prod without them and // without configuring via cli, the SPA will see OIDC disabled + // every browser-facing route stays 401. 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 }