package server import ( "context" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/accountdelete" "scrabble/backend/internal/game" ) // Account deletion is legal retention, not erasure (docs/ARCHITECTURE.md §9.1): the account // row survives as a tombstone while its credentials are journalled + freed, its live // surfaces anonymised, and its own social/ephemeral data dropped. Messages are kept. The // step-up is a mailed code for an account with a confirmed email, or a typed phrase for a // platform-only account (possession-proof is unattainable there, so the phrase is // anti-impulse only). // deletePhrase is the fixed confirmation phrase a no-email account types to delete. // Compared case-insensitively; the client localises only the surrounding instruction. const deletePhrase = "DELETE" // deleteRequestResponse tells the client which step-up the account uses. type deleteRequestResponse struct { Method string `json:"method"` // "email" | "phrase" } // handleRequestDelete starts account deletion: it mails a delete code to an account with a // confirmed email, or reports the typed-phrase path otherwise. func (s *Server) handleRequestDelete(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } ctx := c.Request.Context() hasEmail, err := s.emails.HasEmail(ctx, uid) if err != nil { s.abortErr(c, err) return } if !hasEmail { c.JSON(http.StatusOK, deleteRequestResponse{Method: "phrase"}) return } if err := s.emails.RequestDeleteCode(ctx, uid); err != nil { s.abortErr(c, err) return } c.JSON(http.StatusOK, deleteRequestResponse{Method: "email"}) } // deleteConfirmBody carries the step-up proof: a mailed code (email account) or the typed // phrase (platform-only account). type deleteConfirmBody struct { Code string `json:"code"` Phrase string `json:"phrase"` } // handleConfirmDelete verifies the step-up and deletes the account. func (s *Server) handleConfirmDelete(c *gin.Context) { uid, ok := userID(c) if !ok { abortBadRequest(c, "missing identity") return } var req deleteConfirmBody if err := c.ShouldBindJSON(&req); err != nil { abortBadRequest(c, "invalid request body") return } ctx := c.Request.Context() hasEmail, err := s.emails.HasEmail(ctx, uid) if err != nil { s.abortErr(c, err) return } if hasEmail { if err := s.emails.VerifyDeleteCode(ctx, uid, req.Code); err != nil { s.abortErr(c, err) return } } else if !strings.EqualFold(strings.TrimSpace(req.Phrase), deletePhrase) { abortBadRequest(c, "confirmation phrase does not match") return } if err := s.deleteAccount(ctx, uid); err != nil { s.abortErr(c, err) return } c.JSON(http.StatusOK, okResponse{OK: true}) } // deleteAccount runs the deletion orchestration after the step-up passed: it resigns the // account's active games (so opponents are not stranded and robot games end cleanly), // drops its all-robot games, tombstones + anonymises the account (journalling and freeing // its credentials), and revokes its sessions. The tombstone is the point of no return — // its failure aborts; the game cleanup and session revocation around it are best-effort. func (s *Server) deleteAccount(ctx context.Context, uid uuid.UUID) error { deleter := accountdelete.NewDeleter(s.db) if s.games != nil { if games, err := s.games.ListForAccount(ctx, uid); err == nil { for _, g := range games { if g.Status != game.StatusActive { continue } if _, err := s.games.Resign(ctx, g.ID, uid); err != nil { s.log.Warn("delete: resign game failed", zap.String("game", g.ID.String()), zap.Error(err)) } } } else { s.log.Warn("delete: list games failed", zap.Error(err)) } } if _, err := deleter.DropAllRobotGames(ctx, uid); err != nil { s.log.Warn("delete: drop all-robot games failed", zap.Error(err)) } if err := deleter.AnonymizeAndTombstone(ctx, uid); err != nil { return err } if s.sessions != nil { if err := s.sessions.RevokeAllForAccount(ctx, uid); err != nil { s.log.Warn("delete: revoke sessions failed", zap.Error(err)) } } return nil }