// 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" "os" "strings" ) // EnvConfig holds geniusrund's process-level configuration. type EnvConfig struct { // BackendAddr is the HTTP listen address, e.g. ":8080". BackendAddr string // DBPath is the SQLite database file path. DBPath string // PythonPath is the python3 interpreter used to run the embedded // Garmin wrapper script (internal/garmin's go:embed'd wrapper.py). // Defaults to "python3" resolved via PATH if unset. PythonPath string // TokenStoreRoot is the root directory under which each user's // Garmin session cache lives (one subdirectory per user id, e.g. // "/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured // independently of DBPath -- if unset, it defaults to a ".garmin" // directory relative to the working directory the process is started // from, not derived from DBPath in any way, 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. TokenStoreRoot string // LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error"). LogLevel string // OIDC login gate (Keycloak). BackendURL 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. BackendURL string // FrontendURL is the origin the browser should land on after the OIDC // callback (both success and failure) -- e.g. "http://localhost:5173" in // local dev, where the frontend and backend are different origins // bridged by CORS (see internal/api's corsMiddleware and // frontend/src/api/client.ts's BASE_URL). Defaults to BackendURL when // unset, which is correct for the common production topology where a // reverse proxy unifies frontend and backend under one origin. // BackendURL itself must stay pointed at the backend's own origin // regardless -- it derives OIDCRedirectURL and post_logout_redirect_uri, // which must match wherever those routes are actually served. FrontendURL string OIDCIssuerURL string OIDCClientID string OIDCClientSecret string OIDCRedirectURL string OIDCRequiredRole string SessionSecret []byte SessionSecure bool } // LoadEnv reads configuration from environment variables, applying defaults // for anything optional. Returns an error if a required variable is unset. func LoadEnv() (EnvConfig, error) { cfg := EnvConfig{ BackendAddr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"), DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"), PythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"), TokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"), LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"), BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_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"), } if cfg.BackendURL == "" { return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)") } cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/") 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") } cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback" 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.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://") return cfg, nil } func getEnvDefault(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // EnvEntry is one environment-configuration variable as displayed on the // config page. Display-only: secrets are masked here, so raw values never // leave the process. type EnvEntry struct { Name string Value string } // DisplayEnv returns the environment configuration as a display-safe // list, in stable order, secrets masked. func (c EnvConfig) DisplayEnv() []EnvEntry { mask := func(set bool) string { if set { return "•••• (set)" } return "(unset)" } return []EnvEntry{ {Name: "GENIUSRUN_BACKEND_ADDR", Value: c.BackendAddr}, {Name: "GENIUSRUN_DB_PATH", Value: c.DBPath}, {Name: "GENIUSRUN_PYTHON_PATH", Value: c.PythonPath}, {Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.TokenStoreRoot}, {Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel}, {Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL}, {Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL}, {Name: "GENIUSRUN_OIDC_ISSUER_URL", Value: c.OIDCIssuerURL}, {Name: "GENIUSRUN_OIDC_CLIENT_ID", Value: c.OIDCClientID}, {Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: mask(c.OIDCClientSecret != "")}, {Name: "GENIUSRUN_OIDC_REQUIRED_ROLE", Value: c.OIDCRequiredRole}, {Name: "GENIUSRUN_SESSION_SECRET", Value: mask(len(c.SessionSecret) > 0)}, } }