Compare commits

...

11 Commits

Author SHA1 Message Date
d1d2d612a5 added ideas 2026-07-26 12:00:10 +02:00
4c939be6d6 added go work files 2026-07-26 12:00:00 +02:00
f7bf7e9c79 removed unwanted ide files 2026-07-26 11:57:33 +02:00
8353cd148b feat(auth): pass id_token_hint on Keycloak logout
Carries the raw ID token in the session cookie so logout can hand it back
to Keycloak as id_token_hint, letting it skip its own logout-confirmation
prompt -- otherwise a user could cancel out of it and land back in the app
with a Keycloak SSO session but no geniusrun profile (e.g. right after
deleting their account).
2026-07-26 11:55:13 +02:00
d7202eb9bb docs: add implementation plan for profile deletion 2026-07-26 10:23:32 +02:00
ce5057a309 Merge branch 'worktree-profile-deletion' 2026-07-26 10:22:52 +02:00
efbe6a6760 docs: remove profile deletion from IDEAS backlog (implemented) 2026-07-26 10:21:31 +02:00
e2533bd1a8 feat(profile): add Danger zone account deletion UI 2026-07-26 10:20:56 +02:00
ab8cdea214 feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown 2026-07-26 10:19:00 +02:00
e8c340a00e feat(store): add DeleteUser with cascading account deletion 2026-07-26 10:17:21 +02:00
c5faaf5a17 docs: add design spec for profile deletion 2026-07-26 10:06:04 +02:00
28 changed files with 1497 additions and 83 deletions

10
backend/.idea/.gitignore generated vendored
View File

@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
</modules>
</component>
</project>

6
backend/.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"testing" "testing"
@@ -764,6 +765,85 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
} }
} }
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
s, db, userID := newTestServer(t)
router := s.Router()
// Force the per-user Garmin client to be built and cached.
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
client, err := s.garminFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*mock.Client)
if !ok {
t.Fatalf("expected *mock.Client, got %T", client)
}
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
}
if !mockClient.ClosedCalled {
t.Error("expected the cached garmin client to be Close()d on profile deletion")
}
}
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
s, db, userID := newTestServer(t)
s.mu.Lock()
s.userSyncRunning[userID] = true
s.mu.Unlock()
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
}
}
func TestDeleteProfile_RemovesTokenStoreDirectory(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() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
tokenStoreRoot := t.TempDir()
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
}
}
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) { func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
t.Helper() t.Helper()
s, db, _ := newTestServer(t) s, db, _ := newTestServer(t)
@@ -933,3 +1013,72 @@ func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) {
t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/") t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/")
} }
} }
// TestSessionLogout_PassesIDTokenHintFromSessionCookie confirms the raw ID
// token carried in the session cookie (minted at callback time) is handed
// back to EndSessionURL on logout, so Keycloak can skip its own
// logout-confirmation prompt instead of leaving the user a chance to cancel
// out of it after their geniusrun account is already deleted.
func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout"}
s, _ := newTestServerWithAuth(t, verifier)
cookie, err := auth.MintSessionCookie(
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com", IDToken: "raw-id-token-jwt"},
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
)
if err != nil {
t.Fatalf("mint session cookie: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/session/logout", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
if verifier.LastIDTokenHint != "raw-id-token-jwt" {
t.Errorf("LastIDTokenHint = %q, want %q", verifier.LastIDTokenHint, "raw-id-token-jwt")
}
}
// TestSessionCallback_MintsSessionCookieCarryingIDToken confirms the raw ID
// token from a completed OIDC callback ends up in the session cookie (not
// just Sub/Name/Email), since that's the only place logout can later read
// it back from to build id_token_hint.
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
verifier := &authmock.Verifier{
CallbackResult: auth.LoginResult{
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"},
Authorized: true,
},
}
s, _ := newTestServerWithAuth(t, verifier)
txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false)
if err != nil {
t.Fatalf("mint txn cookie: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
req.AddCookie(txnCookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
var sessionCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.SessionCookieName {
sessionCookie = c
}
}
if sessionCookie == nil {
t.Fatal("expected a session cookie to be set")
}
claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret)
if err != nil {
t.Fatalf("parse session cookie: %v", err)
}
if claims.IDToken != "raw-id-token-jwt" {
t.Errorf("claims.IDToken = %q, want %q", claims.IDToken, "raw-id-token-jwt")
}
}

View File

@@ -153,3 +153,31 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String())
} }
} }
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
// deleting their own profile never touches another user's account, even
// though DeleteUser is keyed purely by the session-resolved userID.
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.ID != userA {
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
}
}

View File

@@ -75,3 +75,29 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }
// handleDeleteProfile permanently deletes the signed-in user's entire
// geniusrun account (profile, workout kinds/paces, activities and
// everything under them, sync state/runs -- see schema.sql's ON DELETE
// CASCADE from users(id)) and tears down their cached Garmin client and
// token-store directory. It does not touch the session cookie itself --
// the frontend follows a successful call with a real logout navigation
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
if inProgress {
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
return
}
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeGarminClient(userID)
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"sync" "sync"
@@ -116,6 +117,36 @@ func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, e
return svc, nil return svc, nil
} }
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
// any) and every other per-user in-memory entry for userID, terminating the
// client's subprocess and best-effort removing its on-disk token-store
// directory. Called when a user's account has just been deleted from the
// DB, so nothing in memory keeps referencing a userID that no longer
// exists.
func (s *Server) removeGarminClient(userID int64) {
s.mu.Lock()
client, ok := s.userGarmin[userID]
delete(s.userGarmin, userID)
delete(s.userSync, userID)
delete(s.userAuthStatus, userID)
delete(s.userAuthMessage, userID)
delete(s.userSyncRunning, userID)
s.mu.Unlock()
if ok {
if err := client.Close(); err != nil {
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
}
}
if s.GarminBase.TokenStorePath == "" {
return
}
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil {
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
}
}
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync // RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
// every provisioned user in turn, replacing the old single-global-Service // every provisioned user in turn, replacing the old single-global-Service
// background loop. // background loop.
@@ -167,6 +198,7 @@ func (s *Server) Router() http.Handler {
r.Route("/profile", func(r chi.Router) { r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile) r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile) r.Put("/", s.handleUpdateProfile)
r.Delete("/", s.handleDeleteProfile)
}) })
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {

View File

@@ -89,9 +89,16 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound) http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound)
} }
// handleSessionLogout clears geniusrun's own session cookie and redirects
// through Keycloak's end-session endpoint, passing the session's ID token
// as id_token_hint (see auth.Claims.IDToken) so Keycloak can skip its own
// logout-confirmation prompt -- otherwise a user could cancel out of it and
// land back on the app with a Keycloak SSO session but no geniusrun profile
// (already deleted, in the profile-deletion case this exists for).
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
claims, _ := auth.ClaimsFromContext(r.Context())
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/"), http.StatusFound) http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound)
} }
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {

View File

@@ -18,6 +18,7 @@ type Verifier struct {
CallbackResult auth.LoginResult CallbackResult auth.LoginResult
CallbackErr error CallbackErr error
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
LastIDTokenHint string // records the idTokenHint passed to the last EndSessionURL call
} }
var _ auth.Verifier = (*Verifier)(nil) var _ auth.Verifier = (*Verifier)(nil)
@@ -33,7 +34,8 @@ func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query
return v.CallbackResult, nil return v.CallbackResult, nil
} }
func (v *Verifier) EndSessionURL(postLogoutRedirectURL string) string { func (v *Verifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
v.LastIDTokenHint = idTokenHint
if v.EndSessionResult != "" { if v.EndSessionResult != "" {
return v.EndSessionResult return v.EndSessionResult
} }

View File

@@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net/url" "net/url"
"slices"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -24,7 +25,11 @@ type Verifier interface {
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
// EndSessionURL builds the identity provider's logout URL, redirecting // EndSessionURL builds the identity provider's logout URL, redirecting
// back to postLogoutRedirectURL once Keycloak's own session is cleared. // back to postLogoutRedirectURL once Keycloak's own session is cleared.
EndSessionURL(postLogoutRedirectURL string) string // idTokenHint, if non-empty, is passed as id_token_hint so Keycloak can
// positively identify the session being ended and skip its own
// logout-confirmation prompt (which would otherwise let the user cancel
// out of logout after their geniusrun account is already deleted).
EndSessionURL(postLogoutRedirectURL, idTokenHint string) string
} }
// LoginResult is what a completed callback exchange resolves to. // LoginResult is what a completed callback exchange resolves to.
@@ -55,12 +60,7 @@ type idTokenClaims struct {
} }
func (c idTokenClaims) hasRole(required string) bool { func (c idTokenClaims) hasRole(required string) bool {
for _, r := range c.RealmAccess.Roles { return slices.Contains(c.RealmAccess.Roles, required)
if r == required {
return true
}
}
return false
} }
type oidcVerifier struct { type oidcVerifier struct {
@@ -135,12 +135,12 @@ func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query u
return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err) return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err)
} }
return LoginResult{ return LoginResult{
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email}, Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email, IDToken: rawIDToken},
Authorized: claims.hasRole(v.requiredRole), Authorized: claims.hasRole(v.requiredRole),
}, nil }, nil
} }
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string { func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
var discovery struct { var discovery struct {
EndSessionEndpoint string `json:"end_session_endpoint"` EndSessionEndpoint string `json:"end_session_endpoint"`
} }
@@ -154,6 +154,9 @@ func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
q := u.Query() q := u.Query()
q.Set("client_id", v.oauth2Config.ClientID) q.Set("client_id", v.oauth2Config.ClientID)
q.Set("post_logout_redirect_uri", postLogoutRedirectURL) q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
if idTokenHint != "" {
q.Set("id_token_hint", idTokenHint)
}
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
return u.String() return u.String()
} }

View File

@@ -1,10 +1,83 @@
package auth package auth
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing" "testing"
) )
// newTestOIDCVerifier spins up a fake OIDC discovery endpoint (just enough
// for oidc.NewProvider's discovery GET to succeed) and returns a real
// oidcVerifier backed by it, for tests that exercise EndSessionURL without
// a live Keycloak.
func newTestOIDCVerifier(t *testing.T) Verifier {
t.Helper()
var issuerURL string
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{
"issuer": %[1]q,
"authorization_endpoint": "%[1]s/auth",
"token_endpoint": "%[1]s/token",
"end_session_endpoint": "%[1]s/logout",
"jwks_uri": "%[1]s/certs"
}`, issuerURL)
})
mux.HandleFunc("/certs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"keys":[]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
issuerURL = srv.URL
verifier, err := NewOIDCVerifier(context.Background(), OIDCConfig{
IssuerURL: issuerURL, ClientID: "geniusrun", ClientSecret: "secret", RedirectURL: issuerURL + "/callback",
})
if err != nil {
t.Fatalf("NewOIDCVerifier: %v", err)
}
return verifier
}
func TestEndSessionURL_IncludesIDTokenHintWhenProvided(t *testing.T) {
verifier := newTestOIDCVerifier(t)
got := verifier.EndSessionURL("https://app.example.com/", "raw-id-token-jwt")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
}
q := u.Query()
if q.Get("id_token_hint") != "raw-id-token-jwt" {
t.Errorf("id_token_hint = %q, want %q", q.Get("id_token_hint"), "raw-id-token-jwt")
}
if q.Get("client_id") != "geniusrun" {
t.Errorf("client_id = %q, want geniusrun", q.Get("client_id"))
}
if q.Get("post_logout_redirect_uri") != "https://app.example.com/" {
t.Errorf("post_logout_redirect_uri = %q, want https://app.example.com/", q.Get("post_logout_redirect_uri"))
}
}
func TestEndSessionURL_OmitsIDTokenHintWhenEmpty(t *testing.T) {
verifier := newTestOIDCVerifier(t)
got := verifier.EndSessionURL("https://app.example.com/", "")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
}
if u.Query().Has("id_token_hint") {
t.Errorf("expected no id_token_hint param when idTokenHint is empty, got %q", got)
}
}
func TestIDTokenClaims_HasRole(t *testing.T) { func TestIDTokenClaims_HasRole(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View File

@@ -1,9 +1,15 @@
// Package auth implements geniusrun's own login gate: an OIDC Authorization // Package auth implements geniusrun's own login gate: an OIDC Authorization
// Code flow against an existing Keycloak realm (see oidc.go), backed by a // Code flow against an existing Keycloak realm (see oidc.go), backed by a
// signed session cookie geniusrun mints itself (this file) and a chi // signed session cookie geniusrun mints itself (this file) and a chi
// middleware that checks it (middleware.go). Keycloak's own tokens are never // middleware that checks it (middleware.go). Keycloak's own access/refresh
// stored or refreshed -- once HandleCallback verifies the ID token and role, // tokens are never stored or refreshed -- once HandleCallback verifies the
// only this package's own cookie matters for subsequent requests. // ID token and role, only this package's own cookie matters for subsequent
// requests. The one exception is the raw ID token itself, carried opaquely
// inside the signed session cookie (Claims.IDToken) solely so a later
// logout can pass it back to Keycloak as id_token_hint -- letting Keycloak
// skip its own logout-confirmation prompt for a session it can positively
// identify, rather than leaving the user a chance to cancel out of it after
// their geniusrun account (and profile) is already gone.
package auth package auth
import ( import (
@@ -26,17 +32,21 @@ const (
) )
// Claims identifies the authenticated user, carried in the signed session // Claims identifies the authenticated user, carried in the signed session
// cookie. // cookie. IDToken is the raw Keycloak ID token JWT from the OIDC callback,
// carried opaquely so a later logout can pass it back to Keycloak as
// id_token_hint (see EndSessionURL) -- geniusrun never inspects it itself.
type Claims struct { type Claims struct {
Sub string Sub string
Name string Name string
Email string Email string
IDToken string
} }
type sessionClaims struct { type sessionClaims struct {
Sub string `json:"sub"` Sub string `json:"sub"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
IDToken string `json:"id_token,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -61,6 +71,7 @@ func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, sec
Sub: claims.Sub, Sub: claims.Sub,
Name: claims.Name, Name: claims.Name,
Email: claims.Email, Email: claims.Email,
IDToken: claims.IDToken,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(duration)), ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
@@ -91,7 +102,7 @@ func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) {
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil { if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
return Claims{}, fmt.Errorf("parse session token: %w", err) return Claims{}, fmt.Errorf("parse session token: %w", err)
} }
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email, IDToken: sc.IDToken}, nil
} }
// MintTxnCookie signs an OIDC login transaction into a short-lived cookie. // MintTxnCookie signs an OIDC login transaction into a short-lived cookie.

View File

@@ -8,7 +8,7 @@ import (
const testSecret = "test-secret-at-least-32-bytes-long!" const testSecret = "test-secret-at-least-32-bytes-long!"
func TestMintAndParseSessionCookie(t *testing.T) { func TestMintAndParseSessionCookie(t *testing.T) {
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"} claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"}
cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true) cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true)
if err != nil { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)

View File

@@ -161,3 +161,46 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run") t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run")
} }
} }
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
// user's account never touches another user's profile, taxonomy, or
// activities, even though DeleteUser is a single blunt DELETE FROM users.
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if _, err := db.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if err := db.DeleteUser(ctx, userA); err != nil {
t.Fatalf("DeleteUser(a): %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
}
profileB, err := db.GetProfile(ctx, userB)
if err != nil {
t.Fatalf("GetProfile(b) after deleting a: %v", err)
}
if profileB.Name != "B" {
t.Errorf("userB's profile changed after deleting userA: %+v", profileB)
}
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
if err != nil || len(kindsB) != 8 {
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
}
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
if err != nil || len(activitiesB) != 1 {
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
}
}

View File

@@ -26,7 +26,7 @@ CREATE TABLE users (
-- below) in one transaction when a new account signs up. -- below) in one transaction when a new account signs up.
CREATE TABLE profile ( CREATE TABLE profile (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default', name TEXT NOT NULL DEFAULT 'Default',
garmin_email TEXT NOT NULL DEFAULT '', garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '',
@@ -82,7 +82,7 @@ CREATE TABLE profile (
-- recursive AND/OR condition tree evaluated by internal/classify. -- recursive AND/OR condition tree evaluated by internal/classify.
CREATE TABLE workout_kinds ( CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '',
@@ -100,7 +100,7 @@ CREATE TABLE workout_kinds (
-- synced activity log is the history. No user_id column of its own -- -- synced activity log is the history. No user_id column of its own --
-- ownership is checked via a JOIN to workout_kinds. -- ownership is checked via a JOIN to workout_kinds.
CREATE TABLE workout_type_paces ( CREATE TABLE workout_type_paces (
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
pace_min_sec_per_km REAL, pace_min_sec_per_km REAL,
pace_max_sec_per_km REAL, pace_max_sec_per_km REAL,
hr_min_pct_hrr REAL, hr_min_pct_hrr REAL,
@@ -118,7 +118,7 @@ CREATE TABLE workout_type_paces (
-- instead of storing a redundant copy. -- instead of storing a redundant copy.
CREATE TABLE activities ( CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_activity_id INTEGER NOT NULL, garmin_activity_id INTEGER NOT NULL,
-- Derived at sync time from Garmin's own eventType.typeKey=="race" -- -- Derived at sync time from Garmin's own eventType.typeKey=="race" --
-- a hard fact, not a rule the user tunes (see internal/classify's -- a hard fact, not a rule the user tunes (see internal/classify's
@@ -219,7 +219,7 @@ JOIN (
-- re-walking years of already-known history on every call. -- re-walking years of already-known history on every call.
CREATE TABLE sync_state ( CREATE TABLE sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
earliest_synced_date TEXT, earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0, backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id) UNIQUE(user_id)
@@ -229,7 +229,7 @@ CREATE TABLE sync_state (
-- status the frontend polls. -- status the frontend polls.
CREATE TABLE sync_runs ( CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')), kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT, finished_at TEXT,

View File

@@ -119,3 +119,17 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
return userID, tx.Commit() return userID, tx.Commit()
} }
// DeleteUser permanently deletes userID's account. Every row that belongs
// to it -- profile, workout kinds (and their paces), activities (and their
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
// single statement rather than per-table deletes. Irreversible; the API
// layer gates this behind a UI confirmation (see
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
return fmt.Errorf("delete user %d: %w", userID, err)
}
return nil
}

View File

@@ -89,3 +89,76 @@ func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) {
t.Fatal("expected each user's seeded kinds to be distinct rows") t.Fatal("expected each user's seeded kinds to be distinct rows")
} }
} }
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
minPace := 300.0
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
}
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
if _, err := db.StartSyncRun(ctx, userID, SyncKindBackfill); err != nil {
t.Fatalf("StartSyncRun: %v", err)
}
if err := db.DeleteUser(ctx, userID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
}
checks := []struct {
query string
arg int64
}{
{`SELECT COUNT(*) FROM profile WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM laps WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
}
for _, c := range checks {
var count int
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
t.Fatalf("count query %q: %v", c.query, err)
}
if count != 0 {
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
}
}
}

View File

@@ -31,7 +31,7 @@ CREATE TABLE users (
```sql ```sql
CREATE TABLE profile ( CREATE TABLE profile (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default', name TEXT NOT NULL DEFAULT 'Default',
garmin_email TEXT NOT NULL DEFAULT '', garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '',
@@ -87,7 +87,7 @@ CREATE TABLE profile (
```sql ```sql
CREATE TABLE workout_kinds ( CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '',
@@ -104,7 +104,7 @@ CREATE TABLE workout_kinds (
```sql ```sql
CREATE TABLE workout_type_paces ( CREATE TABLE workout_type_paces (
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
pace_min_sec_per_km REAL, pace_min_sec_per_km REAL,
pace_max_sec_per_km REAL, pace_max_sec_per_km REAL,
hr_min_pct_hrr REAL, hr_min_pct_hrr REAL,
@@ -117,7 +117,7 @@ CREATE TABLE workout_type_paces (
```sql ```sql
CREATE TABLE activities ( CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_activity_id INTEGER NOT NULL, garmin_activity_id INTEGER NOT NULL,
-- Derived at sync time from Garmin's own eventType.typeKey=="race" -- -- Derived at sync time from Garmin's own eventType.typeKey=="race" --
-- a hard fact, not a rule the user tunes (see internal/classify's -- a hard fact, not a rule the user tunes (see internal/classify's
@@ -227,7 +227,7 @@ CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
```sql ```sql
CREATE TABLE sync_state ( CREATE TABLE sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
earliest_synced_date TEXT, earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0, backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id) UNIQUE(user_id)
@@ -239,7 +239,7 @@ CREATE TABLE sync_state (
```sql ```sql
CREATE TABLE sync_runs ( CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')), kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT, finished_at TEXT,

View File

@@ -9,12 +9,12 @@ for that history) and remove it from here once a spec exists.
## Backlog ## Backlog
- improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on - improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on
- profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation)
- dedicated workouts section: add workouts table in database and create a dedicated page in UI (use an "objective" icon) - dedicated workouts section: add workouts table in database and create a dedicated page in UI (use an "objective" icon)
- improve activities/workouts download: make it modal, add progress bar, error management - improve activities/workouts download: make it modal, add progress bar, error management
- better UX when backend is not available (instead of "TypeError failed to fetch") - better UX when backend is not available (instead of "TypeError failed to fetch")
- better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout) - better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout)
- add icon to indicate if an activity has an associated workout - add icon to indicate if an activity has an associated workout
- modern error displays: have error messages displayed in an ephemeral window
## Someday / maybe ## Someday / maybe

View File

@@ -0,0 +1,766 @@
# Profile Deletion 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:** Let a signed-in geniusrun user permanently delete their entire account (Garmin credentials, custom rules, every synced activity) from a "Danger zone" button on the Profile page, and log them out afterward.
**Architecture:** Add `ON DELETE CASCADE` to every FK pointing at `users(id)` (and `workout_kinds(id)`) in `schema.sql`, so a single `DELETE FROM users` removes everything. Wrap that in a new `store.DeleteUser`, expose it via `DELETE /api/profile`, and have that handler also tear down the deleted user's cached Garmin subprocess/client and on-disk token-store directory. The frontend gates the call behind a type-`DELETE`-to-confirm UI, then performs a real POST navigation to the existing `/api/session/logout` route (the same one the header's Log out button already uses) so the OIDC session ends too.
**Tech Stack:** Go (`net/http`, `database/sql` via `modernc.org/sqlite`, chi router), React + TypeScript (Vite), no frontend test framework.
## Global Constraints
- `internal/store/schema.sql` is edited directly, never migrated — this is a pre-production app (see `CLAUDE.md`).
- Every store/API method that touches per-user data takes an explicit `userID` and must filter/scope by it — the entire cross-user isolation boundary depends on this (see `CLAUDE.md`'s Authentication section).
- Cross-user isolation must be tested adversarially (two real users, real IDs), not just checked for non-collision — matches `internal/store/isolation_test.go` / `internal/api/isolation_test.go` convention.
- After any `schema.sql` change, regenerate `docs/DATABASE.md` via `go run ./cmd/dumpschema` (run from `backend/`).
- `gofmt -l .` must report nothing before committing; `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.
---
### Task 1: Cascading deletes in the store layer
**Files:**
- Modify: `backend/internal/store/schema.sql:29,85,103,121,222,232`
- Modify: `backend/internal/store/users.go` (add `DeleteUser`)
- Modify: `backend/internal/store/users_test.go` (add `TestDeleteUser_RemovesUserAndCascadesEverything`)
- Modify: `backend/internal/store/isolation_test.go` (add `TestIsolation_DeleteUserLeavesOtherUsersDataIntact`)
- Modify: `docs/DATABASE.md` (regenerated, not hand-edited)
**Interfaces:**
- Produces: `func (db *DB) DeleteUser(ctx context.Context, userID int64) error` — deletes the `users` row for `userID`; every owned row (profile, workout_kinds, workout_type_paces, activities, laps, activity_samples, kind_assignments, sync_state, sync_runs) cascades away via the schema FKs added in this task.
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/store/users_test.go`:
```go
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
minPace := 300.0
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
}
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
if _, err := db.StartSyncRun(ctx, userID, SyncKindBackfill); err != nil {
t.Fatalf("StartSyncRun: %v", err)
}
if err := db.DeleteUser(ctx, userID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
}
checks := []struct {
query string
arg int64
}{
{`SELECT COUNT(*) FROM profile WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM laps WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
}
for _, c := range checks {
var count int
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
t.Fatalf("count query %q: %v", c.query, err)
}
if count != 0 {
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
}
}
}
```
Add to `backend/internal/store/isolation_test.go`:
```go
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
// user's account never touches another user's profile, taxonomy, or
// activities, even though DeleteUser is a single blunt DELETE FROM users.
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if _, err := db.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if err := db.DeleteUser(ctx, userA); err != nil {
t.Fatalf("DeleteUser(a): %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
}
profileB, err := db.GetProfile(ctx, userB)
if err != nil {
t.Fatalf("GetProfile(b) after deleting a: %v", err)
}
if profileB.Name != "B" {
t.Errorf("userB's profile changed after deleting userA: %+v", profileB)
}
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
if err != nil || len(kindsB) != 8 {
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
}
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
if err != nil || len(activitiesB) != 1 {
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/store/... -run 'TestDeleteUser|TestIsolation_DeleteUser' -v`
Expected: FAIL — `db.DeleteUser undefined (type *DB has no field or method DeleteUser)`.
- [ ] **Step 3: Add `ON DELETE CASCADE` to schema.sql**
In `backend/internal/store/schema.sql`, change these six lines (each currently ends `REFERENCES users(id),` or `REFERENCES workout_kinds(id),`):
Line 29 (`profile.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 85 (`workout_kinds.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 103 (`workout_type_paces.workout_kind_id`), from:
```sql
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
```
to:
```sql
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
```
Line 121 (`activities.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 222 (`sync_state.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 232 (`sync_runs.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
`laps`/`activity_samples`/`kind_assignments` already cascade off `activities(id)` — no change needed there. SQLite's `foreign_keys` pragma is already on for every connection (`db.go`), so these cascades (including the two-level `users → workout_kinds → workout_type_paces` chain) take effect immediately, no code change required for the pragma itself.
- [ ] **Step 4: Add `DeleteUser` to `backend/internal/store/users.go`**
Append after `ProvisionUser` (end of file):
```go
// DeleteUser permanently deletes userID's account. Every row that belongs
// to it -- profile, workout kinds (and their paces), activities (and their
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
// single statement rather than per-table deletes. Irreversible; the API
// layer gates this behind a UI confirmation (see
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
return fmt.Errorf("delete user %d: %w", userID, err)
}
return nil
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd backend && go test ./internal/store/... -run 'TestDeleteUser|TestIsolation_DeleteUser' -v`
Expected: PASS.
- [ ] **Step 6: Run the full store test suite**
Run: `cd backend && go test ./internal/store/...`
Expected: PASS (confirms the new `ON DELETE CASCADE` clauses didn't break `ResetAllSyncedData` or any other existing behavior).
- [ ] **Step 7: Regenerate `docs/DATABASE.md`**
Run: `cd backend && go run ./cmd/dumpschema`
Expected: `docs/DATABASE.md` updates to show `ON DELETE CASCADE` on the six changed columns.
- [ ] **Step 8: Commit**
```bash
git add backend/internal/store/schema.sql backend/internal/store/users.go backend/internal/store/users_test.go backend/internal/store/isolation_test.go docs/DATABASE.md
git commit -m "feat(store): add DeleteUser with cascading account deletion"
```
---
### Task 2: `DELETE /api/profile` and Garmin client/tokenstore teardown
**Files:**
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeGarminClient` after `syncFor`), `:167-170` (route)
- Modify: `backend/internal/api/profile.go` (add `handleDeleteProfile`)
- Modify: `backend/internal/api/api_test.go` (imports + 3 new tests)
- Modify: `backend/internal/api/isolation_test.go` (1 new test)
**Interfaces:**
- Consumes: `store.DB.DeleteUser(ctx, userID) error` (Task 1); `garmin.Client.Close() error` and `.UpdateCredentials` (existing); `Server.userGarmin/userSync/userAuthStatus/userAuthMessage/userSyncRunning map[int64]...` and `Server.mu sync.Mutex` (existing fields); `Server.GarminBase garmin.Config` (existing, has `.TokenStorePath string`).
- Produces: `func (s *Server) removeGarminClient(userID int64)` — used only within this task's handler, not exported further. `DELETE /api/profile` route → `handleDeleteProfile`, responding `204` on success, `409` if a sync is running for that user.
- [ ] **Step 1: Write the failing tests**
Add `"os"` to `backend/internal/api/api_test.go`'s import block (needed for Step 1's token-store test):
```go
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Append to `backend/internal/api/api_test.go`:
```go
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
s, db, userID := newTestServer(t)
router := s.Router()
// Force the per-user Garmin client to be built and cached.
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
client, err := s.garminFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*mock.Client)
if !ok {
t.Fatalf("expected *mock.Client, got %T", client)
}
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
}
if !mockClient.ClosedCalled {
t.Error("expected the cached garmin client to be Close()d on profile deletion")
}
}
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
s, db, userID := newTestServer(t)
s.mu.Lock()
s.userSyncRunning[userID] = true
s.mu.Unlock()
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
}
}
func TestDeleteProfile_RemovesTokenStoreDirectory(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() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
tokenStoreRoot := t.TempDir()
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
}
}
```
Append to `backend/internal/api/isolation_test.go`:
```go
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
// deleting their own profile never touches another user's account, even
// though DeleteUser is keyed purely by the session-resolved userID.
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.ID != userA {
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
Expected: FAIL — 404s (no `DELETE /api/profile` route registered yet).
- [ ] **Step 3: Add the `os` import and `removeGarminClient` to `server.go`**
Change the import block (add `"os"` between `"net/http"` and `"path/filepath"`):
```go
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Insert this new method right after `syncFor` (i.e. between the existing `syncFor` closing brace and the `RunIncrementalSyncForAllUsers` doc comment):
```go
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
// any) and every other per-user in-memory entry for userID, terminating the
// client's subprocess and best-effort removing its on-disk token-store
// directory. Called when a user's account has just been deleted from the
// DB, so nothing in memory keeps referencing a userID that no longer
// exists.
func (s *Server) removeGarminClient(userID int64) {
s.mu.Lock()
client, ok := s.userGarmin[userID]
delete(s.userGarmin, userID)
delete(s.userSync, userID)
delete(s.userAuthStatus, userID)
delete(s.userAuthMessage, userID)
delete(s.userSyncRunning, userID)
s.mu.Unlock()
if ok {
if err := client.Close(); err != nil {
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
}
}
if s.GarminBase.TokenStorePath == "" {
return
}
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil {
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
}
}
```
- [ ] **Step 4: Register the route in `Router()`**
In `server.go`, change:
```go
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
})
```
to:
```go
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
r.Delete("/", s.handleDeleteProfile)
})
```
- [ ] **Step 5: Add `handleDeleteProfile` to `profile.go`**
Append to `backend/internal/api/profile.go`:
```go
// handleDeleteProfile permanently deletes the signed-in user's entire
// geniusrun account (profile, workout kinds/paces, activities and
// everything under them, sync state/runs -- see schema.sql's ON DELETE
// CASCADE from users(id)) and tears down their cached Garmin client and
// token-store directory. It does not touch the session cookie itself --
// the frontend follows a successful call with a real logout navigation
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
if inProgress {
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
return
}
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeGarminClient(userID)
w.WriteHeader(http.StatusNoContent)
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
Expected: PASS.
- [ ] **Step 7: Run the full backend test suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: `gofmt -l .` prints nothing; `go build`/`go vet`/`go test` all succeed.
- [ ] **Step 8: Commit**
```bash
git add backend/internal/api/server.go backend/internal/api/profile.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
git commit -m "feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown"
```
---
### Task 3: Frontend Danger zone UI
**Files:**
- Modify: `frontend/src/api/client.ts` (add `deleteProfile`)
- Modify: `frontend/src/pages/Profile.tsx` (add Danger zone fieldset + confirm flow)
- Modify: `frontend/src/App.css` (small `.danger-zone-confirm` rule)
**Interfaces:**
- Consumes: `DELETE /api/profile` (Task 2, returns `204` or throws via the existing `request()` helper's non-2xx handling); `BASE_URL` (already exported from `client.ts`).
- Produces: `api.deleteProfile(): Promise<void>`, used only from `Profile.tsx`.
- [ ] **Step 1: Add `deleteProfile` to the API client**
In `frontend/src/api/client.ts`, in the `// Profile` section, change:
```ts
// Profile
getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
```
to:
```ts
// Profile
getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
// Permanently deletes the signed-in user's entire account. The caller is
// responsible for following a successful call with a real logout
// navigation -- this does not touch the session cookie (see Profile.tsx).
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
```
- [ ] **Step 2: Add the Danger zone section to `Profile.tsx`**
Change the import line:
```tsx
import { api } from "../api/client";
```
to:
```tsx
import { api, BASE_URL } from "../api/client";
```
Add these three state variables inside `Profile`, right after the existing `saveTimeoutRef` declaration:
```tsx
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
```
Add this function right after `flushPendingSave`:
```tsx
async function deleteAccount() {
setDeleting(true);
setError(null);
try {
await api.deleteProfile();
// Deletion doesn't clear the session cookie -- follow with a real
// logout navigation (must be a POST, same as the header's Log out
// button in App.tsx) so Keycloak's own SSO session ends too, not just
// geniusrun's local one.
const form = document.createElement("form");
form.method = "post";
form.action = `${BASE_URL}/api/session/logout`;
document.body.appendChild(form);
form.submit();
} catch (e) {
setError(String(e));
setDeleting(false);
}
}
```
Add this fieldset right after `<TrainingTypesCard />` (still inside the closing `</div>` of `.profile-page`):
```tsx
<fieldset className="kind-editor danger-zone">
<legend>Danger zone</legend>
{!deleteConfirmOpen ? (
<button type="button" className="button-danger" onClick={() => setDeleteConfirmOpen(true)}>
Delete profile
</button>
) : (
<div className="danger-zone-confirm">
<p>
This permanently deletes your account -- Garmin credentials, every custom rule, and every synced
activity -- and logs you out. This cannot be undone.
</p>
<label>
Type DELETE to confirm
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
disabled={deleting}
/>
</label>
<div className="controls">
<button
type="button"
disabled={deleting}
onClick={() => {
setDeleteConfirmOpen(false);
setDeleteConfirmText("");
}}
>
Cancel
</button>
<button
type="button"
className="button-danger"
disabled={deleting || deleteConfirmText !== "DELETE"}
onClick={deleteAccount}
>
Permanently delete
</button>
</div>
</div>
)}
</fieldset>
```
- [ ] **Step 3: Add the confirmation block's CSS**
In `frontend/src/App.css`, add right after the `.button-danger:hover:not(:disabled)` rule:
```css
.danger-zone-confirm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.danger-zone-confirm p {
margin: 0;
color: #9aa0ab;
}
```
- [ ] **Step 4: Build and lint**
Run: `cd frontend && npm run build && npm run lint`
Expected: build succeeds; lint reports no new warnings (the 3 pre-existing `PaceField.tsx` `only-export-components` warnings are unrelated and expected to remain).
- [ ] **Step 5: Manual browser verification**
With the backend running (`cd backend && ./start.sh`) and frontend dev server running (`cd frontend && ./start.sh`), in a browser:
1. Log in, go to the Profile page, scroll to "Danger zone".
2. Click "Delete profile" — the confirm block appears; "Permanently delete" is disabled.
3. Type `delete` (lowercase) — button stays disabled. Type `DELETE` — button enables.
4. Click "Cancel" — block collapses, typed text is cleared if reopened.
5. Reopen, type `DELETE`, click "Permanently delete" — expect a full-page navigation ending on the login screen (Keycloak or geniusrun's own login gate, matching what the existing "Log out" button already does).
6. Log back in with the same account — expect the "Create your profile" screen (`CreateProfile.tsx`), confirming the account was actually deleted, not just logged out.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/api/client.ts frontend/src/pages/Profile.tsx frontend/src/App.css
git commit -m "feat(profile): add Danger zone account deletion UI"
```
---
### Task 4: 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: Confirm `docs/DATABASE.md` is current**
Run: `cd backend && go run ./cmd/dumpschema && git status --short docs/DATABASE.md`
Expected: no output from `git status` (already committed in Task 1, and hasn't drifted since).
- [ ] **Step 4: Update `docs/IDEAS.md`**
Remove the now-implemented line from `docs/IDEAS.md`'s Backlog section:
```
- profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation)
```
- [ ] **Step 5: Commit**
```bash
git add docs/IDEAS.md
git commit -m "docs: remove profile deletion from IDEAS backlog (implemented)"
```

View File

@@ -0,0 +1,142 @@
# Profile deletion
Status: approved, not yet implemented.
## Problem
`docs/IDEAS.md` backlog item: "profile deletion: add a way to delete our
profile with a dedicated button on the profile page (which by extension will
also logout the user, to be consistent with the logic that login will trigger
the profile creation)".
Today there is no way for a user to remove their geniusrun account. The
closest existing feature, "Reset all" (`GarminConnection.tsx` /
`POST /api/sync/reset`), only wipes synced activity data and rewinds the
backfill watermark -- it leaves the `users` row, `profile`, Garmin
credentials, and custom workout-kind rules untouched. Profile deletion is a
strictly bigger, irreversible operation: the entire account for the signed-in
OIDC subject, gone, followed by a real logout.
## Design
### Data model: cascading deletes
The schema has FK columns pointing at `users(id)` (`profile.user_id`,
`workout_kinds.user_id`, `activities.user_id`, `sync_state.user_id`,
`sync_runs.user_id`) and at `workout_kinds(id)`
(`workout_type_paces.workout_kind_id`), none of which currently cascade.
`laps`/`activity_samples`/`kind_assignments` already cascade off `activities`
(`ON DELETE CASCADE`), which is what lets `ResetAllSyncedData` get away with
a single `DELETE FROM activities`. SQLite's `foreign_keys` pragma is already
enabled on every connection (`db.go`'s `_pragma=foreign_keys(1)`), so the same
mechanism works for the rest of the schema.
`schema.sql` is edited directly (no migration history, per project
convention) to add `ON DELETE CASCADE` to:
- `profile.user_id REFERENCES users(id)`
- `workout_kinds.user_id REFERENCES users(id)`
- `workout_type_paces.workout_kind_id REFERENCES workout_kinds(id)`
- `activities.user_id REFERENCES users(id)`
- `sync_state.user_id REFERENCES users(id)`
- `sync_runs.user_id REFERENCES users(id)`
With that in place, deleting a user becomes one statement:
```go
// store/users.go
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
_, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
return err
}
```
After this change, regenerate `docs/DATABASE.md` (`go run ./cmd/dumpschema`).
### API: `DELETE /api/profile`
New handler alongside the existing `GET`/`PUT /api/profile` routes, inside
the `requireProvisionedUser` group (`server.go`). `userID` comes from
`userIDFromContext`, same as every other handler -- no client-supplied id.
1. If a sync is currently running for this user (`s.userSyncRunning[userID]`,
the same flag `backgroundSync` sets), respond `409 Conflict` ("a sync is
in progress for this account; wait for it to finish before deleting your
profile") rather than deleting rows out from under an in-flight write.
2. Call `s.DB.DeleteUser(ctx, userID)`.
3. Tear down this user's cached Garmin state (new `Server` method, e.g.
`removeGarminClient(userID)`):
- Under `s.mu`, pop `s.userGarmin[userID]` and delete the userID entry
from `userSync`, `userAuthStatus`, `userAuthMessage`, and
`userSyncRunning`.
- If a client existed, call `client.Close()` outside the lock to
terminate its subprocess (mirrors how `handleUpdateProfile` calls
`client.UpdateCredentials` outside `s.mu`).
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
the same path `garminFor` computes when building a client. Log on
error; this is cleanup of an already-orphaned directory, not something
that should fail the request that already deleted the DB row.
4. Respond `204 No Content`.
### Frontend
- `api/client.ts`: `deleteProfile: () => request<void>("/api/profile", {
method: "DELETE" })`.
- `Profile.tsx`: a final `<fieldset className="kind-editor danger-zone">`
("Danger zone") containing a `button-danger` "Delete profile" button
(same class `GarminConnection`'s "Reset all" uses). Clicking it reveals an
inline confirmation block (local component state, no separate modal
component -- same "reveal inline" shape `ClassifyControl` already uses for
its dropdown):
- Explanatory copy: this permanently deletes the account -- Garmin
credentials, every custom rule, every synced activity -- and logs the
user out. Cannot be undone.
- A text input; a "Permanently delete" button stays disabled until the
input's value is exactly `DELETE`.
- A "Cancel" button collapses the block back.
- On confirm: call `api.deleteProfile()`. On success, build a real
`<form method="post" action="{BASE_URL}/api/session/logout">` via the DOM
and submit it immediately -- the same POST-navigation the header's Log out
button already performs (`App.tsx`), required because logout must be a
real browser navigation through Keycloak's end-session redirect, not a
`fetch`. On failure, surface the error via the same `error` state /
`<p className="error">` pattern the rest of `Profile.tsx` already uses,
and leave the confirmation block open so the user can retry.
### Error handling
- Sync-in-progress: `409`, clear message, surfaced like every other API
error on this page.
- DB/teardown errors: `500` with `err.Error()`, same as the rest of this
codebase's handlers.
- The type-`DELETE`-to-confirm gate is UI-only -- same trust boundary as
every other destructive action in this app (e.g. Reset all's
`window.confirm`). The backend does not require a second confirmation
token.
### Testing
- `backend/internal/store`: `TestDeleteUser` -- provision a user, give them
activities (with laps/samples/kind_assignments), workout kinds (with
paces), sync_state, and sync_runs; delete; assert every row is gone.
Adversarial isolation check per this repo's convention
(`isolation_test.go`): provision two users, delete one, assert the other's
rows (profile, workout kinds, activities, etc.) are untouched.
- `backend/internal/api`: handler test for `DELETE /api/profile` against
`newTestServer` + `mock.Client` -- success path (204, user actually gone
from `GetUserBySub`), and the sync-in-progress 409 (set
`userSyncRunning[userID] = true` first).
- No frontend test suite exists yet (per `CLAUDE.md`) -- manually
smoke-tested in the browser: delete flow ends up back at the login/create
profile screen, a fresh login for that OIDC subject lands on "Create your
profile" again (proving the account is truly gone, not just logged out).
## Out of scope
- Any "export my data before deleting" flow -- not requested.
- Re-authentication (password/MFA re-entry) before deletion -- this app has
no such step anywhere else (e.g. Reset all), so it isn't introduced here
either.
- Admin-initiated deletion of another user's account -- there is no admin UI
in this app.

View File

@@ -231,6 +231,17 @@ button:disabled {
color: #fff; color: #fff;
} }
.danger-zone-confirm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.danger-zone-confirm p {
margin: 0;
color: #9aa0ab;
}
.filter-pills { .filter-pills {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;

View File

@@ -106,6 +106,10 @@ export const api = {
getProfile: () => request<Profile>("/api/profile"), getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) => updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }), request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
// Permanently deletes the signed-in user's entire account. The caller is
// responsible for following a successful call with a real logout
// navigation -- this does not touch the session cookie (see Profile.tsx).
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
// Review queue -- cursor-paginated: pass the previous page's next_cursor // Review queue -- cursor-paginated: pass the previous page's next_cursor
// as `before` to fetch the next one. Fetching every activity's laps and // as `before` to fetch the next one. Fetching every activity's laps and

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api } from "../api/client"; import { api, BASE_URL } from "../api/client";
import { ColorField } from "../components/ColorField"; import { ColorField } from "../components/ColorField";
import { GarminConnection } from "../components/GarminConnection"; import { GarminConnection } from "../components/GarminConnection";
import { NullableNumberField } from "../components/NullableNumberField"; import { NullableNumberField } from "../components/NullableNumberField";
@@ -47,6 +47,9 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
const profileRef = useRef<ProfileType | null>(null); const profileRef = useRef<ProfileType | null>(null);
profileRef.current = profile; profileRef.current = profile;
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
useEffect(() => { useEffect(() => {
api.getProfile().then(setProfile).catch((e) => setError(String(e))); api.getProfile().then(setProfile).catch((e) => setError(String(e)));
@@ -100,6 +103,26 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
} }
} }
async function deleteAccount() {
setDeleting(true);
setError(null);
try {
await api.deleteProfile();
// Deletion doesn't clear the session cookie -- follow with a real
// logout navigation (must be a POST, same as the header's Log out
// button in App.tsx) so Keycloak's own SSO session ends too, not just
// geniusrun's local one.
const form = document.createElement("form");
form.method = "post";
form.action = `${BASE_URL}/api/session/logout`;
document.body.appendChild(form);
form.submit();
} catch (e) {
setError(String(e));
setDeleting(false);
}
}
if (!profile) { if (!profile) {
return ( return (
<div className="page"> <div className="page">
@@ -240,6 +263,51 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
</fieldset> </fieldset>
<TrainingTypesCard /> <TrainingTypesCard />
<fieldset className="kind-editor danger-zone">
<legend>Danger zone</legend>
{!deleteConfirmOpen ? (
<button type="button" className="button-danger" onClick={() => setDeleteConfirmOpen(true)}>
Delete profile
</button>
) : (
<div className="danger-zone-confirm">
<p>
This permanently deletes your account -- Garmin credentials, every custom rule, and every synced
activity -- and logs you out. This cannot be undone.
</p>
<label>
Type DELETE to confirm
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
disabled={deleting}
/>
</label>
<div className="controls">
<button
type="button"
disabled={deleting}
onClick={() => {
setDeleteConfirmOpen(false);
setDeleteConfirmText("");
}}
>
Cancel
</button>
<button
type="button"
className="button-danger"
disabled={deleting || deleteConfirmText !== "DELETE"}
onClick={deleteAccount}
>
Permanently delete
</button>
</div>
</div>
)}
</fieldset>
</div> </div>
); );
} }

3
go.work Normal file
View File

@@ -0,0 +1,3 @@
go 1.26.4
use ./backend

2
go.work.sum Normal file
View File

@@ -0,0 +1,2 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=