diff --git a/docs/superpowers/plans/2026-07-26-onboarding-wizard-deferred-commit.md b/docs/superpowers/plans/2026-07-26-onboarding-wizard-deferred-commit.md new file mode 100644 index 0000000..e388638 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-onboarding-wizard-deferred-commit.md @@ -0,0 +1,1189 @@ +# Onboarding Wizard Deferred Commit Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the two-screen (`CreateProfile` → `ConnectGarmin`) onboarding flow with a single wizard that defers every database write until Garmin authentication actually succeeds, and gains real Previous/Next navigation. + +**Architecture:** Add an ephemeral, OIDC-subject-keyed Garmin session (`Server.setupGarmin`) alongside the existing per-user-id ones, so Garmin login/MFA can be attempted before any `users`/`profile` row exists. Three new endpoints (`/api/setup/garmin/login`, `/api/setup/garmin/mfa`, `/api/setup/complete`) replace the old `/api/setup`. `/api/setup/complete` is the single atomic commit point: it provisions the account, persists the Garmin credentials, marks it connected, and promotes the already-authenticated ephemeral client into the permanent per-user cache. The frontend collapses `CreateProfile.tsx` + `ConnectGarmin.tsx` into one `OnboardingWizard.tsx`, and `LoginGate.tsx` goes back to a two-way fork since `has_profile=true && garmin_connected=false` is now structurally unreachable. + +**Tech Stack:** Go (`net/http`, chi router, `crypto/sha256`), React + TypeScript (Vite), no frontend test framework. + +## Global Constraints + +- Every store/API method touching per-user data takes an explicit `userID`/subject and filters by it. +- Cross-user (here, cross-subject) isolation is tested adversarially, not just checked for non-collision. +- `gofmt -l .` must report nothing; `go vet ./...` and `go build ./...` must pass. +- No frontend test suite exists — frontend changes are verified via `npm run build`, `npm run lint`, and a manual browser check. +- No schema changes this round — `profile.garmin_connected_at` already exists from the prior task. + +--- + +### Task 1: Ephemeral pre-account Garmin sessions and the three new endpoints + +**Files:** +- Modify: `backend/internal/api/server.go` (new `setupSession` type + `Server.setupGarmin` field + helper methods + route registration) +- Modify: `backend/internal/api/setup.go` (replace `handleSetup` with three new handlers) +- Modify: `backend/internal/api/session.go` (`handleSessionLogout` cleans up the subject's ephemeral session) +- Modify: `backend/internal/garmin/mock/mock.go` (add `AuthenticateCalls` counter, needed to prove promotion doesn't re-authenticate) +- Modify: `backend/internal/api/setup_test.go` (replace all three old tests with the new endpoint tests) +- Modify: `backend/internal/api/isolation_test.go` (new adversarial test) + +**Interfaces:** +- Produces: `func (s *Server) setupSessionFor(sub string) (*setupSession, bool)`, `func (s *Server) replaceSetupSession(sub, email, password string) *setupSession`, `func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult)`, `func (s *Server) removeSetupSession(sub string)`, `func setupTokenStoreDir(root, sub string) string` — all in `server.go`, used by `setup.go`'s new handlers and `session.go`'s logout handler. +- Routes: `POST /api/setup/garmin/login` `{garmin_email, garmin_password}` → `authResponse`; `POST /api/setup/garmin/mfa` `{code}` → `authResponse`; `POST /api/setup/complete` `{display_name}` → `{user_id, display_name}`. The old `POST /api/setup` is removed. + +- [ ] **Step 1: Add `AuthenticateCalls` to the mock Garmin client** + +In `backend/internal/garmin/mock/mock.go`, change the struct: + +```go +// Client is a fake garmin.Client returning data supplied by the test/caller. +type Client struct { + AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls + Activities []garmin.Activity + Splits map[int64]garmin.ActivitySplits + Details map[int64]garmin.ActivityDetails + Workouts map[int64]garmin.Workout + Err error // if set, every call returns this error + authResultCursor int + ClosedCalled bool + GetActivitiesCalls int + AuthenticateCalls int + LastEmail string + LastPassword string +} +``` + +Change `Authenticate`: + +```go +func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) { + c.AuthenticateCalls++ + if c.Err != nil { + return garmin.AuthResult{}, c.Err + } + return c.nextAuthResult(), nil +} +``` + +- [ ] **Step 2: Write the failing tests** + +Replace the entire contents of `backend/internal/api/setup_test.go` with: + +```go +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + authmock "geniusrun/backend/internal/auth/mock" + "geniusrun/backend/internal/garmin" + "geniusrun/backend/internal/garmin/mock" + "geniusrun/backend/internal/store" + appsync "geniusrun/backend/internal/sync" +) + +// newUnprovisionedServer builds a Server whose "test-user" OIDC subject +// (the identity doJSON's cookie always mints) has no users/profile row yet +// -- every test in this file needs that starting state, unlike +// newTestServer's auto-provisioned default. +func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *mock.Client) { + t.Helper() + db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + m := &mock.Client{} + garminFactory := func(garmin.Config) garmin.Client { return m } + s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + return s, db, m +} + +func TestSetupGarminLogin_AuthenticatesWithoutCreatingAccount(t *testing.T) { + s, db, _ := newUnprovisionedServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var resp authResponse + unmarshalBody(t, rec, &resp) + if resp.Status != "authenticated" { + t.Fatalf("status = %q, want authenticated", resp.Status) + } + + if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found { + t.Fatalf("expected no user created yet, found=%v err=%v", found, err) + } +} + +func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) { + s, db, m := newUnprovisionedServer(t) + m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}} + router := s.Router() + + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + var resp authResponse + unmarshalBody(t, rec, &resp) + if resp.Status != "mfa_required" { + t.Fatalf("status = %q, want mfa_required", resp.Status) + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"}) + if rec.Code != http.StatusOK { + t.Fatalf("mfa status = %d, body = %s", rec.Code, rec.Body.String()) + } + unmarshalBody(t, rec, &resp) + if resp.Status != "authenticated" { + t.Fatalf("status after mfa = %q, want authenticated", resp.Status) + } + + if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found { + t.Fatalf("expected no user created yet even after MFA success, found=%v err=%v", found, err) + } +} + +func TestSetupGarminMFA_RejectsWithoutPriorLoginAttempt(t *testing.T) { + s, _, _ := newUnprovisionedServer(t) + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"}) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestSetupComplete_CreatesAccountWithGarminCredentialsAndPromotesClient(t *testing.T) { + s, db, m := newUnprovisionedServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusOK { + t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String()) + } + + u, found, err := db.GetUserBySub(newCtx(), "test-user") + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + profile, err := db.GetProfile(newCtx(), u.ID) + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if profile.GarminEmail != "runner@example.com" || profile.GarminPassword != "hunter2" { + t.Errorf("profile garmin creds = %+v, want email=runner@example.com password=hunter2", profile) + } + if profile.GarminConnectedAt == nil { + t.Error("expected GarminConnectedAt to be set") + } + + if m.AuthenticateCalls != 1 { + t.Errorf("AuthenticateCalls = %d, want 1 (no redundant re-authentication after promotion)", m.AuthenticateCalls) + } + if m.ClosedCalled { + t.Error("expected the promoted client to survive (not be Close()d)") + } + + client, err := s.garminFor(newCtx(), u.ID) + if err != nil { + t.Fatalf("garminFor: %v", err) + } + if client != m { + t.Error("expected garminFor to return the promoted (already-authenticated) client") + } +} + +func TestSetupComplete_RejectsWithoutSuccessfulGarminConnection(t *testing.T) { + s, db, _ := newUnprovisionedServer(t) + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } + if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found { + t.Fatalf("expected no user created, found=%v err=%v", found, err) + } +} + +func TestSetupComplete_RejectsEmptyDisplayName(t *testing.T) { + s, _, _ := newUnprovisionedServer(t) + router := s.Router() + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": ""}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) { + s, _, _ := newTestServer(t) // pre-provisioned "test-user" + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestSetupComplete_RejectsWhenAlreadyProvisioned(t *testing.T) { + s, _, _ := newTestServer(t) // pre-provisioned "test-user" + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Someone Else"}) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + m := &mock.Client{} + tokenStoreRoot := t.TempDir() + s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + router := s.Router() + + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "runner@example.com", "garmin_password": "hunter2", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + + // Simulate what a real subprocess would have written under the + // ephemeral (subject-keyed) directory during that login call. + oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user") + if err := os.MkdirAll(oldDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusOK { + t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String()) + } + u, found, err := db.GetUserBySub(newCtx(), "test-user") + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + + if _, err := os.Stat(oldDir); !os.IsNotExist(err) { + t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err) + } + newDir := filepath.Join(tokenStoreRoot, itoa(u.ID)) + if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil { + t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err) + } +} + +func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) { + t.Helper() + if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { + t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err) + } +} +``` + +Append to `backend/internal/api/isolation_test.go`: + +```go +// TestIsolation_SetupSessionsNeverLeakAcrossSubjects confirms one OIDC +// subject's pending ephemeral Garmin session is invisible to another +// subject -- e.g. subject B completing MFA must not accidentally continue +// subject A's in-progress attempt. +func TestIsolation_SetupSessionsNeverLeakAcrossSubjects(t *testing.T) { + s, _, _ := newUnprovisionedServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ + "garmin_email": "a@example.com", "garmin_password": "pw-a", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login(a) status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSONAs(t, router, "user-b", http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "000000"}) + if rec.Code != http.StatusConflict { + t.Fatalf("user-b mfa (no login attempt of their own) status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd backend && go test ./internal/api/... -run 'TestSetup|TestIsolation_SetupSessions' -v` +Expected: FAIL — 404s (routes don't exist), plus `newUnprovisionedServer`/`setupTokenStoreDir` undefined. + +- [ ] **Step 4: Add the ephemeral session infrastructure to `server.go`** + +Change the import block: + +```go +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/go-chi/chi/v5" + + "geniusrun/backend/internal/auth" + "geniusrun/backend/internal/garmin" + "geniusrun/backend/internal/store" + appsync "geniusrun/backend/internal/sync" +) +``` + +Add the `setupGarmin` field to `Server` and initialize it in `NewServer`: + +```go + mu sync.Mutex + userGarmin map[int64]garmin.Client + userSync map[int64]*appsync.Service + userAuthStatus map[int64]garmin.AuthStatus + userAuthMessage map[int64]string + userSyncRunning map[int64]bool + setupGarmin map[string]*setupSession +} +``` + +```go + return &Server{ + DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig, + Auth: authVerifier, Session: session, + userGarmin: map[int64]garmin.Client{}, + userSync: map[int64]*appsync.Service{}, + userAuthStatus: map[int64]garmin.AuthStatus{}, + userAuthMessage: map[int64]string{}, + userSyncRunning: map[int64]bool{}, + setupGarmin: map[string]*setupSession{}, + } +} +``` + +Insert this block right after `NewServer` (before `garminFor`): + +```go + +// 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) +// rather than a user id. Promoted into Server.userGarmin once +// /api/setup/complete actually creates the account; evicted lazily (the +// next setup-endpoint touch for that subject checks staleness first) once +// idle past setupSessionIdleTimeout. +type setupSession struct { + Client garmin.Client + Email, Password string + Status garmin.AuthStatus + Message string + LastUsed time.Time +} + +// setupSessionIdleTimeout 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. +const setupSessionIdleTimeout = 15 * time.Minute + +// setupTokenStoreDir returns the token-store directory an ephemeral setup +// session for sub should use -- hashed rather than sub itself, since sub is +// an opaque string from the identity provider and using it verbatim in a +// filesystem path would be a directory-traversal risk if it ever contained +// path separators. +func setupTokenStoreDir(root, sub string) string { + h := sha256.Sum256([]byte(sub)) + return filepath.Join(root, "setup", hex.EncodeToString(h[:])) +} + +// setupSessionFor returns sub's in-progress ephemeral Garmin session, if +// any and not stale. A stale session is closed and evicted first, so the +// caller always either gets a fresh, live session or none. +func (s *Server) setupSessionFor(sub string) (*setupSession, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.setupGarmin[sub] + if !ok { + return nil, false + } + if time.Since(sess.LastUsed) > setupSessionIdleTimeout { + sess.Client.Close() + delete(s.setupGarmin, sub) + if s.GarminBase.TokenStorePath != "" { + os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub)) + } + return nil, false + } + return sess, true +} + +// replaceSetupSession closes and replaces sub's ephemeral Garmin session +// (if any) with a freshly built one for the given credentials -- same +// "close old, spawn new" semantics as garmin.Client.UpdateCredentials. +func (s *Server) replaceSetupSession(sub, email, password string) *setupSession { + s.mu.Lock() + old, ok := s.setupGarmin[sub] + s.mu.Unlock() + if ok { + old.Client.Close() + } + + cfg := s.GarminBase + cfg.GarminEmail = email + cfg.GarminPassword = password + if cfg.TokenStorePath != "" { + cfg.TokenStorePath = setupTokenStoreDir(s.GarminBase.TokenStorePath, sub) + } + sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()} + + s.mu.Lock() + s.setupGarmin[sub] = sess + s.mu.Unlock() + return sess +} + +// recordSetupAuthResult updates sub's ephemeral session after a login or +// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently). +func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) { + s.mu.Lock() + defer s.mu.Unlock() + if sess, ok := s.setupGarmin[sub]; ok { + sess.Status = res.Status + sess.Message = res.Message + sess.LastUsed = time.Now() + } +} + +// removeSetupSession closes and drops sub's ephemeral Garmin session, if +// any, and best-effort removes its token-store directory. Used when +// abandoning onboarding (logout) -- NOT used during promotion in +// handleSetupComplete, which transfers ownership of the client (and +// renames the directory) instead of discarding them. +func (s *Server) removeSetupSession(sub string) { + s.mu.Lock() + sess, ok := s.setupGarmin[sub] + delete(s.setupGarmin, sub) + s.mu.Unlock() + if !ok { + return + } + sess.Client.Close() + if s.GarminBase.TokenStorePath != "" { + os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub)) + } +} +``` + +- [ ] **Step 5: Replace the route registration** + +In `server.go`'s `Router()`, change: + +```go + r.Get("/session/me", s.handleSessionMe) + r.Post("/session/logout", s.handleSessionLogout) + r.Post("/setup", s.handleSetup) +``` + +to: + +```go + r.Get("/session/me", s.handleSessionMe) + r.Post("/session/logout", s.handleSessionLogout) + r.Route("/setup", func(r chi.Router) { + r.Post("/complete", s.handleSetupComplete) + r.Route("/garmin", func(r chi.Router) { + r.Post("/login", s.handleSetupGarminLogin) + r.Post("/mfa", s.handleSetupGarminMFA) + }) + }) +``` + +- [ ] **Step 6: Replace `handleSetup` in `setup.go` with the three new handlers** + +Replace the entire contents of `backend/internal/api/setup.go` with: + +```go +package api + +import ( + "encoding/json" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + + "geniusrun/backend/internal/auth" + "geniusrun/backend/internal/garmin" +) + +// handleSetupGarminLogin authenticates against Garmin using an ephemeral, +// not-yet-persisted session keyed by the OIDC subject -- no users/profile +// row exists yet at this point (see setupSession in server.go). +func (s *Server) handleSetupGarminLogin(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + if _, found := userFromContext(r.Context()); found { + writeError(w, http.StatusConflict, "profile already exists for this account") + return + } + + var body struct { + GarminEmail string `json:"garmin_email"` + GarminPassword string `json:"garmin_password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.GarminEmail == "" || body.GarminPassword == "" { + writeError(w, http.StatusBadRequest, "garmin_email and garmin_password are required") + return + } + + sess := s.replaceSetupSession(claims.Sub, body.GarminEmail, body.GarminPassword) + res, err := sess.Client.Authenticate(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + s.recordSetupAuthResult(claims.Sub, res) + writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) +} + +// handleSetupGarminMFA continues an in-progress ephemeral Garmin login +// (started by handleSetupGarminLogin) with an MFA code, on the same +// session/subprocess -- never replaces it. +func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + var body struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Code == "" { + writeError(w, http.StatusBadRequest, "code is required") + return + } + + sess, ok := s.setupSessionFor(claims.Sub) + if !ok { + writeError(w, http.StatusConflict, "no Garmin connection attempt in progress; connect again") + return + } + res, err := sess.Client.CompleteMFA(r.Context(), body.Code) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + s.recordSetupAuthResult(claims.Sub, res) + writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) +} + +// handleSetupComplete is the single atomic commit point: only reachable +// once the ephemeral session for this subject last reported +// garmin.AuthSuccess. Provisions the account, persists the Garmin +// credentials, marks it connected, and promotes the already-authenticated +// ephemeral client into the permanent per-user cache instead of discarding +// it (no redundant re-authentication, no repeat MFA prompt, right after +// signup). +func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + if _, found := userFromContext(r.Context()); found { + writeError(w, http.StatusConflict, "profile already exists for this account") + return + } + + var body struct { + DisplayName string `json:"display_name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.DisplayName == "" { + writeError(w, http.StatusBadRequest, "display_name is required") + return + } + + sess, ok := s.setupSessionFor(claims.Sub) + if !ok || sess.Status != garmin.AuthSuccess { + writeError(w, http.StatusConflict, "Garmin isn't connected yet -- connect before completing setup") + return + } + + userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + profile, err := s.DB.GetProfile(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + profile.GarminEmail = sess.Email + profile.GarminPassword = sess.Password + if err := s.DB.UpdateProfile(r.Context(), userID, profile); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.DB.MarkGarminConnected(r.Context(), userID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + s.mu.Lock() + delete(s.setupGarmin, claims.Sub) + s.userGarmin[userID] = sess.Client + s.userAuthStatus[userID] = sess.Status + s.userAuthMessage[userID] = sess.Message + s.mu.Unlock() + + if s.GarminBase.TokenStorePath != "" { + oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub) + newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10)) + if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) { + log.Printf("api: rename setup token store dir for user %d: %v", userID, err) + } + } + + writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName}) +} +``` + +- [ ] **Step 7: Clean up the ephemeral session on logout** + +In `backend/internal/api/session.go`, change `handleSessionLogout`: + +```go +func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) { + claims, _ := auth.ClaimsFromContext(r.Context()) + s.removeSetupSession(claims.Sub) + http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) + http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound) +} +``` + +(`claims.Sub` is `""` for the "unreachable in practice" case where `ClaimsFromContext` fails — `removeSetupSession("")` is a harmless no-op since no session is ever stored under that key.) + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/api/... -run 'TestSetup|TestIsolation_SetupSessions' -v` +Expected: PASS. + +- [ ] **Step 9: Run the full backend test suite** + +Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...` +Expected: `gofmt -l .` empty; everything else passes. (`TestSetup_ProvisionsNewUserWithDisplayName`, `TestSetup_RejectsEmptyDisplayName`, `TestSetup_RejectsWhenAlreadyProvisioned` from the old `/api/setup` no longer exist — they were replaced wholesale in Step 2.) + +- [ ] **Step 10: Commit** + +```bash +git add backend/internal/api/server.go backend/internal/api/setup.go backend/internal/api/session.go backend/internal/api/setup_test.go backend/internal/api/isolation_test.go backend/internal/garmin/mock/mock.go +git commit -m "feat(api): defer account creation until Garmin actually connects" +``` + +--- + +### Task 2: Frontend — single OnboardingWizard replaces CreateProfile/ConnectGarmin + +**Files:** +- Modify: `frontend/src/api/client.ts` (remove `setup`, add `setupGarminLogin`/`setupGarminMFA`/`setupComplete`) +- Create: `frontend/src/OnboardingWizard.tsx` +- Create: `frontend/src/OnboardingWizard.css` +- Delete: `frontend/src/CreateProfile.tsx`, `frontend/src/ConnectGarmin.tsx`, `frontend/src/CreateProfile.css` +- Modify: `frontend/src/LoginGate.tsx` (collapse to two-way fork) + +**Interfaces:** +- Consumes: the three new backend endpoints (Task 1); `AuthResponse` (existing type, unchanged). +- Produces: `OnboardingWizard({ onCreated: (displayName: string) => void })`, a React component. + +- [ ] **Step 1: Replace `setup` with the three new API client methods** + +In `frontend/src/api/client.ts`, change: + +```ts + getSessionInfo: () => request("/api/session/me"), + setup: (displayName: string) => + request<{ user_id: number; display_name: string }>("/api/setup", { + method: "POST", + body: JSON.stringify({ display_name: displayName }), + }), +``` + +to: + +```ts + getSessionInfo: () => request("/api/session/me"), + // Onboarding (pre-account) -- distinct from the Profile page's + // auth.login()/submitMFA() below, since these run before any account + // exists: the Garmin session they build is ephemeral (keyed by OIDC + // subject, not a user id) until setupComplete() actually creates the + // account. See docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md. + setupGarminLogin: (garminEmail: string, garminPassword: string) => + request("/api/setup/garmin/login", { + method: "POST", + body: JSON.stringify({ garmin_email: garminEmail, garmin_password: garminPassword }), + }), + setupGarminMFA: (code: string) => + request("/api/setup/garmin/mfa", { method: "POST", body: JSON.stringify({ code }) }), + setupComplete: (displayName: string) => + request<{ user_id: number; display_name: string }>("/api/setup/complete", { + method: "POST", + body: JSON.stringify({ display_name: displayName }), + }), +``` + +- [ ] **Step 2: Create `frontend/src/OnboardingWizard.css`** + +```css +.onboarding-wizard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 1rem; + text-align: center; +} + +.onboarding-wizard form { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + max-width: 320px; +} + +.onboarding-wizard input { + padding: 0.6rem 0.8rem; + font-size: 1rem; + border-radius: 0.5rem; + border: 1px solid #ccc; +} + +.onboarding-wizard button { + padding: 0.6rem 0.8rem; + font-size: 1rem; + border-radius: 0.5rem; + border: none; + background: #3b82f6; + color: white; + cursor: pointer; +} + +.onboarding-wizard button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.onboarding-wizard-error { + color: #ef4444; +} + +.onboarding-wizard-message { + margin: 0; + color: #9ca3af; +} + +.onboarding-wizard-mfa { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + max-width: 320px; +} + +.onboarding-wizard-actions { + display: flex; + gap: 0.75rem; +} + +.onboarding-wizard-actions button { + flex: 1; +} +``` + +- [ ] **Step 3: Create `frontend/src/OnboardingWizard.tsx`** + +```tsx +import { useState } from "react"; +import { api, BASE_URL } from "./api/client"; +import "./OnboardingWizard.css"; +import type { AuthResponse } from "./types/api"; + +type Step = "name" | "garmin"; + +// Replaces the old CreateProfile.tsx + ConnectGarmin.tsx: a single wizard, +// since nothing is persisted to the database until Garmin actually +// connects (see +// docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md). +// Quitting partway through (closing the tab, logging out) leaves no trace +// in the database -- there's no half-created account to clean up or +// re-prompt for later. +export function OnboardingWizard({ onCreated }: { onCreated: (displayName: string) => void }) { + const [step, setStep] = useState("name"); + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [auth, setAuth] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + function submitName(e: React.FormEvent) { + e.preventDefault(); + if (!displayName.trim()) { + setError("Please enter a display name."); + return; + } + setError(null); + setStep("garmin"); + } + + async function submitGarmin(e: React.FormEvent) { + e.preventDefault(); + if (!email.trim() || !password) { + setError("Please enter your Garmin email and password."); + return; + } + setBusy(true); + setError(null); + try { + setAuth(await api.setupGarminLogin(email.trim(), password)); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong, please try again."); + } finally { + setBusy(false); + } + } + + async function submitMFA() { + if (!code.trim()) return; + setBusy(true); + setError(null); + try { + setAuth(await api.setupGarminMFA(code.trim())); + setCode(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong, please try again."); + } finally { + setBusy(false); + } + } + + async function complete() { + setBusy(true); + setError(null); + try { + const result = await api.setupComplete(displayName.trim()); + onCreated(result.display_name); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong, please try again."); + setBusy(false); + } + } + + if (step === "name") { + return ( +
+

🧞‍♀️ Welcome to geniusrun

+

Let's set up your profile. You'll connect your Garmin account next.

+
+ setDisplayName(e.target.value)} + autoFocus + /> + {error &&

{error}

} + +
+
+ +
+
+ ); + } + + return ( +
+

🧞‍♀️ Connect your Garmin account

+

geniusrun needs your Garmin credentials to sync your activities.

+ + {auth?.status === "authenticated" ? ( + <> +

Connected to Garmin.

+ + + ) : auth?.status === "mfa_required" ? ( +
+ setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submitMFA()} + disabled={busy} + autoFocus + /> + {auth.message &&

{auth.message}

} + {error &&

{error}

} +
+ + +
+
+ ) : ( +
+ setEmail(e.target.value)} + disabled={busy} + autoFocus + /> + setPassword(e.target.value)} + disabled={busy} + /> + {auth?.message &&

{auth.message}

} + {error &&

{error}

} +
+ + +
+
+ )} + +
+ +
+
+ ); +} +``` + +- [ ] **Step 4: Delete the old screens** + +```bash +rm frontend/src/CreateProfile.tsx frontend/src/ConnectGarmin.tsx frontend/src/CreateProfile.css +``` + +- [ ] **Step 5: Collapse `LoginGate.tsx` to a two-way fork** + +Change the imports: + +```tsx +import { useEffect, useState } from "react"; +import { api, BASE_URL } from "./api/client"; +import "./LoginGate.css"; +import App from "./App"; +import { ConnectGarmin } from "./ConnectGarmin"; +import { CreateProfile } from "./CreateProfile"; +import type { SessionInfo } from "./types/api"; +``` + +to: + +```tsx +import { useEffect, useState } from "react"; +import { api, BASE_URL } from "./api/client"; +import "./LoginGate.css"; +import App from "./App"; +import { OnboardingWizard } from "./OnboardingWizard"; +import type { SessionInfo } from "./types/api"; +``` + +Change the doc comment above `LoginGate`: + +```tsx +// Wraps App: on mount, asks the backend whether this browser already has a +// valid session (GET /api/session/me). geniusrun has no anonymous view, so +// this is the first fork -- "show the login screen" vs "show the app." A +// second fork, once authenticated, is whether the session's account has a +// provisioned profile yet (session.has_profile) -- a brand-new OIDC login +// sees CreateProfile instead of App until it submits one. A third fork, +// once provisioned, is whether Garmin has ever been successfully connected +// (session.garmin_connected) -- mandatory, and re-checked on every login, +// not just immediately after signup. +``` + +to: + +```tsx +// Wraps App: on mount, asks the backend whether this browser already has a +// valid session (GET /api/session/me). geniusrun has no anonymous view, so +// this is the first fork -- "show the login screen" vs "show the app." A +// second fork, once authenticated, is whether the session's account has a +// provisioned profile yet (session.has_profile) -- a brand-new OIDC login +// sees OnboardingWizard instead of App until it completes one. Nothing is +// persisted until the wizard's Garmin-connect step actually succeeds (see +// docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md), +// so has_profile alone is always sufficient here -- there's no separate +// "provisioned but not connected" state to gate on. +``` + +Change the final part of the component body: + +```tsx + if (!session!.has_profile) { + return ( + setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))} + /> + ); + } + + if (!session!.garmin_connected) { + return setSession((s) => (s ? { ...s, garmin_connected: true } : s))} />; + } + + return ; +} +``` + +to: + +```tsx + if (!session!.has_profile) { + return ( + setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))} + /> + ); + } + + return ; +} +``` + +- [ ] **Step 6: Build and lint** + +Run: `cd frontend && npm run build && npm run lint` +Expected: build succeeds; lint reports no new warnings beyond the 3 pre-existing `PaceField.tsx` ones. + +- [ ] **Step 7: Manual browser verification** + +With the backend (`cd backend && ./start.sh`) and frontend (`cd frontend && ./start.sh`) running: +1. Delete your profile (or use a fresh OIDC account) so you land on the wizard's name step. +2. Enter a display name, click Next — lands on the Garmin step. Click Previous — back to the name step with the name still filled in (pure local state). +3. Enter an obviously wrong Garmin password, click Next — error shown, fields stay editable, no account created (nothing to check in the UI for this, but note it for your own peace of mind). +4. Enter correct credentials. If MFA is prompted, click Previous from the MFA screen — confirm it returns to the credentials form (not the name step). Complete MFA. +5. On the "Connected to Garmin" screen, click "Continue to geniusrun" — confirm you land in the main app. +6. Log out, log back in with the same account — confirm you land straight in the app (no re-prompt). +7. Separately: start the wizard fresh, enter Garmin credentials, get to the "Connected" screen, then just close the tab without clicking Continue. Log back in — confirm you're back at the *name* step (proving nothing was persisted), not stuck at a half-finished Garmin step. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/api/client.ts frontend/src/OnboardingWizard.tsx frontend/src/OnboardingWizard.css frontend/src/LoginGate.tsx +git rm frontend/src/CreateProfile.tsx frontend/src/ConnectGarmin.tsx frontend/src/CreateProfile.css +git commit -m "feat(onboarding): replace CreateProfile/ConnectGarmin with a single deferred-commit wizard" +``` + +--- + +### Task 3: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Full backend check** + +Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...` +Expected: `gofmt -l .` empty; everything else passes. + +- [ ] **Step 2: Full frontend check** + +Run: `cd frontend && npm run build && npm run lint` +Expected: both succeed, no new lint warnings. + +- [ ] **Step 3: Commit (if anything drifted)** + +If either check above required a fix, commit it: + +```bash +git add -A +git commit -m "fix: address final verification findings" +```