[object Object]

← back to NationalPaperHangers

Split shared claimLimiter into per-route buckets (claim/notify/COI/comment)

868356af423ffe579b3890239f68523a5bd3590b · 2026-05-18 16:13:08 -0700 · SteveStudio2

A single claimLimiter instance was applied to four unrelated routes, so they
shared one 5/hr/IP bucket — a designer who requested 5 COIs could no longer
claim a profile, and burst commenters were blocked by unrelated traffic. Each
route now gets its own independent store via an hourLimiter(prodMax) factory:
claim 5/hr (unchanged, sensitive), notify 10, COI 20, comments 20. Non-prod
stays widened to 100.

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

Files touched

Diff

commit 868356af423ffe579b3890239f68523a5bd3590b
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 18 16:13:08 2026 -0700

    Split shared claimLimiter into per-route buckets (claim/notify/COI/comment)
    
    A single claimLimiter instance was applied to four unrelated routes, so they
    shared one 5/hr/IP bucket — a designer who requested 5 COIs could no longer
    claim a profile, and burst commenters were blocked by unrelated traffic. Each
    route now gets its own independent store via an hourLimiter(prodMax) factory:
    claim 5/hr (unchanged, sensitive), notify 10, COI 20, comments 20. Non-prod
    stays widened to 100.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 server.js | 23 +++++++++++++++--------
 1 file changed, 15 insertions(+), 8 deletions(-)

diff --git a/server.js b/server.js
index 31e8c93..36acb42 100644
--- a/server.js
+++ b/server.js
@@ -165,15 +165,22 @@ const loginLimiter = rateLimit({
   standardHeaders: true, legacyHeaders: false,
   message: { error: 'too_many_requests' }
 });
-// Shared by claim / notify / COI-request / paper-comments. Like loginLimiter,
-// the non-prod max is widened so back-to-back e2e suites (which fire many
-// POSTs from one IP) don't trip it mid-run. Gated on NODE_ENV — prod stays 5.
-const claimLimiter = rateLimit({
+// Per-route hourly abuse limiters. Each call returns its OWN rate-limit store,
+// so the buckets are independent — previously a single `claimLimiter` was
+// shared across claim / notify / COI / comments, meaning a designer who fired
+// 5 COI requests could no longer claim a profile. Non-prod max is widened to
+// 100 so back-to-back e2e suites (many POSTs from one IP) don't trip mid-run;
+// gated on NODE_ENV so prod sees the real per-route cap.
+const hourLimiter = (prodMax) => rateLimit({
   windowMs: 60 * 60 * 1000,
-  max: process.env.NODE_ENV === 'production' ? 5 : 100,
+  max: process.env.NODE_ENV === 'production' ? prodMax : 100,
   standardHeaders: true, legacyHeaders: false,
   message: { error: 'too_many_requests' }
 });
+const claimLimiter   = hourLimiter(5);   // account-claim — sensitive takeover surface, kept tight
+const notifyLimiter  = hourLimiter(10);  // notify-when-live — low-value visitor signup
+const coiLimiter     = hourLimiter(20);  // COI requests — designers hit several installers per project
+const commentLimiter = hourLimiter(20);  // paper-thread comments — knowledge contributors post in bursts
 const bookLimiter = rateLimit({
   windowMs: 60 * 60 * 1000, max: 8,
   standardHeaders: true, legacyHeaders: false,
@@ -192,9 +199,9 @@ app.use('/signup', loginLimiter);
 app.use(['/installer/:slug/claim', '/installer/:slug/claim/complete'], claimLimiter);
 app.use('/api/installers/:slug/book', bookLimiter);
 app.use('/api/installers.geo', geoLimiter);
-app.use('/installer/:slug/notify-when-live', claimLimiter);
-app.use('/installer/:slug/coi-request', claimLimiter);
-app.use('/papers/:slug/comments', claimLimiter);
+app.use('/installer/:slug/notify-when-live', notifyLimiter);
+app.use('/installer/:slug/coi-request', coiLimiter);
+app.use('/papers/:slug/comments', commentLimiter);
 app.use('/papers/:slug/comments/:id/helpful', geoLimiter);
                                              // 60 votes/min/IP — high enough
                                              // to not block real browsing,

← f4d5c8f smoke.test.js: update /map assertion for address-driven near  ·  back to NationalPaperHangers  ·  Split geoLimiter — map JSON and helpful-votes get independen 71aa66d →