Skip to content

feat: harden PUT /users/me — validate display name, re-verify on email change - #145

Merged
windoze95 merged 1 commit into
mainfrom
feat/profile-update-hardening
Jul 13, 2026
Merged

feat: harden PUT /users/me — validate display name, re-verify on email change#145
windoze95 merged 1 commit into
mainfrom
feat/profile-update-hardening

Conversation

@windoze95

Copy link
Copy Markdown
Owner

Started as the first_name follow-up. first_name turned out to be the smaller problem.

The real bug: an email change didn't un-verify the account

email_verified_at is what gates the AI-cost endpoints (as of this morning's flip) and what the stale-signup sweep keys off. But PUT /users/me swapped the address and left the verified flag set — so the flow below kept "verified" status on an address nobody ever proved:

  1. Sign up with a throwaway address, enter the code → verified.
  2. PUT /users/me {"email": "anything@else.com"} → address changes, email_verified_at stays set.

That makes email_verified_at mean "this account once proved some address," which is not what it's gating on.

Fix: changing the address clears verification and mails a fresh code to the new address (best-effort, exactly like signup). When verification isn't live, the new address is auto-verified instead — mirroring signup in that mode, since nothing would ever send a code. Email and verification state now move in a single UPDATE, so an account can't be left verified on an unproved address even if something fails midway.

Also on that path

  • first_name was unvalidated free text on an unbounded text column, at both signup and update. Now: any Unicode letter plus the punctuation real names use — Mary-Jane, O'Brien, J. R., José, Zoë, — and nothing else. Digits, emoji, symbols and control characters are rejected (so no newline-stuffed or 5,000-character names), capped at 50.
  • Deliberately NOT profanity-checked. I checked where it surfaces: the app only ever shows it back to its owner, it's not published, and it never reaches an AI prompt. A filter would buy zero moderation and would reject people genuinely named Dick or Fanny. There's a comment saying to revisit if first names ever become visible to others.
  • A taken email returned 500. Now 409 — the repo was already mapping the unique violation to ErrEmailTaken, the handler just wasn't checking it.
  • Email length capped at 254 (RFC 5321). The column is unbounded text and govalidator doesn't bound it.
  • Re-typing your own address in different case is no longer treated as a change, so it can't cost you your verified status.

Verification

go test ./... -count=1 green. New tests cover the un-verify, the auto-verify-when-disabled path, the case-only no-op, the 409, and the ValidateFirstName table (including José//O'Brien passing and emoji/newline/digits failing).

Two things I checked rather than assumed:

  • The new tests are load-bearing. MockUserRepo hands back the same pointer it stores, so a naive test would assert on the service's in-memory mutation and pass even if it never wrote to the repo. Tests pass a copy (sessionCopy) so the "did it persist?" assertions are real. Reverting the un-verify logic makes TestUpdateUser_EmailChangeUnverifiesAccount fail with "stored account still verified on an address nobody proved" — confirmed.
  • StartVerification refuses to send to an account that still looks verified, and mails user.Email — so UpdateUser updates the in-memory user, or the code would go to the old address.

Note

TestVideoImport_NativeVideoUsed flaked once during a full-suite run here — waitForVideoJob polls an async job against a hard 3s wall-clock deadline (import_video_test.go:187). Unrelated to this change (passes 3/3 isolated, 3/3 in the full package, suite green), but it can redden CI at random. Happy to fix separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_0194PdH4wDTnz5SWfzyoKagc

…l change

Two problems in the profile-update path, found while looking at first_name.

The real one: changing your email did not un-verify the account. Since
email_verified_at now gates the AI-cost endpoints and drives the stale-signup
sweep, a user could verify a throwaway address, swap in an address nobody ever
proved, and keep "verified" status. The address change now clears verification
and mails a fresh code to the NEW address; when verification isn't live the new
address is auto-verified instead, mirroring what signup does in that mode. Email
and verification state move in one statement so an account can never be left
verified on an unproved address.

Also on that path:
- first_name was unvalidated free text on an unbounded text column, at both
  signup and update. It now allows any Unicode letter plus the punctuation real
  names use (Mary-Jane, O'Brien, J. R., José, 李) and rejects digits, emoji,
  symbols and control characters, capped at 50. Deliberately NOT
  profanity-checked: the name is only ever shown back to its owner, so a filter
  would buy no moderation and would reject people named Dick or Fanny.
- A taken email returned 500. It now returns 409, using the sentinel the repo
  was already mapping.
- Email length is capped at 254 (RFC 5321); the column is unbounded text.
- Re-typing your own address in different case no longer counts as a change, so
  it can't cost you your verified status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194PdH4wDTnz5SWfzyoKagc
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@windoze95
windoze95 merged commit 0d6efe7 into main Jul 13, 2026
1 check passed
@windoze95
windoze95 deleted the feat/profile-update-hardening branch July 13, 2026 03:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant