Files
geniusrun/docs/superpowers/plans/2026-07-26-onboarding-wizard-deferred-commit.md

1190 lines
41 KiB
Markdown
Raw Permalink Normal View History

# 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<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:
```ts
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`**
```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<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**
```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 (
<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:
```tsx
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**
```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"
```