# 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 `setupSession` 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 `clientFor`): ```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" ```