refactor(config): mandatory app-config rows seeded in DB; rename to session.setup_timeout

All registry keys must exist as rows in the config table: main seeds
missing keys with their defaults at startup, LoadApp fails fast on a
missing key, and the code-side fallback const/helper for the onboarding
setup timeout is gone -- the value rides in SessionConfig.SetupTimeout.
The key is renamed session.idle_timeout -> session.setup_timeout, and
the /config page's 'overridden' now means 'differs from the default'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:17:24 +02:00
parent 8c9285f33c
commit 0e6cf00dba
16 changed files with 127 additions and 120 deletions

View File

@@ -25,11 +25,12 @@ import (
func newCtx() context.Context { return context.Background() }
var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour,
Secure: false,
BackendURL: "https://geniusrun.example.com",
FrontendURL: "https://app.geniusrun.example.com",
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour,
SetupTimeout: 15 * time.Minute,
Secure: false,
BackendURL: "https://geniusrun.example.com",
FrontendURL: "https://app.geniusrun.example.com",
}
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {

View File

@@ -22,23 +22,27 @@ type configEntry struct {
Description string `json:"description"`
}
// configView assembles the GET/PUT response: registry defaults overlaid
// with DB overrides, plus the env snapshot.
// configView assembles the GET/PUT response: every registry key with its
// stored value (the DB is fully seeded with defaults at startup; the
// registry default only fills in here for a key added since the last
// boot, e.g. under httptest where main's seeding never ran), plus the env
// snapshot. Overridden means "differs from the default", since every key
// always has a row.
func (s *Server) configView(r *http.Request) (map[string]any, error) {
overrides, err := s.DB.ConfigValues(r.Context())
values, err := s.DB.ConfigValues(r.Context())
if err != nil {
return nil, err
}
registry := config.AppRegistry()
app := make([]configEntry, 0, len(registry))
for _, k := range registry {
value, overridden := overrides[k.Key]
if !overridden {
value, ok := values[k.Key]
if !ok {
value = k.Default
}
app = append(app, configEntry{
Key: k.Key, Value: value, Default: k.Default,
Overridden: overridden, Description: k.Description,
Overridden: value != k.Default, Description: k.Description,
})
}
envVars := s.EnvVars

View File

@@ -55,8 +55,8 @@ func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) {
if e := byKey["session.duration"]; e.value != "720" || e.def != "720" || e.overridden {
t.Fatalf("session.duration default entry = %+v", e)
}
if e := byKey["session.idle_timeout"]; e.value != "15" || e.def != "15" || e.overridden {
t.Fatalf("session.idle_timeout default entry = %+v", e)
if e := byKey["session.setup_timeout"]; e.value != "15" || e.def != "15" || e.overridden {
t.Fatalf("session.setup_timeout default entry = %+v", e)
}
if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" {
t.Fatalf("env snapshot not passed through: %+v", resp.Environment)

View File

@@ -53,11 +53,6 @@ type Server struct {
// never call os.Getenv.
EnvVars []EnvVar
// SetupSessionIdleTimeout is the app-config session.idle_timeout value
// (see internal/config); zero falls back to
// defaultSetupSessionIdleTimeout.
SetupSessionIdleTimeout time.Duration
mu sync.Mutex
userClient map[int64]garmin.Client
userSync map[int64]*garmin.Sync

View File

@@ -15,7 +15,13 @@ import (
type SessionConfig struct {
Secret []byte
Duration time.Duration
Secure bool
// SetupTimeout evicts an unfinished onboarding Garmin setup session
// once idle this long (app-config key session.setup_timeout, minutes;
// distinct from Duration, the login cookie lifetime). Mandatory --
// there is no code fallback; the default lives in the DB, seeded at
// startup.
SetupTimeout time.Duration
Secure bool
// BackendURL is this app's own externally reachable origin (e.g.
// "https://geniusrun.example.com", no trailing slash) -- derives
// OIDCRedirectURL (config.Config), the only thing that must stay pointed

View File

@@ -13,24 +13,6 @@ import (
applog "geniusrun/backend/internal/log"
)
// defaultSetupSessionIdleTimeout bounds how long an onboarding Garmin
// session survives without being touched (login, MFA, or complete) before
// it's evicted -- long enough to check email for an MFA code, short enough
// that an abandoned attempt doesn't leave a subprocess running
// indefinitely. Tunable via the session.idle_timeout application-config
// key (minutes -- distinct from session.duration, the login cookie
// lifetime); this constant is the fallback when the Server field was never
// wired (tests building a bare NewServer).
const defaultSetupSessionIdleTimeout = 15 * time.Minute
// setupIdleTimeout returns the configured onboarding-session idle timeout.
func (s *Server) setupIdleTimeout() time.Duration {
if s.SetupSessionIdleTimeout > 0 {
return s.SetupSessionIdleTimeout
}
return defaultSetupSessionIdleTimeout
}
// setupSession is a temporary, not-yet-persisted Garmin authentication
// attempt made during onboarding, before any users/profile row exists --
// keyed by OIDC subject (the only stable identifier available pre-account)
@@ -42,7 +24,7 @@ func (s *Server) setupIdleTimeout() time.Duration {
// the next time anything closes and restarts its subprocess; a later
// garminFor(ctx, userID) call builds a fresh client with the correct path
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
// subject checks staleness first) once idle past setupIdleTimeout().
// subject checks staleness first) once idle past SessionConfig.SetupTimeout.
type setupSession struct {
Client garmin.Client
Email, Password string
@@ -225,7 +207,7 @@ func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
if !ok {
return nil, false
}
if time.Since(sess.LastUsed) > s.setupIdleTimeout() {
if time.Since(sess.LastUsed) > s.SessionConfig.SetupTimeout {
sess.Client.Close()
delete(s.setupSession, sub)
if s.ClientConfig.TokenStorePath != "" {