2ba3958c7a
- Add NFL keys to oddsNormalizer.MARKET_MAP (defensive; same silent-zero class as the Session 30 MLB bug) + NFL surface test - npm audit fix: ws/qs + Supabase transitives, 7 vulns -> 0 (semver-safe) - Audit findings documented in BUILD-STATE: grades cache has no writer, NFL/NHL not wired end-to-end, rate limiting only on /analyze, tests mutate a tracked jsonl, leaked GitHub PAT in origin remote (rotate) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3058 lines
146 KiB
Markdown
Executable File
3058 lines
146 KiB
Markdown
Executable File
# VYNDR — Build State
|
||
|
||
## Last Updated
|
||
2026-06-14
|
||
|
||
## Current Phase
|
||
AUDIT v31.0 — Full code audit + security review + cleanup (Session 31)
|
||
|
||
## Session 31 (2026-06-14) — SHIPPED (audit)
|
||
|
||
Full-codebase audit, not a feature build. Validated 30 sessions of
|
||
assumptions across the API→normalizer→cache→route→proxy→frontend chain.
|
||
Backend 1694 → **1695 tests** (+1), 137 suites, zero regressions. Web
|
||
build clean. `npm audit`: 7 vulns (3 high, 4 moderate) → **0**.
|
||
|
||
### FIXES APPLIED
|
||
1. **MARKET_MAP NFL gap (silent-failure class, same family as the MLB
|
||
bug Session 30 fixed).** `oddsNormalizer.MARKET_MAP` had ZERO NFL
|
||
keys — when NFL wires up (season approaching) every NFL prop would
|
||
normalize to nothing. Added defensive mappings for both odds-api
|
||
`_yds` and `_yards` spellings → internal stat_types aligned with
|
||
`config/statFilters.js` (passing_yards/rushing_yards/receiving_yards/
|
||
interceptions + pass/rush/reception TDs, receptions, anytime_td,
|
||
kicking_points). Additive/zero-risk (only fires when those markets
|
||
are returned). +1 explicit NFL surface test.
|
||
2. **`npm audit fix`** — resolved ws (uninitialized memory disclosure)
|
||
+ qs (DoS) + transitive Supabase deps, all semver-safe. 0 vulns.
|
||
|
||
### SECURITY AUDIT (Ryan Montgomery)
|
||
- Stripe webhook: ✅ verified via `stripe.webhooks.constructEvent`
|
||
(`stripeService.constructWebhookEvent`, signature + 400 on failure).
|
||
- CORS: ✅ allowlist (localhost, vyndr.app, *.vercel.app preview),
|
||
`FRONTEND_ORIGINS` override — NOT open `*`.
|
||
- Internal routes: ✅ `requireInternalAuth` via `router.use` on the whole
|
||
`/api/internal` router (VYNDR_INTERNAL_KEY).
|
||
- Hardcoded secrets in SOURCE: ✅ none (only doc-comment references).
|
||
- npm audit: ✅ 0 after fix.
|
||
- Supabase service-role: scoped to `src/utils/supabase.js` (backend-only;
|
||
Express enforces its own auth) — acceptable.
|
||
- 🔴 **CRITICAL (operator action): live GitHub PAT (`ghp_…`) embedded in
|
||
the `origin` remote URL in `.git/config`.** Not in tracked source (not
|
||
committed/pushed) but exposed on disk. ROTATE the token and scrub the
|
||
URL (use a credential helper). Not auto-fixed — needs rotation.
|
||
|
||
### FINDINGS DOCUMENTED (deliberately left)
|
||
- **`grades:{sport}` cache has NO writer.** `contentTemplateService`
|
||
collector reads it ("when present"), so content slate/POTD never reach
|
||
`dataLevel: 'full'` in prod — degrades to lines/schedule. Graceful by
|
||
design; wiring a grades-cache writer is feature work. Severity: medium.
|
||
- **NFL/NHL props not wired end-to-end.** `oddsService.SPORT_KEYS` has no
|
||
nfl/nhl; `proplineAdapter.MARKETS.nfl/nhl` are empty. Not a current
|
||
silent failure (nothing expects them yet). NFL MARKET_MAP now ready;
|
||
full wiring is feature work. NHL: no product support anywhere (absent
|
||
from statFilters/streaks) — left entirely.
|
||
- **Inbound rate limiting only on `/api/analyze`.** Public cached
|
||
endpoints (/odds, /schedule, /gamelines, /streaks) have no inbound
|
||
throttle. Real risk bounded (Redis-cached, no per-hit upstream cost).
|
||
Recommend adding the existing `middleware/rateLimit` to public routers.
|
||
- **Tests mutate a tracked file** (`data/training/resolutions-2026-06.jsonl`
|
||
gets resolution rows appended on every run → spurious git diff).
|
||
Reverted the artifact; logging path should write to a temp/ignored
|
||
location under test. Severity: low (hygiene).
|
||
- Cache keys VERIFIED aligned: oddsService writes `odds:{sport}:{UTC}`;
|
||
content `getBestLines` reads the same. No mismatches found.
|
||
- Edge cases already covered: PropLine unsupported-sport→null,
|
||
error→null, all-3-keys-exhausted→null, no-keys→null, and
|
||
PropLine-error→odds-api fallback all have tests.
|
||
|
||
### Files modified
|
||
- `src/utils/oddsNormalizer.js` (NFL MARKET_MAP keys)
|
||
- `tests/unit/oddsNormalizer.test.js` (NFL surface test)
|
||
- `package-lock.json` (npm audit fix)
|
||
|
||
---
|
||
|
||
## Previous Phase
|
||
SHIP BUILD v30.0 — Provider backbone: PropLine 3-key adapter, MLB Stats API, ESPN summary (Session 30)
|
||
|
||
## Session 30 (2026-06-14) — SHIPPED
|
||
|
||
Wired three VERIFIED-live data sources (Chrome infra session, Jun 14).
|
||
Props never go dark again: PropLine gives 3,000 req/day FREE vs odds-api's
|
||
500/month. Tank01 player props confirmed EMPTY — skipped entirely.
|
||
|
||
Backend 1660 → **1694 tests** (+34), 137 suites, zero regressions. Web
|
||
build clean.
|
||
|
||
### PHASE 1 — Traced existing architecture
|
||
Registry (`providers.js`) keyed by id (envKey presence = configured,
|
||
quotaType/quotaLimit, priority). `providerGateway.fetch(id, cb, opts)`
|
||
quota-checks via `quotaTracker`, falls over the `getFallbackChain` on
|
||
QUOTA failure only. Normalization lives in `utils/oddsNormalizer`
|
||
(`normalizeProps` filters books to ALLOWED_BOOKS + markets to MARKET_MAP).
|
||
|
||
### PHASE 2 — PropLine adapter + 3-key rotation
|
||
- `proplineAdapter.js` — thin (PropLine IS Odds-API-compatible → reuses
|
||
`normalizeProps`/`extractSpreads`). `?apiKey=` query auth, base
|
||
api.prop-line.com/v1. 3-key rotation: per-key daily usage in Redis
|
||
(`propline:usage:{i}:{date}`, in-memory fallback), picks least-used key
|
||
under the 900 threshold, returns null when all 3 exhausted (gateway
|
||
falls through). Routes through the gateway for the 3,000/day total cap.
|
||
- Registry: `propline` priority 1 (PRIMARY); `odds-api` dropped to 2.
|
||
- **Found + fixed a latent bug:** `MARKET_MAP` had NO MLB market keys, so
|
||
PropLine/odds-api MLB props would normalize to ZERO. Added batter_*/
|
||
pitcher_* keys → internal stat_types. Added `pinnacle` to ALLOWED_BOOKS.
|
||
- 12 tests. Self-eval 9/10.
|
||
|
||
### PHASE 3 — getOdds prefers PropLine + source tracking
|
||
- `getOdds()` tries PropLine first when `hasKeys()` (gated → zero impact on
|
||
existing tests/envs), falls back to odds-api. Response + cache carry a
|
||
`provider` field ('propline' | 'odds-api'). Extracted the shared
|
||
movement/cascade/snapshot block into `recordDownstream` (DRY). 5 tests.
|
||
Self-eval 9/10.
|
||
|
||
### PHASE 4 — MLB Stats API adapter
|
||
- `mlbStatsAdapter.js` — statsapi.mlb.com, FREE/no-auth/unlimited, NOT via
|
||
the gateway. `getScheduleWithPitchers`, `getPlayerGameLog`,
|
||
`getSeasonAverages`, `getBatterVsPitcher`. Cached TTLs (schedule 30m,
|
||
logs/season 6h, BvP 24h), stale-on-error. Registry `mlb-stats`
|
||
(`noAuth: true` → `getConfiguredProviders` now counts no-auth providers).
|
||
11 tests. Self-eval 9/10.
|
||
|
||
### PHASE 5 — ESPN summary enrichment
|
||
- `scheduleService.getGameSummary(sport, eventId)` → ESPN summary
|
||
(injuries, ESPN Bet odds, ATS, leaders, box score). Empty-default
|
||
shape, cached 10m, never throws. 7 tests. Self-eval 9/10.
|
||
|
||
### PHASE 6 — Registry + docs
|
||
- CLAUDE.md "Provider Strategy" section added.
|
||
|
||
### Files created
|
||
- `src/services/adapters/proplineAdapter.js`, `mlbStatsAdapter.js`
|
||
- `tests/unit/{proplineAdapter,oddsProviderPreference,mlbStatsAdapter,espnSummary}.test.js`
|
||
|
||
### Files modified
|
||
- `src/config/providers.js` (propline + mlb-stats, odds-api→priority 2,
|
||
isProviderConfigured/noAuth)
|
||
- `src/utils/oddsNormalizer.js` (MLB market keys + pinnacle)
|
||
- `src/services/oddsService.js` (PropLine-first + provider field + recordDownstream)
|
||
- `src/services/scheduleService.js` (getGameSummary), `CLAUDE.md`
|
||
|
||
---
|
||
|
||
## Previous 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.
|
||
|
||
### PHASE 5+6 — Install prompt & cookie consent (verified, already done)
|
||
- `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 2–3 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 5–10 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.js` — `getConfiguredCacheTTL`
|
||
+ 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.js` — `sendQuotaAlert` + 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.js` — `isDegraded` 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 ~5–15% 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.
|
||
|
||
### FIX 5 — Cookie consent visibility [HIGH — Legal]
|
||
|
||
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.
|
||
|
||
### FIX 8 — Footer support email + stale copy [LOW]
|
||
|
||
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.js` — `pace_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.js` — `PRICE_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.ts`** — `AFRICAN_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.tsx`** — `inAfrica === 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
|
||
|
||
### FIX 1 — i18n infrastructure (10 languages, cookie-based)
|
||
|
||
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.example`** — `STRIPE_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 scrubbing** — `beforeSend` 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 needed** — `user_metadata` is the Supabase auth
|
||
user's existing JSONB scratchpad.
|
||
- **Trigger** — `web/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 middleware** — `src/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 update** — `web/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 update** — `web/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 banner** — `web/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 7h–7j 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_url` → `url` 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.js` — `success_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.js` — `MARKET_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.js` — `VALID_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.js` — `SPORT_KEYS` gains 9 soccer entries
|
||
mapping `soccer_wc` → `soccer_fifa_world_cup`, `soccer_epl` →
|
||
`soccer_epl`, etc. `SOCCER_SPORT_KEYS` exported as a frozen list.
|
||
- `src/services/intelligence/computeFeatures.js` — `sport ∈
|
||
{'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.js` — `poller-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 `users` ↔
|
||
`user_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_URL` — `https://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
|