// Package config loads geniusrund's runtime infrastructure configuration // from environment variables (paths and process settings that don't belong // in the user-editable profile). Garmin credentials and every tunable // analysis-engine parameter live in the profile (internal/store.Profile) // instead -- see docs/superpowers/specs/2026-07-17-profile-taxonomy-analysis-engine-design.md. package config import ( "fmt" "log" "os" "path/filepath" "strconv" "strings" "time" ) // Config holds geniusrund's process-level configuration. type Config struct { // Addr is the HTTP listen address, e.g. ":8080". Addr string // DBPath is the SQLite database file path. DBPath string // GarminPythonPath is mcp-garmin's venv python executable. GarminPythonPath string // GarminServerPath is mcp-garmin's server.py. GarminServerPath string // GarminTokenStoreRoot is the root directory under which each user's // mcp-garmin session cache lives (one subdirectory per user id, e.g. // "/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it // defaults to a "garmin-tokenstores" directory next to DBPath so every // deployment gets per-user isolation automatically -- multi-tenant // operation always relies on this being a real, distinct-per-user path // (see api.Server.garminFor), so it can never be silently left empty. GarminTokenStoreRoot string MinConfidence float64 IncrementalSyncEvery time.Duration // OIDC login gate (Keycloak). PublicBaseURL is this app's own externally // reachable origin (e.g. "https://geniusrun.example.com") -- it derives // OIDCRedirectURL and whether session cookies can be marked Secure, // instead of requiring both to be configured separately and risking them // drifting out of sync. PublicBaseURL string OIDCIssuerURL string OIDCClientID string OIDCClientSecret string OIDCRedirectURL string OIDCRequiredRole string SessionSecret []byte SessionDuration time.Duration SessionSecure bool // LegacyOwnerOIDCSub, if set, is used exactly once at startup (via // store.ClaimLegacyOwner) to bind this deployment's pre-existing // single-tenant data to one named OIDC subject after upgrading to // per-user profiles. Safe to leave set indefinitely -- ClaimLegacyOwner // no-ops once any user already exists. LegacyOwnerOIDCSub string } // Load reads configuration from environment variables, applying defaults // for anything optional. Returns an error if a required variable is unset. func Load() (Config, error) { cfg := Config{ Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"), DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"), GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"), GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"), GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"), MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour), PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"), OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"), OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"), OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"), OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"), SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour), LegacyOwnerOIDCSub: os.Getenv("GENIUSRUN_LEGACY_OWNER_OIDC_SUB"), } if cfg.GarminTokenStoreRoot == "" { cfg.GarminTokenStoreRoot = filepath.Join(filepath.Dir(cfg.DBPath), "garmin-tokenstores") log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot) } if cfg.GarminPythonPath == "" { return cfg, fmt.Errorf("MCP_GARMIN_PYTHON is required (path to mcp-garmin's venv python)") } if cfg.GarminServerPath == "" { return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)") } if cfg.PublicBaseURL == "" { return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)") } if cfg.OIDCIssuerURL == "" { return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required") } if cfg.OIDCClientID == "" { return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_ID is required") } if cfg.OIDCClientSecret == "" { return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required") } sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET") if len(sessionSecret) < 32 { return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters") } cfg.SessionSecret = []byte(sessionSecret) cfg.OIDCRedirectURL = cfg.PublicBaseURL + "/api/session/callback" cfg.SessionSecure = strings.HasPrefix(cfg.PublicBaseURL, "https://") return cfg, nil } func getEnvDefault(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func getEnvFloat(key string, def float64) float64 { if v := os.Getenv(key); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { return f } } return def } func getEnvDuration(key string, def time.Duration) time.Duration { if v := os.Getenv(key); v != "" { if d, err := time.ParseDuration(v); err == nil { return d } } return def }