Files
vyndr/docs/SYSTEM-MANIFEST.md
T

719 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/parlay` and `routes/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.constructEvent` signature
| 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/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/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 — accepts `Soccer` sport 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. Hosts `SportSelector` + per-league match cards, scans selected props through `/api/scan` → Express `/api/analyze/prop` with `sport: 'Soccer'`. Results render in `SoccerGradeResult` (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. Reads `session_id` query 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 `/#pricing` and `/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 via `poller/poller.js`)
- `poller-soccer` (fixture indexing via `poller/soccer.js` — different
data sources and cache shape; honors `SOCCER_LEAGUES` env)
### 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.js`
- `joint_outcomes``routes/props.js` (joint-history)
- `model_predictions_extended``propAnalyzer`
- `performance``performanceService`
- `picks``parlayScanService`
- `scan_sessions``routes/scan.js`
### Shared tables (BetonBLK + VYNDR)
- `users` — both eras
- `waitlist` / `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 `analyzeViaEngine1` helper. /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.js` translates engine1 output
into the legacy shape `DemoScan` reads. 26 unit
tests covering grade collapse, confidence math,
kill-condition mapping, reasoning fallback, partial
input safety.
Step 2-6 deferred — legacy `analyzeProp` and `engine1.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) Extract `gradingOrchestrator.gradeProp()` into a standalone
`computeFeaturesForProp({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 thin
`analyzeViaEngine1(prop)` helper.
(c) Rewire one route at a time — `/api/analyze/prop` first
(lowest risk: single prop, public, well-tested), then
`/api/analyze/batch`, then `/api/scan/parlay`, then
`/api/bets/*`.
(d) Remove `grader.js` + `propAnalyzer.js` + the legacy book
adapters only after every consumer has migrated and a soak
period.
Session 7d's DEPRECATED banner on `src/services/grader.js` stays
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.js`
- `src/services/adapters/ESPNAdapter.js`
- `src/services/adapters/PinnacleAdapter.js`
Full 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
`computeFeaturesForProp` to `soccerFeatureExtractor` when
`sport ∈ {'soccer','football'}`; trap detection branches on the same
in `getTrapScore`; reasoning branches in `buildConcreteReasoning`.
Engine1 is sport-agnostic (passes unknown feature keys through).
Data flow: `poller/soccer.js` writes per-team `nextmatch` /
`lastfixture` pointers; `scripts/soccer-data-prefetch.js` writes
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/batch` has 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/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. 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
represent OPERATIONAL status (server starting, poller tick summary),
not debug. Per project convention `console.log` is 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 via
`UnifiedOddsProvider`, new ones via orchestrator/poller). Removing
any would break either `/api/scan` or `/api/grading/pipeline`.
- **[DEAD-2] `grader.js` is LIVE.** Imported by `propAnalyzer.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 into `src/utils/normalize.js`
with a `keepDigits` option (default false). `trapDetection.js`
imports the default; `scripts/populate-player-ids.js` wraps with
`keepDigits: true` via `normalizeRosterName`. 9 unit tests cover
accent strip, suffix removal, digit options, idempotency, null
input.
- **[DUP-2] `oddsToImplied` etc. only live in `src/utils/odds.js`.**
Confirmed not duplicated despite the `oddsNormalizer.js` neighbor —
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.md` historical notes, the `FOUNDER_CODES` allowlist
(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.** 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.**
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 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
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.ts` proxies 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:
1.`web/src/app/api/checkout/route.ts` now forwards to
`${BACKEND_URL}/api/stripe/checkout` with the user's bearer token.
The route remaps `{ checkout_url, session_id }``{ url, … }` so
the existing client field shape still works.
2.`Pricing.tsx` CTAs were converted from `<a href>` to onClick
handlers that POST to `/api/checkout` and `window.location.assign`
the returned Stripe URL. Loading state during redirect; error
surfaced inline.
3.`/upgrade/success?session_id=…` and `/upgrade/cancel` pages
shipped. Express `stripeService.js` updated to point `success_url`
and `cancel_url` at the new frontend pages via `NEXT_PUBLIC_SITE_URL`
(the only backend file touched in Session 8).
4. 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:
1. Add the row to the appropriate section above.
2. Re-run the discovery greps from this session's prompt to confirm
nothing else changed implicitly.
3. 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.