The login throttle keyed on body.username.lower() while the lookup matched User.username exactly, so "Bob" and "bob" were two accounts sharing one rate-limit bucket — each able to lock the other out — and registration happily accepted near-duplicate names, which on a custodial system is an impersonation vector. Uniqueness is now the database's job: a unique index on lower(username), with register and login both matching through func.lower(). The name is still stored exactly as typed, since that's what /admin and the audit log display, and the username pattern is ASCII-only so lower() is the whole of the normalization. The migration refuses to run if two existing accounts differ only by case. It can't merge or rename one automatically: both are custodial accounts that may hold funds, so that would be the migration silently deciding who owns what. It names the collisions and leaves them to the operator — the container runs `alembic upgrade head` at startup, so it surfaces as a refusal to start rather than a half-applied schema. Verified both directions against a scratch DB, plus `alembic check` (clean) and the collision guard actually firing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generic single-database configuration with an async dbapi.