f110bd63f1
5.1 Archetype defined on-page: one line from the archetype library under
ARCHETYPE DNA (BOMBER — elite raw power…); expander keeps the long form.
5.2 VYNDR-on-team live: getModelAggregate team scope (migration-020 column)
+ /api/ledger/model?team= + ModelRecord mounted on the Team Hub header.
5.3 WNBA/NBA profile parity: minutes-based usage (+MIN season cell) when
the feed carries minutes — absent beats invented.
5.4 Settings read meter: real rolling-24h usage from the SAME store the
limiter enforces (GET /api/user/scan-meter + proxy). Metered tiers see
'X of N reads today' + bar; unlimited tiers see nothing. Corrects the
stale '5 scans / month' copy.
5.5 Per-tier calibration on the MODEL tab: A+/A/B/C chips with hit% at
n>=20 PER TIER, 'building (n/20)' below — the separation between tiers
is the proof the grades mean something.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
116 lines
3.5 KiB
JavaScript
116 lines
3.5 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* Session 60 (night2/F, 5.4) — read-only usage for the Settings meter.
|
|
* Reports the SAME rolling-24h window the limiter enforces, from the same
|
|
* store — the meter can never disagree with the gate. Infinity limit →
|
|
* { unlimited: true }.
|
|
*/
|
|
function scanUsage(req) {
|
|
const tier = req.user?.tier || 'free';
|
|
const limit = getScanLimit(tier);
|
|
if (limit === Infinity) return { tier, unlimited: true, used: null, limit: null };
|
|
const ts = hits.get(clientKey(req));
|
|
const used = ts ? pruneOlderThan(ts, Date.now() - WINDOW_MS) : 0;
|
|
return { tier, unlimited: false, used, limit, remaining: Math.max(0, limit - used) };
|
|
}
|
|
|
|
module.exports = {
|
|
scanLimit,
|
|
scanUsage,
|
|
__internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED },
|
|
};
|