feat(config): app-config registry, env-var renames, wire session.duration from DB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
Backend (from `backend/`):
|
Backend (from `backend/`):
|
||||||
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GARMIN_WRAPPER_PYTHON` if you need a specific one (e.g. a venv with `garminconnect` installed).
|
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed).
|
||||||
- Build/vet: `go build ./...` && `go vet ./...`
|
- Build/vet: `go build ./...` && `go vet ./...`
|
||||||
- Format: `gofmt -l .` must report nothing before committing.
|
- Format: `gofmt -l .` must report nothing before committing.
|
||||||
- All tests: `go test ./...`
|
- All tests: `go test ./...`
|
||||||
@@ -37,7 +37,7 @@ Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to i
|
|||||||
|
|
||||||
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
|
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
|
||||||
|
|
||||||
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
|
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`. Session duration is application configuration, not an env var: `session.duration` (hours, default `720`), editable on the `/config` page and applied on the next backend restart. See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
|||||||
- Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array).
|
- Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array).
|
||||||
- Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `is_race`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`.
|
- Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `is_race`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`.
|
||||||
- **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable.
|
- **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable.
|
||||||
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (default from `GENIUSRUN_MIN_CONFIDENCE`, 0.6). All three populate `Candidates` for the review UI.
|
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (hard-coded `classify.DefaultMinConfidence`, 0.6). All three populate `Candidates` for the review UI.
|
||||||
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance.
|
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance.
|
||||||
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
|
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
|
||||||
- Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring.
|
- Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring.
|
||||||
@@ -92,7 +92,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
|||||||
|
|
||||||
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
|
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
|
||||||
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason).
|
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason).
|
||||||
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory relative to the working directory -- deliberately independent of `GENIUSRUN_DB_PATH`, so the DB file and the token-store root can each be pointed at their own directory -- rather than leaving per-user namespacing silently skipped.
|
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, this token-store root (`config.GarminTokenStoreRoot`, read from `GENIUSRUN_TOKENSTORE_PATH`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory relative to the working directory -- deliberately independent of `GENIUSRUN_DB_PATH`, so the DB file and the token-store root can each be pointed at their own directory -- rather than leaving per-user namespacing silently skipped.
|
||||||
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
||||||
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
||||||
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
|
overrides, err := db.ConfigValues(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("app config: %v", err)
|
||||||
|
}
|
||||||
|
appCfg, err := config.LoadApp(overrides)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("app config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
||||||
IssuerURL: cfg.OIDCIssuerURL,
|
IssuerURL: cfg.OIDCIssuerURL,
|
||||||
ClientID: cfg.OIDCClientID,
|
ClientID: cfg.OIDCClientID,
|
||||||
@@ -49,11 +58,9 @@ func main() {
|
|||||||
server := api.NewServer(db, garmin.NewClient, garmin.Config{
|
server := api.NewServer(db, garmin.NewClient, garmin.Config{
|
||||||
PythonPath: cfg.GarminPythonPath,
|
PythonPath: cfg.GarminPythonPath,
|
||||||
TokenStorePath: cfg.GarminTokenStoreRoot,
|
TokenStorePath: cfg.GarminTokenStoreRoot,
|
||||||
}, appsync.Config{
|
}, appsync.Config{}, authVerifier, api.SessionConfig{
|
||||||
MinConfidence: cfg.MinConfidence,
|
|
||||||
}, authVerifier, api.SessionConfig{
|
|
||||||
Secret: cfg.SessionSecret,
|
Secret: cfg.SessionSecret,
|
||||||
Duration: cfg.SessionDuration,
|
Duration: appCfg.SessionDuration,
|
||||||
Secure: cfg.SessionSecure,
|
Secure: cfg.SessionSecure,
|
||||||
BackendURL: cfg.BackendURL,
|
BackendURL: cfg.BackendURL,
|
||||||
FrontendURL: cfg.FrontendURL,
|
FrontendURL: cfg.FrontendURL,
|
||||||
|
|||||||
80
backend/internal/config/appconfig.go
Normal file
80
backend/internal/config/appconfig.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppKey describes one application-configuration key: instance-global,
|
||||||
|
// stored (as an override) in the DB `config` table, read once at startup.
|
||||||
|
// Cold -- a changed value applies on the next backend restart.
|
||||||
|
type AppKey struct {
|
||||||
|
Key string
|
||||||
|
Default string
|
||||||
|
Description string
|
||||||
|
Validate func(value string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeySessionDuration is the session cookie lifetime, in integer hours.
|
||||||
|
const KeySessionDuration = "session.duration"
|
||||||
|
|
||||||
|
var appRegistry = []AppKey{
|
||||||
|
{
|
||||||
|
Key: KeySessionDuration,
|
||||||
|
Default: "720",
|
||||||
|
Description: "Session cookie lifetime in hours",
|
||||||
|
Validate: validatePositiveInt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppRegistry returns every known application-configuration key, in
|
||||||
|
// display order.
|
||||||
|
func AppRegistry() []AppKey { return appRegistry }
|
||||||
|
|
||||||
|
func validatePositiveInt(v string) error {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return fmt.Errorf("must be a positive integer, got %q", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAppValue rejects unknown keys and invalid values -- every write
|
||||||
|
// path's guard, so the config table can never accumulate junk.
|
||||||
|
func ValidateAppValue(key, value string) error {
|
||||||
|
for _, k := range appRegistry {
|
||||||
|
if k.Key == key {
|
||||||
|
if err := k.Validate(value); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown configuration key %q", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppConfig is the typed result of merging DB overrides over registry
|
||||||
|
// defaults.
|
||||||
|
type AppConfig struct {
|
||||||
|
SessionDuration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
|
||||||
|
// -- it takes the raw map instead of a *store.DB so this package needs no
|
||||||
|
// store dependency and the merge logic tests without a database. A bad
|
||||||
|
// stored value fails fast, same posture as Load() for env vars.
|
||||||
|
func LoadApp(overrides map[string]string) (AppConfig, error) {
|
||||||
|
merged := map[string]string{}
|
||||||
|
for _, k := range appRegistry {
|
||||||
|
merged[k.Key] = k.Default
|
||||||
|
}
|
||||||
|
for key, value := range overrides {
|
||||||
|
if err := ValidateAppValue(key, value); err != nil {
|
||||||
|
return AppConfig{}, fmt.Errorf("app config: %w", err)
|
||||||
|
}
|
||||||
|
merged[key] = value
|
||||||
|
}
|
||||||
|
hours, _ := strconv.Atoi(merged[KeySessionDuration])
|
||||||
|
return AppConfig{SessionDuration: time.Duration(hours) * time.Hour}, nil
|
||||||
|
}
|
||||||
79
backend/internal/config/appconfig_test.go
Normal file
79
backend/internal/config/appconfig_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadApp(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
overrides map[string]string
|
||||||
|
want time.Duration
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour},
|
||||||
|
{name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour},
|
||||||
|
{name: "invalid stored value", overrides: map[string]string{"session.duration": "zero"}, wantErr: "session.duration"},
|
||||||
|
{name: "unknown stored key", overrides: map[string]string{"bogus.key": "1"}, wantErr: "unknown configuration key"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := LoadApp(tt.overrides)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadApp: %v", err)
|
||||||
|
}
|
||||||
|
if got.SessionDuration != tt.want {
|
||||||
|
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAppValue(t *testing.T) {
|
||||||
|
if err := ValidateAppValue("session.duration", "24"); err != nil {
|
||||||
|
t.Fatalf("valid value rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateAppValue("session.duration", "-1"); err == nil {
|
||||||
|
t.Fatal("negative hours accepted")
|
||||||
|
}
|
||||||
|
if err := ValidateAppValue("session.duration", "1.5"); err == nil {
|
||||||
|
t.Fatal("non-integer accepted")
|
||||||
|
}
|
||||||
|
if err := ValidateAppValue("nope", "1"); err == nil {
|
||||||
|
t.Fatal("unknown key accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayEnv_MasksSecrets(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
Addr: ":8080", OIDCClientSecret: "hunter2",
|
||||||
|
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||||
|
}
|
||||||
|
entries := map[string]string{}
|
||||||
|
for _, e := range cfg.DisplayEnv() {
|
||||||
|
entries[e.Name] = e.Value
|
||||||
|
}
|
||||||
|
if entries["GENIUSRUN_BACKEND_ADDR"] != ":8080" {
|
||||||
|
t.Errorf("GENIUSRUN_BACKEND_ADDR = %q", entries["GENIUSRUN_BACKEND_ADDR"])
|
||||||
|
}
|
||||||
|
if entries["GENIUSRUN_OIDC_CLIENT_SECRET"] != "•••• (set)" {
|
||||||
|
t.Errorf("client secret not masked: %q", entries["GENIUSRUN_OIDC_CLIENT_SECRET"])
|
||||||
|
}
|
||||||
|
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
|
||||||
|
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
|
||||||
|
}
|
||||||
|
empty := Config{}
|
||||||
|
for _, e := range empty.DisplayEnv() {
|
||||||
|
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
|
||||||
|
t.Errorf("unset secret = %q, want (unset)", e.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds geniusrund's process-level configuration.
|
// Config holds geniusrund's process-level configuration.
|
||||||
@@ -26,7 +24,7 @@ type Config struct {
|
|||||||
GarminPythonPath string
|
GarminPythonPath string
|
||||||
// GarminTokenStoreRoot is the root directory under which each user's
|
// GarminTokenStoreRoot is the root directory under which each user's
|
||||||
// Garmin session cache lives (one subdirectory per user id, e.g.
|
// Garmin session cache lives (one subdirectory per user id, e.g.
|
||||||
// "<root>/3"). Read from the GARMIN_TOKENSTORE env var, configured
|
// "<root>/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured
|
||||||
// independently of DBPath -- if unset, it defaults to a ".garmin"
|
// independently of DBPath -- if unset, it defaults to a ".garmin"
|
||||||
// directory relative to the working directory the process is started
|
// directory relative to the working directory the process is started
|
||||||
// from, not derived from DBPath in any way, so every deployment gets
|
// from, not derived from DBPath in any way, so every deployment gets
|
||||||
@@ -35,7 +33,6 @@ type Config struct {
|
|||||||
// api.Server.garminFor), so it can never be silently left empty.
|
// api.Server.garminFor), so it can never be silently left empty.
|
||||||
GarminTokenStoreRoot string
|
GarminTokenStoreRoot string
|
||||||
|
|
||||||
MinConfidence float64
|
|
||||||
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
|
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
|
||||||
LogLevel string
|
LogLevel string
|
||||||
|
|
||||||
@@ -62,7 +59,6 @@ type Config struct {
|
|||||||
OIDCRedirectURL string
|
OIDCRedirectURL string
|
||||||
OIDCRequiredRole string
|
OIDCRequiredRole string
|
||||||
SessionSecret []byte
|
SessionSecret []byte
|
||||||
SessionDuration time.Duration
|
|
||||||
SessionSecure bool
|
SessionSecure bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,18 +66,16 @@ type Config struct {
|
|||||||
// for anything optional. Returns an error if a required variable is unset.
|
// for anything optional. Returns an error if a required variable is unset.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"),
|
Addr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
|
||||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||||
GarminPythonPath: getEnvDefault("GARMIN_WRAPPER_PYTHON", "python3"),
|
GarminPythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
|
||||||
GarminTokenStoreRoot: getEnvDefault("GARMIN_TOKENSTORE", ".garmin"),
|
GarminTokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
|
||||||
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
|
||||||
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
||||||
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
||||||
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||||||
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
||||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||||
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||||||
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.BackendURL == "" {
|
if cfg.BackendURL == "" {
|
||||||
@@ -117,20 +111,35 @@ func getEnvDefault(key, def string) string {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnvFloat(key string, def float64) float64 {
|
// EnvEntry is one environment-configuration variable as displayed on the
|
||||||
if v := os.Getenv(key); v != "" {
|
// config page. Display-only: secrets are masked here, so raw values never
|
||||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
// leave the process.
|
||||||
return f
|
type EnvEntry struct {
|
||||||
}
|
Name string
|
||||||
}
|
Value string
|
||||||
return def
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnvDuration(key string, def time.Duration) time.Duration {
|
// DisplayEnv returns the environment configuration as a display-safe
|
||||||
if v := os.Getenv(key); v != "" {
|
// list, in stable order, secrets masked.
|
||||||
if d, err := time.ParseDuration(v); err == nil {
|
func (c Config) DisplayEnv() []EnvEntry {
|
||||||
return d
|
mask := func(set bool) string {
|
||||||
|
if set {
|
||||||
|
return "•••• (set)"
|
||||||
}
|
}
|
||||||
|
return "(unset)"
|
||||||
|
}
|
||||||
|
return []EnvEntry{
|
||||||
|
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.Addr},
|
||||||
|
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
|
||||||
|
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.GarminPythonPath},
|
||||||
|
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.GarminTokenStoreRoot},
|
||||||
|
{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)},
|
||||||
}
|
}
|
||||||
return def
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setRequiredEnv(t *testing.T) {
|
func setRequiredEnv(t *testing.T) {
|
||||||
@@ -30,9 +29,6 @@ func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
|
|||||||
if cfg.OIDCRequiredRole != "geniusrun-user" {
|
if cfg.OIDCRequiredRole != "geniusrun-user" {
|
||||||
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
|
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
|
||||||
}
|
}
|
||||||
if cfg.SessionDuration != 720*time.Hour {
|
|
||||||
t.Errorf("SessionDuration = %v, want default 720h", cfg.SessionDuration)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
||||||
@@ -80,7 +76,7 @@ func TestLoad_SessionSecretTooShort(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
|
func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GARMIN_TOKENSTORE", "")
|
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "")
|
||||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
@@ -94,7 +90,7 @@ func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores")
|
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores")
|
||||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
@@ -134,7 +130,7 @@ func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GARMIN_WRAPPER_PYTHON", "")
|
t.Setenv("GENIUSRUN_PYTHON_PATH", "")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,7 +143,7 @@ func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GARMIN_WRAPPER_PYTHON", "/opt/venv/bin/python3")
|
t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,10 +180,9 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_CustomRoleAndDuration(t *testing.T) {
|
func TestLoad_CustomRole(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
||||||
t.Setenv("GENIUSRUN_SESSION_DURATION", "24h")
|
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,7 +191,4 @@ func TestLoad_CustomRoleAndDuration(t *testing.T) {
|
|||||||
if cfg.OIDCRequiredRole != "admin" {
|
if cfg.OIDCRequiredRole != "admin" {
|
||||||
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
|
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
|
||||||
}
|
}
|
||||||
if cfg.SessionDuration != 24*time.Hour {
|
|
||||||
t.Errorf("SessionDuration = %v", cfg.SessionDuration)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user