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" // KeySessionSetupTimeout bounds an *onboarding Garmin setup session* // (the ephemeral, pre-account login attempt), not the login cookie -- // session.duration is how long a signed-in user stays signed in // (hours); session.setup_timeout is how long an unfinished setup // attempt survives untouched before its subprocess is torn down // (minutes). KeySessionSetupTimeout = "session.setup_timeout" ) var appRegistry = []AppKey{ { Key: KeySessionDuration, Default: "720", Description: "Session cookie lifetime in hours", Validate: validatePositiveInt, }, { Key: KeySessionSetupTimeout, Default: "15", Description: "Idle timeout in minutes for an unfinished onboarding Garmin setup session", 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 application configuration, built from the DB's // config rows. type AppConfig struct { SessionDuration time.Duration SetupTimeout time.Duration } // LoadApp builds the typed AppConfig from the config table's rows. Every // registry key is mandatory: main seeds missing keys with their defaults // at startup (see cmd/geniusrund), so a missing key here means that // seeding didn't run -- fail fast, same posture as LoadEnv() for env // vars, and likewise for an unknown or invalid stored value. Pure -- it // takes the raw map instead of a *store.DB so this package needs no store // dependency and the logic tests without a database. func LoadApp(values map[string]string) (AppConfig, error) { for key, value := range values { if err := ValidateAppValue(key, value); err != nil { return AppConfig{}, fmt.Errorf("app config: %w", err) } } for _, k := range appRegistry { if _, ok := values[k.Key]; !ok { return AppConfig{}, fmt.Errorf("app config: missing key %q (defaults are seeded into the DB at startup)", k.Key) } } hours, _ := strconv.Atoi(values[KeySessionDuration]) setupMinutes, _ := strconv.Atoi(values[KeySessionSetupTimeout]) return AppConfig{ SessionDuration: time.Duration(hours) * time.Hour, SetupTimeout: time.Duration(setupMinutes) * time.Minute, }, nil }