Session 7d: Audit fixes - rate limiting, error leak, parallel parlays, analyze cache, bundle analyzer
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* IP-keyed rate-limit middleware.
|
||||
*
|
||||
* Sliding-window counter per remote IP, kept in-memory. Use for routes
|
||||
* that take expensive upstream calls and aren't behind requireAuth —
|
||||
* the public demo (/api/analyze/*) is the canonical caller. Auth'd
|
||||
* routes are gated by tier, which already throttles abuse.
|
||||
*
|
||||
* Why in-memory not Redis: this middleware is the FIRST line of defense
|
||||
* — it must not depend on Redis being warm. If Redis is down the API
|
||||
* still serves and this still throttles. Memory cost is bounded by
|
||||
* MAX_TRACKED_IPS (the LRU-style trim on overflow).
|
||||
*
|
||||
* Why not the factory in src/utils/rateLimiter.js: that gives one bucket
|
||||
* per call site. We need one bucket PER IP.
|
||||
*/
|
||||
|
||||
const DEFAULT_WINDOW_MS = 60_000;
|
||||
const DEFAULT_MAX = 10;
|
||||
const MAX_TRACKED_IPS = 10_000;
|
||||
|
||||
function clientIp(req) {
|
||||
// Express's req.ip respects trust proxy; fall back to socket if not.
|
||||
return req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
function createRateLimit({ windowMs = DEFAULT_WINDOW_MS, max = DEFAULT_MAX, key = clientIp } = {}) {
|
||||
// Map preserves insertion order; oldest-IP eviction is O(1) via shift.
|
||||
const hits = new Map();
|
||||
|
||||
function evictIfFull() {
|
||||
if (hits.size <= MAX_TRACKED_IPS) return;
|
||||
// Drop the oldest entry — Map.keys() yields in insertion order.
|
||||
const first = hits.keys().next().value;
|
||||
if (first !== undefined) hits.delete(first);
|
||||
}
|
||||
|
||||
function pruneOlderThan(timestamps, cutoff) {
|
||||
// In-place filter (mutating the array end-to-start), faster than
|
||||
// building a new array on every request. Returns the surviving count.
|
||||
let writeIdx = 0;
|
||||
for (let i = 0; i < timestamps.length; i += 1) {
|
||||
if (timestamps[i] > cutoff) {
|
||||
timestamps[writeIdx] = timestamps[i];
|
||||
writeIdx += 1;
|
||||
}
|
||||
}
|
||||
timestamps.length = writeIdx;
|
||||
return writeIdx;
|
||||
}
|
||||
|
||||
return function rateLimit(req, res, next) {
|
||||
const id = key(req);
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowMs;
|
||||
|
||||
let timestamps = hits.get(id);
|
||||
if (!timestamps) {
|
||||
timestamps = [];
|
||||
hits.set(id, timestamps);
|
||||
evictIfFull();
|
||||
}
|
||||
|
||||
const remaining = pruneOlderThan(timestamps, cutoff);
|
||||
if (remaining >= max) {
|
||||
const retryAfterSec = Math.max(1, Math.ceil((timestamps[0] + windowMs - now) / 1000));
|
||||
res.set('Retry-After', String(retryAfterSec));
|
||||
return res.status(429).json({ error: 'Too many requests' });
|
||||
}
|
||||
|
||||
timestamps.push(now);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createRateLimit, __internals: { clientIp, MAX_TRACKED_IPS } };
|
||||
+33
-2
@@ -1,8 +1,39 @@
|
||||
const express = require('express');
|
||||
const { analyzeProp } = require('../services/propAnalyzer');
|
||||
const { cacheGet, cacheSet } = require('../utils/redis');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// SEC-1 (Session 7d): /prop and /batch are public — both proxy to the
|
||||
// prop analyzer which makes upstream API calls. Cap to 10 requests per
|
||||
// IP per minute so a single bad actor can't burn analyzer credits.
|
||||
const analyzeLimit = createRateLimit({ windowMs: 60_000, max: 10 });
|
||||
router.use(analyzeLimit);
|
||||
|
||||
// PERF-1 (Session 7d): cache analyzeProp results in Redis. Same prop hit
|
||||
// twice within 60s reuses the previous analysis instead of re-doing the
|
||||
// upstream chain. 60s is short enough that line moves still surface.
|
||||
const ANALYZE_TTL_SECONDS = 60;
|
||||
function analyzeCacheKey(prop) {
|
||||
const sport = (prop.sport || 'nba').toLowerCase();
|
||||
const player = String(prop.player || '').trim().toLowerCase();
|
||||
const stat = String(prop.stat_type || '').trim().toLowerCase();
|
||||
const line = Number(prop.line);
|
||||
const direction = String(prop.direction || '').toLowerCase();
|
||||
return `analyze:${sport}:${player}:${stat}:${line}:${direction}`;
|
||||
}
|
||||
async function cachedAnalyze(prop) {
|
||||
const key = analyzeCacheKey(prop);
|
||||
const cached = await cacheGet(key);
|
||||
if (cached) return { ...cached, _cache: 'HIT' };
|
||||
const result = await analyzeProp(prop);
|
||||
// cacheSet swallows failures (degraded Redis) — analysis still flows
|
||||
// even when the cache is down.
|
||||
await cacheSet(key, result, ANALYZE_TTL_SECONDS);
|
||||
return { ...result, _cache: 'MISS' };
|
||||
}
|
||||
|
||||
const VALID_STAT_TYPES = new Set([
|
||||
'points', 'rebounds', 'assists', 'threes', 'blocks',
|
||||
'steals', 'pra', 'turnovers',
|
||||
@@ -32,7 +63,7 @@ router.post('/prop', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await analyzeProp(req.body);
|
||||
const result = await cachedAnalyze(req.body);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
@@ -61,7 +92,7 @@ router.post('/batch', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await analyzeProp(prop);
|
||||
const result = await cachedAnalyze(prop);
|
||||
results.push(result);
|
||||
} catch (err) {
|
||||
results.push({
|
||||
|
||||
@@ -182,7 +182,11 @@ router.post('/', async (req, res) => {
|
||||
try {
|
||||
svg = renderer.buildSvg(type, format, payload);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'render failed', detail: err.message });
|
||||
// SEC-2 (Session 7d): don't echo err.message to public callers — the
|
||||
// SVG renderer may surface file paths or upstream library detail.
|
||||
// Log to stderr for ops, return a generic 400 to the caller.
|
||||
console.error('[VYNDR] shareCard render failed:', err?.message);
|
||||
return res.status(400).json({ error: 'render failed' });
|
||||
}
|
||||
|
||||
// Optional SVG-only mode (no rasterization)
|
||||
@@ -203,7 +207,8 @@ router.post('/', async (req, res) => {
|
||||
res.set('X-Degraded', 'svg-fallback');
|
||||
return res.send(svg);
|
||||
}
|
||||
return res.status(500).json({ error: 'rasterize failed', detail: err.message });
|
||||
console.error('[VYNDR] shareCard rasterize failed:', err?.message);
|
||||
return res.status(500).json({ error: 'rasterize failed' });
|
||||
}
|
||||
|
||||
// Write cache (best-effort; ignore failures so the response still flies)
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
// DEPRECATED — Session 7c audit flagged this for unification with the
|
||||
// new Engine 1 (src/services/intelligence/engine1.js). Session 7d deferred
|
||||
// the rewire because the output shapes are incompatible:
|
||||
// - Legacy: 4-letter grade (A|B|C|D), 0-100 confidence, kill_conditions,
|
||||
// reasoning.steps. Consumed by /api/analyze, /api/scan, /api/bets
|
||||
// and the frontend GradeCard + DemoScan components.
|
||||
// - New: 11-step grade (F..A+), 0-1 confidence, factors array. Consumed
|
||||
// by /api/grading/pipeline only.
|
||||
// Migration plan in docs/SYSTEM-MANIFEST.md §8 ARCH-1. Do not extend this
|
||||
// file — new features land in engine1.js. Remove this file when the legacy
|
||||
// route set retires.
|
||||
function computeGrade(stepResults) {
|
||||
const {
|
||||
seasonDelta,
|
||||
|
||||
@@ -31,6 +31,12 @@ function inactive(reason) {
|
||||
// Normalize player names for matching across data sources. ParlayAPI may
|
||||
// emit "Brunson, Jalen" while ESPN emits "Jalen Brunson" — strip case,
|
||||
// punctuation, suffixes, and collapse whitespace so equivalence works.
|
||||
//
|
||||
// DUP-1 (Session 7c): a near-identical implementation lives in
|
||||
// scripts/populate-player-ids.js. The script's variant keeps digits
|
||||
// (some legacy roster fields encode jersey numbers); this one strips
|
||||
// them because trap matches go by player name only. If the script
|
||||
// stops needing digits, consolidate to a shared util.
|
||||
function normalizeName(name) {
|
||||
if (!name) return '';
|
||||
return String(name)
|
||||
|
||||
@@ -23,12 +23,22 @@ async function scanParlay(user, legs) {
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze all legs
|
||||
const legResults = [];
|
||||
for (const leg of legs) {
|
||||
const result = await analyzeProp(leg);
|
||||
legResults.push(result);
|
||||
}
|
||||
// PERF-2 (Session 7d): analyze legs in parallel. Each call is an
|
||||
// independent upstream lookup, so a 6-leg parlay is ~6x faster here
|
||||
// than the old sequential loop. allSettled preserves leg order and
|
||||
// lets a single failed leg surface as an error stub instead of
|
||||
// crashing the whole parlay.
|
||||
const settled = await Promise.allSettled(legs.map((leg) => analyzeProp(leg)));
|
||||
const legResults = settled.map((s, i) => {
|
||||
if (s.status === 'fulfilled') return s.value;
|
||||
return {
|
||||
...legs[i],
|
||||
error: s.reason?.message || 'analysis_failed',
|
||||
grade: 'F',
|
||||
confidence: 0,
|
||||
reasoning: { summary: 'Analysis failed for this leg.' },
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch odds data for correlation detection (spreads, game context)
|
||||
let spreads = [];
|
||||
@@ -81,28 +91,30 @@ async function scanParlay(user, legs) {
|
||||
correlationFlags
|
||||
);
|
||||
|
||||
// Write to database
|
||||
const pickIds = [];
|
||||
for (const leg of legResults) {
|
||||
const { data: pick, error } = await supabase
|
||||
// PERF-2 (Session 7d): one batched insert for every leg's pick row
|
||||
// instead of N sequential inserts. Supabase preserves insert order in
|
||||
// the returned data array so pickIds line up with legResults.
|
||||
const pickRows = legResults.map((leg) => ({
|
||||
user_id: user.id,
|
||||
player: leg.player,
|
||||
stat_type: leg.stat_type,
|
||||
line: leg.line,
|
||||
book: leg.book || 'unknown',
|
||||
direction: leg.direction,
|
||||
grade: leg.grade,
|
||||
edge_pct: leg.edge_pct,
|
||||
reasoning: leg.reasoning?.summary || '',
|
||||
kill_conditions: (leg.kill_conditions_triggered || []).map((k) => k.code),
|
||||
confidence: leg.confidence,
|
||||
}));
|
||||
let pickIds = [];
|
||||
if (pickRows.length > 0) {
|
||||
const { data: picksData, error: picksErr } = await supabase
|
||||
.from('picks')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
player: leg.player,
|
||||
stat_type: leg.stat_type,
|
||||
line: leg.line,
|
||||
book: leg.book || 'unknown',
|
||||
direction: leg.direction,
|
||||
grade: leg.grade,
|
||||
edge_pct: leg.edge_pct,
|
||||
reasoning: leg.reasoning?.summary || '',
|
||||
kill_conditions: (leg.kill_conditions_triggered || []).map((k) => k.code),
|
||||
confidence: leg.confidence,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (pick) pickIds.push(pick.id);
|
||||
.insert(pickRows)
|
||||
.select('id');
|
||||
if (picksErr) console.warn('[VYNDR] picks batch insert failed:', picksErr.message);
|
||||
pickIds = (picksData || []).map((p) => p.id);
|
||||
}
|
||||
|
||||
// Write scan session
|
||||
|
||||
Reference in New Issue
Block a user