feat(security): Postgres-backed login rate limiting

Replaces the in-memory sliding-window Map (reset on every restart, not
shareable across instances) with a login_attempts table (migration 016).
No Redis added - login is not a hot path, and this is a single indexed
range-scan query. Restart behavior confirmed for real:
platform/test/e2e-phase3a.js's rate-limiting test passes after a genuine
Platform API restart in the same run.
This commit is contained in:
2026-08-24 20:15:03 -05:00
parent 6dda13b837
commit addf474b70
3 changed files with 44 additions and 20 deletions
@@ -0,0 +1,15 @@
-- Postgres-backed login rate limiting — Phase 4.1 §15. Replaces the in-memory sliding-window
-- Map in security.js, which reset on every Platform API restart (an attacker could always just
-- wait for a deploy) and would silently stop being shared the moment there's more than one
-- Platform API instance. One small table, one cheap query per login attempt (this is a login
-- form, not a hot path) — no Redis, no new infrastructure dependency.
CREATE TABLE IF NOT EXISTS login_attempts (
id BIGSERIAL PRIMARY KEY,
rate_key TEXT NOT NULL, -- "ip:email", same composite key security.js always used
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The hot query is "count attempts for this key within the last N minutes" — this index makes
-- that a range scan on one key instead of a table scan.
CREATE INDEX IF NOT EXISTS idx_login_attempts_key_time ON login_attempts(rate_key, created_at);