feat(store): add users.email_verified_at column + model plumbing (TASK-1935) (#805)

Wave 1 of PLAN-1933 (email verification). Pure infra — nothing reads the
column until Wave 3, so this is behaviourally a no-op and mergeable early.

- Migration 070 (SQLite) / 048 (Postgres): add nullable email_verified_at
  TEXT, mirroring disabled_at. UNCONDITIONALLY backfill every existing row
  to verified (RFC3339 'Z'-suffixed) so no existing / OAuth / self-host
  account is write-locked on deploy (inverted vs password_set's conditional
  backfill). SQLite ALTER without IF NOT EXISTS; Postgres with it.
- SAFE default = verified (DR-3): CreateUser / CreateOAuthUser write a
  verified timestamp unless UserCreate.Unverified is explicitly requested
  (only the future cloud self-serve branch will set that). A missed call
  site fails SAFE (verified), not write-locked.
- models.User.EmailVerifiedAt + IsEmailVerified() (mirror IsDisabled).
- Update userColumns + BOTH scan sites (scanUser AND the inline SearchUsers
  scan) so the admin user list keeps working.
- Expose derived email_verified bool in sessionUserPayload for a later wave.

Gates: make check + make test-pg both green (dual-dialect verified).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
This commit is contained in:
xarmian
2026-07-04 00:45:48 -04:00
committed by GitHub
parent 6c0e336d9c
commit 6a63fba188
7 changed files with 342 additions and 25 deletions
+23 -7
View File
@@ -19,13 +19,14 @@ type User struct {
RecoveryCodes string `json:"-"` // Never serialized
Plan string `json:"plan"` // "free", "pro", or "self-hosted"
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
StripeCustomerID string `json:"-"` // Never serialized
PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits
OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"]
PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash)
DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled
LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request (any read or write)
LastWriteAt string `json:"last_write_at,omitempty"` // Last mutating action (item/comment/attachment); see Store.TouchUserWrite. PLAN-1542 / TASK-1543.
StripeCustomerID string `json:"-"` // Never serialized
PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits
OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"]
PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash)
DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled
EmailVerifiedAt string `json:"email_verified_at,omitempty"` // Non-empty = email verified (mirror DisabledAt). Empty/NULL = unverified. PLAN-1933 / TASK-1935.
LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request (any read or write)
LastWriteAt string `json:"last_write_at,omitempty"` // Last mutating action (item/comment/attachment); see Store.TouchUserWrite. PLAN-1542 / TASK-1543.
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -35,6 +36,13 @@ func (u *User) IsDisabled() bool {
return u.DisabledAt != ""
}
// IsEmailVerified returns true if the user's email address has been verified.
// Mirrors IsDisabled: a non-empty EmailVerifiedAt timestamp means verified,
// empty (NULL in the DB) means unverified. PLAN-1933 / TASK-1935.
func (u *User) IsEmailVerified() bool {
return u.EmailVerifiedAt != ""
}
// GetOAuthProviders parses the JSON oauth_providers field into a string slice.
func (u *User) GetOAuthProviders() []string {
if u.OAuthProviders == "" {
@@ -72,6 +80,14 @@ type UserCreate struct {
Name string `json:"name"`
Password string `json:"password"` // Plaintext, will be hashed
Role string `json:"role,omitempty"` // Defaults to "member"
// Unverified requests that the created user start with an UNVERIFIED email
// (email_verified_at = NULL). The zero value (false) yields a VERIFIED user,
// so a call site that forgets to set it fails SAFE — verified, not
// write-locked (DR-3). The ONLY path that sets this true is the future
// cloud self-serve signup branch (PLAN-1933 Wave 3), which does not exist
// yet. Not settable via the request body (json:"-") — the handler decides.
Unverified bool `json:"-"`
}
// UserUpdate is the input for updating user profile fields.
+26
View File
@@ -0,0 +1,26 @@
package models
import "testing"
// TestIsEmailVerified locks in the mirror-of-IsDisabled contract: a non-empty
// EmailVerifiedAt means verified, empty (the zero value / NULL round-trip)
// means unverified. PLAN-1933 / TASK-1935.
func TestIsEmailVerified(t *testing.T) {
cases := []struct {
name string
emailVerifiedAt string
want bool
}{
{"empty is unverified", "", false},
{"timestamp is verified", "2026-07-04T12:00:00Z", true},
{"any non-empty string is verified", "x", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
u := &User{EmailVerifiedAt: c.emailVerifiedAt}
if got := u.IsEmailVerified(); got != c.want {
t.Errorf("IsEmailVerified() with EmailVerifiedAt=%q = %v, want %v", c.emailVerifiedAt, got, c.want)
}
})
}
}
+8 -7
View File
@@ -63,13 +63,14 @@ func sessionUserPayload(user *models.User) map[string]interface{} {
return nil
}
return map[string]interface{}{
"id": user.ID,
"email": user.Email,
"username": user.Username,
"name": user.Name,
"role": user.Role,
"totp_enabled": user.TOTPEnabled,
"plan": user.Plan,
"id": user.ID,
"email": user.Email,
"username": user.Username,
"name": user.Name,
"role": user.Role,
"totp_enabled": user.TOTPEnabled,
"plan": user.Plan,
"email_verified": user.IsEmailVerified(),
}
}
@@ -0,0 +1,25 @@
-- Add email_verified_at column for email verification (PLAN-1933 Wave 1 / TASK-1935).
-- NULL = unverified, non-NULL = the time the email was verified. Mirrors
-- disabled_at's nullable-timestamp shape. Pure infra — nothing reads this column
-- until Wave 3, so this migration is a no-op behaviourally.
--
-- NOTE: no `IF NOT EXISTS` — SQLite's ALTER TABLE ADD COLUMN rejects it.
-- NOTE: no expression DEFAULT either — SQLite forbids a parenthesised/CURRENT_*
-- default on ADD COLUMN, so the column defaults to NULL. The SAFE default
-- (verified) is enforced in the application layer instead: store.CreateUser /
-- CreateOAuthUser write a verified timestamp unless a creation path explicitly
-- requests unverified. The ONLY path that will ever leave this NULL is the
-- future cloud self-serve signup branch (Wave 3), which does not exist yet.
ALTER TABLE users ADD COLUMN email_verified_at TEXT;
-- Backfill: UNCONDITIONALLY mark EVERY existing row verified. Existing / OAuth /
-- self-host accounts predate email verification and must NOT be write-locked on
-- deploy. This is INVERTED vs password_set's conditional (oauth-aware) backfill:
-- there is no "was this user ever verified?" signal to key on, and the correct
-- answer for every pre-existing account is "verified". Emit RFC3339 with a 'Z'
-- suffix so Go's time.Parse(time.RFC3339, …) / store.parseTime reads it back
-- (the default datetime(...) 'YYYY-MM-DD HH:MM:SS' output is space-separated and
-- un-parseable by RFC3339) — same convention as disabled_at / created_at.
UPDATE users
SET email_verified_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE email_verified_at IS NULL;
@@ -0,0 +1,24 @@
-- Add email_verified_at column for email verification (PLAN-1933 Wave 1 / TASK-1935).
-- NULL = unverified, non-NULL = the time the email was verified. Mirrors
-- disabled_at's nullable-timestamp shape. TEXT (not TIMESTAMP) to match the
-- codebase's RFC3339-string convention for user timestamps. Pure infra —
-- nothing reads this column until Wave 3.
--
-- The SAFE default (verified) is enforced in the application layer
-- (store.CreateUser / CreateOAuthUser), NOT via a DB DEFAULT, so both dialects
-- stay symmetric and the fail-safe lives in one testable place.
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TEXT;
-- Backfill: UNCONDITIONALLY mark EVERY existing row verified. Existing / OAuth /
-- self-host accounts predate email verification and must NOT be write-locked on
-- deploy. This is INVERTED vs password_set's conditional (oauth-aware) backfill.
-- Emit RFC3339 UTC with a 'Z' suffix so Go's time.Parse(time.RFC3339, …) reads
-- it back. now() AT TIME ZONE 'UTC' yields a naive UTC timestamp that to_char
-- formats as-is, and the hardcoded 'Z' labels it UTC regardless of server
-- locale (the plain ::text cast would be space-separated and un-parseable).
UPDATE users
SET email_verified_at = to_char(
now() AT TIME ZONE 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS"Z"'
)
WHERE email_verified_at IS NULL;
+29 -11
View File
@@ -27,7 +27,7 @@ var usernameCleanRe = regexp.MustCompile(`[^a-z0-9-]+`)
var bcryptCost = 12
// user SELECT columns — used by all user queries.
const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, password_set, disabled_at, last_active_at, last_write_at, created_at, updated_at`
const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, password_set, disabled_at, email_verified_at, last_active_at, last_write_at, created_at, updated_at`
// scanUser scans a user row into a User struct.
// Note: does NOT decrypt the TOTP secret — call store.decryptUserTOTP() after
@@ -36,17 +36,20 @@ func scanUser(row interface{ Scan(...interface{}) error }) (*models.User, error)
var u models.User
var createdAt, updatedAt string
var disabledAt, lastActiveAt, lastWriteAt sql.NullString
var disabledAt, emailVerifiedAt, lastActiveAt, lastWriteAt sql.NullString
err := row.Scan(
&u.ID, &u.Email, &u.Username, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL,
&u.TOTPSecret, &u.TOTPEnabled, &u.RecoveryCodes,
&u.Plan, &u.PlanExpiresAt, &u.StripeCustomerID, &u.PlanOverrides, &u.OAuthProviders,
&u.PasswordSet,
&disabledAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt,
&disabledAt, &emailVerifiedAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt,
)
if disabledAt.Valid {
u.DisabledAt = disabledAt.String
}
if emailVerifiedAt.Valid {
u.EmailVerifiedAt = emailVerifiedAt.String
}
if lastActiveAt.Valid {
u.LastActiveAt = lastActiveAt.String
}
@@ -93,10 +96,20 @@ func (s *Store) CreateUser(input models.UserCreate) (*models.User, error) {
id := newID()
ts := now()
// Email-verification default is SAFE = verified (DR-3). Every creation path
// yields a verified user unless it explicitly requests unverified via
// UserCreate.Unverified. Today only the future cloud self-serve signup
// branch (PLAN-1933 Wave 3) sets that; every current call site inherits
// verified. A nil interface binds as a NULL column (= unverified).
var emailVerifiedAt interface{}
if !input.Unverified {
emailVerifiedAt = ts
}
_, err = s.db.Exec(s.q(`
INSERT INTO users (id, email, username, name, password_hash, role, password_set, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Username), strings.TrimSpace(input.Name), string(hash), role, true, ts, ts)
INSERT INTO users (id, email, username, name, password_hash, role, password_set, email_verified_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Username), strings.TrimSpace(input.Name), string(hash), role, true, emailVerifiedAt, ts, ts)
if err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
@@ -465,7 +478,7 @@ func (s *Store) SearchUsers(params AdminUserSearchParams) (*AdminUserSearchResul
for rows.Next() {
var entry AdminUserListEntry
var createdAt, updatedAt string
var disabledAt, lastActiveAt, lastWriteAt sql.NullString
var disabledAt, emailVerifiedAt, lastActiveAt, lastWriteAt sql.NullString
var workspaceCount int
var storageBytes int64
if err := rows.Scan(
@@ -473,7 +486,7 @@ func (s *Store) SearchUsers(params AdminUserSearchParams) (*AdminUserSearchResul
&entry.TOTPSecret, &entry.TOTPEnabled, &entry.RecoveryCodes,
&entry.Plan, &entry.PlanExpiresAt, &entry.StripeCustomerID, &entry.PlanOverrides, &entry.OAuthProviders,
&entry.PasswordSet,
&disabledAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt,
&disabledAt, &emailVerifiedAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt,
&workspaceCount, &storageBytes,
); err != nil {
return nil, fmt.Errorf("search users scan: %w", err)
@@ -481,6 +494,9 @@ func (s *Store) SearchUsers(params AdminUserSearchParams) (*AdminUserSearchResul
if disabledAt.Valid {
entry.DisabledAt = disabledAt.String
}
if emailVerifiedAt.Valid {
entry.EmailVerifiedAt = emailVerifiedAt.String
}
if lastActiveAt.Valid {
entry.LastActiveAt = lastActiveAt.String
}
@@ -648,10 +664,12 @@ func (s *Store) CreateOAuthUser(email, name, avatarURL string) (*models.User, er
return nil, fmt.Errorf("generate username: %w", err)
}
// OAuth users are always email-verified (the provider asserted the address);
// this matches DR-3's "OAuth = verified" and the SAFE default.
_, err = s.db.Exec(s.q(`
INSERT INTO users (id, email, username, name, password_hash, role, avatar_url, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`), id, strings.ToLower(strings.TrimSpace(email)), username, strings.TrimSpace(name), string(hash), "member", avatarURL, ts, ts)
INSERT INTO users (id, email, username, name, password_hash, role, avatar_url, email_verified_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), id, strings.ToLower(strings.TrimSpace(email)), username, strings.TrimSpace(name), string(hash), "member", avatarURL, ts, ts, ts)
if err != nil {
return nil, fmt.Errorf("insert oauth user: %w", err)
}
+207
View File
@@ -0,0 +1,207 @@
package store
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// emailVerifiedBackfillSQL reads the ACTUAL backfill statement out of the
// embedded migration file (070 for SQLite, 048 for Postgres) so the backfill
// test exercises the real dialect-specific DML rather than a hand-copied
// duplicate. The ALTER TABLE line is dropped — the column already exists
// (migration ran at store open) and SQLite's ADD COLUMN would error on
// re-apply — leaving just the `UPDATE users SET email_verified_at ...` chunk.
func emailVerifiedBackfillSQL(t *testing.T) string {
t.Helper()
var (
data []byte
err error
)
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
data, err = pgMigrationsFS.ReadFile("pgmigrations/048_email_verified.sql")
} else {
data, err = migrationsFS.ReadFile("migrations/070_email_verified.sql")
}
if err != nil {
t.Fatalf("read email_verified migration: %v", err)
}
// The backfill UPDATE is the FINAL statement in the file. Take everything
// from the last "UPDATE users" through end-of-file so the test runs the real
// DML without re-executing the ALTER (which SQLite rejects on re-apply).
// Indexing from the LAST occurrence avoids tripping on the word appearing in
// a preceding comment — and, unlike a naive split on ';', is immune to
// semicolons inside comment prose.
full := string(data)
idx := strings.LastIndex(strings.ToUpper(full), "UPDATE USERS")
if idx < 0 {
t.Fatalf("no UPDATE users statement found in email_verified migration:\n%s", full)
}
return full[idx:]
}
// insertLegacyUnverifiedUser inserts a user row with email_verified_at = NULL,
// simulating an account that existed BEFORE migration 070 added + backfilled
// the column. CreateUser can't produce this state directly (its SAFE default
// is verified), so we go straight to SQL.
func insertLegacyUnverifiedUser(t *testing.T, s *Store, email string) string {
t.Helper()
id := newID()
ts := now()
if _, err := s.db.Exec(s.q(`
INSERT INTO users (id, email, username, name, password_hash, role, email_verified_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)
`), id, email, "u_"+id, "Legacy "+id, "x", "member", ts, ts); err != nil {
t.Fatalf("insert legacy unverified user: %v", err)
}
return id
}
// TestEmailVerifiedBackfill covers DR-3's UNCONDITIONAL backfill: every row
// that predates the column must come out VERIFIED so no existing / OAuth /
// self-host account is write-locked on deploy. Runs the real migration DML
// against pre-existing NULL rows (both dialects via make test-pg).
func TestEmailVerifiedBackfill(t *testing.T) {
s := testStore(t)
legacy := []string{
insertLegacyUnverifiedUser(t, s, "legacy1@example.com"),
insertLegacyUnverifiedUser(t, s, "legacy2@example.com"),
}
// Precondition: the legacy rows start unverified (NULL round-trips as "").
for _, id := range legacy {
u, err := s.GetUser(id)
if err != nil {
t.Fatalf("GetUser(%s): %v", id, err)
}
if u.IsEmailVerified() {
t.Fatalf("precondition failed: legacy row %s should start unverified, got %q", id, u.EmailVerifiedAt)
}
}
// Run the migration's backfill statement.
if _, err := s.db.Exec(emailVerifiedBackfillSQL(t)); err != nil {
t.Fatalf("run backfill DML: %v", err)
}
// Postcondition: every pre-existing row is now verified with a timestamp
// that Go's time.Parse(time.RFC3339, …) can read back (the parse round-trip
// is the whole point of the strftime/to_char 'Z'-suffixed formats).
for _, id := range legacy {
u, err := s.GetUser(id)
if err != nil {
t.Fatalf("GetUser(%s) after backfill: %v", id, err)
}
if !u.IsEmailVerified() {
t.Errorf("backfill did not verify legacy row %s (EmailVerifiedAt=%q)", id, u.EmailVerifiedAt)
}
if _, err := time.Parse(time.RFC3339, u.EmailVerifiedAt); err != nil {
t.Errorf("backfilled timestamp for %s is not RFC3339: %q (%v)", id, u.EmailVerifiedAt, err)
}
}
}
// TestCreateUserEmailVerifiedDefault covers DR-3's SAFE default: a plain
// CreateUser yields a VERIFIED user, and only an explicit Unverified request
// (the future cloud self-serve branch) produces an unverified one.
func TestCreateUserEmailVerifiedDefault(t *testing.T) {
s := testStore(t)
// Default: verified.
u := createTestUser(t, s, "default@example.com", "Default", "password123")
if !u.IsEmailVerified() {
t.Errorf("CreateUser default should be VERIFIED (safe default), got EmailVerifiedAt=%q", u.EmailVerifiedAt)
}
if _, err := time.Parse(time.RFC3339, u.EmailVerifiedAt); err != nil {
t.Errorf("default-verified timestamp not RFC3339: %q (%v)", u.EmailVerifiedAt, err)
}
// Explicit Unverified=true: unverified, and it must persist as NULL (not
// just live on the returned struct).
unv, err := s.CreateUser(models.UserCreate{
Email: "unverified@example.com", Name: "Unv", Password: "password123", Unverified: true,
})
if err != nil {
t.Fatalf("CreateUser(Unverified): %v", err)
}
if unv.IsEmailVerified() {
t.Errorf("CreateUser with Unverified=true should be UNVERIFIED, got EmailVerifiedAt=%q", unv.EmailVerifiedAt)
}
if got, _ := s.GetUser(unv.ID); got == nil || got.IsEmailVerified() {
t.Errorf("unverified user should stay unverified after re-fetch, got %+v", got)
}
}
// TestCreateOAuthUserVerified covers DR-3's "OAuth = verified" rule.
func TestCreateOAuthUserVerified(t *testing.T) {
s := testStore(t)
u, err := s.CreateOAuthUser("oauth@example.com", "OAuth User", "https://example.com/a.png")
if err != nil {
t.Fatalf("CreateOAuthUser: %v", err)
}
if !u.IsEmailVerified() {
t.Errorf("OAuth users must be verified, got EmailVerifiedAt=%q", u.EmailVerifiedAt)
}
}
// TestEmailVerifiedBothScanPaths guards the two-scan-site foot-gun: the field
// must round-trip through scanUser (GetUser/ListUsers) AND the inline scan in
// SearchUsers. Missing the second breaks the admin user list at runtime with a
// column/target mismatch, which a compile check would NOT catch.
func TestEmailVerifiedBothScanPaths(t *testing.T) {
s := testStore(t)
verified := createTestUser(t, s, "scanverified@example.com", "V", "password123")
unverifiedID := insertLegacyUnverifiedUser(t, s, "scanunverified@example.com")
// --- scanUser path (GetUser) ---
if got, err := s.GetUser(verified.ID); err != nil || got == nil || !got.IsEmailVerified() {
t.Fatalf("scanUser (GetUser) verified: err=%v got=%+v", err, got)
}
if got, err := s.GetUser(unverifiedID); err != nil || got == nil || got.IsEmailVerified() {
t.Fatalf("scanUser (GetUser) unverified: err=%v got=%+v", err, got)
}
// --- SearchUsers inline scan path ---
res, err := s.SearchUsers(AdminUserSearchParams{})
if err != nil {
t.Fatalf("SearchUsers: %v", err)
}
byEmail := map[string]AdminUserListEntry{}
for _, u := range res.Users {
byEmail[u.Email] = u
}
if e, ok := byEmail["scanverified@example.com"]; !ok || !e.IsEmailVerified() {
t.Errorf("SearchUsers inline scan: verified user missing/unverified (ok=%v EmailVerifiedAt=%q)", ok, e.EmailVerifiedAt)
}
if e, ok := byEmail["scanunverified@example.com"]; !ok || e.IsEmailVerified() {
t.Errorf("SearchUsers inline scan: unverified user missing/verified (ok=%v EmailVerifiedAt=%q)", ok, e.EmailVerifiedAt)
}
}
// TestListUsersEmailVerified is a lightweight extra assertion that the
// ListUsers scanUser path also surfaces the field (defensive; scanUser is
// shared, but ListUsers is the admin-facing bulk reader).
func TestListUsersEmailVerified(t *testing.T) {
s := testStore(t)
for i := 0; i < 3; i++ {
createTestUser(t, s, fmt.Sprintf("list%d@example.com", i), "L", "password123")
}
users, err := s.ListUsers()
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if len(users) != 3 {
t.Fatalf("want 3 users, got %d", len(users))
}
for _, u := range users {
if !u.IsEmailVerified() {
t.Errorf("ListUsers: user %s should be verified, got EmailVerifiedAt=%q", u.Email, u.EmailVerifiedAt)
}
}
}