2026-07-24 21:08:07 +02:00
|
|
|
// Package config loads geniusrund's runtime infrastructure configuration
|
2026-07-17 19:18:06 +02:00
|
|
|
// 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.
|
2026-07-17 18:33:06 +02:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-07-25 18:55:53 +02:00
|
|
|
"log"
|
2026-07-17 18:33:06 +02:00
|
|
|
"os"
|
2026-07-25 18:55:53 +02:00
|
|
|
"path/filepath"
|
2026-07-17 18:33:06 +02:00
|
|
|
"strconv"
|
2026-07-24 21:11:28 +02:00
|
|
|
"strings"
|
2026-07-17 18:33:06 +02:00
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-24 21:08:07 +02:00
|
|
|
// Config holds geniusrund's process-level configuration.
|
2026-07-17 18:33:06 +02:00
|
|
|
type Config struct {
|
|
|
|
|
// Addr is the HTTP listen address, e.g. ":8080".
|
|
|
|
|
Addr string
|
|
|
|
|
// DBPath is the SQLite database file path.
|
|
|
|
|
DBPath string
|
|
|
|
|
|
2026-07-25 21:07:04 +02:00
|
|
|
// GarminPythonPath 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.
|
2026-07-17 18:33:06 +02:00
|
|
|
GarminPythonPath string
|
2026-07-25 18:55:53 +02:00
|
|
|
// GarminTokenStoreRoot is the root directory under which each user's
|
2026-07-25 21:43:52 +02:00
|
|
|
// Garmin session cache lives (one subdirectory per user id, e.g.
|
2026-07-25 18:55:53 +02:00
|
|
|
// "<root>/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.
|
2026-07-25 17:49:51 +02:00
|
|
|
GarminTokenStoreRoot string
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
MinConfidence float64
|
|
|
|
|
IncrementalSyncEvery time.Duration
|
2026-07-26 14:30:39 +02:00
|
|
|
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
|
|
|
|
|
LogLevel string
|
2026-07-24 21:11:28 +02:00
|
|
|
|
2026-07-25 23:00:35 +02:00
|
|
|
// OIDC login gate (Keycloak). BackendURL is this app's own externally
|
2026-07-24 21:11:28 +02:00
|
|
|
// 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.
|
2026-07-25 23:00:35 +02:00
|
|
|
BackendURL string
|
2026-07-25 22:50:46 +02:00
|
|
|
// 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
|
2026-07-25 23:00:35 +02:00
|
|
|
// frontend/src/api/client.ts's BASE_URL). Defaults to BackendURL when
|
2026-07-25 22:50:46 +02:00
|
|
|
// unset, which is correct for the common production topology where a
|
|
|
|
|
// reverse proxy unifies frontend and backend under one origin.
|
2026-07-25 23:00:35 +02:00
|
|
|
// BackendURL itself must stay pointed at the backend's own origin
|
2026-07-25 22:50:46 +02:00
|
|
|
// regardless -- it derives OIDCRedirectURL and post_logout_redirect_uri,
|
|
|
|
|
// which must match wherever those routes are actually served.
|
|
|
|
|
FrontendURL string
|
2026-07-24 21:11:28 +02:00
|
|
|
OIDCIssuerURL string
|
|
|
|
|
OIDCClientID string
|
|
|
|
|
OIDCClientSecret string
|
|
|
|
|
OIDCRedirectURL string
|
|
|
|
|
OIDCRequiredRole string
|
|
|
|
|
SessionSecret []byte
|
|
|
|
|
SessionDuration time.Duration
|
|
|
|
|
SessionSecure bool
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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{
|
2026-07-24 21:08:07 +02:00
|
|
|
Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"),
|
|
|
|
|
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
2026-07-25 21:07:04 +02:00
|
|
|
GarminPythonPath: getEnvDefault("GARMIN_WRAPPER_PYTHON", "python3"),
|
2026-07-25 17:49:51 +02:00
|
|
|
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
|
2026-07-24 21:08:07 +02:00
|
|
|
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
|
|
|
|
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
2026-07-26 14:30:39 +02:00
|
|
|
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
2026-07-25 23:00:35 +02:00
|
|
|
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
2026-07-24 21:11:28 +02:00
|
|
|
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),
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:55:53 +02:00
|
|
|
if cfg.GarminTokenStoreRoot == "" {
|
|
|
|
|
cfg.GarminTokenStoreRoot = filepath.Join(filepath.Dir(cfg.DBPath), "garmin-tokenstores")
|
|
|
|
|
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 23:00:35 +02:00
|
|
|
if cfg.BackendURL == "" {
|
|
|
|
|
return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)")
|
2026-07-24 21:11:28 +02:00
|
|
|
}
|
2026-07-25 23:00:35 +02:00
|
|
|
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/")
|
2026-07-24 21:11:28 +02:00
|
|
|
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)
|
2026-07-25 23:00:35 +02:00
|
|
|
cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback"
|
|
|
|
|
cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://")
|
2026-07-24 21:11:28 +02:00
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
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
|
|
|
|
|
}
|