-- Two independent auth defects that share a migration because they share a table. -- 1. RESERVED NAMES — free any guest squatting on a name the admin path used to depend on. -- -- Migration 007 made display_name unique per event case-insensitively, and `join` had no -- reserved-name guard. So any guest could join as "admin"/"Admin"/"ADMIN" before the operator's -- first admin login; admin_login then looked its user up BY NAME, missed (wrong role), fell -- through to creating "Admin", violated that unique index, and returned a 500 — permanently, -- with no in-app recovery. Moderation, config and gallery release all gone, fixed only by SQL. -- -- The real fix is in code (look the admin up by role, never by name — see auth/handlers.rs). -- This clears the state an already-deployed database may be carrying. -- -- RENAMED, NEVER DELETED: the guest keeps their uploads, their PIN and their session. Only -- non-admin rows are touched — a real admin row named "Admin" is the expected state. UPDATE "user" u SET display_name = u.display_name || ' (' || left(u.id::text, 8) || ')' WHERE u.role <> 'admin' AND lower(u.display_name) IN ('admin', 'administrator', 'host', 'eventsnap'); -- 2. PIN LOCKOUT DECAY. -- -- failed_pin_attempts only ever cleared on a successful recovery or after a lockout expired, so -- honest typos accumulated across days: a guest who fat-fingered their PIN twice last night -- arrives today already two-thirds of the way to being locked out. With the threshold now -- raised (see below) a decay window is what keeps that raise safe rather than merely lenient. ALTER TABLE "user" ADD COLUMN IF NOT EXISTS last_failed_pin_at TIMESTAMPTZ; -- Rate-limit knobs introduced with this release. -- -- recover_name_rate_per_15min (4, was a hardcoded 5): the per-(IP, name) ceiling. It MUST stay -- below the account-lock threshold, which is the whole defect — at 5-per-IP against a 3-strike -- lock, three requests from one IP locked any guest whose name is visible on the feed, every 15 -- minutes, forever. The lock threshold moves to 12 in code, so locking a victim now needs at -- least three distinct sources while an honest guest never comes close. -- -- pin_reset_ip_rate_per_min (30): /recover/request was the one unauthenticated endpoint with no -- per-IP ceiling at all — /join got one in 017 and /recover in 019, and this third one was -- simply missed. Its per-name key is attacker-chosen, so cycling names minted a fresh bucket -- every time and the per-IP cost was unbounded. -- -- upload_edit_rate_per_min (30): PATCH /upload/{id} had no rate limit of any kind. INSERT INTO config (key, value) VALUES ('recover_name_rate_per_15min', '4'), ('pin_reset_ip_rate_per_min', '30'), ('upload_edit_rate_per_min', '30'), ('upload_edit_rate_enabled', 'true') ON CONFLICT (key) DO NOTHING;