From 099e746119064ed3bdacaba508b7730303a92aca Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 25 Jul 2026 23:00:35 +0200 Subject: [PATCH] refactor(config): rename GENIUSRUN_PUBLIC_BASE_URL to GENIUSRUN_BACKEND_URL Now that GENIUSRUN_FRONTEND_URL exists as a separate config value, keeping the backend's own origin named "PublicBaseURL" invited exactly the kind of mixup that caused the OIDC callback 404 in the first place. Renamed consistently: env var, Config.BackendURL, api.SessionConfig.BackendURL. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- backend/cmd/geniusrund/main.go | 10 +++++----- backend/internal/api/api_test.go | 10 +++++----- backend/internal/api/session.go | 8 ++++---- backend/internal/config/config.go | 20 ++++++++++---------- backend/internal/config/config_test.go | 14 +++++++------- 6 files changed, 32 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e4bdb87..f41d697 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,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. -Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `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`, `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). ## Repo layout diff --git a/backend/cmd/geniusrund/main.go b/backend/cmd/geniusrund/main.go index e5103ba..bc1baa2 100644 --- a/backend/cmd/geniusrund/main.go +++ b/backend/cmd/geniusrund/main.go @@ -48,11 +48,11 @@ func main() { }, appsync.Config{ MinConfidence: cfg.MinConfidence, }, authVerifier, api.SessionConfig{ - Secret: cfg.SessionSecret, - Duration: cfg.SessionDuration, - Secure: cfg.SessionSecure, - PublicBaseURL: cfg.PublicBaseURL, - FrontendURL: cfg.FrontendURL, + Secret: cfg.SessionSecret, + Duration: cfg.SessionDuration, + Secure: cfg.SessionSecure, + BackendURL: cfg.BackendURL, + FrontendURL: cfg.FrontendURL, }) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 380feb4..5c149f4 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -24,11 +24,11 @@ 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, - PublicBaseURL: "https://geniusrun.example.com", - FrontendURL: "https://app.geniusrun.example.com", + 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", } func newTestServer(t *testing.T) (*Server, *store.DB, int64) { diff --git a/backend/internal/api/session.go b/backend/internal/api/session.go index 5519a8e..46d5a5f 100644 --- a/backend/internal/api/session.go +++ b/backend/internal/api/session.go @@ -15,15 +15,15 @@ type SessionConfig struct { Secret []byte Duration time.Duration Secure bool - // PublicBaseURL is this app's own externally reachable origin (e.g. + // BackendURL is this app's own externally reachable origin (e.g. // "https://geniusrun.example.com", no trailing slash), used to build an // absolute post_logout_redirect_uri for the identity provider -- some // providers, including Keycloak, require this to be an absolute URL // matching one registered on the client, not a bare relative path. - PublicBaseURL string + BackendURL string // FrontendURL is the origin the browser should land on after the OIDC // callback (success or failure) -- see config.Config.FrontendURL for why - // this can differ from PublicBaseURL in a split-origin deployment. + // this can differ from BackendURL in a split-origin deployment. FrontendURL string } @@ -87,7 +87,7 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) - http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.PublicBaseURL+"/"), http.StatusFound) + http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.BackendURL+"/"), http.StatusFound) } func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 5527f3b..90ec0b0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -38,20 +38,20 @@ type Config struct { MinConfidence float64 IncrementalSyncEvery time.Duration - // OIDC login gate (Keycloak). PublicBaseURL is this app's own externally + // 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. - PublicBaseURL string + 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 PublicBaseURL when + // 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. - // PublicBaseURL itself must stay pointed at the backend's own 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 @@ -75,7 +75,7 @@ func Load() (Config, error) { GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"), MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour), - PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"), + 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"), @@ -88,10 +88,10 @@ func Load() (Config, error) { log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot) } - if cfg.PublicBaseURL == "" { - return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)") + 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.PublicBaseURL), "/") + cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/") if cfg.OIDCIssuerURL == "" { return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required") } @@ -106,8 +106,8 @@ func Load() (Config, error) { return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters") } cfg.SessionSecret = []byte(sessionSecret) - cfg.OIDCRedirectURL = cfg.PublicBaseURL + "/api/session/callback" - cfg.SessionSecure = strings.HasPrefix(cfg.PublicBaseURL, "https://") + cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback" + cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://") return cfg, nil } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 35e52bd..ec09c9d 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -8,14 +8,14 @@ import ( func setRequiredEnv(t *testing.T) { t.Helper() - t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "https://geniusrun.example.com") + t.Setenv("GENIUSRUN_BACKEND_URL", "https://geniusrun.example.com") t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm") t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun") t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret") t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long") } -func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) { +func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) { setRequiredEnv(t) cfg, err := Load() @@ -38,7 +38,7 @@ func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) { func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) { setRequiredEnv(t) - t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "http://localhost:8080") + t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080") cfg, err := Load() if err != nil { @@ -54,7 +54,7 @@ func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) { func TestLoad_MissingRequiredOIDCVars(t *testing.T) { cases := []string{ - "GENIUSRUN_PUBLIC_BASE_URL", + "GENIUSRUN_BACKEND_URL", "GENIUSRUN_OIDC_ISSUER_URL", "GENIUSRUN_OIDC_CLIENT_ID", "GENIUSRUN_OIDC_CLIENT_SECRET", @@ -134,7 +134,7 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) { } } -func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) { +func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) { setRequiredEnv(t) t.Setenv("GENIUSRUN_FRONTEND_URL", "") @@ -142,8 +142,8 @@ func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - if cfg.FrontendURL != cfg.PublicBaseURL { - t.Errorf("FrontendURL = %q, want it to default to PublicBaseURL %q", cfg.FrontendURL, cfg.PublicBaseURL) + if cfg.FrontendURL != cfg.BackendURL { + t.Errorf("FrontendURL = %q, want it to default to BackendURL %q", cfg.FrontendURL, cfg.BackendURL) } }