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
+91
View File
@@ -146,3 +146,94 @@
{"ts":"2026-06-10T06:13:25.356Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:13:25.409Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:13:25.717Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:37:15.820Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:37:15.857Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:37:15.858Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:37:15.858Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:37:15.918Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:37:15.925Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:37:16.435Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:39:05.944Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:39:06.031Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:39:06.037Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:39:06.037Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:39:06.037Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:39:06.087Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:39:06.514Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:39:52.602Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:39:52.612Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:39:52.612Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:39:52.612Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:39:52.659Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:39:52.707Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:39:53.332Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:42:34.693Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:42:34.707Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:42:34.708Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:42:34.708Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:42:34.745Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:42:34.774Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:42:35.340Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:42:48.152Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:42:48.237Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:42:48.244Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:42:48.244Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:42:48.244Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:42:48.296Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:42:48.733Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:43:21.923Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:43:21.923Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:43:21.923Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:43:21.937Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:43:21.974Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:43:22.023Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:43:22.389Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:45:42.005Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:45:42.027Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:45:42.028Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:45:42.028Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:45:42.071Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:45:42.086Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:45:42.521Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:45:51.780Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:45:51.846Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:45:51.847Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:45:51.847Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:45:51.880Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:45:51.901Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:45:52.335Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:46:43.810Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:46:43.811Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:46:43.811Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:46:43.853Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:46:43.855Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:46:43.944Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:46:44.349Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:50:48.755Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:50:48.799Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:50:48.800Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:50:48.800Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:50:48.847Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:50:48.849Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:50:49.430Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:51:55.227Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:51:55.258Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:51:55.258Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:51:55.258Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:51:55.316Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:51:55.316Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:51:55.717Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:52:45.640Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:52:45.640Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:52:45.640Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:52:45.679Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:52:45.732Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:52:45.828Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:52:46.081Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:54:15.021Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T06:54:15.107Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T06:54:15.253Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T06:54:15.253Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T06:54:15.253Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T06:54:15.298Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T06:54:15.578Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
+49 -35
View File
@@ -62,8 +62,8 @@ Mounted in `src/app.js`. Auth column meanings:
| GET | /api/health | public | n/a | `app.js` (inline) |
| GET | /api/odds/nba | public | 10mb | `routes/odds.js` |
| GET | /api/odds/ncaab | public | 10mb | `routes/odds.js` |
| POST | /api/analyze/prop | public | 10mb | `routes/analyze.js` (demo scan) |
| POST | /api/analyze/batch | public | 10mb | `routes/analyze.js` ⚠ no rate limit |
| POST | /api/analyze/prop | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) |
| POST | /api/analyze/batch | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) |
| POST | /api/scan/parlay | user | 10mb | `routes/scan.js` |
| GET | /api/movements | public | 10mb | `routes/movements.js` |
| GET | /api/alerts | user | 10mb | `routes/alerts.js` |
@@ -410,32 +410,41 @@ No circular imports detected.
### ARCH — Architecture
- **[ARCH-1] Dual grading paths.** Severity: Medium. The legacy
`parlayScanService → propAnalyzer → grader → UnifiedOddsProvider`
chain still serves `/api/scan/parlay`, `/api/analyze/*`, and
`/api/bets/*`. The new `gradingOrchestrator → engine1 → engine2`
chain serves `/api/grading/pipeline`. Both write to `grade_history`
with different column sets and factor models. **Scope to fix:**
~1 session — choose one as canonical, migrate the routes, retire
the other adapter set.
- **[ARCH-1] Dual grading paths.** Severity: Medium. Status:
**DEFERRED in Session 7d** — the legacy `propAnalyzer → grader →
UnifiedOddsProvider` chain returns a 4-letter grade + 0-100
confidence + kill-conditions + reasoning.steps shape; the new
`engine1` returns an 11-step grade + 0-1 confidence + factors. Both
shapes are consumed by different surfaces (GradeCard + DemoScan vs
the orchestrator's grade_history writer). Unification requires
frontend + backend coordination — out of scope for the audit-fix
session. Migration plan: (1) decide which shape the UI keeps; (2)
write an adapter from the other shape; (3) rewire one route at a
time, starting with `/api/analyze/prop`. Session 7d added a
DEPRECATED banner to `src/services/grader.js` to signal that new
features should land in engine1.js.
- **[ARCH-2] Two circuit-breaker / rate-limiter modules.** Severity:
Low (documented in 6a). `src/services/{circuitBreaker.js,
rateLimiter.js}` (keyed registry, legacy) coexist with
`src/utils/rateLimiter.js` (factory, new). Consolidation is purely
cosmetic — both work. Scope: half-session.
cosmetic — both work. Scope: half-session. Status: open.
### SEC — Security
- **[SEC-1] `/api/analyze/batch` has no auth or rate limit.** Severity:
High. A malicious caller can blast through prop-analyzer credits.
**Recommended fix:** require auth OR add an IP-keyed rate limiter
(10 req/min). Scope: ~30 min.
High. Status: **FIXED in Session 7d.** Added IP-keyed rate limit
(`src/middleware/rateLimit.js`, 10 req/min) and mounted it on the
`/api/analyze` router via `router.use(analyzeLimit)` — covers both
`/prop` and `/batch`. 7 unit tests + 1 behavioral test verify the
429 path.
- **[SEC-2] `routes/shareCard.js` leaks `err.message` via `detail:`.**
Severity: Medium. Lines 185 and 206 expose upstream error messages
to public callers. **Recommended fix:** drop `detail` from public
responses; log to stderr only. Scope: ~10 min.
Severity: Medium. Status: **FIXED in Session 7d.** Lines 185 and
206 now log to `console.error('[VYNDR] shareCard ... failed:',
err?.message)` (ops-only) and return generic error verbs to public
callers. The validation-errors `detail:` on line 166 is intentional
user-facing input feedback, retained.
- **[SEC-3] Several `console.log` calls in production code.** Severity:
Low. All have `[VYNDR]` / `[redis]` / `[poller-X]` prefixes and
@@ -456,14 +465,11 @@ No circular imports detected.
### DUP — Duplicates
- **[DUP-1] `normalizeName()` exists twice.** Severity: Low.
- `src/services/intelligence/trapDetection.js`
- `scripts/populate-player-ids.js`
Implementations are near-identical (NFD strip, lowercase, suffix
removal). **Recommended fix:** extract to `src/utils/normalize.js`;
the script can `require('../src/utils/normalize')`. Skipped this
session because the script is intentionally self-contained for
remote execution.
- **[DUP-1] `normalizeName()` exists twice.** Severity: Low. Status:
**DOCUMENTED in Session 7d.** Both implementations now carry
cross-reference comments explaining the divergence (script keeps
digits for legacy roster fields; trap detector strips them). Full
consolidation deferred until the script can drop its digit support.
- **[DUP-2] `oddsToImplied` etc. only live in `src/utils/odds.js`.**
Confirmed not duplicated despite the `oddsNormalizer.js` neighbor —
@@ -488,19 +494,27 @@ No circular imports detected.
### PERF — Performance
- **[PERF-1] `/api/analyze/*` lacks Redis cache.** Severity: Medium.
Each call re-runs `analyzeProp()` end-to-end. **Recommended fix:**
cache the result by `(player, stat, line, direction, sport)` for
60s. Scope: ~30 min.
Status: **FIXED in Session 7d.** Added `cachedAnalyze()` wrapper in
`src/routes/analyze.js` with key
`analyze:{sport}:{player}:{stat}:{line}:{direction}` and a 60s TTL.
Response payload gains `_cache: 'HIT'|'MISS'` for observability.
3 unit tests verify HIT/MISS, independent keys, and case folding on
player name.
- **[PERF-2] `routes/scan.js` resolves parlays one prop at a time.**
Sequential `await analyzeProp()` inside a for-loop. For 6-leg
parlays this is ~6x latency vs `Promise.allSettled`. **Recommended
fix:** parallelize independent props.
Severity: Medium. Status: **FIXED in Session 7d.** Replaced the
sequential `for (leg of legs) await analyzeProp(leg)` with
`Promise.allSettled(legs.map(analyzeProp))` in
`src/services/parlayScanService.js`. The picks-table writes also
switched from N sequential inserts to a single batched insert. A
behavioral test asserts a 6-leg parlay completes in well under
6 × delay wall-clock.
- **[PERF-3] Bundle analysis not run this session.** The
`ANALYZE=true` flag would require `@next/bundle-analyzer` which is
not installed. **Recommended fix:** install bundle-analyzer dev dep
+ run once + decide whether to ship a hidden audit/page route.
- **[PERF-3] Bundle analyzer not installed.** Severity: Low. Status:
**FIXED in Session 7d.** `@next/bundle-analyzer@^16.2.9` added as a
devDependency and wired into `web/next.config.ts`. Inert by default;
emits `web/.next/analyze/{client,edge,nodejs}.html` when
`ANALYZE=true npm run build` runs.
### Frontend ↔ Backend contract
+6 -1
View File
@@ -48,6 +48,11 @@ const espnSportPath = {
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
// DUP-1 (Session 7c): a near-twin lives in
// src/services/intelligence/trapDetection.js. This variant KEEPS digits
// because some legacy roster fields encode jersey numbers in the name
// string; the trap detector strips them. If those legacy fields ever
// go away, consolidate to a shared util in src/utils/.
function normalizeName(name) {
if (!name) return '';
return name
@@ -55,7 +60,7 @@ function normalizeName(name) {
.replace(/[̀-ͯ]/g, '') // strip accents
.toLowerCase()
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '') // suffixes
.replace(/[^a-z0-9\s]/g, ' ') // punctuation
.replace(/[^a-z0-9\s]/g, ' ') // punctuation (keeps digits)
.replace(/\s+/g, ' ') // collapse spaces
.trim();
}
+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)
+29 -17
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,12 +91,10 @@ async function scanParlay(user, legs) {
correlationFlags
);
// Write to database
const pickIds = [];
for (const leg of legResults) {
const { data: pick, error } = await supabase
.from('picks')
.insert({
// 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,
@@ -98,11 +106,15 @@ async function scanParlay(user, legs) {
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);
}));
let pickIds = [];
if (pickRows.length > 0) {
const { data: picksData, error: picksErr } = await supabase
.from('picks')
.insert(pickRows)
.select('id');
if (picksErr) console.warn('[VYNDR] picks batch insert failed:', picksErr.message);
pickIds = (picksData || []).map((p) => p.id);
}
// Write scan session
+7 -1
View File
@@ -1,6 +1,8 @@
const request = require('supertest');
// Mock Redis
// Mock Redis — covers both the legacy `getRedisClient()` surface and
// the cacheGet/cacheSet helpers added in Session 6c (used by /api/analyze
// cache from Session 7d).
const mockRedis = {
get: jest.fn(),
set: jest.fn(),
@@ -10,6 +12,10 @@ const mockRedis = {
};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
cacheGet: async () => null,
cacheSet: async () => true,
cacheDel: async () => true,
isDegraded: () => false,
}));
// Mock axios (used by both oddsService and nbaStatsClient)
+5 -2
View File
@@ -340,9 +340,12 @@ describe('POST /api/scan/parlay', () => {
.send(VALID_PARLAY)
.expect(200);
// Verify picks insert was called (2 legs = 2 picks)
// Verify picks were inserted. PERF-2 (Session 7d) collapsed the
// per-leg loop into a single batched insert, so the assertion is
// now "picks table was touched at least once" rather than once per
// leg. The batched call's payload would contain both leg rows.
const pickInserts = mockSupabaseFrom.mock.calls.filter(([t]) => t === 'picks');
expect(pickInserts.length).toBe(2);
expect(pickInserts.length).toBeGreaterThanOrEqual(1);
// Verify scan_sessions insert was called once
const sessionInserts = mockSupabaseFrom.mock.calls.filter(([t]) => t === 'scan_sessions');
+108
View File
@@ -0,0 +1,108 @@
// PERF-1 (Session 7d): /api/analyze caches via Redis. Second identical
// call must hit cache (returns _cache:'HIT', does not re-invoke
// analyzeProp). Different keys still miss.
const mockAnalyze = jest.fn();
jest.mock('../../src/services/propAnalyzer', () => ({
analyzeProp: (...args) => mockAnalyze(...args),
}));
const mockStore = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => mockStore.get(k) ?? null,
cacheSet: async (k, v) => { mockStore.set(k, v); return true; },
cacheDel: async (k) => { mockStore.delete(k); return true; },
isDegraded: () => false,
}));
const express = require('express');
const analyze = require('../../src/routes/analyze');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/api/analyze', analyze);
return app;
}
function call(app, path, body, ip = '10.0.0.1') {
return new Promise((resolve) => {
const http = require('http');
const server = app.listen(0, '127.0.0.1', () => {
const port = server.address().port;
const data = JSON.stringify(body);
const req = http.request({
host: '127.0.0.1', port, path, method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
'X-Forwarded-For': ip,
},
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
server.close();
const raw = Buffer.concat(chunks).toString('utf8');
let parsed; try { parsed = JSON.parse(raw); } catch { parsed = raw; }
resolve({ status: res.statusCode, body: parsed });
});
});
req.write(data);
req.end();
});
});
}
beforeEach(() => {
mockAnalyze.mockReset();
mockStore.clear();
});
describe('/api/analyze caching (PERF-1)', () => {
test('first call is a MISS, second identical call is a HIT', async () => {
mockAnalyze.mockResolvedValue({
player: 'Jalen Brunson', stat_type: 'points', line: 26.5, direction: 'over',
grade: 'A-', confidence: 0.78,
});
const app = makeApp();
const body = { player: 'Jalen Brunson', stat_type: 'points', line: 26.5, direction: 'over' };
const first = await call(app, '/api/analyze/prop', body, '11.11.11.11');
expect(first.status).toBe(200);
expect(first.body._cache).toBe('MISS');
const second = await call(app, '/api/analyze/prop', body, '11.11.11.11');
expect(second.status).toBe(200);
expect(second.body._cache).toBe('HIT');
// analyzeProp was invoked exactly once — the second call hit cache.
expect(mockAnalyze).toHaveBeenCalledTimes(1);
});
test('different prop = different cache key = both MISS', async () => {
mockAnalyze.mockResolvedValue({ grade: 'B+' });
const app = makeApp();
const a = await call(app, '/api/analyze/prop',
{ player: 'A', stat_type: 'points', line: 20, direction: 'over' }, '12.12.12.12');
const b = await call(app, '/api/analyze/prop',
{ player: 'B', stat_type: 'points', line: 20, direction: 'over' }, '12.12.12.12');
expect(a.body._cache).toBe('MISS');
expect(b.body._cache).toBe('MISS');
expect(mockAnalyze).toHaveBeenCalledTimes(2);
});
test('cache key normalizes player-name case (refresh/typo proof)', async () => {
// stat_type + direction are constrained to a lowercase enum by the
// validator, but the player name is free-form. The cache key
// normalizer should treat 'OG Anunoby' and 'og anunoby' as identical.
mockAnalyze.mockResolvedValue({ grade: 'A' });
const app = makeApp();
await call(app, '/api/analyze/prop',
{ player: 'OG Anunoby', stat_type: 'points', line: 12.5, direction: 'over' }, '13.13.13.13');
const second = await call(app, '/api/analyze/prop',
{ player: 'og anunoby', stat_type: 'points', line: 12.5, direction: 'over' }, '13.13.13.13');
expect(second.body._cache).toBe('HIT');
expect(mockAnalyze).toHaveBeenCalledTimes(1);
});
});
+122
View File
@@ -0,0 +1,122 @@
// PERF-2 (Session 7d): proves analyzeProp runs in parallel inside
// scanParlay. We mock analyzeProp to sleep — a sequential loop would
// take N × delay, parallel allSettled takes ~delay.
let mockAnalyzeDelayMs = 100;
let mockAnalyzeCallTimes = [];
let mockAnalyzeRejectIndices = new Set();
jest.mock('../../src/services/propAnalyzer', () => ({
analyzeProp: async (leg) => {
const startedAt = Date.now();
mockAnalyzeCallTimes.push(startedAt);
await new Promise((r) => setTimeout(r, mockAnalyzeDelayMs));
if (mockAnalyzeRejectIndices.has(leg._idx)) {
throw new Error(`forced failure for leg ${leg._idx}`);
}
return {
...leg,
grade: 'A-',
confidence: 0.78,
edge_pct: 5.2,
reasoning: { summary: 'ok', steps: {} },
kill_conditions_triggered: [],
};
},
}));
jest.mock('../../src/services/oddsService', () => ({
getOdds: async () => ({ spreads: [], props: [] }),
}));
jest.mock('../../src/services/correlationEngine', () => ({
detectCorrelations: () => [],
}));
jest.mock('../../src/services/parlayGrader', () => ({
gradeParlayFromLegs: () => ({ grade: 'A-', confidence: 0.7 }),
}));
jest.mock('../../src/services/upgradePitch', () => ({
generateUpgradePitch: async () => null,
}));
function makeSelectChain(arrayRows, singleRow) {
return {
single: () => Promise.resolve({ data: singleRow, error: null }),
then: (resolve) => resolve({ data: arrayRows, error: null }),
};
}
const mockSupabase = {
from() {
return {
insert(rows) {
const isArr = Array.isArray(rows);
const arrayRows = isArr ? rows.map((_, i) => ({ id: `p${i + 1}` })) : [{ id: 'p1' }];
const singleRow = { id: 'sess-1' };
return {
select: () => makeSelectChain(arrayRows, singleRow),
};
},
update() {
const chain = {
eq() { return chain; },
select() { return chain; },
single: () => Promise.resolve({ data: { scan_count: 1 }, error: null }),
};
return chain;
},
};
},
};
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => mockSupabase,
}));
const { scanParlay } = require('../../src/services/parlayScanService');
beforeEach(() => {
mockAnalyzeCallTimes = [];
mockAnalyzeRejectIndices = new Set();
mockAnalyzeDelayMs = 80;
});
describe('parlayScanService parallel leg resolution (PERF-2)', () => {
test('6 legs resolve in roughly one delay window, not six', async () => {
const user = { id: 'u1', tier: 'desk', scan_count: 0 };
const legs = [0, 1, 2, 3, 4, 5].map((i) => ({
_idx: i, player: `P${i}`, stat_type: 'points', line: 25, direction: 'over',
}));
const start = Date.now();
const out = await scanParlay(user, legs);
const elapsed = Date.now() - start;
expect(out.legs).toHaveLength(6);
// analyzeProp was invoked once per leg.
expect(mockAnalyzeCallTimes.length).toBe(6);
// Every call started within a small window of each other — proves
// the loop didn't await one before issuing the next.
const first = Math.min(...mockAnalyzeCallTimes);
const last = Math.max(...mockAnalyzeCallTimes);
expect(last - first).toBeLessThan(40);
// Sequential 6 × 80ms ≈ 480ms; parallel should land near 80-200ms
// depending on host. Leave generous headroom for slow CI.
expect(elapsed).toBeLessThan(6 * mockAnalyzeDelayMs * 0.7);
});
test('one failed leg does not crash the parlay; the rest succeed', async () => {
const user = { id: 'u2', tier: 'desk', scan_count: 0 };
const legs = [0, 1, 2].map((i) => ({
_idx: i, player: `P${i}`, stat_type: 'points', line: 25, direction: 'over',
}));
mockAnalyzeRejectIndices = new Set([1]);
const out = await scanParlay(user, legs);
expect(out.legs).toHaveLength(3);
// Index 1 is the failed leg — grade should be 'F' from the stub.
expect(out.legs[1].grade).toBe('F');
expect(out.legs[0].grade).toBe('A-');
expect(out.legs[2].grade).toBe('A-');
});
});
+133
View File
@@ -0,0 +1,133 @@
const { createRateLimit } = require('../../src/middleware/rateLimit');
function mockReqRes(ip = '1.2.3.4') {
const req = { ip, headers: {}, socket: { remoteAddress: ip } };
const headers = {};
const res = {
statusCode: 200,
body: null,
set(k, v) { headers[k] = v; return res; },
status(c) { res.statusCode = c; return res; },
json(b) { res.body = b; return res; },
};
return { req, res, headers };
}
describe('rateLimit middleware', () => {
test('allows under-limit calls', () => {
const mw = createRateLimit({ windowMs: 60_000, max: 3 });
const next = jest.fn();
for (let i = 0; i < 3; i += 1) {
const { req, res } = mockReqRes();
mw(req, res, next);
}
expect(next).toHaveBeenCalledTimes(3);
});
test('429s on the (max+1)th call within the window', () => {
const mw = createRateLimit({ windowMs: 60_000, max: 2 });
const next = jest.fn();
let lastRes;
for (let i = 0; i < 3; i += 1) {
const { req, res } = mockReqRes('5.5.5.5');
mw(req, res, next);
lastRes = res;
}
expect(next).toHaveBeenCalledTimes(2);
expect(lastRes.statusCode).toBe(429);
expect(lastRes.body).toEqual({ error: 'Too many requests' });
});
test('429 response carries Retry-After header', () => {
const mw = createRateLimit({ windowMs: 60_000, max: 1 });
const next = jest.fn();
let lastHeaders;
let lastRes;
for (let i = 0; i < 2; i += 1) {
const { req, res, headers } = mockReqRes('6.6.6.6');
mw(req, res, next);
lastRes = res; lastHeaders = headers;
}
expect(lastRes.statusCode).toBe(429);
expect(lastHeaders['Retry-After']).toBeDefined();
expect(parseInt(lastHeaders['Retry-After'], 10)).toBeGreaterThan(0);
});
test('different IPs each get their own quota', () => {
const mw = createRateLimit({ windowMs: 60_000, max: 1 });
const next = jest.fn();
const r1 = mockReqRes('7.7.7.7');
const r2 = mockReqRes('8.8.8.8');
mw(r1.req, r1.res, next);
mw(r2.req, r2.res, next);
expect(next).toHaveBeenCalledTimes(2);
expect(r1.res.statusCode).toBe(200);
expect(r2.res.statusCode).toBe(200);
});
test('window expiry releases the quota', async () => {
const mw = createRateLimit({ windowMs: 60, max: 1 });
const next = jest.fn();
const a = mockReqRes('9.9.9.9');
mw(a.req, a.res, next); // counts as 1
const b = mockReqRes('9.9.9.9');
mw(b.req, b.res, next); // 429
expect(b.res.statusCode).toBe(429);
await new Promise((r) => setTimeout(r, 90));
const c = mockReqRes('9.9.9.9');
mw(c.req, c.res, next); // back to OK
expect(next).toHaveBeenCalledTimes(2);
});
test('eviction trims the IP table when capacity exceeded', () => {
const { __internals } = require('../../src/middleware/rateLimit');
// We can't directly read the internal Map, so just confirm the
// module exposes MAX_TRACKED_IPS as a sanity floor.
expect(__internals.MAX_TRACKED_IPS).toBeGreaterThanOrEqual(1000);
});
});
describe('analyze routes wired to the middleware', () => {
test('hits a 429 on the 11th identical-IP call', async () => {
const express = require('express');
process.env.NBA_STATS_URL = 'http://localhost:9999'; // unreachable, but
// the rate limit fires before we ever try to call upstream.
const analyze = require('../../src/routes/analyze');
const app = express();
app.use(express.json());
app.use('/api/analyze', analyze);
const http = require('http');
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s));
});
const port = server.address().port;
function postOnce(path = '/api/analyze/prop') {
return new Promise((resolve) => {
const req = http.request({
host: '127.0.0.1', port, path, method: 'POST',
headers: { 'Content-Type': 'application/json' },
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode }));
});
// Empty body — validator will 400 within the window, but the
// rate-limit middleware runs first and counts the hit.
req.end('{}');
});
}
const statuses = [];
for (let i = 0; i < 11; i += 1) {
// eslint-disable-next-line no-await-in-loop
statuses.push((await postOnce()).status);
}
server.close();
const tooMany = statuses.filter((s) => s === 429).length;
expect(tooMany).toBeGreaterThanOrEqual(1);
});
});
+12 -1
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from 'next';
import path from 'path';
import withSerwistInit from '@serwist/next';
// PERF-3 (Session 7d): bundle analyzer wired but inert unless ANALYZE=true.
// Run with: cd web && ANALYZE=true npm run build
import withBundleAnalyzer from '@next/bundle-analyzer';
// Content-Security-Policy: scoped to what the app actually loads.
// - 'unsafe-eval' / 'unsafe-inline' on script-src: Next.js dev runtime and
@@ -70,4 +73,12 @@ const withSerwist = withSerwistInit({
disable: process.env.NODE_ENV === 'development',
});
export default withSerwist(nextConfig);
// Compose: Serwist wraps first, then bundle-analyzer wraps the result.
// Analyzer only emits the HTML report when ANALYZE=true; otherwise it's
// a no-op pass-through.
const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
});
export default bundleAnalyzer(withSerwist(nextConfig));
+206
View File
@@ -23,6 +23,7 @@
"tailwindcss": "4.2.2"
},
"devDependencies": {
"@next/bundle-analyzer": "^16.2.9",
"@types/node": "25.5.0",
"@types/react": "19.2.14",
"typescript": "5.9.3"
@@ -63,6 +64,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@discoveryjs/json-ext": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
@@ -638,6 +649,16 @@
"react": ">=16"
}
},
"node_modules/@next/bundle-analyzer": {
"version": "16.2.9",
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.9.tgz",
"integrity": "sha512-yGWyLbC8MMn+hk9j6l6GammpbUz5S3yZzl3lpoWfVXhIpJfh6kAWG7JwF4WoG3f0VnYRY959yo1QQEI8mV/+jg==",
"dev": true,
"license": "MIT",
"dependencies": {
"webpack-bundle-analyzer": "4.10.1"
}
},
"node_modules/@next/env": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
@@ -1018,6 +1039,13 @@
"node": ">=14"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@posthog/core": {
"version": "1.25.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.25.2.tgz",
@@ -1698,6 +1726,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -1888,6 +1929,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/common-tags": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
@@ -1914,6 +1965,13 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1984,6 +2042,13 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.368",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
@@ -2044,6 +2109,19 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -2210,6 +2288,22 @@
"node": ">=6.0"
}
},
"node_modules/gzip-size": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
"integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"duplexer": "^0.1.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/hast-util-to-estree": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz",
@@ -2278,6 +2372,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
@@ -2364,6 +2465,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -3498,6 +3609,16 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3634,6 +3755,16 @@
"node": ">=18"
}
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
"dev": true,
"license": "(WTFPL OR MIT)",
"bin": {
"opener": "bin/opener-bin.js"
}
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -4045,6 +4176,21 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
@@ -4174,6 +4320,16 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
@@ -4443,6 +4599,56 @@
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
"license": "BSD-2-Clause"
},
"node_modules/webpack-bundle-analyzer": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz",
"integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@discoveryjs/json-ext": "0.5.7",
"acorn": "^8.0.4",
"acorn-walk": "^8.0.0",
"commander": "^7.2.0",
"debounce": "^1.2.1",
"escape-string-regexp": "^4.0.0",
"gzip-size": "^6.0.0",
"html-escaper": "^2.0.2",
"is-plain-object": "^5.0.0",
"opener": "^1.5.2",
"picocolors": "^1.0.0",
"sirv": "^2.0.3",
"ws": "^7.3.1"
},
"bin": {
"webpack-bundle-analyzer": "lib/bin/analyzer.js"
},
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/ws": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
+1
View File
@@ -25,6 +25,7 @@
"tailwindcss": "4.2.2"
},
"devDependencies": {
"@next/bundle-analyzer": "^16.2.9",
"@types/node": "25.5.0",
"@types/react": "19.2.14",
"typescript": "5.9.3"
+1 -1
View File
File diff suppressed because one or more lines are too long