Session 7d: Audit fixes - rate limiting, error leak, parallel parlays, analyze cache, bundle analyzer

This commit is contained in:
Kev
2026-06-10 03:12:20 -04:00
parent d954e4d952
commit 6f4a353de9
18 changed files with 913 additions and 72 deletions
+76
View File
@@ -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
View File
@@ -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({
+7 -2
View File
@@ -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)
+11
View File
@@ -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)
+39 -27
View File
@@ -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