All three are the same mistake in different clothes: a limit keyed on an IP that, behind the venue's NAT, is the entire party plus the host. * join_ip_rate_per_min was raised 60 -> 300 last round and it never took effect. A config default is only a fallback for a MISSING key, and migration 017 seeds this one, so the seed won and the raise was dead code on every real install. Migration 030 raises the seeded value the way 015 already did for upload_rate_per_hour. The e2e guard could not see this: it fires 12 concurrent joins, which is green at 60 and at 300 alike. * /recover's per-(IP, name) bucket charged EVERY request, including successful ones, and refused before verifying the PIN. Its ceiling clamps to 4. So four POSTs naming "Braut Sophie" with PIN 0000, from any phone on the venue wifi, locked Sophie out of her own recovery for fifteen minutes WITH THE CORRECT PIN — and four more every fifteen minutes sustained it indefinitely, at a rate far under every volume ceiling above it. The benign version needs no attacker: the host mistypes their own PIN four times. Hosts are promoted guests whose only credential is that PIN, and /recover is their only way back after losing a session. Now it counts failures, and a spent budget changes what a FAILURE answers instead of refusing outright. Guessing is bounded exactly as before — wrong PINs are what spend it — with the per-account lockout underneath. * /admin/login's pre-verify ceiling had the same shape, and the escape hatch was circular: admin_login_rate_enabled is only flippable through PATCH /admin/config, which needs the session being refused. One phone posting twice a minute cost the operator moderation, gallery release and every config key, including the ones that would undo it. Exceeding the ceiling now shortens the hash-permit wait rather than refusing: the CPU bound was always the semaphore, never this bucket, so a flood still sheds itself while a correct password gets a truthful answer. Adds a regression test that reads the value a fresh database actually ends up with, by replaying the migrations — the drift that made the first bullet invisible is not otherwise detectable from the code.
Migrations
SQLx-managed Postgres migrations. Each NNN_topic.up.sql has a matching
NNN_topic.down.sql. Run by sqlx::migrate!() at app start.
Rules
- Never edit a shipped migration. If a column needs to change or a fix needs to land, write a new migration. Production has already applied the old one and SQLx tracks each by checksum — editing in place will fail to apply on existing databases.
- Always pair
.up.sqlwith a.down.sql. Reverts may not be perfect (data loss is sometimes unavoidable) but the file must exist and do the best it can. - Prefer additive changes. New columns, new tables, new keys in
config. Drop / rename only when there is no alternative. - No business logic in migrations. Schema + seeds only. Anything that needs Rust code goes in a one-off binary, not a migration file.
- One concern per migration. Easier to revert. Easier to read in
git log.
Numbering
Zero-padded three digits, monotonically increasing. The next free number lives at the bottom of the directory listing — pick that.
Seed-only migrations
When you only need to add config keys (feature flags, defaults), use
INSERT … ON CONFLICT DO NOTHING so existing operator overrides survive. See
009_feature_toggles.up.sql for the canonical shape.