// Package config loads pansy's runtime configuration from the environment. // // Every value has a sensible default so `pansy` runs with zero configuration // for local use; production deployments override via PANSY_* env vars. Auth // values (local + OIDC) are plumbed here now and consumed by the auth issues // (#4 local, #5 OIDC). package config import ( "log/slog" "os" "strconv" "strings" ) // Registration gates local self-service signup. const ( RegistrationOpen = "open" RegistrationClosed = "closed" ) // DefaultAgentModel is the assistant's model when PANSY_AGENT_MODEL is unset. const DefaultAgentModel = "ollama-cloud/glm-5.2:cloud" // Config is the fully-resolved runtime configuration. type Config struct { // Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080). Port int // DBPath is the SQLite database file path (PANSY_DB, default ./pansy.db). DBPath string // BaseURL is the externally-visible base URL, used to derive the OIDC // redirect URI and absolute links (PANSY_BASE_URL). Empty in bare local dev. BaseURL string // Registration is "open" or "closed"; gates POST /auth/register for local // accounts (PANSY_REGISTRATION, default open). OIDC JIT provisioning ignores // this — the IdP gates access. Registration string // LocalAuth enables argon2id username/password auth (PANSY_LOCAL_AUTH, // default true). Set false for pure-Authentik deployments. LocalAuth bool // OIDC holds the optional OpenID Connect provider settings. OIDC OIDCConfig // Agent holds the garden-assistant settings. Agent AgentConfig // TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP // resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none. TrustedProxies []string } // OIDCConfig holds the OpenID Connect provider settings (Authentik is the // primary IdP). Consumed by #5. type OIDCConfig struct { Issuer string // PANSY_OIDC_ISSUER — discovery base URL ClientID string // PANSY_OIDC_CLIENT_ID ClientSecret string // PANSY_OIDC_CLIENT_SECRET ButtonLabel string // PANSY_OIDC_BUTTON_LABEL — login-page button text } // AgentConfig holds the garden assistant's settings. type AgentConfig struct { // Model is the majordomo model spec, passed VERBATIM to majordomo.Parse // (PANSY_AGENT_MODEL). The grammar is majordomo's, not pansy's — parsing or // validating it here would only mean two places to update when it grows. A // comma-separated spec is a failover chain, so // "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud" gets a fallback // for free. Model string // OllamaCloudAPIKey authenticates against Ollama Cloud // (OLLAMA_CLOUD_API_KEY — the same secret name gadfly uses, which is why // it isn't majordomo's own OLLAMA_API_KEY; pansy passes it explicitly rather // than relying on ambient environment). OllamaCloudAPIKey string // Enabled turns the assistant on (PANSY_AGENT_ENABLED). Defaults to on when // a key is present, so an instance with no key starts cleanly and simply // doesn't offer the agent — the same shape as OIDC 404ing when unconfigured. Enabled bool // VisionModel is the model that reads a photographed seed packet // (PANSY_VISION_MODEL). Empty by default: the seed-packet capture feature is // only offered when a vision-capable model is configured (in env or Settings) // and a key is present. Passed verbatim to majordomo.Parse, like Model. VisionModel string } // Enabled reports whether enough OIDC config is present to attempt discovery. func (o OIDCConfig) Enabled() bool { return o.Issuer != "" && o.ClientID != "" } // OIDCReady reports whether OIDC login can actually be offered: it needs an // issuer + client ID and a BaseURL to build the absolute redirect URI that // providers require. Both the login page (via /auth/providers) and route // registration gate on this, so the advertised methods match the live routes. func (c *Config) OIDCReady() bool { return c.OIDC.Enabled() && c.BaseURL != "" } // RegistrationOpen reports whether local self-service signup is allowed. func (c *Config) RegistrationOpen() bool { return c.Registration == RegistrationOpen } // Load reads configuration from the environment, applying defaults. It never // fails: invalid numeric/boolean values fall back to the default and are logged. func Load() *Config { cfg := &Config{ Port: envInt("PANSY_PORT", 8080), DBPath: envStr("PANSY_DB", "./pansy.db"), BaseURL: strings.TrimRight(envStr("PANSY_BASE_URL", ""), "/"), Registration: envStr("PANSY_REGISTRATION", RegistrationOpen), LocalAuth: envBool("PANSY_LOCAL_AUTH", true), OIDC: OIDCConfig{ Issuer: envStr("PANSY_OIDC_ISSUER", ""), ClientID: envStr("PANSY_OIDC_CLIENT_ID", ""), ClientSecret: envStr("PANSY_OIDC_CLIENT_SECRET", ""), ButtonLabel: envStr("PANSY_OIDC_BUTTON_LABEL", "Sign in with Authentik"), }, TrustedProxies: envList("PANSY_TRUSTED_PROXIES"), } agentKey := envStr("OLLAMA_CLOUD_API_KEY", "") cfg.Agent = AgentConfig{ Model: envStr("PANSY_AGENT_MODEL", DefaultAgentModel), OllamaCloudAPIKey: agentKey, // Default on when a key is present: having configured the key IS the // opt-in, and making people set a second flag to use what they just // configured is a papercut with no upside. Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""), // No default vision model: unlike chat there's no obvious safe default, // and the feature stays off until an admin names one that can see. VisionModel: envStr("PANSY_VISION_MODEL", ""), } if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed { slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration) cfg.Registration = RegistrationOpen } if cfg.Port < 1 || cfg.Port > 65535 { slog.Warn("config: PANSY_PORT out of range, using default", "value", cfg.Port, "default", 8080) cfg.Port = 8080 } return cfg } func envStr(key, def string) string { if v, ok := os.LookupEnv(key); ok && v != "" { return v } return def } func envInt(key string, def int) int { v, ok := os.LookupEnv(key) if !ok || v == "" { return def } n, err := strconv.Atoi(v) if err != nil { slog.Warn("config: invalid int env, using default", "key", key, "value", v, "default", def) return def } return n } func envBool(key string, def bool) bool { v, ok := os.LookupEnv(key) if !ok || v == "" { return def } b, err := strconv.ParseBool(v) if err != nil { slog.Warn("config: invalid bool env, using default", "key", key, "value", v, "default", def) return def } return b } func envList(key string) []string { v, ok := os.LookupEnv(key) if !ok || v == "" { return nil } parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }