Session 7h: Stripe products, tier config, scan limits, response gating, free tier

This commit is contained in:
Kev
2026-06-10 13:24:11 -04:00
parent 4e18eb1efe
commit d4e5e76452
16 changed files with 750 additions and 6 deletions
+99
View File
@@ -0,0 +1,99 @@
/**
* Per-tier daily scan limit middleware.
*
* Reads `req.user.tier` (set by requireAuth) and counts scans in a
* rolling 24h window. Anonymous callers fall through to a 'free'
* bucket keyed by IP so the open demo can't be DOS'd via shell.
*
* The middleware is separate from the IP rate limiter (SEC-1, Session
* 7d) on /api/analyze: that one caps RAW request rate (10/min) per IP
* regardless of who's authenticated. This one caps PAID-OPERATION
* counts per user per day per tier.
*
* Counts live in-memory (Map<userOrIpKey, Array<timestamp>>) — same
* trade-off as rateLimit.js. Survives a Coolify redeploy by resetting
* to zero, which is the user-friendly fallback.
*/
const { getScanLimit } = require('../config/tiers');
const WINDOW_MS = 24 * 60 * 60 * 1000;
const MAX_TRACKED = 50_000;
const hits = new Map();
function clientKey(req) {
if (req.user?.id) return `u:${req.user.id}`;
return `ip:${req.ip || req.socket?.remoteAddress || 'unknown'}`;
}
function evictIfFull() {
if (hits.size <= MAX_TRACKED) return;
const oldest = hits.keys().next().value;
if (oldest !== undefined) hits.delete(oldest);
}
function pruneOlderThan(arr, cutoff) {
let w = 0;
for (let i = 0; i < arr.length; i += 1) {
if (arr[i] > cutoff) {
arr[w] = arr[i];
w += 1;
}
}
arr.length = w;
return w;
}
function scanLimit() {
return function scanLimitMiddleware(req, res, next) {
const tier = req.user?.tier || 'free';
const limit = getScanLimit(tier);
// Infinity → never block; skip the bookkeeping entirely so the
// hot Desk path doesn't allocate Map entries it'll never read.
if (limit === Infinity) return next();
const key = clientKey(req);
const now = Date.now();
const cutoff = now - WINDOW_MS;
let ts = hits.get(key);
if (!ts) {
ts = [];
hits.set(key, ts);
evictIfFull();
}
const used = pruneOlderThan(ts, cutoff);
if (used >= limit) {
const oldest = ts[0];
const retryAfterSec = Math.max(1, Math.ceil((oldest + WINDOW_MS - now) / 1000));
res.set('Retry-After', String(retryAfterSec));
res.set('X-Scans-Used', String(used));
res.set('X-Scans-Limit', String(limit));
return res.status(429).json({
error: 'Daily scan limit reached. Upgrade for more scans.',
scans_used: used,
scans_limit: limit,
retry_after_seconds: retryAfterSec,
tier,
});
}
ts.push(now);
res.set('X-Scans-Used', String(used + 1));
res.set('X-Scans-Limit', String(limit));
return next();
};
}
// Test helper — drop all tracked counts. Not exported in module.exports
// to keep prod surface clean; reachable via the __internals bag.
function resetForTests() {
hits.clear();
}
module.exports = {
scanLimit,
__internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED },
};