Files
vyndr/BUILD-STATE.md
T

139 KiB
Executable File
Raw Blame History

VYNDR — Build State

Last Updated

2026-06-12

Current Phase

SHIP BUILD v29.0 — Content generation templates: structured social/newsletter content from live data (Session 29)

Session 29 (2026-06-13) — SHIPPED

The data engine that produces raw material for daily social content. Each template consumes live VYNDR data and returns STRUCTURED OBJECTS (not text, not images) that degrade gracefully by data level. A formatter renders plain text; the image/design layer comes later.

Backend 1623 → 1660 tests (+37), 133 suites, zero regressions. Web build clean.

PHASE 1-3 — Template engine + slate thread + POTD

  • contentTemplateService.js:
    • collectSlateData(sport, deps?) — gathers schedule + game lines + grades + streaks + movers + best lines via Promise.allSettled, INJECTABLE collectors (default wires the real services). Sets dataLevel: full / lines / schedule / empty.
    • generateSlateThread — hook + content posts + CTA. Full → top-5 graded picks; lines → game-line highlights (best ML, consensus total/spread, book disagreement) + movers; schedule → game list.
    • generatePOTD — best grade (full) or game-of-the-day (lines) or { available: false }.
    • Field-alias normalizers so grades from any shape (player/player_name, side/direction, edge/edge_pct) work.

PHASE 4-5 — Recap + matchup preview

  • generateResultsRecap(sport, resolvedGrades) — record, win rate, top hits, biggest miss, by-tier (A/B/C), Brier score + avg CLV. Pure.
  • generateMatchupPreview(game, gameLines, streaks) — teams, lines summary (consensus spread/total, home-favorite), streaks matched to the two teams, one-line narrative. Degrades to lines: null.

PHASE 6 — Content API

  • GET /api/content/{slate,potd,recap,preview}/:sport (preview takes /:gameId). ?format=text adds post-ready strings. Mounted in app.js; Next proxy api/content/[...path]/route.ts.

PHASE 7 — Formatter

  • contentFormatter.js — slate thread → array of plain-text posts (one per role), POTD + recap text blocks. Defensive: never emits "undefined".

Files created

  • src/services/contentTemplateService.js, src/services/contentFormatter.js
  • src/routes/content.js
  • web/src/app/api/content/[...path]/route.ts
  • tests/unit/contentTemplateService.test.js (22), tests/unit/contentFormatter.test.js (7), tests/integration/contentRoutes.test.js (8)

Files modified

  • src/app.js (mount /api/content)

Previous Phase

SHIP BUILD v28.0 — Parlay builder, line-movement tracking, book comparison (Session 28)

Session 28 (2026-06-13) — SHIPPED

The three features every competitor has: parlay building, line movement, book comparison. All zero-credit (pure math / Redis snapshots / cached odds). Reused existing primitives heavily (payoutCalculator, the existing ParlayTray/ParlayContext frontend).

Backend 1584 → 1623 tests (+39), 130 suites, zero regressions. Web build clean.

PHASE 1-2 — Parlay builder

  • parlayService.js — combined American/decimal odds (reuses payoutCalculator), confidence-weighted combined grade, correlation detection via an interaction matrix (same-game teammates = positive, opposing rebounds = negative, cross-game = independent), kill-condition aggregation, and suggestParlays (greedy, conflict-avoiding).
  • POST /api/parlay/calculate + /suggestions. Frontend parlay builder already existed (ParlayTray + ParlayContext → /api/scan/parlay grading); left intact. Added the calculate proxy for the lightweight path.
  • 15 unit + 3 route tests.

PHASE 3-4 — Line movement

  • lineSnapshotService.js — Redis-only rolling history (linehistory:{sport}:{gameId}:{player}:{stat}, cap 100, 48h TTL), classifyMovement (stable/rising/dropping + sharp signal ≥1.5 pts), getBiggestMovers (scan + classify + sort by |delta|). Complements the existing Supabase-backed lineMovementService rather than replacing it.
  • Wired recordSnapshots into oddsService's existing best-effort block.
  • GET /api/lines/:sport/movers + per-prop history.
  • Frontend: LineMovementChart (dependency-free SVG sparkline) + MoversPanel (mounted in the Slate, self-hiding, tier-gated).
  • 9 unit + 2 route tests.

PHASE 5-6 — Book comparison

  • bookComparisonService.js — best line per side (highest decimal payout), savings vs field average per $100, over the grouped odds lines[]. GET /api/books/:sport (best lines) + per-prop grid, reading CACHED odds props (zero credits).
  • Frontend: BookComparison (book grid, BEST badge) + BestLinesPanel (mounted in the Slate, self-hiding, tier-gated).
  • 7 unit + 3 route tests.

PHASE 7 — Wiring

  • Mounted /api/parlay, /api/lines, /api/books in app.js.
  • Next proxies: parlay/calculate/route.ts (explicit, avoids catch-all conflict with existing grade/add-leg), lines/[...path], books/[...path].
  • MoversPanel + BestLinesPanel added to the Slate below streaks/hot lists.

Files created

  • src/services/parlayService.js, src/routes/parlay.js
  • src/services/lineSnapshotService.js, src/routes/lineMovement.js
  • src/services/bookComparisonService.js, src/routes/bookComparison.js
  • web/src/components/{LineMovementChart,MoversPanel,BestLinesPanel,BookComparison}.tsx
  • web/src/app/api/parlay/calculate/route.ts, api/lines/[...path]/route.ts, api/books/[...path]/route.ts
  • 4 new test files (parlayService, lineSnapshotService, bookComparisonService, session28Routes)

Files modified

  • src/app.js (3 mounts), src/services/oddsService.js (snapshot recording)
  • web/src/components/Slate.tsx (2 panels)

Previous Phase

SHIP BUILD v27.0 — PWA autopilot: deployment-aware service worker, push foundation, offline fallback, install + cookie + tier polish (Session 27)

Session 27 (2026-06-13) — SHIPPED

Upgraded the PWA from "caches stale content after deploy" to bulletproof. The service worker stays — it powers push/offline/installs — but its cache POLICY is now deployment-aware. Much of the foundation already existed (skipWaiting/clientsClaim, push handlers, InstallPrompt, CookieConsent); this session fixed the cache strategy and filled the gaps.

Backend 1579 → 1584 tests (+5), 126 suites, zero regressions. Web build clean.

PHASE 1 — SW cache policy (the deploy-staleness fix)

  • Replaced defaultCache with explicit runtimeCaching (Serwist v9 strategy classes): API + navigations + RSC/everything-else are NetworkFirst (5s timeout) so sports data is never stale; only content-hashed /_next/static/ and images/fonts are CacheFirst.
  • skipWaiting/clientsClaim already set — kept.
  • Added an activate handler that deletes legacy cache buckets (the old defaultCache set: start-url, next-data, apis, pages-rsc, …), preserving CURRENT_CACHES + Serwist-managed precache.

PHASE 2 — Push foundation

  • SW push + notificationclick handlers already existed — kept + added a tag. Created web/src/lib/pushNotifications.ts (subscribeToPush / unsubscribeFromPush / isPushSupported / pushPermission). Returns null gracefully until NEXT_PUBLIC_VAPID_PUBLIC_KEY exists.

PHASE 3 — Offline fallback

  • Created web/src/app/offline/page.tsx (dependency-free client page).
  • SW pre-caches /offline on install; navigation handler serves it via a handlerDidError plugin when network + cache both miss.

PHASE 4 — Manifest polish

  • manifest.json: full name "VYNDR — Sports Prop Intelligence", added categories, explicit icon purpose. Kept brand #06060B (NOT the spec's #0A0A0F) for splash/status-bar consistency. Layout already emits theme-color + apple-mobile-web-app-* via Next metadata API.
  • InstallPrompt (beforeinstallprompt + iOS hint + 7-day dismissal cooldown, gated on ≥2 reads) and CookieConsent (persists vyndr_cookie_consent, shows once) already implemented and mounted in layout. No change needed.

PHASE 7 — Copy

  • The literal "NBA · MLB · WNBA" hero badge was already gone (Session 24 → "EVERY SPORT · EVERY PROP"). Reframed the layout metadata description to lead with "every sport" (kept per-sport SEO keywords).

PHASE 8 — Profile tier

  • {profile.tier} rendered blank when the API returned null/undefined. Now falls back to 'free' so the tier field is never empty.

Files created

  • web/src/app/offline/page.tsx
  • web/src/lib/pushNotifications.ts
  • tests/unit/pwaManifest.test.js

Files modified

  • web/src/sw.ts (cache strategies + activate cleanup + offline precache)
  • web/public/manifest.json (name, categories, icon purpose)
  • web/src/app/layout.tsx (description), web/src/app/profile/page.tsx (tier)

Previous Phase

SHIP BUILD v26.0 — Cross-sport tab counts, scan copy, game-card visual polish, empty-section auto-hide (Session 26)

Session 26 (2026-06-12) — SHIPPED

Finished making every sport visible and polished the presentation. Traced the MLB/WNBA "no count" symptom to its real cause before touching code.

Backend unchanged: 1579 tests, 125 suites, zero regressions. Web build clean.

PHASE 1 — MLB/WNBA tab counts (traced)

  • TRACE: hit ESPN live — MLB returns 15 events, WNBA 2, with exactly the shape scheduleService.normalizeEvent expects. The backend was correct; Session 25's proxy fix already unblocked the data flow.
  • Real gap: the Slate's tab counts were derived ONLY from the active tab's loaded games, so a sport showed no count until you clicked its tab.
  • FIX: a mount-time effect fetches schedule counts for nba/wnba/mlb (free, cached) so every tab shows "MLB (15)" / "WNBA (2)" regardless of which tab is active. tabCount prefers loaded data, falls back to the count.

PHASE 2 — Scan copy

  • Removed "Books usually open player props 23 hours before tip" from scan/page.tsx and game/[id]/page.tsx (we don't assume book timing). Kept Features' "30 min before tip" — that's a lineup-intel claim, not a book-line timing assumption.

PHASE 3 — Game-card visual polish

  • Header: 18px/800 abbreviations, 16×20 padding for breathing room.
  • Game-lines strip: aligned 4-column grid (book · away · home · O/U) with em-dash placeholders, more padding.
  • Inline streaks: accent-colored label + subtle red gradient wash, premium.
  • Empty-props line: smaller, left-aligned, dimmed — informational, not an error wall.
  • Verified StatFilterPills (filled active pill) and the Hero sport-badge strip (active filled / coming-soon dimmed, all 9 sports) already match the design spec; notice banner already neutral (no red).

PHASE 4 — Empty-section auto-hide

  • "Most parlayed tonight" now hides entirely when loaded-but-empty instead of showing a "be the first" prompt (dead space on a fresh platform).

PHASE 5 — BACKEND_URL (verified)

  • All proxies (new + odds) default to http://localhost:3000, consistent with odds-cache.ts. Prod sets BACKEND_URL; the new routes inherit it. No change — deliberately kept consistent with the working odds proxies rather than introducing a 3001 default that would diverge from them.

Files modified

  • web/src/components/Slate.tsx (schedule-count effect, tabCount fallback)
  • web/src/components/GameCard.tsx (header, lines strip, streaks, empty)
  • web/src/app/scan/page.tsx, web/src/app/game/[id]/page.tsx (copy)
  • web/src/app/dashboard/page.tsx (auto-hide Most-parlayed)

Previous Phase

SHIP BUILD v25.0 — Fix every data-rendering bug: the frontend now actually SHOWS the backend's data (Session 25)

Session 25 (2026-06-12) — SHIPPED

Traced data from API response → normalizer → cache → frontend fetch → render and fixed every break. The backend was serving real data; the frontend showed "NO SLATE." Root causes found and fixed.

Backend 1571 → 1579 tests (+8), 125 suites, zero regressions. Web build clean.

PHASE 1 — Tank01 game-lines normalizer (traced + fixed)

  • TRACE: the real Tank01 betting-odds shape puts each sportsbook as a TOP-LEVEL key on the game object ({ awayTeam, homeTeam, bet365:{...}, betmgm:{...} }), NOT inside a sportsBooks array. The old normalizer looked for the array → books: {} every time.
  • FIX: extractBooks() filters out NON_BOOK_KEYS and treats remaining object values as books (counted only if they yield a real odds field). normalizeBook now reads homeTeamML/totalOver/homeTeamRunLine (MLB) alongside the older spellings. Legacy array shape still handled.

PHASE 2 — Slate schedule rendering (THE root cause)

  • TRACE: the all-day endpoints (/api/schedule, /api/gamelines, /api/streaks, /api/hotlist) existed on Express but had NO Next.js proxy route — so the browser's fetch('/api/schedule/mlb') 404'd on the Next origin and the slate was always empty.
  • FIX: created 4 Next.js proxy route handlers (mirroring /api/odds/*).
  • Sport tabs now show merged counts ("MLB (8)") from schedule+odds.
  • Games already rendered with 0 props (Session 24 merge); now they get data.

PHASE 3 — Dashboard

  • The Session 24 schedule fallback was 404ing for the same proxy reason; the Phase 2 proxy unblocks it. Dashboard now shows ESPN schedule games.

PHASE 4 — Hero prop

  • The static Jokic fallback card is now labelled "EXAMPLE" so its fixed stats don't read as stale live data when no live hero-prop is flowing.

PHASE 5 — Per-game inline streaks

  • GameCard renders a 🔥 STREAKS section inline (below props/lines), matched to the game by team abbreviation in the Slate. Renders only when streaks exist for that game's teams. Sport-wide panels kept as the board.

PHASE 6 — Game-log cache key alignment (traced + bridged)

  • TRACE: prefetch writes tank01:{sport}:boxscore:{gameId}; rosterLogs read gamelogs:{sport}:* / rosterlogs:{sport}. NBA/WNBA are fed by gameLogService (Python) during grading — ALIGNED. MLB had NO writer for the keys rosterLogs read — MISALIGNED, so MLB streaks were always empty.
  • FIX: rosterLogs now falls back to aggregating the cached Tank01 box scores (tank01:{sport}:boxscore:*) into per-player multi-game logs, flattening MLB _raw and ordering games most-recent-first by the date in the gameID. Honest limitation: streaks need 2+ cached games to surface, so coverage grows as box scores accumulate across prefetch runs.

Files created

  • web/src/app/api/schedule/[sport]/route.ts
  • web/src/app/api/gamelines/[sport]/route.ts
  • web/src/app/api/streaks/[sport]/route.ts
  • web/src/app/api/hotlist/[sport]/route.ts

Files modified

  • src/routes/gameLines.js (normalizer rewrite + extractBooks)
  • src/services/rosterLogs.js (box-score aggregation bridge)
  • web/src/components/Slate.tsx (streaks fetch+match, tab counts)
  • web/src/components/GameCard.tsx (inline streaks section)
  • web/src/components/LiveHeroProp.tsx (EXAMPLE label)
  • tests/integration/gameLinesRoute.test.js, tests/unit/rosterLogs.test.js

Previous Phase

SHIP BUILD v24.0 — Connect Everything: wired the all-day intelligence layer into the live UI + killed stale copy (Session 24)

Session 24 (2026-06-12) — SHIPPED

Connected Session 23's backend to what users actually see. The Ferrari engine got wheels. Frontend-heavy: the Slate now fetches every free/cheap layer, the site shows content even with odds-api at 0 credits, and every piece of stale copy is gone.

Backend 1567 → 1571 tests (+4), 125 suites, zero regressions. Web build clean.

PHASE 1 — Slate wired to ALL sources

  • fetchSlate now fetches odds + schedule (ESPN) + gamelines (Tank01) per sport in parallel. mergeSlate() makes the SCHEDULE the foundation (always shows), overlays odds props (matched by nickname token) and Tank01 lines (matched by team abbreviation). Unmatched odds games are appended so props are never dropped. Schedule empty → odds-only fallback.
  • GameCard extended with optional status/score (LIVE/FINAL badge + score) and gameLines (book-by-book ML / spread / total strip).
  • Odds-down-but-schedule-up → soft inline notice, NOT a wall-of-error.

PHASE 2 — Stat filter pills

  • Pills hidden on the ALL tab (filtering by "points" across mixed sports is meaningless). Sport-specific categories on a single-sport tab.
  • Switching sport resets activeStat to 'all' (stale filter would blank the panels).

PHASE 3 — Copy

  • Hero badge "NBA · MLB · WNBA" → "EVERY SPORT · EVERY PROP"; subhead de-listed the three leagues. Features "Three sports, one engine" → "Every sport, one engine". FAQ updated. LivePropsStrip "TONIGHT'S GRADES LOAD AT 5 PM ET" → "LIVE GRADES APPEAR HERE AS BOOKS POST LINES". Removed the developer-facing "odds endpoint not configured yet" footer. No BetonBLK references existed.

PHASE 4 — Nav for paid users

  • Paid (analyst/desk) users see "Account" where free/anon see "Pricing".
  • /account page created → redirects to /profile (canonical plan + subscription-management surface; no duplicate UI).

PHASE 5 — Cache population

  • src/startupPrefetch.js — non-blocking, crash-safe Tank01 cache warm scheduled 5s after boot (server.js). Skips when RAPID_API_KEY unset; prefetch failure never crashes the server. Bounded by prefetch's budget.

PHASE 6 — Language switcher

  • Removed <LocaleSwitcher /> from the Nav (no translations behind it). i18n infrastructure (LocaleContext, useT, react-i18next, the LocaleSwitcher component file) kept for when translations land.

PHASE 7 — Empty states

  • Dashboard falls back to the free ESPN schedule when the odds slate is empty, so it shows today's matchups instead of "NO SLATE". "NO SLATE" now appears only when BOTH odds and schedule are genuinely empty.
  • "Tonight's slate is loaded. 0 games across 3 sports." → honest, sport-aware count (or "Your ledger starts here." when zero).

Files created

  • src/startupPrefetch.js
  • web/src/app/account/page.tsx
  • tests/unit/startupPrefetch.test.js

Files modified

  • web/src/components/Slate.tsx (parallel fetch + merge, notice, pills)
  • web/src/components/GameCard.tsx (status/score/game-lines layers)
  • web/src/components/Nav.tsx (paid→Account, locale switcher removed)
  • web/src/components/Hero.tsx, Features.tsx, FAQ.tsx, LivePropsStrip.tsx (copy)
  • web/src/app/dashboard/page.tsx (schedule fallback + copy)
  • src/server.js (startup prefetch hook)

Session 23 (2026-06-12) — SHIPPED

Built the all-day content layer that makes VYNDR an intelligence terminal, not a prop-grading widget. EVERYTHING coexists: schedule, stats, streaks, hot lists, and game lines all visible at once — nothing replaces anything. When odds-api props are empty, the other (free/cheap) layers keep the platform alive. NO odds-api credits were spent this session.

Baseline 1505 → 1567 tests (+62), 124 suites, zero regressions. Web build clean.

PHASE 1 — Schedule API (/api/schedule/:sport)

  • src/services/scheduleService.js — cache-aside read of free ESPN scoreboards. Reads schedule:{sport}:{date} first; on a miss it self-heals by fetching ESPN directly (the same free endpoint the pollers hit), normalizes, caches 60s. The platform is NEVER empty.
  • Per-game hasOdds / hasGameLines flags read OTHER caches (odds-api props, Tank01 lines) WITHOUT triggering a fetch.
  • src/routes/schedule.js — returns an empty slate (never 5xx) on error. Unknown sport → 404. Mounted in app.js.

PHASE 2 — Tank01 Game Lines (/api/gamelines/:sport)

  • Added getMLBBettingOdds to tank01MlbAdapter (NBA already had getNBABettingOdds). 15-min cache TTL, shares RAPID_API_KEY quota.
  • src/routes/gameLines.js — normalizes the book-by-book body (bet365 / betmgm / caesars: ML, spread, total) into a flat shape, parses teams from the YYYYMMDD_AWAY@HOME gameID. Missing key → graceful configured:false. Adapter throw → empty, never 500.

PHASE 3 — Streaks Engine (/api/streaks/:sport)

  • src/services/streaksService.js — pure, data-driven. Consecutive run from the latest game backward; collapses tiered specs (25+/20+ pts) to the more impressive one. NBA (14 specs incl. dd/td/PRA/hot- shooter), MLB (9), NFL (5), soccer (4). Through VYNDR's lens — "4-game 28+ scoring streak", not "31 PPG".
  • src/services/rosterLogs.js — Redis-only roster loader (prefetch blob fast-path, else SCAN over gamelogs:{sport}:*). Never throws.

PHASE 4 — Hot Lists (/api/hotlist/:sport)

  • src/services/hotListService.js — "hot" = ABOVE the player's own baseline (explicit seasonAvg, else games outside the window), not just high raw numbers. Ranked by delta, tie-broken by raw recent average. Date-based 7-day window when rows carry dates.

PHASE 5 — Stat Filtering

  • src/config/statFilters.js (+ web/src/config/statFilters.ts mirror). ?stat= param on streaks/hotlist. Discovery endpoint GET /api/stats/filters/:sport. StatFilterPills component.

PHASE 6 — Unified Dashboard

  • StreaksPanel + HotListPanel (headshots, tier-gated, self-hide when empty), wired into the Slate below the games AND mounted as landing-page teasers. Stat pills narrow both; schedule + game lines stay visible regardless. Free tier sees 3, paid sees all.

PHASE 7 — Cleanup

  • ParlayAPI marked status: 'dead' in src/config/providers.js (Chrome Claude: api.parlayapi.io unreachable on 2026-06-12). Excluded from getFallbackChain + getConfiguredProviders; new isDeadProvider helper. Config still resolves so adapter tests (network-mocked) pass unchanged.

Files created

  • src/services/scheduleService.js, src/routes/schedule.js
  • src/routes/gameLines.js
  • src/services/streaksService.js, src/routes/streaks.js
  • src/services/hotListService.js, src/routes/hotlist.js
  • src/services/rosterLogs.js
  • src/config/statFilters.js
  • web/src/config/statFilters.ts
  • web/src/components/StatFilterPills.tsx
  • web/src/components/StreaksPanel.tsx
  • web/src/components/HotListPanel.tsx
  • 7 new test files (schedule, gamelines, streaks/hotlist routes, streaksService, hotListService, rosterLogs, statFilters, providersRegistry)

Files modified

  • src/app.js (mounted 4 routes)
  • src/services/adapters/tank01MlbAdapter.js (getMLBBettingOdds)
  • src/routes/stats.js (filters discovery endpoint)
  • src/config/providers.js (ParlayAPI dead)
  • web/src/app/page.tsx, web/src/components/Slate.tsx
  • tests/unit/tank01MlbAdapter.test.js

Previous Phase

SHIP BUILD v22.0 — Tracker-driven quota guard, env-configurable cache TTL, opt-in odds prewarmer (Session 22)

Session 22 (2026-06-12) — SHIPPED

Plumbed the cache + quota machinery so the platform can survive a free-tier (500 credits/month) odds-api budget. Honest scope: Chrome Claude's diagnosis ("pollers write to one key, API reads from another") didn't hold up under trace — the keys it pointed at were internal sentinels in cascadeService and lineMovementService, not duplicate caches. No PM2 poller ever fed the odds cache. The actual root cause is that the cache is populated on-demand by getOdds itself, and when odds-api fails the cache stays empty.

After confirming the trace with the user, the agreed scope was:

  1. Replace the legacy stale quota guard with Session 20's tracker
  2. Make the cache TTL env-configurable (default raised from 15min to 1h)
  3. Build an opt-in odds prewarmer script

PHASE 1 — Trace (honest scope correction)

Grepped for odds:players:* and odds:baseline_set:* — both are written by cascadeService.detectScratches and lineMovementService.processNewOdds AFTER a successful getOdds() call, as internal sentinels for scratch detection and opening-line baseline capture respectively. Neither is a duplicate cache feed.

Documented in BUILD-STATE so future operators don't re-chase the same false lead.

PHASE 3 — Tracker-driven quota guard

src/services/oddsService.js#getOdds previously checked getQuotaRemaining(redis) — a Redis hash that only the file itself updated, so it drifted (Chrome Claude observed 46 in the hash while reality was 7). The check is now delegated to Session 20's quotaTracker.getQuotaStatus('odds-api'), which:

  • is synced from x-requests-remaining / x-requests-used on every successful odds-api call (via gateway.fetch's syncHeadersFrom hook)
  • BLOCKs at ≥95% (matches the WARN/BLOCK constants the dashboard surfaces)
  • fails OPEN when Redis is degraded so a Redis hiccup doesn't take down the platform

The 429 error now attaches quotaStatus to the thrown Error so operators inspecting the response can see the actual used / limit / pct that triggered the block.

Three new tests in tests/unit/oddsService.test.js:

  • 80% (WARN, not BLOCK) → call proceeds
  • 96% (BLOCK) → 429 thrown with quotaStatus attached
  • 95% (BLOCK boundary) → axios.get never invoked

The legacy getQuotaRemaining / updateQuota machinery stays exported for now — other call sites (the /api/odds/* route layer pulls quota_remaining straight out of the response envelope) still rely on the hash being populated. The hash is a redundant signal; the tracker is the decision.

Env-configurable cache TTL

oddsService.CACHE_TTL is now resolved from ODDS_CACHE_TTL_SECONDS at module load, falling back to a new default of 3600 seconds (1 hour) — up from the legacy 900s.

Rationale: each cache miss fans out to (1 + N) upstream calls, costing 510 credits per refresh. At 15-min TTL across 4 sports that's ~3,840 credits/day — an order of magnitude over the free tier's 500/month. At 1h TTL it's ~960/day — still over, but a factor of 4 closer. Operators on the free tier with many sports should bump to 7200 (2h) via Coolify.

Bounds-checked: rejects overrides <60 (would shred credits) and

86400 (would hold stale forever); both fall back to 3600.

getConfiguredCacheTTL exported for direct test coverage. Five new tests pin the parser.

Opt-in odds prewarmer

scripts/odds-prefetch.js calls getOdds(sport) for each configured sport to warm the cache out-of-band. Gated by ODDS_PREWARM=1 — the first thing main() does is check the flag and bail out with exit code 2 if unset. This is a hard safety: at the free tier the script would blow the monthly budget if run accidentally.

CLI:

ODDS_PREWARM=1 node scripts/odds-prefetch.js --sports=nba,mlb
ODDS_PREWARM=1 node scripts/odds-prefetch.js --dry-run

Returns a structured summary including credits spent (computed as the delta between pre-run and post-run tracker reads). The script bails the moment the tracker reports allowed:false mid-run, so subsequent sports don't add to the bleeding.

Module-exports main and __internals.parseArgs for testing. 11 unit tests cover gating, dry-run, happy path, credit-delta calculation, mid-run block, and per-sport error isolation.

PHASE 4 — Poller frequency review

Audit complete: the existing PM2 pollers (poller.js for NBA/WNBA/MLB) hit ESPN scoreboards — free, no quota. The 60s default is correct for ESPN. The soccer poller (soccer.js) already received quota-aware tick-skipping in Session 20.

No changes — the spec's "60s → 900s" change would have applied to a hypothetical odds-api poller that doesn't exist.

Honest scope flags

  • The actual production 503 is NOT fully fixed by this session. This session changes the cost ceiling (4x lower per-cache-miss) and the quota check accuracy (tracker, not drifting hash). It does NOT change the fundamental constraint that the free 500-credit/month tier cannot serve live props across 3+ sports continuously. The real fix is a tier upgrade or accepting longer cache (4h+).
  • The prewarmer is deliberately not wired to cron or PM2. When/if the account upgrades, the operator can schedule it manually. Auto-mounting it would silently spend credits.
  • The getQuotaRemaining legacy hash is kept, not removed. Other call paths (the routes' response envelope) consume it for the quota_remaining field. Removing requires migrating those consumers — out of scope for a "make the guard trustworthy" pass.

Battery

  • Express suite: 116 passed / 1505 tests (+19 over baseline 1476 → 1486 → 1505). 11 prewarmer + 5 TTL parser
    • 3 tracker guard.
  • Web build: clean.

Files changed (Session 22)

Created:

  • scripts/odds-prefetch.js
  • tests/unit/oddsPrefetch.test.js

Modified:

  • src/services/oddsService.jsgetConfiguredCacheTTL
    • tracker-driven preflight guard + new module exports
  • tests/unit/oddsService.test.js — 3 tracker tests + 5 TTL parser tests + 1 informational default-TTL test, removed the legacy hgetall.remaining:0 block test

Session 21 (2026-06-12) — SHIPPED

Session 20 built the gateway; Session 21 wires every adapter through it. Plus: ntfy push at WARN (80%) and BLOCK (95%), an end-to-end integration test, and an honest correction to the provider registry that flags a critical misconception in the original spec.

PHASE 1 — Provider trace + registry correction (CRITICAL)

The Session 20 registry classified oddspapi and parlayapi as live-odds fallback providers for the-odds-api. They are not. Tracing the existing adapters revealed:

  • oddsPapiAdapter — Pinnacle CLOSING-line capture at tip-off. Writes to closing_lines table for CLV. One row per game. NOT a live-props source.
  • parlayApiAdapter — historical archive (1K credits/month). Used by bulk scripts and trap detection. NOT real-time.

Registry corrected:

  • oddspapi.capabilities = ['closing_lines'], name = "ODDSPAPI (Pinnacle close)"
  • parlayapi.capabilities = ['historical_props', 'historical_lines'], name = "ParlayAPI (historical)"
  • Both bumped to priority: 1 for their actual capability sets

Consequence: getFallbackChain('odds', 'nba', 'odds-api') now returns [] because no other configured provider serves live odds. The gateway's QuotaExhaustedError path is honest about this: when the-odds-api hits 95%, there is no fallback to take over. The fix to the original 503 incident is operational (higher tier, better caching, or a real new provider), not architectural.

PHASE 2 — Tank01 NBA + MLB through gateway

Single axios.get call site in each adapter's fetchWithCache — wrapped in gateway.fetch('tank01', () => axios.get(...), { capability: 'box_scores', sport }). Existing cache + stale-while- revalidate logic untouched.

Tests: existing 51 Tank01 tests all pass unchanged.

PHASE 3 — API-Football through gateway

Same surgical pattern at the fetchWithCache axios.get. The adapter keeps its own apifootball:daily_count Redis counter (legacy SOFT_LIMIT=90 trigger); the tracker is now ALSO advancing on every successful call. Two counters, one truth source: tracker drives WARN/BLOCK; legacy counter drives the local stale-while-revalidate switch.

Tests: 16/16 unchanged.

PHASE 4 — Football-Data through gateway

Wrap pattern as above. The adapter's in-process token bucket (8 req/min) short-circuits BEFORE the gateway — so the gateway counter only ticks for calls that actually went over the wire. Order: bucket → gateway → axios.

Tests: 15/15 unchanged.

PHASE 5 — ODDSPAPI + ParlayAPI through gateway

Wired for their actual purposes:

  • oddsPapiAdapter.fetchPinnacleProp → gateway with capability: 'closing_lines'
  • parlayApiAdapter.fetchWithGuards → gateway with capability: 'historical_props'

Test mock update: parlayApiAdapter.test.js mocked redis without isDegraded, which made the gateway's quotaTracker.recordCall throw. Added isDegraded: () => true so the gateway falls through in degraded-mode fail-open — preserves the test's existing axios+cache assertions.

Tests: 13/13 (10 oddsPapi + 3 parlayApi) pass.

PHASE 6 — ntfy alerts at WARN + BLOCK

quotaTracker.sendQuotaAlert(providerCfg, pct, used, limit):

  • WARN (≥80%) → priority 4 ("high"), title Warning
  • BLOCK (≥95%) → priority 5 ("urgent"), title BLOCKED
  • Disabled when NTFY_URL env unset (default in dev)
  • Fire-and-forget (.catch(() => {})) so a slow ntfy server can't add latency to the adapter's HTTP call
  • ntfy POST failure → console.warn only; recordCall still returns the normal status

Two dedupe keys per period:

  • quota_warned:{provider}:{period} — WARN sentinel
  • quota_warned:{provider}:{period}:block — BLOCK sentinel

This handles the WARN→BLOCK transition correctly: a provider that jumps from 79% → 96% in one call fires the BLOCK alert even though the WARN sentinel was never set. Without the separate key, the operator wouldn't get the BLOCK notice (the actionable one).

6 new tests cover: no-post when NTFY_URL unset, priority 4 at 80%, priority 5 at 95%, dedupe (3 calls → 1 alert), WARN→BLOCK transition fires BOTH alerts, axios.post failure preserves recordCall return.

PHASE 7 — End-to-end gateway wiring test

tests/integration/providerGatewayWiring.test.js — 4 tests through the Tank01 NBA adapter (chosen because its fetchWithCache has no token-bucket/circuit-breaker; the gateway behavior dominates):

  1. Successful adapter call → tank01 counter goes 0 → 1
  2. Cache hit → no HTTP, counter stays
  3. Counter seeded to 95% via syncFromHeaders → adapter returns null (cache miss + no stale = degrade to null); axios.get NEVER called
  4. axios throws → gateway rolls back the optimistic increment; counter restored to pre-call value

Honest scope flags

  • No new ODDSPAPI/ParlayAPI live-props adapter. The spec asked for one; reality is they don't serve live props. Built documentation in the registry instead.
  • No "provider-aware callback architecture" abstraction (Phase 2 of the spec). Each adapter is already provider-aware (it knows its URL, key, auth) — adding a meta-adapter that switches between them per-call is premature without a real fallback chain. Worth revisiting if/when a true live-odds alternative provider is onboarded.
  • The "documentation" phase wasn't applied to a separate playbook file (none exists at the repo root); the corrections
    • per-provider wiring rationale live in the adapter files and this BUILD-STATE entry, which is the closest the repo has to a playbook.

Battery

  • Express suite: 115 passed / 1486 tests (+10 over baseline 1476). Breakdown of new tests:
    • 6 ntfy in quotaTracker.test.js
    • 4 in providerGatewayWiring.test.js (new file)
  • Web build: clean, no TS errors. Admin route still resolves.

Files changed (Session 21)

Created:

  • tests/integration/providerGatewayWiring.test.js

Modified:

  • src/config/providers.js — capability corrections for oddspapi + parlayapi
  • src/services/quotaTracker.jssendQuotaAlert + WARN/BLOCK dedupe key split
  • src/services/adapters/tank01NbaAdapter.js — gateway wrap
  • src/services/adapters/tank01MlbAdapter.js — gateway wrap
  • src/services/adapters/apiFootballAdapter.js — gateway wrap
  • src/services/adapters/footballDataAdapter.js — gateway wrap
  • src/services/adapters/oddsPapiAdapter.js — gateway wrap
  • src/services/adapters/parlayApiAdapter.js — gateway wrap
  • tests/unit/quotaTracker.test.js — 6 ntfy tests + axios mock
  • tests/unit/parlayApiAdapter.test.jsisDegraded in mock

Provider wiring status (after Session 21)

Provider Gateway-wired Capability Quota visible
the-odds-api (Session 20) odds/props
Tank01 NBA+MLB box_scores
API-Football lineups/stats
Football-Data fixtures/tables
ODDSPAPI closing_lines
ParlayAPI historical

Every external HTTP call from the app now flows through gateway.fetch(). The admin dashboard's Provider quotas tile shows real numbers for every one of them.


Session 20 (2026-06-12) — SHIPPED

Built the data-pipeline backbone: a per-provider quota tracker, a unified gateway that routes through fallback providers when one approaches its limit, and structural visibility into all of it via the admin dashboard. This is the infrastructure that prevents the "odds-api at 0/500 → all sports 503" incident from happening again.

PHASE 1 — Provider registry

src/config/providers.js enumerates the six providers VYNDR talks to (the-odds-api, ODDSPAPI, ParlayAPI, Tank01, API-Football, Football-Data.org). Each entry declares envKey, quotaType (monthly|daily|per_minute), quotaLimit, sports, capabilities, and priority. Exports getProvider, listProviderIds, getConfiguredProviders, getFallbackChain(capability, sport, excludeId). Thresholds (WARN 80%, BLOCK 95%) live in the same module so the tracker and gateway can't drift.

src/server.js now logs which providers have keys at boot: [VYNDR] providers configured (4): odds-api, tank01, api-football, football-data and warns about any with missing keys.

PHASE 2 — Quota tracker

src/services/quotaTracker.js is the Redis-backed counter. Keys:

  • quota:{provider}:{period}{used, limit, syncedAt}
  • quota_warned:{provider}:{period} → dedupe the 80% log line

Period format is quota-type-driven: YYYY-MM for monthly, YYYY-MM-DD for daily, YYYY-MM-DDTHH:MM for per-minute. UTC so operators in different timezones see one consistent picture.

API:

  • getQuotaStatus(provider) — read without mutation
  • recordCall(provider) — increment + return new status
  • rollback(provider) — decrement after a failed call
  • syncFromHeaders(provider, headers) — truth-source override from upstream response headers (odds-api returns x-requests-used + x-requests-remaining)
  • getAllQuotaStatuses() — snapshot for the dashboard
  • getTickInterval(pct) — scheduler step function (<50% → 5min, <80% → 15min, <95% → 30min, ≥95% → null)
  • shouldThrottle(provider) — composite for schedulers

Degraded mode — when Redis is down, the tracker fails OPEN (allowed: true, degraded: true) rather than closed. The alternative (degrade closed) would mean a Redis blip blocks every provider call platform-wide, which is worse than the original quota-exhaustion bug.

21 unit tests cover period keys, recordCall counting, syncFromHeaders truth-source override, the 80% warning dedupe, threshold flips, rollback, getTickInterval steps, and degraded-mode fail-open.

PHASE 3 — Provider gateway

src/services/providerGateway.js is the single entry point every external-data call passes through:

const result = await gateway.fetch('odds-api', cb, {
  capability: 'odds',
  sport: 'nba',
  fallbackProviders: ['oddspapi'],  // optional
  syncHeadersFrom: (r) => r.headers,  // optional
});

Flow: check quota → invoke callback → on quota block, walk the fallback chain (explicit or capability-derived) → on full exhaustion throw QuotaExhaustedError with the attempt log so operators can see what was tried. The callback receives the provider ID it's running under so adapter code can pick the right base URL / API key per fallback.

Critical safety property: only QUOTA failures trigger fallover. A generic upstream error (network blip, 502) propagates from the primary instead of silently shifting the whole platform to the fallback. That mask was the symptom that hid the original outage.

Wired into oddsService.fetchEventsFromApi + fetchEventOddsFromApi. The gateway's syncHeadersFrom callback pumps x-requests-used / x-requests-remaining straight into the tracker on every successful odds-api response.

8 unit tests cover happy path, single-fallback walk, multi-fallback skip, explicit chain override, full exhaustion, adapter-error propagation, and header sync invocation.

PHASE 4 — Scheduler hooks

getTickInterval(pct) exposed for any future polling code. Wired into poller/soccer.js — each tick checks quotaTracker.shouldThrottle('football-data') and skips if quota is exhausted (logs tick skipped — football-data quota exhausted).

Honest scope flag: the NBA/WNBA/MLB pollers hit ESPN scoreboards (no quota), so they don't need wiring. The spec implied a generic poller that hits odds-api on a schedule; that poller doesn't exist — odds-api is on-demand-cached at 15min in oddsService. The gateway + recordCall on every odds-api call gives the same effect (per-call quota enforcement) without a separate scheduler.

PHASE 5 — Admin integration

GET /api/internal/quota added to src/routes/internal.js. Uses the existing requireInternalAuth({loopbackOnly:false}) gate so the Next.js admin route proxies through with the shared key.

web/src/app/api/admin/stats/route.ts now also fetches the quota snapshot (best-effort, 4s timeout, surfaces missing-key as a note instead of blanking the dashboard).

web/src/app/admin/page.tsx renders a Provider quotas table: provider name + period, used/limit + usage bar, quota type (monthly|daily|/min), status indicator (✅ 18%, ⚠️ 82%, ❌ BLOCKED 97%). Bar color tracks the threshold (green < 80, yellow 80-95, red ≥ 95). Table hides when no providers reported.

3 new integration tests on the /quota endpoint: rejects without internal key, returns snapshot when keyed, returns 500 on tracker error.

PHASE 6 — Header sync into tracker

oddsService.updateQuota now also lazily-requires the tracker and calls syncFromHeaders('odds-api', headers) so the new counter stays current alongside the legacy hash-based quota in Redis. The gateway's syncHeadersFrom already does this on each call — the updateQuota hook is belt-and-suspenders for any call path that bypasses the gateway in the future.

Honest scope flags

  • Only oddsService is wired through the gateway. Tank01, API-Football, and Football-Data adapters still call axios directly. They can be migrated by wrapping their existing axios calls in gateway.fetch(<providerId>, () => axios.get(...), { capability, sport }) — no upstream contract change. Holding off this session to avoid blast radius on stable adapter code; the gateway + tracker stand alone and are ready when needed.
  • The Provider Quotas tile renders only the providers whose keys are present on the Express side. If a key is set in prod but unset locally, the local admin view will look thinner than prod — by design.
  • "Smart scheduler" is wired only for the soccer poller (the one poller that does hit a quota'd provider). The other PM2 pollers don't need it.

Battery

  • Express suite: 114 passed / 1476 tests (+32 over baseline 1444; 21 quotaTracker + 8 providerGateway + 3 /quota integration). Two pre-existing test files needed their redis mocks extended with cacheGet/cacheSet/isDegraded for the gateway path; degraded-mode fail-open preserves their axios-driven assertions.
  • Web build: clean/admin + /api/admin/stats register as dynamic; no TS errors.

Files changed (Session 20)

Created:

  • src/config/providers.js
  • src/services/quotaTracker.js
  • src/services/providerGateway.js
  • tests/unit/quotaTracker.test.js
  • tests/unit/providerGateway.test.js

Modified:

  • src/services/oddsService.js — gateway wrap + tracker sync
  • src/routes/internal.js/api/internal/quota endpoint
  • src/server.js — startup provider log
  • poller/soccer.js — quota-aware tick
  • tests/unit/oddsService.test.js — mock extension
  • tests/integration/odds.test.js — mock extension
  • tests/integration/internalRoutes.test.js/quota coverage
  • web/src/app/api/admin/stats/route.ts — provider_quotas tile
  • web/src/app/admin/page.tsx — Provider quotas table

Session 19 (2026-06-12) — SHIPPED

The platform had every backend piece in place but read like a spreadsheet. Same player name listed four times in a row, blank scan page, generic game headers. This session restructured the visual hierarchy so the player is the hero of every card.

PHASE 1 — NBA proxy diagnosis

User reported "/api/odds/nba returns 503 while Express has live data." Trace: NBA and WNBA proxies are byte-identical in shape. Probe of production confirmed all three sports (NBA, WNBA, MLB) return 503 with the same payload — root cause is upstream of the proxy. The Express oddsService.getOdds 503 path fires when odds-api fails AND no Redis cache exists.

Likely production cause: ODDS_API_KEY rotation, quota exhaustion, or Redis disconnect (cache always empty so every request goes live, then fails). Not fixable from code without env access.

Code change: added a console.error line at the 503 fallthrough that surfaces upstream status + axios error code + truncated upstream body. Next time someone gets paged with a 503, the log gives them the answer instead of "Odds service unavailable."

Test: pinned the log shape (upstream_status=, sport name, body substring) so a future log-cleanup PR can't silently delete it.

PHASE 2 — PlayerCard + headshot utility

web/src/lib/playerHeadshot.ts exposes getHeadshotUrl({sport, playerId, espnId, cachedPhotoUrl}) with fallback chain:

  • cached photo URL → league CDN → ESPN CDN → silhouette
  • League CDNs: cdn.nba.com/headshots/nba/latest/260x190/{id}.png, cdn.wnba.com/headshots/wnba/..., img.mlbstatic.com/mlb-photos/...
  • ESPN CDN used ONLY when no league ID and espnId present
  • Soccer doesn't get a synthetic URL — API-Football's photo field is cached separately and passed as cachedPhotoUrl

web/public/images/player-silhouette.svg — 64x64 generic silhouette, dark-theme colors.

web/src/components/PlayerCard.tsx — new component. Header (headshot + name + team) over N PropRow children. <img onError> falls back to the silhouette so a CDN 404 doesn't leave a broken image. Exports groupPropsByPlayer(props) helper.

web/src/components/GameCard.tsx updated:

  • Imports PlayerCard + groupPropsByPlayer
  • Visibility budget (defaultVisible=4) now applies to PLAYERS, not raw props — previously a single player with 4+ props consumed the whole budget and other players were hidden
  • "+ N more prop(s)" → "+ N more player(s)"

PHASE 3 — Game card header redesign

teamAbbr(fullName, sport) exported from GameCard:

  • Override table for 30+ well-known multi-word names (Los Angeles Lakers → LAL, St. Louis Cardinals → STL, etc.)
  • Two-word names fall back to the first word's 3 letters
  • Soccer composes initials when 2+ words, else truncates

Header now shows: 🏀 BOS vs DEN [NBA] in bold mono, with the sport label on a colored badge to the right. Below: full names in muted text + time/venue meta line. Sport colors:

  • NBA #E94B3C · WNBA #FFB347 · MLB #1E90FF · Soccer #00D4A0

PHASE 4 — Scan page tonight's players

New "TONIGHT'S PLAYERS" chip grid above the search input, pulled from /api/odds/{sport} (the canonical list of players who have props posted today — same source The Slate uses). Each chip: 24×24 headshot + name. Click prefills the player and, when only ONE stat type has props for that player, prefills the stat too.

Section auto-hides when the array is empty (off-season, odds-api down, etc.) — no sad "couldn't load tonight's players" stripe.

Search dropdown enhanced: every suggestion now has a 28×28 headshot. Falls back to silhouette via onError for players the CDN doesn't have yet.

PHASE 5 — CSP img-src expanded

web/next.config.ts — img-src now includes cdn.wnba.com and img.mlbstatic.com. Was cdn.nba.com + a.espncdn.com.

PHASE 6 — Tier-gate utility (wired in Session 20)

web/src/lib/tierGate.ts — exports canSeeFullLists(tier), canSeeGradeDetails(tier), getVisibleCount(tier, totalCount), getHiddenCount(tier, totalCount). Free users see top 3; africa, analyst, desk see everything. Free + africa see grade letters but NOT detailed grade breakdowns (analyst+ only).

Not consumed yet — exported for the streaks/hot-lists work planned in Session 20.

Honest scope flags

  • I did not run the actual UI in a browser. The web build is clean, types resolve, and the Slate's data flow is intact, but I can't verify the visual end state without a live render.
  • Headshot CDNs will 404 for some players (rookies the league hasn't shot yet, traded players whose league ID we haven't re-mapped). The onError fallback prevents broken images, but expect ~515% silhouette rate on coverage.
  • The NBA proxy 503 is NOT fixed in code. The diagnostic log helps the next operator pinpoint the root cause; the fix itself needs env config access.

Battery

  • Express suite: 112 passed / 1444 tests (+1 — odds service diagnostic log test; baseline 1443)
  • Web build: clean — all new routes register, no TS errors, no ESLint failures
  • All new TypeScript modules tree-shake into existing pages

Files changed (Session 19)

Created:

  • web/src/lib/playerHeadshot.ts
  • web/src/lib/tierGate.ts
  • web/src/components/PlayerCard.tsx
  • web/public/images/player-silhouette.svg

Modified:

  • src/services/oddsService.js — diagnostic log at 503 path
  • tests/unit/oddsService.test.js — pinned log shape
  • web/src/components/GameCard.tsx — PlayerCard integration + teamAbbr + sport-colored header
  • web/src/app/scan/page.tsx — tonight's players chip grid + headshot-enriched search suggestions
  • web/next.config.ts — CSP img-src for cdn.wnba.com + img.mlbstatic.com

Session 18 (2026-06-11) — SHIPPED

Built an operator-facing admin dashboard at /admin so Kev can pull the three numbers he needs every morning (total users, paying users, grades today) without dropping into psql. Added the missing HTTP surface for the Tank01 prefetch script so it can be triggered from the dashboard (or any internally-keyed caller) instead of only from a host shell.

Section 1 — Admin allowlist + UI guard

web/src/lib/isAdmin.ts exposes isAdmin(email) over a hard-coded allowlist (kevdevelops@gmail.com). Case-insensitive on input; trims whitespace. Trivial by design — the security boundary is the server check, not this helper.

web/src/app/admin/page.tsx is a client component that uses useAuth() and isAdmin() to redirect non-admins to /dashboard. This is UX-only — anyone with devtools can flip the boolean. The real check is on the API route.

Section 2 — Stats API with server-side admin check

web/src/app/api/admin/stats/route.ts (force-dynamic, no-store) validates the bearer token via getUserFromRequest, then asserts isAdmin(user.email) before any data leaves Supabase. Non-admin tokens get 403 (not 401 / redirect) so the route's existence doesn't leak. Service-role queries are wrapped in Promise.allSettled so one failed aggregate doesn't blank the dashboard — the notes[] field surfaces partial failures inline.

Aggregates returned: total users, tier breakdown (free|africa|analyst|desk), last-24h signups (max 20, emails masked as j***@gmail.com), all-time grade count, today's grade count, per-sport odds health (NBA/WNBA/MLB/soccer-wc), shared odds-api quota remaining.

Spec assumed table grading_log; actual table is grade_history. The route queries the real table.

Health probes share a 4-second AbortController budget so a stalled upstream can't block the page.

Section 3 — Dashboard UI

Key-metrics row → tier breakdown with proportional bars → recent signups table → system-health table. Mono numbers, VYNDR dark tokens (--bg-surface, --grade-a, --grade-d, --text-tertiary). Not linked from nav — operator bookmarks the URL.

Section 5 — Tank01 prefetch HTTP endpoint

src/routes/internal.js mounts at /api/internal/prefetch/tank01, gated by requireInternalAuth({loopbackOnly:false}). Accepts JSON {max?, sports?, dryRun?} and translates it into argv for the existing scripts/tank01-prefetch.js module's exported main().

Deviation from spec: spec suggested execSync('node scripts/...'). We import the module instead — testable in-process, no PATH dependency, no permission-shell stack. Module already supports the exact CLI flags so the body shape stays the same.

Wired through src/app.js (app.use('/api/internal', internalRoutes)). The shared VYNDR_INTERNAL_KEY is set in Coolify; the Next.js admin page never sees the key (UI button will proxy through a server route in a follow-up — out-of-scope for Session 18).

Tests

tests/integration/internalRoutes.test.js — 5 new tests:

  • rejects without x-internal-key
  • translates body into argv (sports list, max, dryRun)
  • forwards --dry-run correctly
  • accepts string-form sports (single sport)
  • returns 500 with the underlying error message on module rejection

All 5 tests pass. Existing 1438 tests untouched.

Battery

  • Express suite: 112 passed / 1443 tests (5 new, baseline was 1438)
  • Web build: clean/admin and /api/admin/stats registered as dynamic routes
  • TypeScript: clean (initial build flagged a NextResponse-vs-Response mismatch on jsonError returns; relaxed the route's return type to the shared supertype)

What Kev sees now (next session, in a browser)

Visit /admin while signed in as kevdevelops@gmail.com:

  • Three big numbers across the top: Total Users / Paying Users / Free Users / Grades Today
  • Tier-distribution bars
  • Last-24h signups (masked emails, relative timestamps)
  • Per-sport health (NBA · ✅ Live · 234 props / WNBA · ⚪ No props / etc.)
  • Odds-api quota remaining

Anyone else visiting /admin → soft-redirect to /dashboard. Anyone calling /api/admin/stats without an admin token → 403.

Files changed (Session 18)

Created:

  • web/src/lib/isAdmin.ts
  • web/src/app/admin/page.tsx
  • web/src/app/api/admin/stats/route.ts
  • src/routes/internal.js
  • tests/integration/internalRoutes.test.js

Modified:

  • src/app.js — mount /api/internal router

Pending (out-of-scope for Session 18)

  • Wire a "Prefetch Tank01 now" button on the admin page that POSTs through a Next.js server route (so VYNDR_INTERNAL_KEY stays out of the browser).
  • Add a real "monthly revenue" tile (requires Stripe-side aggregation; spec said three numbers — we shipped two and added Grades Today as the third operational signal).

Session 17 (2026-06-12) — SHIPPED

A platform audit from a signed-in / signed-out walkthrough flagged 12 issues. This session traced each to root cause and shipped fixes. Stripe is live with real products + webhooks; the symptoms audited were code-side, not Stripe-side.

FIX 1 — Checkout 401 "User profile not found" [CRITICAL]

src/middleware/auth.js 401'd authenticated users whose auth.users row had no matching public.users profile. Signup writes to auth.users automatically; the application-side row never landed for SSO callbacks and legacy accounts that pre-dated the trigger.

Fix: when .single() returns PostgREST's PGRST116 ("no rows"), the middleware now upserts a default {id, email, tier:'free'} row and re-reads. Idempotent under concurrent requests. Distinct 401 message (User profile creation failed) when the upsert itself fails — lets the operator separate missing-row recovery from real DB outages in logs. 9 tests cover happy path, missing row → upsert, message-only PGRST116 detection, upsert error, post-upsert empty re-read, and non-PGRST116 errors NOT triggering an upsert.

FIX 2 — Hero prop 404 [CRITICAL]

web/src/app/api/hero-prop/route.ts shipped Session 16 with BOTH dynamic = 'force-dynamic' AND revalidate = 900. Next.js App Router silently 404s on this conflict. Removed revalidate. The 15-minute cache still works via the existing Cache-Control: s-maxage=900 response header.

FIX 3 — WNBA games not surfacing [HIGH]

Slate.tsx's groupByGame skipped every prop because Number.isFinite(r.line) failed on the actual Express response shape. Express's groupProps returns props with lines: [{ book, line, over_odds, under_odds }], but the Slate expected a flat line: number. Every WNBA / NBA / MLB prop was filtered out.

Fix: added a pickLine() unwrapper that prefers the flat r.line when present (legacy callers + test fixtures) and otherwise picks the first numeric line out of r.lines[]. The Slate now correctly surfaces game cards for any sport with a populated lines array.

FIX 4 — ALL tab error cascade [HIGH]

Slate.tsx's cascade surfaced a top-level error whenever ANY single sport rejected — even when the other sports succeeded with empty data. Reworked to track per-sport failures separately and only show the top-level banner when EVERY attempted sport rejected. Failed- but-attempted sports get appended to the existing footer "endpoint not configured" line.

Root cause was visual overlap, not the component's logic: BottomTabBar and CookieConsent both position: fixed; bottom: 0, and BottomTabBar's 64px height visually obscured the banner. Resolved transitively by FIX 7 — anonymous visitors no longer see BottomTabBar, so the cookie banner has the bottom of the viewport to itself on first visit.

FIX 6 — Scan autocomplete silent failure [MEDIUM]

The dropdown logic was correct — the silent failure happened when /api/players/search returned { players: [] } (NBA service down, or no spelling match). Added a visible "no players matched" state when the search has run but returned empty, so users get feedback.

FIX 7 — Mobile bottom nav auth gate [MEDIUM]

BottomTabBar.tsx rendered for all users on all eligible routes. Anonymous visitors on /pricing saw Home/Read/Parlay/Ledger/Profile — all auth-gated destinations that would 401 on click. Gated behind useAuth() with a loading || !user early-return. Also fixed FIX 5 transitively.

Added Support link (mailto:support@vyndr.app) to the Legal column. Removed (test mode while we onboard founders) from Pricing.tsx — Stripe is live. Replaced with "First 100 users lock $14.99/mo Analyst for life."

FIX 9 — Sentry zero events [MEDIUM]

Code wiring is correct in both backend (initSentry() + setupExpress ErrorHandler mounted) and frontend (SentryInit reads NEXT_PUBLIC_SENTRY_DSN). Audit found zero events because the DSN env vars aren't set in Coolify. Code-level no-op; documented as a Coolify env action.

FIX 10 — Read counter visibility [LOW]

Quota pill appeared in the global Nav across every page. Restricted to /scan and /dashboard (the surfaces where it acts as quota context next to the scan action) via a pathname check in Nav.tsx.

FIX 11 — Profile page [NO-OP]

Audit reported "no profile page exists." Verified: it does, at web/src/app/profile/page.tsx (196 lines, includes email, tier, subscription_status, subscription_end, founder_pricing, cancel_at_period_end). Audit looked at a stale build.

FIX 12 — Tonight's slate landing preview [MEDIUM]

web/src/components/TonightsSlate.tsx (new) — game-count strip mounted between Hero and LivePropsStrip. Fetches the three sport-odds proxies in parallel, dedupes games by (away, home, time), renders "X NBA · Y WNBA · Z MLB games being graded right now." with a signup CTA. Hides itself when every sport returns zero.

Tests added (Session 17)

Suite Tests
tests/unit/requireAuth.test.js 9
Session 17 total 9

Quality gates

  • npm test: 1438 / 1438 passing (1429 + 9 new), 111 suites, 0 regressions
  • web/npm run build: clean — /api/hero-prop now compiles to ƒ (was silently 404'd in production by the conflicting directives)
  • License audit: third-party deps remain permissive

Honest verification status

Build + tests verified. I CANNOT verify the following on the live site from here — they need a deploy + re-audit smoke test:

  • Checkout 401 ↔ actual Supabase row creation under load
  • Hero prop endpoint returning JSON in production
  • WNBA Slate tab actually showing games
  • Cookie banner visible on first incognito load
  • Mobile bottom nav truly absent for signed-out visitors

Coolify follow-ups (operator action)

  1. Set SENTRY_DSN and NEXT_PUBLIC_SENTRY_DSN env vars to enable server-side and browser-side error capture. Currently unset → Sentry dashboard sees zero events even when 503/401 errors occur.
  2. The Session 16 sport-scoped markets fix is in code; the NODE_OPTIONS=--require /app/data/patch.js workaround can be dropped from the web service env after this deploy.

Session 16 (2026-06-11) — SHIPPED

Phase 1 — Sport-specific market map

src/services/oddsService.js now scopes the markets-list parameter to the requested sport. Previously every odds-api request sent ALL_MARKETS (the union of every sport's markets), which the upstream 422'd on because soccer markets (player_goals, player_shots_on_target, etc.) aren't valid for basketball endpoints. Production briefly worked around this with a runtime axios interceptor injected via NODE_OPTIONS=--require /app/data/patch.js.

This session retires that hack at the code layer:

  • New SPORT_MARKETS map alongside SPORT_KEYS — separate lists per sport, all frozen with Object.freeze. NBA + NCAAB share basketball markets; WNBA is basketball minus PRA (odds-api doesn't carry that for WNBA); MLB sends batter + pitcher markets; every soccer league shares the soccer set.
  • fetchEventOddsFromApi(sportKey, eventId, apiKey, sport) — third arg added; reads getMarketsForSport(sport) instead of the union. Backwards-compatible: omitted sport falls back to NBA (safe default).
  • fetchAllOdds(sport, apiKey) — already had the local sport key; now passes it through.

Coolify follow-up: after this deploy, the operator can drop NODE_OPTIONS=--require /app/data/patch.js from the web service env and delete /app/data/patch.js. The runtime patch is now dead code.

Phase 2 — Live hero prop

web/src/app/api/hero-prop/route.ts (new) — picks one fresh real prop from today's NBA → WNBA → MLB cascade and grades it. Two-stage flow: GET /api/odds/{sport} → POST /api/analyze/prop. Both calls share a 6s AbortController timeout. Server-side cached for 15 minutes via Cache-Control: s-maxage=900. Falls back to a static Jokic example (isStatic: true) when every sport is empty so the landing page never blanks out.

web/src/components/LiveHeroProp.tsx (new) — replaces the hard-coded FloatingDemoCard inside Hero.tsx. Renders the live prop with:

  • "LIVE" badge with a pulsing green dot
  • Sport-colored category tag (NBA red, WNBA orange, MLB blue, soccer green)
  • Player name + line + projection + edge visible (hook)
  • Grade letter + confidence visible via GradePill (proof)
  • Reasoning section blurred with backdrop blur(4px), a scan-line gradient (repeating-linear-gradient), a bottom-fade mask, and a "CLASSIFIED · Sign up to unlock" label (paywall)
  • Single CTA: "Sign up to read the full analysis →"

While loading OR when the API returns isStatic: true, renders the original Jokic mockup byte-for-byte. No flash-of-blank-card.

Hero.tsx — old FloatingDemoCard, Stat, and row constant deleted. GradePill import moved into LiveHeroProp.

Phase 3 — Soccer weather

soccerFeatureExtractor.js now calls weatherService.getWeather() for outdoor WC venues after resolving the venue. Dome venues skip the fetch. Unknown venues skip silently. New feature fields: weather_temp_f, weather_wind_mph, weather_wind_dir, weather_precip_mm. All null when skipped/failed.

Phase 4/5 — OG tags + CSP (mostly already done)

OG meta + Twitter card + og-image.png were all wired in Session 9. Existing CSP in next.config.ts was comprehensive. Session 16 added:

  • https://browser.sentry-cdn.com to script-src (Sentry SDK)
  • https://*.sentry.io and https://*.ingest.sentry.io to connect-src (event ingestion). Without these the browser Sentry client silently dropped events.

Tests added (Session 16)

Suite Tests
tests/unit/sportMarkets.test.js 16
tests/unit/soccerWeather.test.js 7
Session 16 total 23

Quality gates

  • npm test: 1429 / 1429 passing (1405 + 24), 110 suites, 0 regressions
  • web/npm run build: clean
  • License audit: third-party deps remain permissive

Honest gaps

  • LiveHeroProp's glitch effect (scan lines + blur + fade) renders only in a browser. Build verified. Deploy smoke-test recommended.
  • Hero endpoint depends on /api/odds/{sport} returning populated props. If upstream odds-api is rate-limited or proxies aren't reaching Express, the static fallback fires — cold visitors see the Jokic mockup, not live data.
  • Sentry CSP entries added but require redeploy to take effect. Until then, the browser SDK silently drops events.

Coolify follow-ups

  1. Drop the patch.js workaround: remove NODE_OPTIONS=--require /app/data/patch.js from the web service env. Code-layer fix in Session 16 makes the runtime patch obsolete.

Session 15 (2026-06-11) — SHIPPED

Phase 0 — Correctness

  • Africa short-circuit removed (Pricing.tsx:152). The Session 14 backend handles 'africa' end-to-end: validation accepts it; missing STRIPE_PRICE_AFRICA returns a 503 with code:'tier_unconfigured' the existing inline error surface displays. The frontend short- circuit was blocking checkout even after the backend was ready.
  • Odds + Sentry + welcome email audits: all already correct from prior sessions. Documented for posterity; no fixes required.
  • Poller → odds pipeline: confirmed there's NO key-mismatch pipeline issue. Pollers handle game resolution (game:{id}:status, poller:{SPORT}:heartbeat); oddsService populates odds:{sport}:{date} on-demand. The "1 games shown but Slate empty" report would be a separate odds-api quota / key issue.
  • Founder price fallback hardened. PRICE_MAP no longer falls back to fake strings like 'price_analyst_monthly' that would 400 from Stripe in live mode. Missing env → PRICE_UNCONFIGURED sentinel → 503 with code:'tier_unconfigured'. Founder codes presented against an unwired founder-price env now fall back GRACEFULLY to the standard tier price rather than dropping the checkout — the founder discount is operator-controlled and shouldn't break the user's purchase.

Phase 1 — Signal audit

Documented in a new comment block at the top of src/services/intelligence/computeFeatures.js. Every signal cited to its data source: injury (ESPN injury feed), coach (Supabase coach_profiles + JSON seed), consistency (game logs via gameLogService), Tank01 fields (Session 14 + 15 prefetch), soccer cascade (Session 9), park factors (Session 15 — static), weather (Session 15 — Open-Meteo), pace factors (Session 15 — static). No phantom signals.

Phase 2 — MLB park factors

src/data/parkFactors.js — all 30 MLB parks indexed to 100 league average. FanGraphs 2024-25 three-year weighted data. Coors at hr=128, SF Oracle at hr=85 (the two extremes by design). getParkFactor() returns null on unknown teams so the feature extractor drops the signal cleanly rather than falsely reporting "neutral".

Wired into computeFeatures.js MLB branch — features pick up park_hr, park_h, park_r, park_home when the home team resolves.

Phase 3 — Weather (Open-Meteo)

src/services/weatherService.js — Open-Meteo proxy (no API key required). 5-second hard timeout, 1-hour Redis cache, silent degrade on failure (never blocks the grade). Fahrenheit + mph units to match the bettors' mental model.

src/data/venueCoordinates.js — lat/lon + dome flag for all 30 MLB venues and all 16 World Cup 2026 venues. Retractable stadiums are marked dome:true because operators close the roof when conditions warrant — weather doesn't drive grade in that case.

Wired into computeFeatures.js MLB branch — fetches weather when the home venue is outdoor + has finite coordinates.

Phase 4 — Tank01 daily prefetch

scripts/tank01-prefetch.js — orchestrator that pulls the Redis cache keys Session 14's tank01Augment.js reads. Default budget ≤80 requests/run, configurable via --max=N. NBA path pulls schedule + final-game box scores + daily odds. MLB path pulls scoreboard + final-game box scores (BvP pull awaits batter/pitcher ID resolution on the scoreboard payload).

Recommended trigger: extend the n8n "Morning Ops" workflow to exec the script daily at 7am UTC.

Phase 5 — MLB matchup context

src/services/intelligence/mlbContext.js — pure functions for platoonAdvantage(pitcherHand, batterHand) and projectedPA(lineupPosition). Tested with all hand combinations + all lineup slots. Wiring into computeFeatures is deferred until odds-api carries those fields (it doesn't today).

Phase 6 — NBA pace factors

src/data/paceFactors.js — all 30 NBA teams (NBA.com/stats 2024-25, indexed to 100). Legacy-abbreviation aliases (NJN→BKN, NOH→NOP, SEA→OKC, CHO→CHA) so historical lookups resolve. Wired into the NBA branch of computeFeatures.jspace_factor (player's team) + opp_pace_factor (opponent).

Tests added (Session 15)

Suite Tests
tests/unit/parkFactors.test.js 14
tests/unit/weatherService.test.js 14
tests/unit/tank01Prefetch.test.js 14
tests/unit/mlbContext.test.js 21
tests/unit/paceFactors.test.js 12
Session 15 total 75

Quality gates

  • npm test: 1405 / 1405 passing (1330 + 75 new), 108 suites, 0 regressions. One pre-existing computeFeatures test was updated: the contract used to be "ESPN failure → empty features"; the contract is now "ESPN failure → static context augmentation (pace, park) still surfaces."
  • web/npm run build: clean
  • License audit: third-party deps remain permissive

Honest gaps

  • Tank01 prefetch must be triggered by n8n/cron before the augmentor reads return data. Grades work as before until then.
  • BvP pull is no-op until probable-pitcher IDs land on the Tank01 MLB scoreboard projection.
  • Phase 5 helpers tested but not wired — odds-api doesn't carry batter handedness or lineup position fields today.
  • Weather for soccer venues: only MLB is wired this session. Soccer venue weather is a 5-line follow-up in the soccer extractor.

Coolify env (Session 15 additions)

None new from this session.


Session 14 (2026-06-11) — SHIPPED

Phase 1 — Africa tier checkout

  • src/services/stripeService.jsPRICE_MAP.africa added (reads STRIPE_PRICE_AFRICA, null when unset). getPriceId('africa') returns the new PRICE_UNCONFIGURED sentinel when the env var isn't set. createCheckoutSession translates the sentinel to a 503 with code: 'tier_unconfigured' so the frontend can render a helpful message instead of a generic failure.
  • src/routes/stripe.js — validation whitelist extended: ['africa', 'analyst', 'desk']. The catch block recognizes err.code === 'tier_unconfigured' and surfaces it cleanly.
  • Tests: +6 (3 integration around /api/stripe/checkout for the africa tier, 3 unit around getPriceId('africa') and the exported sentinel).
  • DB CHECK constraint blocker from Session 12 still applies — Stripe webhook writes of tier='africa' to users.tier / user_profiles.tier will 23514 until the manual SQL drops + re- adds the constraint with 'africa' included. Validation-layer fix is in place; the migration is the next step.

Phase 2 + 3 — Tank01 NBA + MLB wired into computeFeatures

Architectural choice: cache-read path only on the user request path. The Tank01 adapters (Session 9) already wrap their primitives behind Redis with TTL'd tank01:* keys. The new src/services/intelligence/tank01Augment.js reads those keys directly without ever calling RapidAPI — that keeps the user request path off the 1000/mo free-tier budget. A daily prefetch (future session) will populate the keys; until then the augmentor returns empty objects and the existing ESPN-derived features stand alone.

  • augmentNbaFeatures({gameId, playerName, ymd}) reads tank01:nba:boxscore:{gameId} and tank01:nba:odds:{ymd}, surfaces t01_pts/reb/ast/threes/blk/stl/tov/minutes/_final for the named player when present, plus a t01_market_present marker when daily odds are cached.
  • augmentMlbFeatures({gameId, batterName, batterId, pitcherId, pitcherName, ymd}) reads tank01:mlb:bvp:{batterId}:{pitcherId} and surfaces BvP signals (t01_bvp_pa/ab/h/hr/so + derived t01_bvp_so_rate). Best-effort fallbacks: name-only markers when IDs are absent (future ID resolution), daily-scoreboard presence marker when pitcher is unknown.
  • computeFeatures.js calls both augmentors after safeGetFeatures and merges the result with Object.assign. Wrapped in try/catch so a Redis hiccup never poisons a grade.
  • Tests: 13 new in tests/unit/tank01Augment.test.js. Existing computeFeatures + soccerBranch suites still green (no regressions).

Phase 4 — WNBA + MLB odds proxies

  • oddsService.SPORT_KEYS — added wnba: 'basketball_wnba' and mlb: 'baseball_mlb'. Off-season odds-api responses return empty arrays which the Slate handles cleanly.
  • src/routes/odds.js — new buildSportRoute() factory drives /api/odds/wnba and /api/odds/mlb (clones of the existing /api/odds/nba handler).
  • Next.js proxies: web/src/app/api/odds/{nba,wnba,mlb}/route.ts (the NBA one was also missing — Slate had been pointing at a non-existent route).
  • Slate.tsx FETCH_URLS — WNBA + MLB no longer flagged as unsupported. ALL tab fans out to all four sports via Promise.allSettled.

Phase 5 — UX polish

  • web/src/components/OAuthIcons.tsx — inline SVGs for Google G, Apple silhouette, X glyph. ~1 KB each, no icon library import.
  • Login + signup pages wire icons into the OAuth buttons with a shared layout helper.
  • Slate loading state — bare "Loading the slate…" text replaced with three shimmer-skeleton placeholder cards approximating GameCard dimensions. @keyframes vyndr-shimmer added to globals.css so other loading surfaces can reuse the animation.
  • Empty state messaging — the Slate's empty-result case already shows a "Scan it manually →" CTA from Session 13; Session 14 preserves that path.
  • Mobile nav — added a subtle "Scan manually →" tertiary link in the mobile hamburger panel. The desktop nav stays clean (the Slate IS the scan surface there).

Tests added (Session 14)

Suite Tests
tests/unit/tank01Augment.test.js 13
tests/integration/stripe.test.js extended (Africa checkout) +3
tests/unit/stripeService.test.js extended (Africa getPriceId) +3
Session 14 total 19

Quality gates

  • npm test: 1330 / 1330 passing (1311 + 19), 103 suites, 0 regressions
  • web/npm run build: clean — all four odds proxies prerender
  • License audit: third-party deps remain permissive

Honest gaps

  • Tank01 cache keys are not yet populated by any prefetch — the augmentor wiring is in place but reads will miss until a daily prefetch script lands. The augmentor returns {} on miss, so grades work exactly as before until the keys populate.
  • Africa-tier writes to users.tier will still 23514 (CHECK violation) post-checkout. The DB constraint migration remains a manual SQL step from Session 12.
  • STRIPE_PRICE_AFRICA env var is not set in Coolify yet. Until it is, /api/stripe/checkout returns 503 with code: 'tier_unconfigured' for tier:'africa'.
  • WNBA odds: odds-api may not always carry props during off-season. Slate degrades cleanly (empty props array + empty state UX).
  • OAuth: Google works (if Supabase Site URL + Redirect URLs are configured). Apple + X buttons render with their icons but the redirect won't succeed until provider configuration lands in the Supabase dashboard (Apple Developer Service ID + key; X OAuth 2.0 client).

Coolify env (Session 14 additions)

# New, required to unblock Africa checkout end-to-end:
STRIPE_PRICE_AFRICA=price_...   # After creating the product in Stripe dashboard

Session 13 (2026-06-11) — SHIPPED

Phase 1 — Africa geo-restriction via CF-IPCountry

The Session 12 Africa tier was visible to anyone on a Swahili locale (too narrow: most African users browse in English/French; too broad: Swahili speakers anywhere got the discount). Session 13 swaps the locale proxy for real Cloudflare IP geolocation.

  • web/middleware.ts — reads cf-ipcountry (uppercase), stamps x-vyndr-country on the request alongside the locale header. Empty string when traffic bypasses Cloudflare (local dev).
  • web/src/lib/locales.tsAFRICAN_COUNTRIES set covering all 54 sovereign African nations (NG/KE/ZA/GH + sub-Saharan + MENA overlap). isAfricanCountry(code) is case-insensitive and degrades closed on empty/null inputs.
  • LocaleContext — extended with country/inAfrica fields; new useRegion() hook for components that gate by geography.
  • Pricing.tsxinAfrica === false filters the Africa tier out of the render entirely. inAfrica === true puts it first. Locale-based reorder removed.
  • Pricing grid CSS — desktop column count now tracks the visible tier count via a --pricing-cols CSS custom property on the grid root (3 outside Africa, 4 inside). Sidesteps a styled-jsx limitation with attribute selectors inside :global().

Phase 2 — OAuth: Google + Apple + X

  • AuthContext — added generic signInWithProvider(provider) alongside the legacy signInWithGoogle() (kept as an alias so existing callers don't break). Translates Supabase OAuth errors into a flat { error: string } so the UI can surface a friendly inline message when a provider isn't configured.
  • login/page.tsx + signup/page.tsx — both pages now render three OAuth buttons (Google, Apple, X). The handleOAuth helper routes to signInWithProvider and shows an inline error when the provider isn't configured ("apple login isn't available yet. Use email or another method.").
  • External configuration required (operator action, not code):
    • Supabase Auth → Providers → Apple: needs an Apple Developer Service ID + private key
    • Supabase Auth → Providers → Twitter: needs an X Developer OAuth 2.0 client
    • Google should already work — if it doesn't, verify Supabase Auth → URL Configuration → Site URL = https://vyndr.app and Redirect URLs include https://vyndr.app/**, and that the Google Cloud Console OAuth consent screen has the Supabase callback URL in Authorized redirect URIs.

Phase 3 — The Slate (browse-first dashboard)

Generalizes the Session 8 /soccer page pattern across every sport.

  • web/src/components/PropRow.tsx — single-prop UI with three states (ungraded/grading/graded). Pure presentational — parent owns the API call so there's one shared rate-limited grading queue. Free-tier expansion shows blurred reasoning + Unlock CTA; paid tier shows full reasoning + kill conditions. Exports propRowKey() for stable Map keys.
  • web/src/components/GameCard.tsx — game header + expandable prop list. Sport emoji prefix (🏀 NBA/WNBA, MLB, soccer), sport-accented left border, formatted local game time, + N more expander when props > defaultVisible.
  • web/src/components/Slate.tsx — the orchestrator. Sport tabs (ALL / NBA / WNBA / MLB / Soccer), sticky search input, group-by-game pipeline, gradedProps Map, single-flight grading queue (gradingKey). Promise.allSettled fan-out for the ALL tab so a single sport failing doesn't blank the slate. FETCH_URLS is null-aware — sports without an odds proxy yet (WNBA, MLB) render a bottom-of-page "endpoint not configured yet" note rather than spamming 404s.
  • Search filter + manual-scan fallback — sticky search filters game cards by team name and prop rows by player/stat. Empty result shows a CTA linking to /scan?q=<query> so users land on a partially-filled scan form.
  • /dashboard<Slate /> mounted as the lead surface above the existing Top Graded / Most Parlayed / Recent Reads sections. Those sections stay as supplementary intelligence layers — not removed.
  • Nav.tsx — "Scan" link removed from primary nav. The Slate is the scan surface; /scan stays reachable from the slate's empty-state CTA.

Tests added

Suite Tests
tests/unit/africaCountries.test.js 6
Session 13 total 6

Quality gates

  • npm test: 1311 / 1311 passing (1305 + 6 new), 102 suites, 0 regressions
  • web/npm run build: clean — Slate page + components prerender
  • License audit: third-party deps remain permissive

Honest gaps (documented, not bugs)

  • I could not visually verify The Slate in a browser. Build/type correctness is confirmed; "renders correctly with live odds data" needs a deploy smoke test.
  • Google/Apple/X OAuth: button wiring is complete. Whether the buttons actually authenticate depends on external dashboard configuration (Supabase + Google Cloud Console + Apple Developer + X Developer Portal). Apple and X are guaranteed to show the "isn't available yet" inline error until configured.
  • WNBA + MLB don't have /api/odds/* proxies on the Next.js side yet. The Slate degrades cleanly (footer note), but those tabs return empty until the proxies exist. Session-14 work.
  • Africa tier still can't be SOLD even when geo gates open it — the Stripe price + the DB CHECK migration remain outstanding from Session 12.

Coolify env (Session 13 additions)

None. CF-IPCountry is set by Cloudflare automatically; no env-var change required.


Session 12 (2026-06-11) — SHIPPED

Honest scope decision: skipped the full [locale]/ URL-prefix refactor (would have touched all 24+ pages). Went cookie-based + header-stamping middleware instead — same UX, much smaller blast radius. URL-prefix routing can layer on later without breaking anything.

  • web/src/lib/locales.ts — locale registry. 10 locales: en (source), es, fr, pt, ar (RTL), sw, hi, ja, ko, zh. LOCALE_META carries native names + dir + region. AFRICA_LOCALES = {sw} used by the pricing reorder logic.
  • web/src/middleware.ts — locale resolver. Priority: URL prefix → NEXT_LOCALE cookie → Accept-Language parsing → default 'en'. Stamps x-vyndr-locale on the request so server components can read it via next/headers.
  • web/src/locales/{en,es,fr,pt,ar,sw,hi,ja,ko,zh}.json — 10 translation dictionaries, each ~17 keys covering nav, slate, grade, pricing, sports, auth, common, cookie. Every file declares its _meta.review_status: en is source, the other 9 are translated_unreviewed. Sports terminology is locale-correct (Fútbol/Football/サッカー/كرة القدم/Soka, etc.).
  • web/src/lib/i18n.ts — synchronous server-side loader (getTranslations(locale) → {t, locale, dir}) plus getServerTranslations() which reads the middleware-stamped header. English fallback per key, falls to the key string itself when missing on both. {name} interpolation supported.
  • web/src/contexts/LocaleContext.tsx — client provider + useT() / useLocale() hooks. Mounted in the root layout above every other provider.
  • RTL<html dir="rtl"> set in root layout when locale is Arabic. globals.css flips nav direction and isolates monospace blocks (numbers stay LTR — financial data convention).
  • LocaleSwitcher.tsx — compact mono dropdown with native language names. Sets the cookie, reloads the page. Mounted in Nav for both authenticated and anonymous states.
  • Wired into: Nav (5 links + login button), CookieConsent (message + accept + privacy link), Pricing (CTAs translate per tier). High-impact components first; longer-tail strings remain English with t('key') calls scheduled for a follow-up.

FIX 2 — Africa tier ($4.99/mo)

  • src/config/tiers.js — adds the africa tier between free and analyst: 10 scans/day, reasoning_visible:true, kill_conditions_detail:true, alerts:false, api_access:false. Frozen.
  • Scan-limit middleware — no change needed. scanLimit() reads via getScanLimit(), which now resolves 'africa' to 10.
  • web/src/components/Pricing.tsx — adds the VYNDR Africa card. The pricing-grid CSS unfolds from 2-up (tablet) to 4-up (≥1100px desktop). When the user's locale is Swahili (a proxy for African markets — IP-based geolocation deferred to a future session), the Africa tier renders FIRST.
  • Honest UX gap: Africa-tier checkout short-circuits to an inline "coming soon" message instead of triggering Stripe. Two reasons: (a) the backend /api/stripe/checkout route validates tier against ['analyst','desk'] and the spec forbids backend edits this session; (b) STRIPE_PRICE_AFRICA is unset and the Stripe product hasn't been created in the dashboard yet.
  • DB CHECK constraint blocker: migrations 001 + 011 declare tier IN ('free','analyst','desk'). The webhook will 23514 (check_violation) if it tries to write africa until the constraint is extended. Documented in tiers.js header + in SYSTEM-MANIFEST. Out of scope this session per the no-migration rule.
  • .env.exampleSTRIPE_PRICE_AFRICA=price_... placeholder with explanatory comment.

Tests added (Session 12)

Suite Tests
tests/unit/i18n.test.js 14
tests/unit/tiers.test.js (extended) +5
Total new 19

Quality gates

  • npm test: 1305 / 1305 passing (1286 + 19), 101 suites, 0 regressions
  • web/npm run build: clean. NOTE — every page is now ƒ Dynamic rather than ○ Static because the root layout reads request headers (next/headers) for locale resolution. This is the expected cost of SSR i18n. If FCP regresses, the fallback is client-side cookie reads (brief English flash on first paint, but static prerender returns).
  • License audit: third-party deps remain permissive (no new licenses introduced — translation files are JSON in our own repo).

Open items / follow-ups

  1. DB CHECK constraint must be updated before the Africa tier can actually be assigned to users. Manual SQL:
    ALTER TABLE users DROP CONSTRAINT users_tier_check;
    ALTER TABLE users ADD CONSTRAINT users_tier_check
      CHECK (tier IN ('free','africa','analyst','desk'));
    -- same for user_profiles
    
  2. Stripe product for VYNDR Africa not created. Manual step: create the product + price in the Stripe dashboard, set STRIPE_PRICE_AFRICA in Coolify, then extend the backend checkout route's validation list.
  3. Translation review — only en is source quality. The other 9 locales are translated_unreviewed. Native-speaker review recommended for Arabic, Chinese, Korean, Japanese, Hindi before public launch.
  4. Browser geolocation — Africa tier currently sorts first only for Swahili-locale users. IP-based detection (NG/KE/ZA/GH/etc.) would catch English-speaking African users; deferred to a session with proper geo middleware (Cloudflare headers, etc.).
  5. Per-page meta translations — page <title> and OG tags are still English. Adding per-locale metadata requires the [locale]/ segment refactor, deferred.

Coolify env (Session 12 additions)

# Already required:
NEXT_LOCALE         # No env — set as a per-user cookie by the switcher.

# New, optional:
STRIPE_PRICE_AFRICA=price_...   # Once you create the Stripe product

Session 10 (2026-06-10) — SHIPPED

FIX 1 — Internal auth refactor + /pipeline off-host support

Pre-audit revealed the spec's premise was wrong: /api/grading/pipeline and /api/grading/resolve ALREADY EXISTED with requireInternal middleware inline in each route file. The actual n8n bug was a header- name mismatch (n8n sends x-internal-key, code read X-VYNDR-Internal-Key) PLUS a hard loopback-IP check that blocks any caller from a separate container.

  • src/middleware/internalAuth.js (new) — centralized middleware. Accepts BOTH x-internal-key (Session 10 short form, n8n) AND X-VYNDR-Internal-Key (legacy, poller + existing tests). Timing-safe string compare. loopbackOnly is now an OPT-IN flag (default off).
  • src/routes/grading.js — replaced inline requireInternal with the centralized middleware. /resolve uses {loopbackOnly: true} (poller from localhost). /pipeline uses the off-host variant (n8n from a separate container). __helpers.requireInternal kept exported for the existing test suite — backwards compatible.
  • src/routes/corrections.js — same refactor; /correct stays loopback-only (morning sweep is co-located).
  • /api/grading/pipeline body shape — empty body now iterates nba/wnba/mlb (n8n's "Morning Ops" workflow case). Single-sport body still works and returns the legacy summary object so existing per-sport tests continue to pass.

FIX 2 — Soccer prefetch cascade keys

Session 9's adapters write to apifootball:* and footapi:* cache keys; the daily prefetch was still only writing soccer:* (the tertiary fallback). The cascade in soccerFeatureExtractor never hit PRIMARY because nothing populated those keys.

  • scripts/soccer-data-prefetch.js — new enrichFromApiFootball() walks finished WC fixtures via apiFootballAdapter.getFixtures + getFixturePlayerStats, aggregates per-player season stats across matches (minutes, goals, assists, shots, tackles, cards, rating), collapses to per-90 rates, and writes apifootball:player_by_name:{normalizedName} (24h TTL). Hard-capped at --max-players=80 per run.
  • CLI flags added--source=api-football|footapi|football-data|all (default all), --max-players=N, --season=N. Existing --leagues and --dry-run flags unchanged.
  • enrichRefereesFromFootApi() — best-effort referee enrichment. Writes footapi:referee_by_name:{name} (7d TTL).
  • Behavior preserved — legacy soccer:player:* writes still happen when football-data source is selected (and it's the default in all mode). The cascade resolves at PRIMARY when api-football data is available, TERTIARY otherwise.
  • Boot guard relaxed — previously bailed when FOOTBALL_DATA_API_KEY was unset; now bails only when EVERY source is unavailable. The script can run on api-football alone.

FIX 3 — Sentry error tracking

  • src/utils/sentry.js (new) — graceful no-op when SENTRY_DSN is unset (every Sentry surface becomes a noop). Initialized at the top of src/app.js BEFORE express is required.
  • Sentry.setupExpressErrorHandler(app) mounted AFTER all routes in app.js — catches uncaught route errors automatically.
  • PII scrubbingbeforeSend strips user.ip_address, user.email, request.cookies, request.headers.authorization, request.headers.cookie, and BOTH internal-key headers. Bearer tokens never reach Sentry.
  • Sampling — 10% traces, 100% errors. Free-tier friendly.
  • Frontend — manual init via web/src/components/SentryInit.tsx (client component, mounted in root layout). Lazy import('@sentry/nextjs') fires on mount only if NEXT_PUBLIC_SENTRY_DSN is set. Avoids the withSentryConfig plugin which conflicts with standalone output mode (per Session 10 spec note).

FIX 4 — Welcome email on signup

The sendWelcomeEmail function in web/src/services/email.ts already existed; nobody called it.

  • Copy updated — 5/month → 3/day, NexaPay → Stripe founder pricing ($14.99/mo locked for life), added the soccer/World Cup mention per Session 10 spec. Both HTML and plain-text variants.
  • web/src/app/api/welcome-email/route.ts (new) — POST endpoint, bearer-auth required. Reads Supabase user_metadata via the service-role admin client, checks welcome_email_sent, sends if absent, sets the flag. Idempotent — re-trigger is a cheap noop. No migration neededuser_metadata is the Supabase auth user's existing JSONB scratchpad.
  • Triggerweb/src/app/welcome/page.tsx fires the POST once on mount via useRef guard. Server-side idempotency keeps it safe across refreshes too.
  • Graceful failure — if RESEND_API_KEY is unset, send returns { ok: false } but the flag is still set (manual operator override if a batch needs re-sending).

Tests added

Suite Tests
tests/unit/internalAuth.test.js 15
tests/unit/soccerDataPrefetchCascade.test.js 20
tests/unit/sentry.test.js 10
Existing suites (pipeline, resolution, prefetch) re-verified 0 new
Session 10 total 45+

Quality gates

  • npm test: 1286 / 1286 passing (1240 + 46 new), 100 suites, 0 regressions
  • web/npm run build: clean — Sentry mount + /api/welcome-email prerender
  • License audit: only permissive licenses (Sentry adds nothing exotic)

Env vars to set in Coolify

# Already required from prior sessions:
VYNDR_INTERNAL_KEY=<existing — header is now x-internal-key OR X-VYNDR-Internal-Key>
RESEND_API_KEY=<existing>
RESEND_FROM_EMAIL=<existing, defaults to "VYNDR <grades@vyndr.app>">

# New in Session 10 (all optional — wrappers degrade gracefully):
SENTRY_DSN=<from sentry.io project settings>
NEXT_PUBLIC_SENTRY_DSN=<same DSN — needs the NEXT_PUBLIC_ prefix to reach browser bundle>

Open items

  • Soccer prefetch hasn't run against live api-football yet — first cron tick after deploy will populate the cascade. Until then, the feature extractor resolves at tertiary (football-data).
  • Sentry's frontend manual-init pattern means errors before the React tree mounts (e.g. SSR errors) bypass Sentry. The backend handler catches Express-side errors; for browser-side SSR errors we'd need instrumentation.ts, deferred.
  • Welcome email idempotency relies on Supabase user_metadata. If a user signs in via SSO and never lands on /welcome, they don't get the email. Acceptable Day-1 — track via PostHog if it becomes a real conversion gap.

Session 9 (2026-06-10) — SHIPPED

World Cup opens tomorrow. This session closed three live-site emergencies (404, OOM cycle, slow FCP), added three new soccer data sources with a priority cascade, two new RapidAPI sports adapters, a real grace-period downgrade middleware, and updated the legal pages.

Phase 0 — critical fixes

  • /pricing 404 → fixed. web/src/app/pricing/page.tsx created; wraps the existing Pricing component on a standalone route so email renewal CTAs (which link to /pricing via web/src/services/email.ts:204) no longer land on 404. Metadata block ships with OG + Twitter tags.
  • Web container OOM cycle → cause identified, fix documented. docker logs on the live host (z2zyki…-032334469519, 44 restarts and climbing) returned FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. Docker mem limit is unlimited (0) — this is Node's own ~2 GB V8 default. Fix is a Coolify env-var change: NODE_OPTIONS=--max-old-space-size=4096 on the web container. Cannot be applied from this session — listed under the Coolify env requirements at the end of this entry.
  • 7.5s FCP → root cause traced to the OOM cycle. All page routes are static-prerendered; root layout makes no blocking calls. The FCP measurement is dominated by cold-start latency hit during each restart. The NODE_OPTIONS fix is the primary FCP fix too — re-measure after deploy.

Phase 1 — soccer source upgrade

New adapter cascade for soccer (priority order):

  1. api-football.com (PRIMARY)src/services/adapters/apiFootballAdapter.js. 100 req/day soft limit (90, with 10-req safety margin). 6 endpoints: getFixtures, getFixtureLineups, getFixturePlayerStats, getFixtureEvents, getPlayerSeasonStats, getStandings. Auth via x-apisports-key header (NOT RapidAPI). Per-endpoint TTLs match data volatility (fixtures 6h, lineups/playerstats 24h, events 12h).
  2. FootApi via RapidAPI (BACKUP)src/services/adapters/footApiAdapter.js. 50 req/day (soft 45). 4 endpoints: getMatchLineups (28 stat keys), getMatchIncidents (minute + addedTime), getRefereeStatistics (yellow/red per game), getWorldCupSchedule (tournament ID 16).
  3. football-data.org (TERTIARY) — existing Session 7j adapter unchanged.

The soccerFeatureExtractor now cascades through these via a new loadFromCascade() helper. Each load returns a _source tag so debugging is straightforward; meta.sources exposes the attribution per lookup (player, nextMatch, lastFixture, referee). Existing 17 soccer-extractor tests still pass; 7 new cascade tests prove the priority order.

Phase 1 — Tank01 RapidAPI adapters

  • tank01NbaAdapter.js — live NBA box scores, schedule, betting odds. Status-aware TTL: 5-min cache while a game is in-progress, 24-hour cache once it reports Final. Free tier 1,000 req/mo; TTL-bound rather than counter-bound.
  • tank01MlbAdapter.js — live MLB box scores, daily scoreboard, and batter-vs-pitcher (the headline new MLB signal — a batter's historical PA/AB/H/HR/SO line against a specific pitcher). Same status-aware TTL pattern as NBA.

Both Tank01 adapters use the shared RAPID_API_KEY (also used by FootApi). Host overridable via TANK01_NBA_HOST / TANK01_MLB_HOST.

Phase 2 — production readiness

  • Grace-period downgrade middlewaresrc/middleware/gracePeriod.js. Fires at request time on tier-gated routes (/api/scan/parlay, /api/alerts, /api/props/joint-history). Reads req.user.grace_period_until (now selected by requireAuth in src/middleware/auth.js), and on expiry atomically downgrades users.tier and user_profiles.tier to 'free', clears the timestamp, sets subscription_status='expired' on the profile mirror, and rewrites req.user so the route immediately sees the downgrade. Closes the long-standing "cancelled users keep paid access forever" gap. Ordering matters: grace must run AFTER requireAuth and BEFORE scanLimit, because scanLimit reads tier off req.user — a just-expired Desk user would otherwise burn one final unlimited-quota request.
  • TOS updateweb/src/app/terms/page.tsx Subscription Terms switched from NexaPay to Stripe; Acceptable Use now explicitly states "VYNDR does NOT offer API access at any tier" — closes the Session 7h immutable.
  • Privacy updateweb/src/app/privacy/page.tsx Payment Data section switched from NexaPay to Stripe with specifics on what Stripe receives. New "Sub-processors" section explicitly lists Stripe, Supabase, PostHog, Resend.
  • Cookie consent bannerweb/src/components/CookieConsent.tsx, mounted in root layout. Thin bottom bar, SSR-safe (renders nothing until client mount checks localStorage), single-button accept, links to Privacy Policy.
  • Root layout metadata — keywords + description extended to include soccer and World Cup 2026 intelligence terms. OG + Twitter cards already comprehensive from prior sessions. Per-page metadata for /soccer + /scan deferred (those pages are 'use client'; would need server-component wrappers — cosmetic).

Tests added

Suite Tests
tests/unit/apiFootballAdapter.test.js 16
tests/unit/footApiAdapter.test.js 13
tests/unit/soccerFeatureExtractorCascade.test.js 7
tests/unit/tank01NbaAdapter.test.js 12
tests/unit/tank01MlbAdapter.test.js 12
tests/unit/gracePeriod.test.js 7
Session 9 total 67

Quality gates

  • npm test: 1240 / 1240 passing (1173 baseline + 67 new), 97 suites, 0 regressions
  • web/npm run build: clean — /pricing + everything else prerenders, no type errors
  • License audit: only permissive licenses

Coolify env vars (apply on the web container — keys not in repo)

NODE_OPTIONS=--max-old-space-size=4096        # fixes the OOM cycle
API_FOOTBALL_KEY=<from api-sports.io>          # PRIMARY soccer source
FOOTBALL_DATA_API_KEY=<from football-data.org> # TERTIARY soccer source
RAPID_API_KEY=<from RapidAPI marketplace>      # FootApi + Tank01 NBA + Tank01 MLB
FOOTAPI_HOST=footapi7.p.rapidapi.com           # default — override only for mirrors
TANK01_NBA_HOST=tank01-fantasy-stats.p.rapidapi.com
TANK01_MLB_HOST=tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com

Open items

  • NODE_OPTIONS must be set in Coolify before the next deploy; until then, the web container will keep OOM-looping. This is the single most important production action item.
  • The 2 GB+ heap usage that triggered the OOM suggests a memory leak in the Next.js standalone server. Heap-snapshot investigation deferred — the env-var bump buys headroom but doesn't fix the leak root cause.
  • Per-page OG metadata on /soccer and /scan requires those pages to be refactored to a server-component wrapper pattern. Not blocking.
  • The new adapter cascade improves data quality WHEN API_FOOTBALL_KEY / RAPID_API_KEY are populated and a daily prefetch has run against them. Until then, the cascade silently falls through to football-data.org and static reference data. Updating scripts/soccer-data-prefetch.js to write the new apifootball:* / footapi:* cache keys is a follow-up.

Session 8 (2026-06-10) — SHIPPED

Frontend layer that connects users to the Session 7h7j backend. NexaPay → Stripe cutover on the pricing flow + a /soccer page that exposes the soccer intelligence pipeline.

Files created (frontend)

  • web/src/app/api/odds/soccer/[league]/route.ts — Next.js proxy → Express GET /api/odds/soccer/:league. Validates league against the 9 accepted codes upstream so a typo bounces at the Next boundary.
  • web/src/app/soccer/page.tsx — live soccer odds feed. Hosts SportSelector, fetches /api/odds/soccer/:league, groups props by match → stat type. "Grade" button triggers inline scan via /api/scan (sport: Soccer) and renders the result through SoccerGradeResult. Soccer-only page; switching the selector to another sport bounces to /scan.
  • web/src/app/upgrade/success/page.tsx — Stripe success landing. Reads session_id, refreshes AuthContext so the new tier flips immediately. Does NOT verify against Stripe from the client (no secret key on the browser) — the webhook is the source of truth.
  • web/src/app/upgrade/cancel/page.tsx — Stripe cancel landing.
  • web/src/components/SportSelector.tsx — pill tabs (NBA/WNBA/MLB/ Soccer); Soccer reveals a sub-row of the 9 league codes matching Express's SOCCER_SPORT_KEYS. Emits { sport, league? } via onChange — pure UI, no fetches.
  • web/src/components/SoccerGradeResult.tsx — soccer-themed result card. Parses the engine's reasoning summary into visual chips ( goals/90, 📊 xG, 🎯 penalty taker, 🏹 free-kick taker, corner taker, 🏔️ altitude, 🟨 referee, ⏱️ minutes discount, 🛡️ opponent defense, 🏆 tournament pedigree). Color-coded by tone (positive / caution / warning / neutral). Free-tier responses (carrying tier_gated: true) render the chip row blurred under an upgrade CTA; the structured grade + confidence + edge stay visible. Kept separate from GradeCard so the NBA/MLB/WNBA path is untouched.

Files modified (frontend)

  • web/src/app/api/checkout/route.ts — full rewrite. Was a NexaPay payment-link creator; is now a thin proxy that forwards { tier, founder_code? } + bearer to Express /api/stripe/checkout. Response remap: checkout_urlurl for callsite compat; both fields shipped so either reads cleanly.
  • web/src/app/api/scan/route.ts — accepts Soccer sport in addition to NBA/MLB/WNBA. Soccer stat-type allowlist mirrors the backend VALID_STAT_TYPES (goals, shots_on_target, shots, tackles, cards, corners, saves, goals_conceded, passes, clean_sheet, assists).
  • web/src/components/Pricing.tsx — CTAs converted from <a href> to onClick handlers. Uses useAuth() for the bearer token, POSTs to /api/checkout, window.location.assign to the returned Stripe URL. Loading state on the active tier, inline error banner. Anonymous visitors bounce to /signup?return=/%23pricing. Footnote rewritten from "NexaPay" to "Stripe (test mode while we onboard founders)".
  • web/src/components/Nav.tsx — small BETA tag next to the wordmark. Glitch-styled, monospace, low-opacity green border. Renders on every page that mounts Nav.

Files modified (backend — ONE allowed change)

  • src/services/stripeService.jssuccess_url / cancel_url point at the frontend (NEXT_PUBLIC_SITE_URL with BASE_URL fallback, default http://localhost:3000). Previously the routes pointed at the Express origin which would have 404'd the redirect. New URLs:
    • ${frontendUrl}/upgrade/success?session_id={CHECKOUT_SESSION_ID}
    • ${frontendUrl}/upgrade/cancel All 23 Stripe tests still pass (none asserted on the URL strings).

Files modified (docs)

  • docs/SYSTEM-MANIFEST.md/api/odds/soccer/[league] row in Next.js routes, new section listing the three new Next.js pages, the Session 7h "dual-provider divergence" callout flipped from open-work to complete.
  • BUILD-STATE.md — Session 8 entry.

Honest verification status

Build-verified (passed web/npm run build after every component):

  • All TypeScript types resolve
  • All routes prerender / build correctly (24 pages, 30+ API routes)
  • No ESLint errors

NOT runtime-verified in this session (I have no browser to click through):

  • Actual Stripe checkout redirect end-to-end (test mode card flow)
  • Soccer odds rendering with live data (depends on FOOTBALL_DATA_API_KEY being set in prod and the daily prefetch having run)
  • SoccerGradeResult signal parsing against a real engine response (signal-chip regex tested against the exact phrasing buildSoccerReasoningLines emits in analyzeViaEngine1.js, but not against live engine output)
  • AuthContext.refresh() actually triggering a profile re-read after the Stripe redirect

These are the expected next-session sanity checks once Coolify deploys this build.

Quality gates

  • npm test (backend): 1173 / 1173 passing, 91 suites, 0 regressions from Session 7j baseline
  • web/npm run build: clean — all new routes prerendered, no type errors
  • License audit: only permissive licenses

Session 7j (2026-06-10) — SHIPPED

Permanent soccer sport vertical, launching with FIFA World Cup 2026 (opens June 11). League-agnostic architecture supports WC, EPL, La Liga, Bundesliga, Serie A, Ligue 1, UCL, MLS, Liga MX from the same code paths.

Files created

  • src/data/worldcup2026.js — 16 venues + altitudes + climate, CONCACAF
    • CONMEBOL teams, penalty/corner/free-kick takers (top 25 teams), tournament players (≥3 career WC goals). All frozen. Helpers: isPenaltyTaker, isCornerTaker, isFreeKickTaker, getTournamentHistory, isHomeContinent, getVenue, altitudeImpact.
  • src/services/adapters/footballDataAdapter.js — football-data.org v4 REST adapter. 8/min token bucket (2-req safety margin vs the 10/min upstream cap). Tier-matched Redis TTLs (fixtures 6h, standings 12h, squads 24h, scorers 6h). Stale-while-revalidate fallback when the bucket is drained or the API 5xx's. Returns null when no API key — callers degrade gracefully.
  • src/services/intelligence/soccerFeatureExtractor.js — reads from prefetch-populated Redis cache (NEVER hits external APIs on the user request path). Builds the engine1 feature vector + a soccer overlay (goals_per_90, xG, penalty/corner/FK role, altitude, referee, tournament history, rest_days).
  • poller/soccer.js — league-agnostic fixture poller. WC pulls from the rezarahiminia/worldcup2026 OSS API (no rate limit) and falls back to football-data.org. Other leagues use the adapter directly. Writes soccer:nextmatch:{team} (24h TTL) + soccer:lastfixture:{team} (7d TTL) per fixture. Self-rescheduling: 5-min ticks during live matches, 30-min otherwise. PM2-managed.
  • scripts/soccer-data-prefetch.js — daily batch job. Pulls standings
    • scorers per configured league, computes per-team defensive aggregate (goals_conceded_per_game, defensive_rank_norm on a 0..1 scale that slots into engine1's opp_rank_stat) and per-player per-90 rates. Writes soccer:teamdefense:{league}:{team} and soccer:player:{normalizedName}. --leagues=WC,PL --dry-run flags supported. xG fields left null on Day 1 (soccerdata-Python bridge is a follow-up; engine handles nulls gracefully).
  • tests/unit/worldcup2026.test.js (20 tests)
  • tests/unit/footballDataAdapter.test.js (15 tests)
  • tests/unit/soccerFeatureExtractor.test.js (17 tests)
  • tests/unit/trapDetectionSoccer.test.js (21 tests)
  • tests/unit/computeFeaturesSoccerBranch.test.js (4 tests)
  • tests/unit/analyzeViaEngine1Soccer.test.js (8 tests)
  • tests/unit/soccerPoller.test.js (22 tests)
  • tests/unit/soccerDataPrefetch.test.js (14 tests)
  • tests/integration/oddsSoccer.test.js (6 tests)

Files modified

  • src/utils/oddsNormalizer.jsMARKET_MAP gains 10 soccer market keys (player_goals, player_shots_on_target, etc → goals, shots_on_target, etc). Existing NBA mappings untouched.
  • src/routes/analyze.js, src/routes/scan.jsVALID_STAT_TYPES set extended with 10 soccer stat types. 'assists' is shared with NBA; sport field discriminates downstream.
  • src/routes/odds.js — new GET /api/odds/soccer/:league route. Validates league against SOCCER_SPORT_KEYS (9 leagues), surfaces 405 valid-list hint on miss.
  • src/services/oddsService.jsSPORT_KEYS gains 9 soccer entries mapping soccer_wcsoccer_fifa_world_cup, soccer_eplsoccer_epl, etc. SOCCER_SPORT_KEYS exported as a frozen list.
  • src/services/intelligence/computeFeatures.jssport ∈ {'soccer','football'} dispatches to extractSoccerFeatures. NBA path unchanged.
  • src/services/intelligence/trapDetection.js — six soccer signals (xg_regression, altitude_risk, rotation_risk, minute_discount, referee_card_bias [positive — excluded from composite], strong_defense). getTrapScore branches on input.sport.
  • src/services/intelligence/analyzeViaEngine1.js — soccer reasoning branch (buildSoccerReasoningLines). Uses "matches" not "games", surfaces xG / penalty taker / altitude / referee / minutes / WC pedigree. NBA-specific sentences (back-to-back, injury report) guarded by !isSoccer.
  • poller/ecosystem.config.jspoller-soccer PM2 app added. Same restart policy as box-score pollers; SOCCER_LEAGUES env wired.
  • .env.example — soccer block (FOOTBALL_DATA_API_KEY, SOCCER_LEAGUES, WORLDCUP_API_URL, RAPID_API_KEY).
  • docs/SYSTEM-MANIFEST.md/api/odds/soccer/:league row in §2, Soccer env block in §3, soccer poller in poller-set, four new external API rows in §6, [ARCH-3] soccer-pipeline note in §8.

Quality gates (all green)

  • npm test: 1173 / 1173 passing (1042 baseline + 131 new soccer tests across 9 new suites), 91 suites, 0 failures
  • web/npm run build: clean
  • License audit: only permissive third-party licenses

Session 7i (2026-06-10) — SHIPPED

Stripe checkout + webhook (no new routes — gap-fill on existing)

Pre-audit revealed Session 3.4 already shipped a fuller Stripe integration than this session's spec asked for: route, sig verify, all 4 event handlers with 48h grace, customer create + persist, portal + status endpoints, founder-code system, and usersuser_profiles dual writes. Raw-body middleware was already correctly positioned at src/app.js:52 (before global express.json()).

What this session added on top:

  • tests/integration/stripe.test.js — refactored stripe mock to a singleton handle, then added two route-level tests:
    1. constructEvent throws → route returns 400 with { error: /signature/i }
    2. valid signature → route dispatches to handleWebhookEvent and returns { received: true }
  • tests/unit/stripeService.test.js — added customer.subscription.updated test covering portal-driven plan-change: maps items.data[0].price.id back to a tier via PRICE_MAP, writes to both users + user_profiles, clears grace.
  • docs/SYSTEM-MANIFEST.md — appended a Payments: dual-provider divergence subsection under § 8 Findings → Frontend ↔ Backend contract, documenting that the Next.js /api/checkout still routes to NexaPay while Express Stripe is wired but uncalled by the frontend, with a 4-step cutover punch list for a follow-up session.

Quality gates (all green)

  • npm test: 1042 / 1042 passing (delta +3 from 1039 baseline, 0 regressions)
  • web/npm run build: clean
  • License audit: third-party deps only permissive (MIT/Apache-2.0/BSD/ISC/MPL/BlueOak/CC-BY/0BSD)
  • curl https://api.vyndr.app/api/health{"status":"healthy"}

Session 7h (2026-06-10) — SHIPPED

Stripe (test mode)

Resources created against sk_test_* via direct REST API (Stripe MCP plugin OAuth flow was non-functional in this environment; bypassed by hitting https://api.stripe.com/v1 with the secret key in a single shell subprocess, then shredding the on-disk key file).

  • prod_UgBel9RYTROCxr — VYNDR (metadata.tier=analyst)
    • price_1TgpGxIp1Mec3r2E6Wh6oeaP — $14.99/mo recurring (metadata.tier=analyst)
  • prod_UgBeSBYw2j9oXL — VYNDR Desk (metadata.tier=desk)
    • price_1TgpGyIp1Mec3r2EQq50KKhF — $44.99/mo recurring (metadata.tier=desk)
  • we_1TgpGzIp1Mec3r2ERtDIF2n2 — webhook → https://api.vyndr.app/api/stripe/webhook
    • Subscribed events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed
    • Signing secret saved to ~/.stripe-webhook-secret (chmod 600) — read once, paste into Coolify, then shred -u.

Tier infrastructure

  • src/config/tiers.js — frozen access matrix (free / analyst / desk); api_access:false on every tier (non-negotiable consumer-product invariant)
  • src/middleware/scanLimit.js — 24h rolling per-user/IP quota (free=3, analyst=15, desk=∞); 429 + Retry-After + X-Scans-Used/Limit headers on overflow; in-memory LRU with MAX_TRACKED=50_000
  • src/utils/tierGating.js — pure response gating; free tier keeps grade/confidence/edge_pct, redacts reasoning + kill_conditions_triggered; paid tiers pass through
  • Wired into src/routes/scan.js (/parlay after requireAuth) and src/routes/analyze.js (/prop + /batch, gating applied per-result)

SQL (run manually in Supabase SQL Editor)

  • docs/sql/pricing_slots.sql — creates pricing_slots table + RLS + price IDs seeded. Not added to the migrations chain per session policy.

Tests

  • tests/unit/tiers.test.js (10 tests) — frozen matrix, api_access=false invariant, fallback behavior
  • tests/unit/tierGating.test.js (9 tests) — free-tier redaction, paid passthrough, no input mutation
  • tests/unit/scanLimit.test.js (10 tests) — per-tier limits, anonymous IP fallback, independent quotas, desk skip
  • Existing suites adapted for the new middleware: tests/unit/analyzeCache.test.js, tests/integration/analyze.test.js, tests/integration/scan.test.js reset the scan-limit map in beforeEach; the integration suite for /api/analyze mocks applyTierGating as pass-through so engine-shape assertions stay focused on the engine contract (gating has its own suite).

Quality gates (all green)

  • npm test: 1039 / 1039 passing, 82 suites, 0 failures
  • web/npm run build: production build clean, all 24 routes prerendered
  • License audit: only permissive third-party licenses (MIT/Apache-2.0/BSD/ISC/etc.); single UNLICENSED entry is our own vyndr-web workspace

Web Tier v6 (2026-05-18) — SHIPPED

Complete frontend overhaul. 18 pages, 22 API routes. npm run build passes with zero errors.

New pages

  • /dashboard — post-login slate (sport tabs, top grades, tonight's games, most parlayed, recent scans, first-time onboarding)
  • /game/[id] — game preview with spread/total/ML, starting lineups with injury flags, expandable prop list, add-to-parlay
  • /profile — tier status, subscription state, founder badge, cancel-at-period-end flow
  • /intelligence — Desk-tier timeline of evolution/coaching/cascade/ABS/line-movement signals (blurred for non-Desk)
  • /terms, /privacy, /responsible-gambling — branded legal pages with brand voice
  • /scan — full rebuild (sport tabs, real /api/scan with tier gating, parlay tray hook)
  • /login, /signup — wired to Supabase Auth via AuthContext (Google OAuth + email/password + age check)
  • /marketplace — coming-soon waitlist (API access, custom alerts, capsule drop)
  • /ledger, /tracker — design system refresh, accuracy buckets, miss autopsy, quick-slip
  • / — auth-aware: logged-in users redirect to /dashboard; anonymous see marketing

New API routes

  • /api/games/tonight, /api/games/[id], /api/games/[id]/props
  • /api/props/top-graded, /api/props/most-parlayed
  • /api/players/search
  • /api/user/recent-scans
  • /api/intelligence/feed
  • /api/parlay/add-leg, /api/parlay/grade
  • /api/ledger, /api/ledger/accuracy
  • All cached via Supabase odds_cache table (5-min TTL) — never hit Odds API directly

Services + middleware

  • services/odds-cache.ts — Supabase-backed TTL cache for upstream calls (loader + stale-fallback)
  • services/email.ts — Resend wrapper: sendWelcomeEmail, sendPaymentReceipt, sendRenewalReminder
  • middleware/rateLimit.ts — per-tier per-minute scan throttle (5/30/60 free/analyst/desk)
  • services/nexapay.ts — already shipped (createPaymentLink + HMAC webhook verify), now wired to email receipts

Components

  • GradeCard.tsx — premium grade card with tier-gated blur (factors locked for free; alt-lines locked for non-Desk)
  • ParlayContext.tsx + ParlayTray.tsx — cross-page parlay state, slide-up tray, /api/parlay/grade integration
  • BottomTabBar.tsx — mobile-only 5-tab navigation (Home/Scan/Parlay/Ledger/Profile) with parlay badge
  • ShareCard.tsx — canvas-rendered 1200x630 OG share image with grade letter; download + copy-to-clipboard
  • Nav.tsx, Hero.tsx, LivePropsStrip.tsx, Features.tsx, Pricing.tsx, HowItWorks.tsx, FAQ.tsx, Footer.tsx — design system refresh already shipped

PWA + meta

  • public/manifest.json (192/512/maskable icons)
  • public/icons/icon-{192,512,maskable-512}.png, apple-touch-icon.png, favicon.ico, favicon.png
  • public/og-image.png — 1200x630 social share card
  • appleWebApp + manifest + theme-color wired in layout.tsx

Supabase migrations

  • 011_user_profiles_web.sql (already deployed): user_profiles (+RLS+trigger), parlay_leg_frequency (+RPC), scan_history
  • 012_web_caching_waitlist.sql (NEW): odds_cache (TTL cache), waitlist_signups, founder_pricing_seats view, prune_expired_odds_cache() helper

Backend

  • src/app.js — CORS middleware added (localhost dev + vyndr.app + *.vercel.app + FRONTEND_ORIGINS env var)
  • package.json — added cors@2.8.5

Bug fixes

  • Scan page sibling-div JSX bug fixed (rewritten from scratch)
  • Lockfile warning silenced via next.config.ts turbopack.root (already in place)
  • Auth callback rewritten to use Supabase JS session API instead of raw localStorage parse

Environment variables (set in Vercel + Railway)

Vercel (Next.js)

  • NEXT_PUBLIC_SUPABASE_URL — Supabase project URL
  • NEXT_PUBLIC_SUPABASE_ANON_KEY — Supabase anon key
  • SUPABASE_SERVICE_ROLE_KEY — service role (server-only, NEVER expose to client)
  • NEXT_PUBLIC_SITE_URLhttps://vyndr.app
  • BACKEND_URL — Railway URL of Express grading engine
  • NEXT_PUBLIC_API_URL — same as BACKEND_URL (for legacy client fetches)
  • NEXT_PUBLIC_NBA_SERVICE_URL — FastAPI nba_api wrapper URL
  • NEXAPAY_API_KEY — bearer token from NexaPay dashboard
  • NEXAPAY_WEBHOOK_SECRET — HMAC secret from NexaPay dashboard
  • NEXAPAY_API_URL — defaults to https://api.nexapay.one/v1
  • RESEND_API_KEY — from resend.com
  • RESEND_FROM_EMAIL — defaults to VYNDR <grades@vyndr.app>
  • NEXT_PUBLIC_POSTHOG_KEY — PostHog project key (optional)
  • NEXT_PUBLIC_POSTHOG_HOST — defaults to https://us.i.posthog.com

Railway (Express backend)

  • All existing engine vars (Odds API key, Supabase, etc.)
  • FRONTEND_ORIGINS — comma-separated additional CORS origins (optional; defaults cover localhost + vyndr.app + *.vercel.app)

Vercel deployment

  1. Repo root → /home/kev/mastermind/vyndr
  2. Root Directory in Vercel project settings: web
  3. Framework Preset: Next.js (auto-detected)
  4. Build Command: npm run build (default)
  5. Install Command: npm install (default)
  6. Output Directory: .next (default; we use output: 'standalone')
  7. Node version: 20.x or 22.x
  8. Add all env vars from the list above

Railway deployment (backend)

  1. railway.toml already configured in repo root
  2. Connect GitHub → Deploy from main
  3. Set env vars (same as Vercel backend list)
  4. Get URL → set BACKEND_URL in Vercel

NexaPay configuration

  1. Create NexaPay account → get API key + webhook secret
  2. Webhook URL: https://vyndr.app/api/webhook/nexapay
  3. Webhook events to enable: payment.succeeded, payment.failed, payment.refunded, subscription.canceled
  4. Settlement wallet: USDC on Polygon (or your preferred chain)
  5. Set NEXAPAY_* env vars in Vercel

Resend configuration

  1. Create Resend account → verify vyndr.app domain
  2. Add DNS records (SPF, DKIM, DMARC) from Resend dashboard
  3. Create API key → set RESEND_API_KEY in Vercel
  4. Test: trigger a signup, check the welcome email arrives

Supabase Auth setup

  1. Run migrations 011_user_profiles_web.sql and 012_web_caching_waitlist.sql (Supabase SQL editor or CLI)
  2. Auth → Providers → enable Email/Password (default)
  3. Auth → Providers → enable Google: paste client ID/secret from Google Cloud Console
  4. Auth → URL Configuration → Site URL: https://vyndr.app
  5. Auth → URL Configuration → Redirect URLs: https://vyndr.app/auth/callback, http://localhost:3001/auth/callback

What Has Shipped (Backend — Already Live)

Phase 1 — Foundation (COMPLETE)

  • Feature 1.1 — Odds API Integration
  • Feature 1.2 — NBA_API Stats Wrapper (FastAPI microservice)
  • Feature 1.3 — Prop Analysis Engine (6-step grading pipeline)
  • Feature 1.4 — Database Schema (9 tables, RLS, triggers)
  • Feature 1.5 — Bet Submission (3 methods + performance tracking)

Phase 2 — Core Product (COMPLETE)

  • Feature 2.1 — Parlay Scan (correlation detection, monetization)
  • Feature 2.2 — Line Movement + Cascade Detection

Phase 3 — Web MVP (COMPLETE)

  • Feature 3.1 — Landing Page + Blog (Next.js, MDX, VYNDR voice, SEO)
  • Feature 3.2 — Scan UI (leg builder, grade results, upgrade pitch)
  • Feature 3.3 — Bet Tracker (performance dashboard, quick slip, settle flow)
  • Feature 3.4 — Stripe Integration (checkout, webhooks, portal, founder codes)

Also Shipped (Separate Repo)

Mastermind Agency Site

  • /home/kev/mastermind/agency-site/
  • Glitch aesthetic, scan lines, CRT flicker, JetBrains Mono
  • Home, VYNDR case study, Contact pages

Phase 1 Additions — Intelligence Engine (COMPLETE)

  • Addition 1 — Stats endpoints (parlays-graded, public, live props)
  • Addition 2 — Dynamic role profile system (8 roles, Shannon entropy, conditional profiles)
  • Addition 3 — Player selector (placeholder — Cowork handles design)
  • Addition 4 — Parlay probability (phi coefficient, juice-adjusted EV, correlation math)
  • Addition 5 — MLB prop grading (14 stat types, 10 kill conditions, 30 parks, weather API)
  • Addition 6 — Intelligence engine (similarity, evolution/PELT, line discrepancy, alt line, Bayesian, model trainer)
  • Addition 7 — Lineup watch speed (role activation detection framework)
  • Addition 8 — Database additions (7 new tables, migration 003, indexes, RLS)
  • Addition 9 — Design system update (forest green, Hero tagline, live props strip, DemoScan result card)
  • Addition 10 — Accuracy ledger page (/ledger)
  • Addition 11 — Marketplace page (/marketplace, waitlist, honeypot)
  • Addition 12 — ARCHITECTURE.md v1.0
  • Permanent: FOUNDER_NOTE constant (immutable, tested for integrity)
  • Permanent: X-VYNDR-Mission header on all API responses

Also Shipped (Separate Repo)

Mastermind Agency Site

  • /home/kev/mastermind/agency-site/
  • Glitch aesthetic, scan lines, CRT flicker, JetBrains Mono
  • Home, VYNDR case study, Contact pages

Test Summary

  • Node.js: 662 tests passing (unit + integration) — 357 original + 187 ship + 65 supplement + 35 patch + 45 security
  • Python: 27 tests passing
  • Total: 689 tests, all green
  • 8 new test files: shipInfrastructure, shipGradingEngine, shipDataSources, shipResolution, shipSchemeClassifier, supplementSystems, patchIntegration, securityAudit
  • Next.js project builds (pending Vercel deploy)

Active Blockers

  • BLOCKER-003: WSL2 DNS cannot resolve *.supabase.co
  • Migrations 003-010 need manual apply via Supabase SQL Editor

Phase 1 Additions Part 2 (COMPLETE)

  • Addition 13 — Simplified scan selector (sport toggle NBA/MLB, player search, stat dropdown, line pre-fill from Odds API)
  • Addition 14 — PostHog analytics integration (5 events: scan_completed, grade_viewed, upgrade_cta_clicked, prop_shared, alt_line_viewed)
  • Addition 15 — Affiliate database (Migration 004: referral_codes, referral_conversions, affiliate_payouts, wallet_addresses, RLS on all)
  • Addition 16 — Scheme intelligence data layer (schemeClassifier.js: PnR coverage classification DROP/SWITCH/HEDGE/MIXED/UNKNOWN, 8-possession min, 6hr cache, graceful degradation, silent logging to model_predictions_extended)
    • Scheme intelligence: data layer active, user activation pending Day 31

Phase 2 Pending

  • Model learning loop (Feature 4.1 spec exists)
  • Player selector UI completion (Cowork handles design)
  • Full parlay probability UI integration
  • Real-time lineup watch CRON implementation
  • Evolution watch UI on ledger page
  • Pre-registered predictions system activation
  • Physical ledger fulfillment
  • Education library content

Manual Actions Required

  1. Paste SQL migrations 003-010 in Supabase SQL Editor (in order)
  2. Run node scripts/seedRoleProfiles.js after NBA API access configured
  3. Set Stripe env vars (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, price IDs)
  4. Set NEXT_PUBLIC_POSTHOG_KEY env var for PostHog analytics
  5. Set ODDS_API_KEY env var for Odds API
  6. Set SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY for Python service
  7. Deploy Next.js frontend to Vercel
  8. Start Python service: cd src/services/python && pip install -r requirements.txt && python3 app.py
  9. Set up GitHub Actions crons: lineup monitoring (15min), morning odds (10am ET), pre-game odds (90min), weather (30min), nightly resolution (2am ET)
  10. Run cold_start_boot() on first launch (seeds reporters, loads data files)
  11. SHADOW_MODE=True for first 2 weeks — grades logged but not published to capper

Session Log

Sessions 1-6 — 2026-03-21/22

  • Built all backend: Phase 1 + Phase 2 + Feature 1.5
  • 221 backend tests passing

Session 7 — 2026-03-22

  • Built Feature 3.1: Landing page + blog (Hero, Pricing, Blog/MDX, Auth pages)
  • Built Mastermind Agency Site (glitch aesthetic, 5 pages)
  • Built Features 3.2 + 3.3: Scan UI + Bet Tracker
  • Built Feature 3.4: Stripe Integration (checkout, webhooks, portal, founder codes)
  • ALL FEATURES COMPLETE
  • Total: 237 tests (210 Node.js + 27 Python), all green

Session 8 — 2026-03-28

  • Built all 12 Phase 1 additions in single session
  • 68 new tests (305 total), all green
  • New services: roleProfileEngine, roleStabilityEngine, similarityEngine, evolutionEngine, lineDiscrepancyDetector, altLineScanner, bayesianEngine, modelTrainer, correlationMath, mlbGrader, mlbKillConditions, mlbStatsClient
  • New routes: stats, props, waitlist
  • New frontend: LivePropsStrip, ledger page, marketplace page
  • New constants: founderNote, mlbParks
  • New middleware: mission header
  • Migration 003: 7 new tables with indexes and RLS
  • Python microservice: evolutionEngine.py (Flask/PELT on port 5001)
  • ARCHITECTURE.md v1.0 created

Session 9 — 2026-04-12

  • Built 4 Phase 1 Part 2 additions
  • 52 new tests (357 total), all green
  • New component: SimplifiedSelector (sport toggle, player search, stat dropdown, line pre-fill)
  • PostHog analytics: 5 tracked events, initialized in layout.tsx
  • Migration 004: 4 affiliate tables (referral_codes, referral_conversions, affiliate_payouts, wallet_addresses)
  • New service: schemeClassifier.js (PnR coverage classification, 6hr cache, graceful degradation)
  • Scheme intelligence: data layer active, user activation pending Day 31

Session 10 — 2026-04-13 (SHIP BUILD v5.1)

  • Built complete dual-sport grading engine from vyndr-SHIP.md spec
  • 187 new tests (544 total), all green across 38 test suites
  • Phase 1 — Infrastructure:
    • Flask app.py with blueprints, health check, rate limiting (60/min default, 20/min grade), flask-cors, /api/docs
    • evolutionEngine.py moved to blueprints/evolution.py (structural only — logic unchanged)
    • utils: retry.py, data_warehouse.py (game-day TTL), bayesian.py (per-stat-type weights, skewness, data sufficiency curve), edge_calculator.py (real edge + quarter-Kelly), context_aggregator.py (15 factors), similarity.py (min 0.7), regime_detector.py (disabled <20 games), blind_spot_detector.py (worst 5%), supabase_client.py
    • Data files: park_factors.json (30 parks, lat/lng, roof_status), reporter_database.json (80+ handles), timezone_map.json (30 arenas), grade_thresholds.json, odds_api_config.json
    • requirements.txt with all 15 dependencies
    • Cold start boot sequence with reporter seeding
  • Phase 2 — Data Sources:
    • blueprints/synergy.py (team play types, matchup, tracking, defensive scheme)
    • blueprints/nba_context.py (teammate impact, game script, home/road, rest/travel, matchup pace, foul trouble, B2B stat-specific, positional defense, usage-efficiency, playoff modifiers, NBA sub-scores endpoint)
    • blueprints/lineup_intelligence.py (3-source architecture, reporter trust tiers, tweet parsing, two-stage grading, reporter-line correlation)
    • blueprints/odds_scanner.py (free tier 2 pulls/day, odds warehouse, line movement detection, slate scanner)
    • utils/weather.py (Open-Meteo, continuous 30min, dome detection, regrade triggers)
    • utils/archetypes.py (5 pitcher dimensions, 5 batter dimensions, 6 NBA dimensions — ALL with weight_profiles, batting order, batter approach, pitcher identity, weight blending)
    • schemeClassifier.js enhanced: Synergy-first with regex fallback, backward compatible
  • Phase 3 — Grading Engines:
    • blueprints/mlb.py (14-step pipeline, pitcher/batter profiles, ABS challenge system with player-specific discipline score, TTO decay, platoon-specific opponent quality, lineup protection, day/night, bullpen state, catcher framing)
    • blueprints/image_grade.py (OCR pipeline with low-confidence confirmation)
    • utils/sportsbooks.py (10 books, parlay grading with correlation check, phi coefficient)
    • utils/capper.py (pick numbers, breaking alerts, daily recap, miss autopsy)
  • Phase 4 — Self-Improving Loop:
    • blueprints/resolution.py (nightly job: actuals from nba_api/MLB-StatsAPI, hit/miss, CLV, alignment, joint outcomes, calibration triggers)
    • blueprints/calibration.py (point-biserial weights, global offset, Brier score, blind spots, CLV/alignment reports)
  • Phase 5 — Database + Tests:
    • Migration 005: lineup_scheme_data
    • Migration 006: nba_data_cache, mlb_data_cache, grade_outcomes (ALL ship columns incl discipline_score, CLV, alignment), player_calibrated_weights
    • Migration 007: lineup_updates, reporter_trust (with source_type + starting_trust), odds_warehouse, ship_line_movements, reporter_line_correlation, api_health_log, global_calibration, ship_joint_outcomes
    • 5 new test files covering infrastructure, grading engine, data sources, resolution pipeline, scheme classifier enhancement
  • Key Spec Compliance:
    • Grade thresholds LOCKED (A+ through F)
    • SHADOW_MODE = True (first 2 weeks)
    • Bayesian weights are INITIAL ESTIMATES (marked as such)
    • Abstention check BEFORE data cap
    • Point-biserial bounds 0.05-0.50, global offset ±0.15
    • Real edge with vig + quarter-Kelly
    • Brier + CLV from day one
    • Capper A- and above ONLY
    • ABS is CHALLENGE system (successful challenges don't deplete)
    • Foul trouble widens std, not mean
    • Stat-specific B2B adjustments
    • Matchup-specific pace (home 60/40)
    • Positional defense (tracking > roster position)
    • Usage-efficiency tradeoff (-1.5% TS per +5% usage)
    • Tier limits documented but NOT enforced (gate manually later)
    • Node.js stays Node.js, Python is data/utility layer via HTTP

Session 10c — 2026-04-13 (FINAL INTEGRATION PATCH)

  • Applied 15-item integration patch — wiring + features + infrastructure
  • 35 new tests (644 total), all green across 40 test suites
  • Wiring (items 1-5):
    • Scratch → redistribution → re-grade → alt line scan → alert chain in lineup_intelligence.py
    • Slate scan → alt line auto-scan for A-grades in odds_scanner.py
    • Nightly resolution steps 14-18: coaching update, player-out history, evolution scan, unconventional data collection, monthly validation
    • Migration 009: supplement columns on grade_outcomes (coaching_context, redistribution_context, evolution_flag, alt_line_opportunity, unconventional_factors) + unconventional_factor_data table
    • API docs updated with 7 supplement endpoints
  • Features (items 6-10):
    • MLB lineup shift logic (PA multiplier changes when player scratched)
    • high_leverage_hook_tendency added to MLB coaching schema
    • Evolution persistence check (3 games before public promotion, false positive detection)
    • Unconventional daily data collection + monthly validation functions
    • Alt line ladder mode (ALT_LINE_MODE env var — 'manual' generates probability ladder)
  • Infrastructure (items 11-15):
    • 5 GitHub Actions YAML files: nightly (2am ET), morning odds (10am ET), pre-game (3pm/5pm/6:30pm ET), reporter poll (every 15min), weather (every 30min)
    • scripts/seed_historical.py — one-time historical data seeder (NBA 2024-25 + MLB 2024)
    • railway.toml (Flask service, port 5001, health check)
    • web/vercel.json (Next.js deployment)
    • MLB coaching helper functions for historical seeding
  • Product is DEPLOYMENT-READY

Session 10d — 2026-04-13 (SECURITY AUDIT)

  • Applied 19-item security hardening pass — Ryan Montgomery panel reviewed
  • 45 new tests (689 total), all green across 41 test suites
  • Authentication (items 1, 8, 11):
    • utils/auth.py: require_auth (JWT with issuer check) + require_service_role (BETONBLK_INTERNAL_KEY)
    • PyJWT added to requirements.txt
    • BETONBLK_INTERNAL_KEY separates cron auth from service key — service key never leaves Railway
  • Input Security (items 3, 10, 13):
    • utils/validation.py: whitelist stat types, sanitize strings (strip SQL/HTML), validate line 0-500, image upload (magic bytes, 10MB max, PNG/JPEG/GIF), parlay legs 2-12
    • OCR rate limit 3/min, max 2 concurrent
    • MAX_CONTENT_LENGTH 1MB globally, 413 JSON response
  • Network Security (items 2, 12):
    • CORS locked to ALLOWED_ORIGINS env var (no more wildcard)
    • Real IP from X-Forwarded-For for rate limiter and security logger
  • Error Handling (item 9):
    • Production returns generic "Internal server error" — no stack traces
    • 404, 405, 413, 429 all return JSON
  • Monitoring (items 4, 5, 6, 15, 17, 18):
    • Security headers: X-Frame-Options DENY, HSTS, CSP, nosniff, XSS protection, Server removed
    • utils/security_logger.py: request logging, rate tracking, SQL injection detection, security_events table
    • utils/env_check.py: startup validation, exits on missing required vars, never logs secrets
    • security-scan.yml: weekly pip-audit + npm audit
    • security.txt: /.well-known/security.txt with contact
    • 90-day security event retention cleanup + weekly security digest (50+ events per IP = action required)
  • Infrastructure (items 7, 14, 16, 19):
    • Migration 010: security_events table with RLS
    • Supabase client timeout guidance, retry with 30s default timeout
    • Source code secret scan test (sk_live_, eyJhbGci, sbp_)
    • .gitignore: .env, .env.local, .env.production, *.pem, *.key, .vercel/

Session 10b — 2026-04-13 (SUPPLEMENT BUILD)

  • Built 5 intelligence supplement systems — ADDITIVE, no existing code modified
  • 65 new tests (609 total), all green across 39 test suites
  • System 1 — Coaching Tendency Database:
    • blueprints/coaching.py (NEW) — per-coach NBA + MLB tendencies, nightly update from game logs, shift detection (15%+ threshold on last 15 vs season baseline)
    • 12 NBA fields (pace, 3PT rate, ISO freq, PnR usage, rotation depth, late-game player, score-state lineups, second-unit patterns, redistribution profile, shot location, timeout tendency)
    • 10 MLB fields (starter hook, quick hook, bullpen philosophy, IBB rate, PH freq, bunts, closer-only, platoon, lineup consistency, challenge aggressiveness)
  • System 2 — Usage Redistribution Engine:
    • blueprints/redistribution.py (NEW) — two-layer calculation (Layer A: minutes redistribution from historical player-out data + coaching rotation depth; Layer B: offensive system change from archetype shifts)
    • Uses coaching database, applies usage-efficiency tradeoff (-1.5% TS per +5% usage)
    • Three tiers: primary (>=0.20 boost, >=0.75 confidence), secondary (>=0.10, >=0.60), tertiary (>=0.05)
    • Auto-grades at 15%+ boost / 0.65+ confidence, formats 60-second absorption alerts
  • System 3 — Alt Line Scanner:
    • Added to existing odds_scanner.py — auto-runs on A-grade props after slate scan
    • Pulls alt lines from odds_warehouse, calculates model probability via Bayesian norm_cdf
    • Real edge with vig on each alt, finds optimal (best EV/dollar)
    • Only recommends if alt edge exceeds standard by 3%+
  • System 4 — Unconventional Data Pipeline:
    • blueprints/unconventional.py (NEW) — validation gate for non-traditional correlates
    • 500 instance minimum, Pearson r > 0.15, Bonferroni-corrected p-value
    • 5 tracked factors: altitude, contract year, referee crew history, travel distance (pre-validated), arena altitude
    • Factors only enter grading engine AFTER passing validation
  • System 5 — Player Evolution Alerting:
    • Added to existing evolution.py — daily scan across multiple metrics simultaneously
    • NBA: usage_rate, assist_rate, three_pa_rate, fg_pct, minutes
    • MLB: k_rate, bb_rate, exit_velocity, hard_hit_pct, fb_velo
    • PLAYER_EVOLUTION_DETECTED when 2+ metrics show concurrent inflection (10%+ change, 15 game minimum)
    • Timestamped records in evolution_detections table, Evolution Watch content formatter
  • Migration 008: coaching_tendencies, player_out_history, evolution_detections, unconventional_validations (all with indexes + RLS)
  • Integration: 3 new blueprints registered in app.py (coaching_bp, redistribution_bp, unconventional_bp), evolution + odds_scanner extended with new endpoints