Files
geniusrun/backend/internal/config/envconfig.go

146 lines
5.9 KiB
Go
Raw Normal View History

// 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.
// "<root>/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)},
}
}