Files
geniusrun/docs/superpowers/plans/2026-07-26-onboarding-wizard-deferred-commit.md
Christophe Vila e2b2bf9611 refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes
garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes
internal/log; the test mock moves into the garmin package as MockClient
(breaking the test-only import cycle the merge created); stale test
URLs and type names updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:04:18 +02:00

41 KiB

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 (CreateProfileConnectGarmin) 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:

// 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:

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:

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:

// 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:

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:

	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
}
	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):


// 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:

				r.Get("/session/me", s.handleSessionMe)
				r.Post("/session/logout", s.handleSessionLogout)
				r.Post("/setup", s.handleSetup)

to:

				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:

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:

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
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:

  getSessionInfo: () => request<SessionInfo>("/api/session/me"),
  setup: (displayName: string) =>
    request<{ user_id: number; display_name: string }>("/api/setup", {
      method: "POST",
      body: JSON.stringify({ display_name: displayName }),
    }),

to:

  getSessionInfo: () => request<SessionInfo>("/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<AuthResponse>("/api/setup/garmin/login", {
      method: "POST",
      body: JSON.stringify({ garmin_email: garminEmail, garmin_password: garminPassword }),
    }),
  setupGarminMFA: (code: string) =>
    request<AuthResponse>("/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
.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
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<Step>("name");
  const [displayName, setDisplayName] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [code, setCode] = useState("");
  const [auth, setAuth] = useState<AuthResponse | null>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(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 (
      <div className="onboarding-wizard">
        <h1>🧞‍♀️ Welcome to geniusrun</h1>
        <p>Let's set up your profile. You'll connect your Garmin account next.</p>
        <form onSubmit={submitName}>
          <input
            type="text"
            placeholder="Display name"
            value={displayName}
            onChange={(e) => setDisplayName(e.target.value)}
            autoFocus
          />
          {error && <p className="onboarding-wizard-error">{error}</p>}
          <button type="submit">Next</button>
        </form>
        <form method="post" action={`${BASE_URL}/api/session/logout`}>
          <button type="submit" className="logout-link">
            Log out
          </button>
        </form>
      </div>
    );
  }

  return (
    <div className="onboarding-wizard">
      <h1>🧞‍♀️ Connect your Garmin account</h1>
      <p>geniusrun needs your Garmin credentials to sync your activities.</p>

      {auth?.status === "authenticated" ? (
        <>
          <p className="onboarding-wizard-message">Connected to Garmin.</p>
          <button type="button" disabled={busy} onClick={complete}>
            {busy ? "Finishing…" : "Continue to geniusrun"}
          </button>
        </>
      ) : auth?.status === "mfa_required" ? (
        <div className="onboarding-wizard-mfa">
          <input
            placeholder="MFA code"
            value={code}
            onChange={(e) => setCode(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && submitMFA()}
            disabled={busy}
            autoFocus
          />
          {auth.message && <p className="onboarding-wizard-message">{auth.message}</p>}
          {error && <p className="onboarding-wizard-error">{error}</p>}
          <div className="onboarding-wizard-actions">
            <button
              type="button"
              disabled={busy}
              onClick={() => {
                setAuth(null);
                setError(null);
              }}
            >
              Previous
            </button>
            <button type="button" disabled={busy} onClick={submitMFA}>
              Submit code
            </button>
          </div>
        </div>
      ) : (
        <form onSubmit={submitGarmin}>
          <input
            type="text"
            placeholder="Garmin email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            disabled={busy}
            autoFocus
          />
          <input
            type="password"
            placeholder="Garmin password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            disabled={busy}
          />
          {auth?.message && <p className="onboarding-wizard-message">{auth.message}</p>}
          {error && <p className="onboarding-wizard-error">{error}</p>}
          <div className="onboarding-wizard-actions">
            <button
              type="button"
              disabled={busy}
              onClick={() => {
                setStep("name");
                setError(null);
              }}
            >
              Previous
            </button>
            <button type="submit" disabled={busy}>
              {busy ? "Connecting…" : "Next"}
            </button>
          </div>
        </form>
      )}

      <form method="post" action={`${BASE_URL}/api/session/logout`}>
        <button type="submit" className="logout-link">
          Log out
        </button>
      </form>
    </div>
  );
}
  • Step 4: Delete the old screens
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:

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:

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:

// 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:

// 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:

  if (!session!.has_profile) {
    return (
      <CreateProfile
        onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
      />
    );
  }

  if (!session!.garmin_connected) {
    return <ConnectGarmin onConnected={() => setSession((s) => (s ? { ...s, garmin_connected: true } : s))} />;
  }

  return <App session={session!} />;
}

to:

  if (!session!.has_profile) {
    return (
      <OnboardingWizard
        onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
      />
    );
  }

  return <App session={session!} />;
}
  • 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
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:

git add -A
git commit -m "fix: address final verification findings"