All files / src/safety gate-rate-limit.js

100% Statements 21/21
81.81% Branches 9/11
100% Functions 3/3
100% Lines 19/19

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59                                41x 41x     41x 41x     1157x 10x 10x 1x                         1157x 1157x 1157x 1157x 45x 45x   1112x 1112x         13x 13x     41x  
'use strict';
 
/**
 * SHY-0060 — per-user rate limit on the age-gate check (AC86).
 *
 * Caps gate evaluations at MAX_PER_WINDOW per user per WINDOW_MS to blunt
 * threshold-enumeration attacks (scripting the gate across features to map each
 * threshold). Keyed by userId — NOT IP — so it is immune to the shared-`::1`
 * loopback problem that forces the express-rate-limit middleware to skip
 * non-prod, and is therefore active (and testable) in every environment.
 *
 * Pure in-memory sliding window with an injected clock (no real timers), so the
 * whole thing is unit-testable. A lazy sweep purges expired buckets so the map
 * stays bounded by the count of users active within one window.
 */
 
const WINDOW_MS = 60 * 1000;
const MAX_PER_WINDOW = 100;
 
// userId -> { count, windowStart }
const buckets = new Map();
let lastSweepMs = 0;
 
function sweepExpired(nowMs) {
  if (nowMs - lastSweepMs < WINDOW_MS) return;
  lastSweepMs = nowMs;
  for (const [key, bucket] of buckets) {
    Eif (nowMs - bucket.windowStart >= WINDOW_MS) buckets.delete(key);
  }
}
 
/**
 * Record one gate check for a user and report whether it is within budget.
 *
 * @param {number|string} userId
 * @param {number} [nowMs=Date.now()]
 * @returns {{ allowed: boolean, count: number }} `count` keeps climbing past the
 *   limit so the caller can alert exactly once (on `count === MAX + 1`).
 */
function recordGateCheck(userId, nowMs = Date.now()) {
  sweepExpired(nowMs);
  const key = String(userId ?? '');
  const bucket = buckets.get(key);
  if (!bucket || nowMs - bucket.windowStart >= WINDOW_MS) {
    buckets.set(key, { count: 1, windowStart: nowMs });
    return { allowed: true, count: 1 };
  }
  bucket.count += 1;
  return { allowed: bucket.count <= MAX_PER_WINDOW, count: bucket.count };
}
 
/** Test hook — clear all buckets + the sweep clock. */
function __resetGateRateLimit() {
  buckets.clear();
  lastSweepMs = 0;
}
 
module.exports = { recordGateCheck, __resetGateRateLimit, MAX_PER_WINDOW, WINDOW_MS };