-
Notifications
You must be signed in to change notification settings - Fork 36
feat: add unrecognized device email alerts (#168) #224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: SSOC26
Are you sure you want to change the base?
Changes from all commits
87b7784
43177c6
0118829
7bac421
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ services: | |
| - PORT=8080 | ||
| - DATABASE_URL=host=postgres user=postgres password=postgres dbname=auth_db port=5432 sslmode=disable | ||
| - REDIS_URL=redis://auth-redis:6379 | ||
| - SMTP_HOST=auth-mailpit | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Add Mailpit to the app startup dependencies. The app now sends SMTP traffic to Also applies to: 52-60 🤖 Prompt for AI Agents |
||
| - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET:-secret} | ||
| - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-refresh} | ||
| depends_on: | ||
|
|
@@ -22,6 +23,13 @@ services: | |
| env_file: | ||
| - .env | ||
|
|
||
| healthcheck: | ||
| test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"] | ||
| interval: 30s | ||
| timeout: 10s | ||
| retries: 3 | ||
| start_period: 15s | ||
|
|
||
| postgres: | ||
| image: postgres:15-alpine | ||
| container_name: auth-postgres | ||
|
|
@@ -48,6 +56,16 @@ services: | |
| networks: | ||
| - auth-network | ||
|
|
||
| mailpit: | ||
| image: axllent/mailpit | ||
| container_name: auth-mailpit | ||
| restart: unless-stopped | ||
| ports: | ||
| - "8025:8025" | ||
| - "1025:1025" | ||
| networks: | ||
| - auth-network | ||
|
|
||
| networks: | ||
| auth-network: | ||
| driver: bridge | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -160,6 +160,29 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) { | |
| c.JSON(http.StatusOK, utils.SuccessResponse("Password has been reset successfully. You can now login with your new password.", nil)) | ||
| } | ||
|
|
||
| // LockAccount handles account locking via email link | ||
| // @Summary Lock account | ||
| // @Tags auth | ||
| // @Accept json | ||
| // @Produce json | ||
| // @Param token query string true "Lock token" | ||
| // @Success 200 {object} utils.Response | ||
| // @Router /api/auth/lock-account [get] | ||
| func (h *AuthHandler) LockAccount(c *gin.Context) { | ||
| token := c.Query("token") | ||
| if token == "" { | ||
| c.JSON(http.StatusBadRequest, utils.ValidationErrorResponse("Token is required")) | ||
| return | ||
| } | ||
|
|
||
| if err := h.authService.LockAccount(token); err != nil { | ||
| c.JSON(http.StatusBadRequest, utils.ErrorResponse("Failed to lock account", err)) | ||
| return | ||
|
Comment on lines
+173
to
+180
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Map lock-account failures to the right status/code payload. This branch turns every 🤖 Prompt for AI AgentsSources: Coding guidelines, Learnings |
||
| } | ||
|
|
||
| c.JSON(http.StatusOK, utils.SuccessResponse("Your account has been locked to prevent unauthorized access. Please reset your password to regain access.", nil)) | ||
| } | ||
|
|
||
| // UpdateProfile handles profile updates | ||
| // @Summary Update user profile | ||
| // @Tags auth | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,29 @@ | ||||||||||
| package models | ||||||||||
|
|
||||||||||
| import ( | ||||||||||
| "time" | ||||||||||
|
|
||||||||||
| "github.com/google/uuid" | ||||||||||
| "gorm.io/gorm" | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| type DeviceFingerprint struct { | ||||||||||
| ID string `gorm:"type:uuid;primary_key" json:"id"` | ||||||||||
| UserID string `gorm:"type:uuid;not null;index" json:"userId"` | ||||||||||
| FingerprintHash string `gorm:"not null;index" json:"fingerprintHash"` | ||||||||||
|
Comment on lines
+12
to
+13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Enforce uniqueness for The current read-then-create flow can insert duplicate device rows under concurrent logins without a DB-level composite unique constraint. Suggested fix- UserID string `gorm:"type:uuid;not null;index" json:"userId"`
- FingerprintHash string `gorm:"not null;index" json:"fingerprintHash"`
+ UserID string `gorm:"type:uuid;not null;uniqueIndex:idx_user_fingerprint" json:"userId"`
+ FingerprintHash string `gorm:"not null;uniqueIndex:idx_user_fingerprint" json:"fingerprintHash"`📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| UserAgent string `gorm:"size:500" json:"userAgent"` | ||||||||||
| IPAddress string `gorm:"size:45" json:"ipAddress"` | ||||||||||
| LastSeenAt time.Time `json:"lastSeenAt"` | ||||||||||
| CreatedAt time.Time `json:"createdAt"` | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func (d *DeviceFingerprint) BeforeCreate(tx *gorm.DB) error { | ||||||||||
| if d.ID == "" { | ||||||||||
| d.ID = uuid.New().String() | ||||||||||
| } | ||||||||||
| return nil | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func (DeviceFingerprint) TableName() string { | ||||||||||
| return "device_fingerprints" | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package repository | ||
|
|
||
| import ( | ||
| "errors" | ||
| "time" | ||
|
|
||
| "github.com/roshankumar0036singh/auth-server/internal/models" | ||
| "gorm.io/gorm" | ||
| ) | ||
|
|
||
| type DeviceRepository struct { | ||
| db *gorm.DB | ||
| } | ||
|
|
||
| func NewDeviceRepository(db *gorm.DB) *DeviceRepository { | ||
| return &DeviceRepository{db: db} | ||
| } | ||
|
|
||
| func (r *DeviceRepository) FindByFingerprint(userID, hash string) (*models.DeviceFingerprint, error) { | ||
| var device models.DeviceFingerprint | ||
| err := r.db.Where("user_id = ? AND fingerprint_hash = ?", userID, hash).First(&device).Error | ||
| if err != nil { | ||
| if errors.Is(err, gorm.ErrRecordNotFound) { | ||
| return nil, nil // Return nil if not found, instead of error | ||
| } | ||
| return nil, err | ||
| } | ||
| return &device, nil | ||
| } | ||
|
|
||
| func (r *DeviceRepository) Create(device *models.DeviceFingerprint) error { | ||
| return r.db.Create(device).Error | ||
| } | ||
|
|
||
| func (r *DeviceRepository) UpdateLastSeen(id string, lastSeen time.Time) error { | ||
| return r.db.Model(&models.DeviceFingerprint{}).Where("id = ?", id).Update("last_seen_at", lastSeen).Error | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, redisClient *redis.Client, cfg | |
| oauthTokenRepo := repository.NewOAuthTokenRepository(db) | ||
| userConsentRepo := repository.NewUserConsentRepository(db) | ||
| oauthProviderConfigRepo := repository.NewOAuthProviderConfigRepository(db) | ||
| deviceRepo := repository.NewDeviceRepository(db) | ||
|
|
||
| // Initialize services | ||
| tokenService := service.NewTokenService(cfg) | ||
|
|
@@ -53,6 +54,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, redisClient *redis.Client, cfg | |
| emailService, | ||
| auditService, | ||
| mfaService, | ||
| deviceRepo, | ||
| cfg, | ||
| ) | ||
|
|
||
|
|
@@ -151,6 +153,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, redisClient *redis.Client, cfg | |
| auth.POST("/resend-verification", authHandler.ResendVerification) | ||
| auth.POST("/forgot-password", authHandler.ForgotPassword) | ||
| auth.POST("/reset-password", authHandler.ResetPassword) | ||
| auth.GET("/lock-account", authHandler.LockAccount) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift Avoid a destructive action on a GET route. This endpoint locks the account, and the alert email links to it directly. Mail scanners, link preview bots, and safe-browsing crawlers routinely fetch GET links automatically, which can lock accounts without the user ever choosing to do so. Make the email land on a confirmation page and perform the lock with a POST/one-time form submission instead. 🤖 Prompt for AI Agents |
||
|
|
||
| // WebAuthn Login | ||
| auth.POST("/webauthn/login/begin", webAuthnHandler.BeginLogin) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -47,6 +47,7 @@ type AuthService struct { | |||||||||||||||||||||||||||||
| emailService EmailSender | ||||||||||||||||||||||||||||||
| auditService *AuditService | ||||||||||||||||||||||||||||||
| mfaService *MFAService | ||||||||||||||||||||||||||||||
| deviceRepo *repository.DeviceRepository | ||||||||||||||||||||||||||||||
| config *config.Config | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -60,6 +61,7 @@ func NewAuthService( | |||||||||||||||||||||||||||||
| emailService EmailSender, | ||||||||||||||||||||||||||||||
| auditService *AuditService, | ||||||||||||||||||||||||||||||
| mfaService *MFAService, | ||||||||||||||||||||||||||||||
| deviceRepo *repository.DeviceRepository, | ||||||||||||||||||||||||||||||
| cfg *config.Config, | ||||||||||||||||||||||||||||||
| ) *AuthService { | ||||||||||||||||||||||||||||||
| return &AuthService{ | ||||||||||||||||||||||||||||||
|
|
@@ -72,6 +74,7 @@ func NewAuthService( | |||||||||||||||||||||||||||||
| emailService: emailService, | ||||||||||||||||||||||||||||||
| auditService: auditService, | ||||||||||||||||||||||||||||||
| mfaService: mfaService, | ||||||||||||||||||||||||||||||
| deviceRepo: deviceRepo, | ||||||||||||||||||||||||||||||
| config: cfg, | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
@@ -299,6 +302,28 @@ func (s *AuthService) DeleteAccount(userID string) error { | |||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // LockAccount locks the user account indefinitely using a valid lock token | ||||||||||||||||||||||||||||||
| func (s *AuthService) LockAccount(tokenString string) error { | ||||||||||||||||||||||||||||||
| userID, err := s.tokenService.ValidateLockToken(tokenString) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| return errors.New("invalid or expired lock token") | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Lock the account (100 years) | ||||||||||||||||||||||||||||||
| lockedUntil := time.Now().Add(100 * 365 * 24 * time.Hour) | ||||||||||||||||||||||||||||||
| if err := s.userRepo.LockUser(userID, lockedUntil); err != nil { | ||||||||||||||||||||||||||||||
| return errors.New("failed to lock account") | ||||||||||||||||||||||||||||||
|
Comment on lines
+307
to
+315
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Return typed lock-account errors here. These ad-hoc 🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Revoke all existing sessions for security | ||||||||||||||||||||||||||||||
| s.tokenRepo.RevokeAllUserTokens(userID) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Audit Log | ||||||||||||||||||||||||||||||
| s.auditService.LogEvent(&userID, "ACCOUNT_LOCKED_BY_USER", "USER", userID, "", "", map[string]interface{}{"reason": "unrecognized_device_alert"}) | ||||||||||||||||||||||||||||||
|
Comment on lines
+318
to
+322
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Don't report success before session revocation succeeds.
Suggested fix- // Revoke all existing sessions for security
- s.tokenRepo.RevokeAllUserTokens(userID)
+ // Revoke all existing sessions for security
+ if err := s.tokenRepo.RevokeAllUserTokens(userID); err != nil {
+ return errors.New("failed to revoke user sessions")
+ }
// Audit Log
- s.auditService.LogEvent(&userID, "ACCOUNT_LOCKED_BY_USER", "USER", userID, "", "", map[string]interface{}{"reason": "unrecognized_device_alert"})
+ if err := s.auditService.LogEvent(&userID, "ACCOUNT_LOCKED_BY_USER", "USER", userID, "", "", map[string]interface{}{"reason": "unrecognized_device_alert"}); err != nil {
+ log.Printf("failed to write audit log for locked account %s: %v", userID, err)
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // EnableMFA generates a secret and returns it with QR code URL | ||||||||||||||||||||||||||||||
| func (s *AuthService) EnableMFA(userID string) (*dto.MFAEnableResponse, error) { | ||||||||||||||||||||||||||||||
| user, err := s.userRepo.FindByID(userID) | ||||||||||||||||||||||||||||||
|
|
@@ -616,6 +641,9 @@ func (s *AuthService) ProcessPostLogin(ctx context.Context, user *models.User, i | |||||||||||||||||||||||||||||
| log.Printf("Failed to update last login for user %s: %v", user.ID, err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Check device fingerprint | ||||||||||||||||||||||||||||||
| s.handleDeviceFingerprint(user, ipAddress, userAgent) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| response, err := s.CreateLoginResponse(user, ipAddress, userAgent) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||
|
|
@@ -629,6 +657,44 @@ func (s *AuthService) ProcessPostLogin(ctx context.Context, user *models.User, i | |||||||||||||||||||||||||||||
| return response, nil | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| func (s *AuthService) handleDeviceFingerprint(user *models.User, ipAddress, userAgent string) { | ||||||||||||||||||||||||||||||
| fingerprintHash := utils.GenerateDeviceFingerprint(userAgent, ipAddress) | ||||||||||||||||||||||||||||||
| device, err := s.deviceRepo.FindByFingerprint(user.ID, fingerprintHash) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| log.Printf("Error checking device fingerprint: %v", err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if device == nil { | ||||||||||||||||||||||||||||||
| // Unrecognized device | ||||||||||||||||||||||||||||||
| newDevice := &models.DeviceFingerprint{ | ||||||||||||||||||||||||||||||
| UserID: user.ID, | ||||||||||||||||||||||||||||||
| FingerprintHash: fingerprintHash, | ||||||||||||||||||||||||||||||
| UserAgent: userAgent, | ||||||||||||||||||||||||||||||
| IPAddress: ipAddress, | ||||||||||||||||||||||||||||||
| LastSeenAt: time.Now(), | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if err := s.deviceRepo.Create(newDevice); err != nil { | ||||||||||||||||||||||||||||||
| log.Printf("Failed to save device fingerprint: %v", err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Generate lock token and send alert email asynchronously | ||||||||||||||||||||||||||||||
| go func(u *models.User, ip, agent string) { | ||||||||||||||||||||||||||||||
| lockToken, err := s.tokenService.GenerateLockToken(u.ID) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| log.Printf("Failed to generate lock token for unrecognized login alert: %v", err) | ||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| err = s.emailService.SendUnrecognizedLoginAlert(u.Email, ip, agent, lockToken, s.config.App.URL) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| log.Printf("Failed to send unrecognized login alert to %s: %v", u.Email, err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }(user, ipAddress, userAgent) | ||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||
| // Update last seen | ||||||||||||||||||||||||||||||
| s.deviceRepo.UpdateLastSeen(device.ID, time.Now()) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // LoginWithOAuth handles login or registration via OAuth provider | ||||||||||||||||||||||||||||||
| func (s *AuthService) LoginWithOAuth(email, oauthID, firstName, lastName, provider, ipAddress, userAgent string) (*dto.LoginResponse, error) { | ||||||||||||||||||||||||||||||
| user, err := s.userRepo.FindByEmail(email) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,9 +10,12 @@ import ( | |
| "github.com/roshankumar0036singh/auth-server/internal/config" | ||
| ) | ||
|
|
||
| const appName = "Auth Server" | ||
|
|
||
| type EmailSender interface { | ||
| SendVerificationEmail(email, token, appURL string) error | ||
| SendPasswordResetEmail(email, token, appURL string) error | ||
| SendUnrecognizedLoginAlert(email, ip, userAgent, lockToken, appURL string) error | ||
| } | ||
|
|
||
| type EmailService struct { | ||
|
|
@@ -55,7 +58,10 @@ func (s *EmailService) SendEmail(to []string, subject string, templateName strin | |
| message += "\r\n" + body.String() | ||
|
|
||
| // Authenticate | ||
| auth := smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPassword, s.config.SMTPHost) | ||
| var auth smtp.Auth | ||
| if s.config.SMTPUser != "" && s.config.SMTPPassword != "" { | ||
| auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPassword, s.config.SMTPHost) | ||
| } | ||
|
|
||
| // Send email | ||
| addr := fmt.Sprintf("%s:%d", s.config.SMTPHost, s.config.SMTPPort) | ||
|
|
@@ -76,7 +82,7 @@ func (s *EmailService) SendVerificationEmail(email, token, appURL string) error | |
| AppName string | ||
| }{ | ||
| VerifyURL: verifyURL, | ||
| AppName: "Auth Server", | ||
| AppName: appName, | ||
| } | ||
|
|
||
| return s.SendEmail([]string{email}, "Verify your email", "verify_email.html", data) | ||
|
|
@@ -92,8 +98,27 @@ func (s *EmailService) SendPasswordResetEmail(email, token, appURL string) error | |
| AppName string | ||
| }{ | ||
| ResetURL: resetURL, | ||
| AppName: "Auth Server", | ||
| AppName: appName, | ||
| } | ||
|
|
||
| return s.SendEmail([]string{email}, "Reset your password", "reset_password.html", data) | ||
| } | ||
|
|
||
| // SendUnrecognizedLoginAlert sends an alert for an unfamiliar device login | ||
| func (s *EmailService) SendUnrecognizedLoginAlert(email, ip, userAgent, lockToken, appURL string) error { | ||
| lockURL := fmt.Sprintf("%s/api/auth/lock-account?token=%s", appURL, lockToken) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift Avoid one-click state-changing GET lock links in email alerts. Line 107 builds a direct 🤖 Prompt for AI Agents |
||
|
|
||
| data := struct { | ||
| IPAddress string | ||
| UserAgent string | ||
| LockURL string | ||
| AppName string | ||
| }{ | ||
| IPAddress: ip, | ||
| UserAgent: userAgent, | ||
| LockURL: lockURL, | ||
| AppName: appName, | ||
| } | ||
|
|
||
| return s.SendEmail([]string{email}, "Security Alert: Unrecognized Login", "unrecognized_login.html", data) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Only downgrade the backfill error when the table is actually absent.
After this change, any failure in
UPDATE refresh_tokens SET family_id = id ...is treated as "expected on fresh DB". On an existing database, the same branch would also swallow real permission/locking/query errors and continue with partially migrated token data. CheckHasTable/missing-table explicitly and keep failing startup for all other backfill failures.🤖 Prompt for AI Agents