44 KiB
VYNDR — System Manifest
Source of truth for every interface in the codebase as of Session 7c (958 tests across 73 suites, zero regressions). When this drifts from reality, update this file FIRST.
1. Architecture overview
┌──────────────────┐
│ Next.js (3000) │ ─── public web app + app/api/* proxies
└────────┬─────────┘
│ BACKEND_URL
▼
n8n cron ── POST ─► ┌──────────────────┐ ─► Supabase (RLS-enforced reads,
PM2 pollers ──────► │ Express (3001) │ service-role writes from API)
Coolify healthcheck │ │ ─► Redis (cache + locks)
└────────┬─────────┘
│
▼
┌───────────────────────────────────┐
│ External adapters (configured()- │
│ gated, rate-limited, breakered) │
│ SharpAPI · OddsPapi · PropOdds · │
│ ParlayAPI · CFBD · OpenRouter · │
│ ESPN · MLB Stats API · Pinnacle │
└───────────────────────────────────┘
Unified grading path (closed by Session 7g):
routes/scan/parlayandroutes/analyze/{prop,batch}→analyzeViaEngine1(computeFeatures→engine1→gradeAdapter).routes/grading/pipeline(scheduled) →gradingOrchestrator→featureCache+trapDetection+consistencyScore+probabilityEstimator→engine1→ (A/B-tier only)engine2.
The book-adapter chain (UnifiedOddsProvider + 9 legacy adapters +
services/{rateLimiter,circuitBreaker}.js) remains live but only for
the DATA REFRESH path at /api/pipeline/refresh — a separate concern
from grading. ARCH-1 retired the legacy GRADING path (propAnalyzer,
grader.js) in Session 7g.
2. Express API endpoints
Mounted in src/app.js. Auth column meanings:
- public — no auth middleware
- user —
requireAuth(Supabase JWT bearer) - internal —
requireInternal(X-VYNDR-Internal-Key + loopback IP) - pipeline —
requirePipelineSecret(X-Pipeline-Secret) - stripe-sig —
stripe.webhooks.constructEventsignature
| Method | Path | Auth | Body limit | Handler |
|---|---|---|---|---|
| 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 |
| GET | /api/odds/wnba | public | 10mb | routes/odds.js (Session 14) |
| GET | /api/odds/mlb | public | 10mb | routes/odds.js (Session 14) |
| GET | /api/odds/soccer/:league | public | 10mb | routes/odds.js (Session 7j) |
| 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 |
| PATCH | /api/alerts/:id/read | user | 10mb | routes/alerts.js |
| POST | /api/bets/quickslip | user | 10mb | routes/bets.js |
| POST | /api/bets/screenshot | user | 10mb | routes/bets.js |
| POST | /api/bets/screenshot/confirm | user | 10mb | routes/bets.js |
| POST | /api/bets/sync | user | 10mb | routes/bets.js |
| PATCH | /api/bets/:id/settle | user | 10mb | routes/bets.js |
| GET | /api/bets | user | 10mb | routes/bets.js |
| GET | /api/bets/performance | user | 10mb | routes/bets.js |
| POST | /api/stripe/checkout | user | 10mb | routes/stripe.js |
| POST | /api/stripe/webhook | stripe-sig | raw bytes | routes/stripe.js |
| POST | /api/stripe/portal | user | 10mb | routes/stripe.js |
| GET | /api/stripe/status | user | 10mb | routes/stripe.js |
| GET | /api/stats/parlays-graded | public | 10mb | routes/stats.js |
| GET | /api/stats/public | public | 10mb | routes/stats.js |
| GET | /api/stats/live | public | 10mb | routes/stats.js |
| GET | /api/props/joint-history | user | 10mb | routes/props.js |
| POST | /api/waitlist | public | 10mb | routes/waitlist.js |
| POST | /api/pipeline/refresh | pipeline | 10mb | routes/pipeline.js |
| GET | /api/pipeline/status | public | 10mb | routes/pipeline.js |
| POST | /api/share-card | public | 10mb | routes/shareCard.js |
| POST | /api/push/subscribe | user | 10mb | routes/push.js |
| DELETE | /api/push/unsubscribe | user | 10mb | routes/push.js |
| POST | /api/grading/resolve | internal | 10mb | routes/grading.js |
| POST | /api/grading/pipeline | internal | 10mb | routes/grading.js (Session 10: off-host n8n callers; empty body iterates active sports) |
| POST | /api/grading/correct | internal | 256kb | routes/corrections.js |
| GET | /api/widget | public | 10mb | routes/widget.js |
| OPTIONS | /api/widget | public | n/a | routes/widget.js |
Next.js API routes (frontend app/api/*)
These are proxies or thin wrappers; they hit Express via BACKEND_URL
or the Python service via NEXT_PUBLIC_NBA_SERVICE_URL.
/api/checkout(POST/GET) — Stripe checkout proxy (Session 8 cutover — was NexaPay)/api/games/[id]and/api/games/tonight— list / detail/api/games/[id]/props— props for a game/api/intelligence/feed— homepage live signals/api/ledger,/api/ledger/accuracy— Ledger feed/api/odds/soccer/[league]— soccer odds proxy → Express/api/odds/soccer/:league(Session 8)/api/odds/nba— NBA odds proxy → Express/api/odds/nba(Session 14)/api/odds/wnba— WNBA odds proxy → Express/api/odds/wnba(Session 14)/api/odds/mlb— MLB odds proxy → Express/api/odds/mlb(Session 14)/api/parlay/add-leg,/api/parlay/grade— proxy to/api/scan/parlay/api/players/search— proxy to Python/players/search/api/props/live,/api/props/most-parlayed,/api/props/top-graded/api/scan— bare scan endpoint (Session 8 — acceptsSoccersport in addition to NBA/MLB/WNBA)/api/stats/parlays-graded,/api/stats/public— proxy/api/user/profile,/api/user/scans,/api/user/recent-scans/api/waitlist— proxy/api/webhook/nexapay— NexaPay webhook (legacy — Stripe cutover Session 8; webhook still listening for any in-flight NexaPay events)
Next.js pages (Session 8 additions)
/soccer— live soccer odds feed + inline prop grading. HostsSportSelector+ per-league match cards, scans selected props through/api/scan→ Express/api/analyze/propwithsport: 'Soccer'. Results render inSoccerGradeResult(parses the engine's reasoning summary into visual signal chips: ⚽ goals/90, 📊 xG, 🏔️ altitude, 🟨 referee, 🎯 penalty taker, 🏆 WC pedigree). Free tier gets a blurred preview + upgrade CTA./upgrade/success— Stripe checkout success landing. Readssession_idquery param, refreshes the AuthContext so the new tier flips immediately. Stripe webhook is the source of truth; this page does not verify the session against Stripe (no secret key on the client)./upgrade/cancel— Stripe checkout cancel landing. No judgment, links back to/#pricingand/scan.
3. Environment variables
Source: grep -rn 'process\.env\.' src/ poller/ scripts/ web/src/.
✓ = documented in .env.example. ⚠ = used but optional (default falls
back). Updated this session in Section 1 of Session 7c.
Core
| Var | Required | Default | Used By | Doc? |
|---|---|---|---|---|
NODE_ENV |
no | (none) | various | ✓ |
PORT |
no | 3001 |
src/server.js |
implicit |
BASE_URL |
yes | http://localhost:3001 |
stripeService, widget |
✓ |
FRONTEND_ORIGINS |
yes | (none) | app.js (CORS) |
✓ |
VYNDR_INTERNAL_KEY |
yes | (none) | middleware/internalAuth (S10), routes/grading, routes/corrections |
✓ |
SENTRY_DSN |
no | (none) | utils/sentry (S10) — graceful skip when unset |
✓ S10 |
NEXT_PUBLIC_SENTRY_DSN |
no | (none) | web/components/SentryInit (S10) — same DSN, browser-side |
✓ S10 |
Supabase
| Var | Required | Used By | Doc? |
|---|---|---|---|
SUPABASE_URL |
yes | both clients | ✓ |
SUPABASE_ANON_KEY |
yes | getSupabaseClient |
✓ |
SUPABASE_SERVICE_ROLE_KEY |
yes | getSupabaseServiceClient |
✓ |
SUPABASE_SERVICE_KEY (legacy) |
no | fallback only (7b) | ✓ commented |
SUPABASE_DB_URL |
yes | scripts/backup.sh, migrations |
✓ |
SUPABASE_DB_PASSWORD |
no | legacy backup form | ✓ commented |
NEXT_PUBLIC_SUPABASE_URL |
yes | Next.js client | ✓ |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
yes | Next.js client | ✓ |
NEXT_PUBLIC_SITE_URL |
yes | Next.js metadata | ✓ |
Web ↔ Backend
| Var | Required | Default | Used By | Doc? |
|---|---|---|---|---|
NEXT_PUBLIC_API_URL |
yes | http://localhost:3001 |
web/src/lib/api.ts |
✓ (added 7c) |
NEXT_PUBLIC_NBA_SERVICE_URL |
yes | http://localhost:8000 |
web/api/players/search |
✓ (added 7c) |
BACKEND_URL |
yes | http://localhost:3001 |
web/services/odds-cache.ts |
✓ (added 7c) |
Stripe + Payments
| Var | Doc? |
|---|---|
STRIPE_SECRET_KEY |
✓ |
STRIPE_WEBHOOK_SECRET |
✓ |
STRIPE_PRICE_ANALYST |
✓ |
STRIPE_PRICE_ANALYST_FOUNDER |
✓ |
STRIPE_PRICE_DESK |
✓ |
STRIPE_PRICE_DESK_FOUNDER |
✓ |
FOUNDER_CODES |
✓ |
FOUNDER_CODE_EXPIRY |
✓ |
NEXAPAY_API_URL |
✓ (added 7c) |
NEXAPAY_API_KEY |
✓ (added 7c) |
NEXAPAY_WEBHOOK_SECRET |
✓ (added 7c) |
Push
| Var | Doc? |
|---|---|
VAPID_PUBLIC_KEY |
✓ |
VAPID_PRIVATE_KEY |
✓ |
VAPID_SUBJECT |
✓ |
NEXT_PUBLIC_VAPID_PUBLIC_KEY |
✓ |
Odds adapters
| Var | Doc? |
|---|---|
SHARPAPI_KEY |
✓ |
SHARPAPI_BASE_URL |
✓ |
ODDSPAPI_KEY |
✓ |
ODDSPAPI_BASE_URL |
✓ |
PARLAYAPI_KEY |
✓ |
PARLAYAPI_BASE_URL |
✓ |
PARLAYAPI_PULL_RATE_MS |
✓ |
PROPODDS_KEY |
✓ |
PROPODDS_BASE_URL |
✓ |
CFBD_KEY |
✓ |
CFBD_BASE_URL |
✓ |
PINNACLE_API_KEY |
✓ commented (legacy) |
PINNACLE_API_BASE |
✓ commented (legacy) |
ODDS_API_KEY |
✓ commented (legacy) |
Soccer / World Cup 2026 (Session 7j + 9)
| Var | Required | Default | Used By | Doc? |
|---|---|---|---|---|
FOOTBALL_DATA_API_KEY |
no | (none) | footballDataAdapter (TERTIARY) |
✓ |
API_FOOTBALL_KEY |
no | (none) | apiFootballAdapter (PRIMARY, Session 9) |
✓ S9 |
SOCCER_LEAGUES |
no | WC |
poller/soccer.js, soccer-data-prefetch |
✓ |
WORLDCUP_API_URL |
no | https://worldcup2026-api.up.railway.app/api/... |
poller/soccer.js |
✓ |
RAPID_API_KEY |
no | (none) | footApiAdapter (BACKUP), tank01NbaAdapter, tank01MlbAdapter |
✓ S9 |
FOOTAPI_HOST |
no | footapi7.p.rapidapi.com |
footApiAdapter |
✓ S9 |
TANK01_NBA_HOST |
no | tank01-fantasy-stats.p.rapidapi.com |
tank01NbaAdapter |
✓ S9 |
TANK01_MLB_HOST |
no | tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com |
tank01MlbAdapter |
✓ S9 |
Container runtime (Session 9 finding):
NODE_OPTIONS=--max-old-space-size=4096— set on the web container in Coolify. Without it, Next.js's V8 default ceiling (~2 GB) is hit in production and the container OOM-loops (44 restarts observed on the live host before the fix was identified).
Pricing tiers (Session 12 — Africa tier added; Session 13 — geo-gated)
| Var | Required | Default | Used By | Doc? |
|---|---|---|---|---|
STRIPE_PRICE_AFRICA |
no | (none) | web/components/Pricing, stripeService (post-DB-CHECK migration) |
✓ S12 |
Cloudflare CF-IPCountry |
n/a | (none) | middleware.ts → x-vyndr-country → useRegion() |
✓ S13 |
Blocker: the existing migrations (001 + 011) declare tier IN ('free','analyst','desk') as a CHECK constraint on users.tier and
user_profiles.tier. The africa tier value will VIOLATE that
constraint until the constraint is updated. The frontend Pricing page
shows the tier now (UX), but the click handler short-circuits to an
honest "coming soon" message rather than triggering checkout. Required
follow-up: manual SQL to drop + re-add the CHECK across both tables
including 'africa' (cannot be done in this session per the no-migration
rule).
Session 13 — Africa tier visibility is now driven by real IP geo
(Cloudflare CF-IPCountry header), not by locale. The middleware
copies CF-IPCountry to x-vyndr-country; the root layout reads it
into LocaleProvider; useRegion() exposes inAfrica: boolean. The
Pricing component filters the Africa tier out of the render entirely
when inAfrica === false. Empty header (traffic bypassing Cloudflare)
degrades closed.
Internationalization (Session 12)
| Var / file | Required | Default | Used By | Doc? |
|---|---|---|---|---|
NEXT_LOCALE cookie |
no | en | web/middleware.ts, switcher |
✓ S12 |
web/src/lib/locales.ts |
n/a | n/a | locale registry (10 languages) | ✓ S12 |
web/src/locales/{code}.json |
n/a | n/a | per-locale translation dictionaries | ✓ S12 |
Locale-detection priority (left wins): URL prefix (reserved for future
[locale]/ segment) → NEXT_LOCALE cookie → Accept-Language
header → 'en'. Resolved locale rides on the request via the
x-vyndr-locale header; server components read it through
next/headers, client components consume it via LocaleContext.
Side effect: every Next.js page is now ƒ (Dynamic) instead of
○ (Static) because the root layout reads request headers. If FCP
regresses meaningfully, the fix is to move locale resolution
client-side (cookie-only, no headers() in layout) — that re-enables
static prerendering at the cost of a brief English flash on first
paint for non-English users.
Engine 2
| Var | Doc? |
|---|---|
OPENROUTER_API_KEY |
✓ |
OPENROUTER_PRIMARY_MODEL |
✓ |
OPENROUTER_FALLBACK_MODEL |
✓ |
OPENROUTER_BASE_URL |
✓ |
OPENROUTER_REFERER |
✓ |
OPENROUTER_TITLE |
✓ |
ENGINE2_BATCH_SIZE |
✓ |
ENGINE2_ENABLED |
✓ |
Redis + Python
| Var | Doc? |
|---|---|
REDIS_URL |
✓ |
PYTHON_SERVICE_URL |
✓ |
NBA_SERVICE_URL |
✓ commented (legacy alias) |
NBA_STATS_URL |
✓ commented (legacy alias) |
EVOLUTION_SERVICE_URL |
✓ commented (legacy) |
VYNDR_PYTHON |
✓ commented (legacy alias) |
Distribution + Analytics
| Var | Doc? |
|---|---|
TELEGRAM_BOT_TOKEN |
✓ |
TELEGRAM_CHANNEL_ID |
✓ |
DISCORD_WEBHOOK_DAILY |
✓ |
DISCORD_WEBHOOK_RESULTS |
✓ |
DISCORD_WEBHOOK_ALERTS |
✓ |
DISCORD_WEBHOOK_RARE |
✓ |
RESEND_API_KEY |
✓ (added 7c) |
RESEND_FROM_EMAIL |
✓ (added 7c) |
NEXT_PUBLIC_POSTHOG_KEY |
✓ (added 7c) |
NEXT_PUBLIC_POSTHOG_HOST |
✓ (added 7c) |
Poller-set (per-process via PM2)
| Var | Default | Doc? |
|---|---|---|
SPORT |
(none) | ✓ commented |
POLL_INTERVAL |
60000 |
✓ commented |
BUFFER_MS |
30000 |
✓ commented |
VYNDR_API_URL |
http://localhost:3001 |
✓ commented |
OFF_HOURS_POLL_MS |
hardcoded 5min | not env |
PM2 ecosystem (Session 7j) — four poller processes per container:
poller-nba,poller-wnba,poller-mlb(box-score resolution path viapoller/poller.js)poller-soccer(fixture indexing viapoller/soccer.js— different data sources and cache shape; honorsSOCCER_LEAGUESenv)
Backup + Ops
| Var | Doc? |
|---|---|
NTFY_PORT |
✓ |
NTFY_TOPIC |
✓ |
BACKUP_RETENTION_DAYS |
✓ |
PIPELINE_SECRET |
✓ commented |
PUBLIC_SHARE_CARD_BASE |
✓ commented |
API_BASE_URL |
✓ commented (alias of BASE_URL) |
ENGINE |
✓ commented (legacy) |
4. Supabase tables
Source: grep -rn "\.from('[a-z_]*')" src/ poller/.
Tables created by VYNDR migrations
| Table | Migration | Read by | Written by |
|---|---|---|---|
user_profiles |
011, 015 | AuthContext, stripeService mirror, accuracyTracker | handle_new_user, stripeService |
parlay_leg_frequency |
011 | parlay analytics | scan flow |
scan_history |
011 | user scans listing | scanRoutes |
odds_cache |
014 | oddsService | oddsService, n8n pipeline |
line_history |
014 | lineMovementService | lineMovementService |
cascade_alerts |
014 | cascadeService, intelligence feed | cascadeService |
player_stats_cache |
014 | propAnalyzer | propAnalyzer |
grade_history |
014/16/18 | Ledger, resolution route, weight adjuster, accuracy | orchestrator, resolution, engine2, corrections |
prop_correlations |
014 | correlationEngine | correlationEngine |
player_id_map |
014→016 | populate-player-ids, oddsPapiAdapter | populate-player-ids |
push_subscriptions |
015 | webPush | push routes, webPush prune |
closing_lines |
016 | clvTracker, oddsPapiAdapter | oddsPapiAdapter.batchCapture |
resolution_results |
016 | clvTracker, weightAdjuster, corrections | resolution route |
historical_props |
017 | trap historical signal (future) | pull-parlayapi-history |
line_snapshots |
017 | lineMovement intel | sharpApiAdapter (fire-and-forget) |
ref_profiles |
017 | refSignals | scrape-sports-reference |
coach_profiles |
017 | coachSignals | scrape-sports-reference, seed file |
game_ref_assignments |
017 | refSignals | ops manual entry |
engine1_weights |
018 | weightAdjuster | weightAdjuster |
accuracy_tracking |
018 | accuracyTracker, Ledger accuracy page | accuracyTracker |
user_notifications |
013 | notifications route | notification senders |
Tables present in DB but NOT in VYNDR migrations (BetonBLK era)
bets, daily_scan, discrepancy_reliability_scores, joint_outcomes,
lineup_role_profiles, model_predictions_extended, outcomes,
performance, picks, player_role_activations,
player_role_profiles, prediction_registry, scan_sessions.
VYNDR code reads/writes only the subset still in use:
bets—routes/bets.js,betService.jsjoint_outcomes—routes/props.js(joint-history)model_predictions_extended—propAnalyzerperformance—performanceServicepicks—parlayScanServicescan_sessions—routes/scan.js
Shared tables (BetonBLK + VYNDR)
users— both eraswaitlist/waitlist_signups— created in 012 (latter is canonical)
5. Redis keys
Source: grep -rn "cacheSet\|cacheGet\|redis\.set".
| Key pattern | TTL | Set by | Read by |
|---|---|---|---|
odds:{sport}:{gameId}:player_props |
60s (300s stale) | sharpApiAdapter | sharpApiAdapter, orchestrator |
odds:{sport}:{gameId}:game |
60s | sharpApiAdapter | sharpApiAdapter |
parlayapi:hist:{sport}:{p}:{stat}:{n} |
24h | parlayApiAdapter | trapDetection (future) |
parlayapi:close:{sport}:{date} |
24h | parlayApiAdapter | pull script |
parlayapi:checkpoint:{sport}:{season} |
30d | pull-parlayapi-history | pull-parlayapi-history |
propodds:{sport}:{game}:{player}:{stat} |
90s (300s stale) | propOddsAdapter | propOddsAdapter |
cfbd:teamstats:{team}:{year} |
6h | cfbdAdapter | cfbdAdapter |
cfbd:usage:{player}:{team}:{year} |
6h | cfbdAdapter | cfbdAdapter |
cfbd:talent:{team}:{year} |
6h | cfbdAdapter | cfbdAdapter |
cfbd:lines:{team}:{year} |
6h | cfbdAdapter | cfbdAdapter |
gamelogs:{sport}:{player}:{n} |
4h | gameLogService | featureCache, consistency |
team_stats:{sport}:{teamAbbr} |
24h | teamStatsCache | featureCache, engine1 |
features:{sport}:{playerId}:{stat}:{game} |
2m | featureCache | orchestrator |
injuries:{sport}:{teamId} |
2h | injuryParser | featureCache |
game:{gameId}:status |
36h | poller | poller |
game:{gameId}:resolution_lock |
5min (NX) | poller | poller (dedupe) |
game:{gameId}:live_status |
1h | poller | frontend badges (future) |
poller:{sport}:heartbeat |
3min | poller | ops monitoring |
vyndr:line_baseline:{...} |
24h | lineMovementService | lineMovementService |
| (cascadeService player-set key) | 24h | cascadeService | cascadeService |
| (oddsService legacy game cache) | 5min | oddsService | routes/odds |
| (schemeClassifier cache) | varies | schemeClassifier | schemeClassifier |
6. External API dependencies
| Service | Adapter | Env vars | Rate budget | Used by |
|---|---|---|---|---|
| SharpAPI | sharpApiAdapter.js |
SHARPAPI_KEY, SHARPAPI_BASE_URL |
10/min | orchestrator |
| OddsPapi | oddsPapiAdapter.js |
ODDSPAPI_KEY, ODDSPAPI_BASE_URL |
5/min | poller (tip-off CLV) |
| ParlayAPI | parlayApiAdapter.js |
PARLAYAPI_KEY |
5/min | pull script |
| PropOdds | propOddsAdapter.js |
PROPODDS_KEY |
3/min | trap consensus |
| CFBD | cfbdAdapter.js |
CFBD_KEY |
10/min | college features |
| OpenRouter | openRouterAdapter.js |
OPENROUTER_API_KEY + model env |
20/min, 1000/day | engine2 |
| ESPN scoreboard | poller, teamStatsCache, injuryParser |
none | 2/min (limiter) | poller, refresh job |
| MLB Stats API | poller (gamePk feed) |
none | 2/min | poller (mlb final) |
| Pinnacle (legacy) | PinnacleAdapter.js |
PINNACLE_API_KEY, PINNACLE_API_BASE |
tunable | UnifiedOddsProvider |
| DraftKings (legacy) | DraftKingsAdapter.js |
none | tunable | UnifiedOddsProvider |
| FanDuel/BetMGM/Caesars/PrizePicks/Covers/Rotowire (legacy) | each *Adapter.js |
none | tunable | UnifiedOddsProvider |
| Sports-Reference HTML | scripts/scrape-sports-reference.js |
REF_HTML_FILE, COACH_HTML_FILE (optional) |
1 req / 5s | scraper |
| Resend (email) | web/src/services/email.ts |
RESEND_API_KEY, RESEND_FROM_EMAIL |
n/a | transactional email |
| NexaPay | web/src/services/nexapay.ts |
NEXAPAY_* |
n/a | checkout fallback |
| PostHog | web/src/lib/analytics.ts |
NEXT_PUBLIC_POSTHOG_KEY/HOST |
n/a | browser analytics |
| football-data.org | footballDataAdapter.js |
FOOTBALL_DATA_API_KEY |
10/min (8 enforced) | poller-soccer, prefetch (TERTIARY) |
| api-football.com | apiFootballAdapter.js |
API_FOOTBALL_KEY |
100/day (soft 90) | soccer cascade (PRIMARY, Session 9) |
| FootApi (RapidAPI) | footApiAdapter.js |
RAPID_API_KEY, FOOTAPI_HOST |
50/day (soft 45) | soccer cascade (BACKUP, Session 9) |
| Tank01 NBA (RapidAPI) | tank01NbaAdapter.js |
RAPID_API_KEY, TANK01_NBA_HOST |
1000/mo (TTL bound) | live NBA box scores (Session 9) |
| Tank01 MLB (RapidAPI) | tank01MlbAdapter.js |
RAPID_API_KEY, TANK01_MLB_HOST |
1000/mo (TTL bound) | live MLB box + batter-vs-pitcher (Session 9) |
| Stripe | services/stripeService.js |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_* |
n/a | checkout + webhook |
| The Odds API | services/oddsService.js |
ODDS_API_KEY |
quota tracked | per-sport odds endpoints |
| worldcup2026 OSS | poller/soccer.js |
WORLDCUP_API_URL |
none (free) | WC fixture poll |
7. File dependency graph (entry points)
Express entry: src/server.js
server.js
└─ app.js
├─ routes/{odds,analyze,scan,movements,alerts,bets,stripe,stats,
│ props,waitlist,pipeline,shareCard,push,grading,corrections,widget}
└─ middleware/{auth, mission}
routes/grading.js (POST /pipeline)
└─ services/intelligence/gradingOrchestrator
├─ adapters/sharpApiAdapter
├─ services/intelligence/featureCache
│ ├─ teamStatsCache · refSignals · coachSignals
│ ├─ injuryParser · lineMovement · gameLogService
│ └─ lineupSignals
├─ services/intelligence/trapDetection
│ └─ lineMovement
├─ services/intelligence/consistencyScore
├─ services/intelligence/probabilityEstimator
├─ services/intelligence/engine1
└─ services/intelligence/engine2
└─ adapters/openRouterAdapter
routes/grading.js (POST /resolve)
└─ services/training/jsonlLogger
└─ services/distribution/{webPush, telegram, discord}
└─ services/intelligence/{clvTracker, accuracyTracker, weightAdjuster}
routes/scan.js (POST /parlay — legacy path, still wired)
└─ services/parlayScanService
└─ services/propAnalyzer
└─ services/grader ← legacy grading (see ARCH-1)
└─ services/UnifiedOddsProvider
└─ adapters/{ESPN,Pinnacle,DraftKings,FanDuel,BetMGM,
Caesars,PrizePicks,Covers,Rotowire}
└─ services/processing/{LineShoppingEngine,
MiddlesDetector, EVCalculator}
Poller entry: poller/poller.js
poller.js
├─ src/config/sports
├─ src/utils/redis (cacheGet/cacheSet/lock)
├─ src/utils/rateLimiter (espnLimiter, mlbLimiter)
└─ src/services/adapters/oddsPapiAdapter (tip-off CLV capture)
No circular imports detected.
8. Findings
ARCH — Architecture
-
[ARCH-1] Dual grading paths. Severity: Medium. Status: FIXED in Session 7g — all grading routes (/api/analyze/prop, /api/analyze/batch, /api/scan/parlay) now grade through engine1 via the unified
analyzeViaEngine1helper. /api/bets/* never used the legacy path. Dead files removed:src/services/propAnalyzer.js,src/services/grader.js,tests/unit/grader.test.js(-10 tests).UnifiedOddsProvider + the 9 legacy book adapters + the legacy rateLimiter/circuitBreaker stay live — they're still consumed by
/api/pipeline/refresh(the data refresh path, a separate concern). When that route eventually migrates off them, those files can also retire.Migration story for the record: Session 7c — audit catalogued ARCH-1. Session 7d — adapter scoped + DEPRECATED banner on grader.js. Session 7e — adapter built + tested (gradeAdapter.js, 26 tests). Session 7f — computeFeatures (8 tests) + analyzeViaEngine1 (7 tests) + /api/scan/parlay migrated. /api/analyze escape-hatched (integration test asserted legacy-engine values). Session 7g — analyze test mocks rotated to drive the new path; /api/analyze migrated; dead code removed.
Adapter, computeFeaturesForProp, and analyzeViaEngine1 helpers all shipped and tested. Future migration can flip the analyze route any time the integration test mocks are updated to drive the new upstream chain.
ORIGINAL Session 7e ARCH-1 narrative below for history.
PARTIAL (carried from 7e) — Step 1 of the migration (the adapter) is complete and tested. Steps 2-6 partially executed. Step 1 ✓ —
src/utils/gradeAdapter.jstranslates engine1 output into the legacy shapeDemoScanreads. 26 unit tests covering grade collapse, confidence math, kill-condition mapping, reasoning fallback, partial input safety. Step 2-6 deferred — legacyanalyzePropandengine1.gradeProp()have incompatible INPUT contracts: the legacy analyzer takes a raw prop and self-fetches data (odds, stats, opponent, spread, kill conditions); engine1 takes a pre-computed feature vector and is a pure grading function. To rewire any route to engine1 we'd first need to extract an orchestrator-lite preprocessor — that's the kind of architectural change 7c/7d's no-restructure rule blocks. The reasoning.summary string-level parity is also currently lost (concrete numbers in legacy vs abstract factor labels in engine1). Migration roadmap (now actionable for the next session): (a) ExtractgradingOrchestrator.gradeProp()into a standalonecomputeFeaturesForProp({player, stat_type, line, direction})that lifts the player-roster + feature-fetch + trap-detect + consistency-score logic into a reusable callable. (b) Wrap engine1 + gradeAdapter behind a thinanalyzeViaEngine1(prop)helper. (c) Rewire one route at a time —/api/analyze/propfirst (lowest risk: single prop, public, well-tested), then/api/analyze/batch, then/api/scan/parlay, then/api/bets/*. (d) Removegrader.js+propAnalyzer.js+ the legacy book adapters only after every consumer has migrated and a soak period. Session 7d's DEPRECATED banner onsrc/services/grader.jsstays put. -
[ARCH-2] Two circuit-breaker / rate-limiter modules. Severity: Low. Status: DOCUMENTED in Session 7e. Both legacy modules now carry banners listing their three callers:
src/services/UnifiedOddsProvider.jssrc/services/adapters/ESPNAdapter.jssrc/services/adapters/PinnacleAdapter.jsFull removal blocks on ARCH-1 Step 6 — the legacy book adapters retire together with the legacy grading path.
-
[ARCH-3] Soccer pipeline added as a parallel branch. Severity: Info. Status: SHIPPED in Session 7j. Soccer routes off
computeFeaturesForProptosoccerFeatureExtractorwhensport ∈ {'soccer','football'}; trap detection branches on the same ingetTrapScore; reasoning branches inbuildConcreteReasoning. Engine1 is sport-agnostic (passes unknown feature keys through). Data flow:poller/soccer.jswrites per-teamnextmatch/lastfixturepointers;scripts/soccer-data-prefetch.jswrites per-player + per-team-defense aggregates. The feature extractor reads ONLY from cache — no external HTTP on the user request path. Day-1 gap: xG fields (xg_per_90,xg_delta) are null until the soccerdata-Python bridge ships; engine handles the nulls gracefully.
SEC — Security
-
[SEC-1]
/api/analyze/batchhas no auth or rate limit. Severity: High. Status: FIXED in Session 7d. Added IP-keyed rate limit (src/middleware/rateLimit.js, 10 req/min) and mounted it on the/api/analyzerouter viarouter.use(analyzeLimit)— covers both/propand/batch. 7 unit tests + 1 behavioral test verify the 429 path. -
[SEC-2]
routes/shareCard.jsleakserr.messageviadetail:. Severity: Medium. Status: FIXED in Session 7d. Lines 185 and 206 now log toconsole.error('[VYNDR] shareCard ... failed:', err?.message)(ops-only) and return generic error verbs to public callers. The validation-errorsdetail:on line 166 is intentional user-facing input feedback, retained. -
[SEC-3] Several
console.logcalls in production code. Severity: Low. All have[VYNDR]/[redis]/[poller-X]prefixes and represent OPERATIONAL status (server starting, poller tick summary), not debug. Per project conventionconsole.logis acceptable for operational events. Documenting so future audits don't re-flag.
DEAD — Dead code
-
[DEAD-1] No truly orphaned files. Every adapter in
src/services/adapters/has at least one consumer (legacy ones viaUnifiedOddsProvider, new ones via orchestrator/poller). Removing any would break either/api/scanor/api/grading/pipeline. -
[DEAD-2]
grader.jsis LIVE. Imported bypropAnalyzer.js→parlayScanService.js→routes/scan.js. Kept until ARCH-1 is resolved.
DUP — Duplicates
-
[DUP-1]
normalizeName()exists twice. Severity: Low. Status: FIXED in Session 7e. Consolidated intosrc/utils/normalize.jswith akeepDigitsoption (default false).trapDetection.jsimports the default;scripts/populate-player-ids.jswraps withkeepDigits: truevianormalizeRosterName. 9 unit tests cover accent strip, suffix removal, digit options, idempotency, null input. -
[DUP-2]
oddsToImpliedetc. only live insrc/utils/odds.js. Confirmed not duplicated despite theoddsNormalizer.jsneighbor — the two files serve different purposes (math primitives vs book-level prop shape).
LEGACY — Documentation / naming
- [LEG-1] BetonBLK references. Severity: Informational. Confined to
BUILD-STATE.mdhistorical notes, theFOUNDER_CODESallowlist (intentional grandfathering — documented in stripeService comment), and tests that assert the grandfathered code still validates. No action.
TODO
- [TODO-1] PrizePicks / DraftKings / Pinnacle adapter TODOs.
Severity: Low. Tracked in
specs/data-pipeline-books.md. Each has a comment line referencing the spec. Keep until the corresponding feature ships.
PERF — Performance
-
[PERF-1]
/api/analyze/*lacks Redis cache. Severity: Medium. Status: FIXED in Session 7d. AddedcachedAnalyze()wrapper insrc/routes/analyze.jswith keyanalyze:{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.jsresolves parlays one prop at a time. Severity: Medium. Status: FIXED in Session 7d. Replaced the sequentialfor (leg of legs) await analyzeProp(leg)withPromise.allSettled(legs.map(analyzeProp))insrc/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 analyzer not installed. Severity: Low. Status: FIXED in Session 7d.
@next/bundle-analyzer@^16.2.9added as a devDependency and wired intoweb/next.config.ts. Inert by default; emitsweb/.next/analyze/{client,edge,nodejs}.htmlwhenANALYZE=true npm run buildruns.
Frontend ↔ Backend contract
All frontend API paths discovered are either:
- Direct Express paths confirmed in
src/app.js(e.g./api/scan/parlay,/api/waitlist), or - Next.js
app/api/*/route.tsproxies that internally fetch${BACKEND_URL}/....
No broken contracts found — every fetched path maps to an existing
handler. Spot-checked: /api/players/search (Next → Python),
/api/scan (Next → Express), /api/intelligence/feed (Next direct DB).
Payments: Stripe cutover (Session 8 — COMPLETE)
The dual-provider divergence flagged in 7h is closed:
- ✅
web/src/app/api/checkout/route.tsnow forwards to${BACKEND_URL}/api/stripe/checkoutwith the user's bearer token. The route remaps{ checkout_url, session_id }→{ url, … }so the existing client field shape still works. - ✅
Pricing.tsxCTAs were converted from<a href>to onClick handlers that POST to/api/checkoutandwindow.location.assignthe returned Stripe URL. Loading state during redirect; error surfaced inline. - ✅
/upgrade/success?session_id=…and/upgrade/cancelpages shipped. ExpressstripeService.jsupdated to pointsuccess_urlandcancel_urlat the new frontend pages viaNEXT_PUBLIC_SITE_URL(the only backend file touched in Session 8). - NexaPay is still wired but no UI calls it. Disposition (remove vs keep as fallback) is a follow-up call — leaving it in place doesn't cost anything and gives the team a fallback if Stripe goes down during the World Cup window.
9. How to update this manifest
When you add a route, table, env var, or external API:
- Add the row to the appropriate section above.
- Re-run the discovery greps from this session's prompt to confirm nothing else changed implicitly.
- If you remove a table or env var, mark it
[REMOVED Session NX]instead of deleting the row outright — keeps audit trail.
Last updated: Session 7c.