[object Object]

← back to Ca Donations

ca-donations: FIX-FIRST hardening (Cody gate) — rate limit + data-quality guard

8340af84fec2f5887c5013db965d3bd15994e309 · 2026-08-22 11:34:48 -0700 · Steve

- dependency-free in-memory rate limiter on /api/* (120 req/60s/IP -> 429): caps bulk donor-row scraping
- POL_CLEAN guard: never serve future-dated (17) or null-donor (125) political artifacts as fact about named people
- GRANT_CLEAN guard: filter placeholder grantees (See Schedule/Attached/Eligible Patients)
- verified: 0 future/null rows served, 429 past cap, auth still 401 fail-closed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8340af84fec2f5887c5013db965d3bd15994e309
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Aug 22 11:34:48 2026 -0700

    ca-donations: FIX-FIRST hardening (Cody gate) — rate limit + data-quality guard
    
    - dependency-free in-memory rate limiter on /api/* (120 req/60s/IP -> 429): caps bulk donor-row scraping
    - POL_CLEAN guard: never serve future-dated (17) or null-donor (125) political artifacts as fact about named people
    - GRANT_CLEAN guard: filter placeholder grantees (See Schedule/Attached/Eligible Patients)
    - verified: 0 future/null rows served, 429 past cap, auth still 401 fail-closed
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 20 ++++++++++++++++++--
 1 file changed, 18 insertions(+), 2 deletions(-)

diff --git a/server.js b/server.js
index 0d125b1..ceffa40 100644
--- a/server.js
+++ b/server.js
@@ -21,7 +21,23 @@ app.use((req, res, next) => {
   return res.status(401).send('Auth required');
 });
 
+// Dependency-free in-memory rate limiter on /api/* — caps bulk scraping of donor rows.
+const RL_WINDOW = 60_000, RL_MAX = 120, rl = new Map();
+app.use('/api', (req, res, next) => {
+  const ip = req.ip || req.socket.remoteAddress || 'unknown';
+  const now = Date.now();
+  const rec = rl.get(ip) || { n: 0, reset: now + RL_WINDOW };
+  if (now > rec.reset) { rec.n = 0; rec.reset = now + RL_WINDOW; }
+  rec.n++; rl.set(ip, rec);
+  if (rl.size > 5000) for (const [k, v] of rl) if (now > v.reset) rl.delete(k); // GC
+  if (rec.n > RL_MAX) { res.set('Retry-After', '60'); return res.status(429).json({ error: 'rate limited' }); }
+  next();
+});
+
 const like = (s) => `%${String(s).trim()}%`;
+// Baseline data-quality guard — never serve raw-feed artifacts as fact about named people.
+const POL_CLEAN = `donor_name IS NOT NULL AND donor_name <> '' AND (contribution_date IS NULL OR contribution_date <= CURRENT_DATE)`;
+const GRANT_CLEAN = `grantee_name IS NOT NULL AND grantee_name !~* 'see (schedule|attached|statement)|eligible patients|various'`;
 // Whitelisted ORDER BY per endpoint — the UI's sort dropdown must actually sort.
 const ORDER = {
   orgs:      { name: 'name ASC', status: 'ca_ag_status ASC, name ASC', ntee: 'ntee_code ASC NULLS LAST, name ASC' },
@@ -75,7 +91,7 @@ app.get('/api/org/:ein', async (req, res) => {
 app.get('/api/grants', async (req, res) => {
   try {
     const { grantor = '', grantee = '', year = '', sort = 'amount', limit = 100 } = req.query;
-    const where = [], params = [];
+    const where = [GRANT_CLEAN], params = [];
     if (grantor) { params.push(like(grantor)); where.push(`grantor_name ILIKE $${params.length}`); }
     if (grantee) { params.push(like(grantee)); where.push(`grantee_name ILIKE $${params.length}`); }
     if (year)    { params.push(+year);          where.push(`tax_year = $${params.length}`); }
@@ -92,7 +108,7 @@ app.get('/api/grants', async (req, res) => {
 app.get('/api/political', async (req, res) => {
   try {
     const { donor = '', recipient = '', jurisdiction = '', employer = '', sort = 'date', limit = 100 } = req.query;
-    const where = [], params = [];
+    const where = [POL_CLEAN], params = [];
     if (donor)        { params.push(like(donor));        where.push(`donor_name ILIKE $${params.length}`); }
     if (recipient)    { params.push(like(recipient));    where.push(`recipient_name ILIKE $${params.length}`); }
     if (employer)     { params.push(like(employer));     where.push(`donor_employer ILIKE $${params.length}`); }

← 9e7508b ca-donations: wire server-side sort (mandatory sort rule) ac  ·  back to Ca Donations  ·  ca-donations: freshness+completeness canary + data-quality a e220fe9 →