diff --git a/docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md b/docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md new file mode 100644 index 0000000..afedd4f --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md @@ -0,0 +1,232 @@ +# Onboarding wizard with deferred DB commit + +Status: approved, not yet implemented. + +## Problem + +Feedback on the just-shipped mandatory Garmin-connect onboarding +(`docs/superpowers/specs/2026-07-26-improve-first-connection-design.md`): + +1. UX: a wizard-style Previous/Next pair reads better than the current + Continue/Log out pairing across `CreateProfile` → `ConnectGarmin`. +2. If the user quits mid-flow without logging out, the account is left in a + provisioned-but-not-Garmin-connected state -- confirmed this should + instead mean **nothing is written to the database at all** until Garmin + authentication actually succeeds, at which point display name + Garmin + credentials + the connected flag are all committed together, atomically. + +(A third piece of feedback, structured JSON logging, is unrelated and gets +its own design afterward.) + +## Design + +### Consequence: the three-way LoginGate fork collapses + +Since account provisioning and Garmin-connection now happen together in one +atomic step, `has_profile=true && garmin_connected=false` becomes +structurally unreachable -- confirmed to collapse `LoginGate` back to a +two-way fork (`has_profile ? App : OnboardingWizard`). `profile.garmin_connected_at` +stays as an informational timestamp (still written by the new atomic +commit); it just no longer gates anything on its own. + +### The core problem: Garmin auth needs an account today + +`garminFor` builds a per-user `garmin.Client` keyed by a DB user id and +reads Garmin credentials from that user's `profile` row. To authenticate +with Garmin *before* an account exists, onboarding needs a **temporary, +not-yet-persisted** Garmin session -- keyed by the OIDC subject (already +known from the session cookie, before any `users` row exists) instead of a +user id. A long-held-open SQL transaction across the wizard's multiple +HTTP round trips is not an option: SQLite's single connection +(`SetMaxOpenConns(1)`, see `db.go`) means one held-open transaction would +block every other request in the app for as long as the user takes to +finish onboarding. + +### Backend: ephemeral pre-account Garmin sessions + +New type and `Server` field (`server.go`, alongside the existing +`userGarmin`/`userSync`/etc. per-user maps): + +```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 +} + +const setupSessionIdleTimeout = 15 * time.Minute +``` + +`Server` gains `setupGarmin map[string]*setupSession` (keyed by OIDC +subject string), initialized alongside the other maps in `NewServer`. + +Helper methods on `Server` (`server.go`): + +- `setupSessionFor(sub string) (*setupSession, bool)` -- returns the + session if present and not stale; a stale one is closed and evicted + first (lazy cleanup, no background sweep goroutine -- an abandoned + session otherwise leaks one idle subprocess until the backend restarts, + an accepted trade-off for a single-operator app). +- `replaceSetupSession(sub, email, password string) *setupSession` -- + closes and replaces any existing session for `sub` (same "close old, + spawn new" semantics as `garmin.Client.UpdateCredentials`), builds a + fresh ephemeral client whose `TokenStorePath` is namespaced under + `{root}/setup/{sub}` (distinct from the permanent `{root}/{userID}` + namespacing `garminFor` uses, so two people onboarding concurrently never + collide, and so the ephemeral session's persisted Garmin tokens have + *some* home even though no user id exists yet). +- `recordSetupAuthResult(sub string, res garmin.AuthResult)` -- updates a + session's `Status`/`Message`/`LastUsed` after a login or MFA attempt. +- `removeSetupSession(sub string)` -- closes and drops `sub`'s session + (best-effort removes its `{root}/setup/{sub}` token-store directory too). + Called both when `/api/setup/complete` promotes a session into the + permanent cache, and from `handleSessionLogout` so abandoning mid-wizard + via logout doesn't linger until the idle timeout. + +### Backend: three new endpoints replace `/api/setup` + +Registered where `r.Post("/setup", s.handleSetup)` used to be (same +middleware group: `RequireSession` + `resolveUser`, but *not* +`requireProvisionedUser` -- these must work before a profile exists): + +```go +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) + }) +}) +``` + +- **`POST /api/setup/garmin/login`** `{garmin_email, garmin_password}`: + 401 if unauthenticated; 409 if a profile already exists for this subject + (mirrors old `handleSetup`'s existing check); 400 on missing fields. + Otherwise `replaceSetupSession` + `Authenticate()` + `recordSetupAuthResult`, + responding with the same `{status, message}` shape `/api/auth/login` + already uses. +- **`POST /api/setup/garmin/mfa`** `{code}`: 409 if no session exists for + this subject (never started, or expired) -- frontend resets to the + Garmin step and asks the user to reconnect. Otherwise + `CompleteMFA()` + `recordSetupAuthResult` on the *same* session (never + replaces it -- MFA continues an in-progress attempt on the same + subprocess). +- **`POST /api/setup/complete`** `{display_name}`: 409 if already + provisioned; 409 if no session exists for this subject or its `Status != + garmin.AuthSuccess` ("Garmin isn't connected yet"). Otherwise, in order: + 1. `ProvisionUser(sub, displayName)` (unchanged). + 2. `GetProfile` + set `GarminEmail`/`GarminPassword` from the session + + `UpdateProfile` (unchanged method, just called here instead of from + the Profile page). + 3. `MarkGarminConnected(userID)`. + 4. Promote: move the session's already-authenticated `garmin.Client` + into `s.userGarmin[userID]` and its recorded status into + `s.userAuthStatus[userID]`/`userAuthMessage[userID]` (avoids a + redundant re-authentication -- and possibly a repeat MFA prompt -- + immediately after signup), then `delete(s.setupGarmin, sub)` without + closing the client (ownership transferred, not discarded). + 5. Best-effort `os.Rename` the ephemeral token-store directory + (`{root}/setup/{sub}`) to the permanent one (`{root}/{userID}`), so a + later respawn of this user's subprocess (e.g. after a server restart) + resumes the already-established Garmin session instead of requiring a + fresh login. Logged, not fatal, on error. + +`internal/api/auth.go`'s existing `handleAuthLogin`/`handleAuthMFA`/`handleAuthStatus` +(used by the Profile page's `GarminConnection.tsx` for reconnecting an +already-provisioned user) are untouched. + +### Frontend: one wizard component instead of two screens + +`CreateProfile.tsx` and `ConnectGarmin.tsx` are deleted, replaced by +`frontend/src/OnboardingWizard.tsx` (and `OnboardingWizard.css`, replacing +`CreateProfile.css`). Two top-level steps (`"name" | "garmin"`); the +in-progress-MFA and already-authenticated states are just conditions on +`auth?.status` *within* the `"garmin"` step, not a third step value -- +simpler than threading a separate `"mfa"` step through, and matches how +`GarminConnection.tsx` already branches on `status` today: + +- **`name` step**: display name field, held in local state only -- no API + call. "Next" just validates non-empty and advances `step` to `"garmin"`. +- **`garmin` step**, branching on `auth?.status`: + - `undefined` / `"failed"`: email/password fields. "Previous" returns to + the `name` step (pure local state, nothing to undo server-side). + "Next" calls `api.setupGarminLogin(email, password)`. + - `"mfa_required"`: MFA code field. "Previous" clears `auth` back to + `null`, which re-shows the credentials form (submitting it again just + replaces the ephemeral session, same as the backend's "close old, + spawn new" semantics). "Submit code" calls `api.setupGarminMFA(code)`. + - `"authenticated"`: a confirmation message and "Continue to geniusrun", + which calls `api.setupComplete(displayName)` and then `onCreated(...)`. +- "Log out" (`