Session 7d: Audit fixes - rate limiting, error leak, parallel parlays, analyze cache, bundle analyzer
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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-');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Generated
+206
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user