Files
vyndr/BUILD-STATE.md
T

1413 lines
74 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VYNDR — Build State
## Last Updated
2026-06-11
## Current Phase
SHIP BUILD v15.0 — Intelligence hardening + platform correctness (Session 15)
## 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 7h7j backend.
NexaPay → Stripe cutover on the pricing flow + a `/soccer` page that
exposes the soccer intelligence pipeline.
### Files created (frontend)
- `web/src/app/api/odds/soccer/[league]/route.ts` — Next.js proxy →
Express `GET /api/odds/soccer/:league`. Validates league against the
9 accepted codes upstream so a typo bounces at the Next boundary.
- `web/src/app/soccer/page.tsx` — live soccer odds feed. Hosts
`SportSelector`, fetches `/api/odds/soccer/:league`, groups props by
match → stat type. "Grade" button triggers inline scan via
`/api/scan` (sport: Soccer) and renders the result through
`SoccerGradeResult`. Soccer-only page; switching the selector to
another sport bounces to `/scan`.
- `web/src/app/upgrade/success/page.tsx` — Stripe success landing.
Reads `session_id`, refreshes AuthContext so the new tier flips
immediately. Does NOT verify against Stripe from the client (no
secret key on the browser) — the webhook is the source of truth.
- `web/src/app/upgrade/cancel/page.tsx` — Stripe cancel landing.
- `web/src/components/SportSelector.tsx` — pill tabs (NBA/WNBA/MLB/
Soccer); Soccer reveals a sub-row of the 9 league codes matching
Express's `SOCCER_SPORT_KEYS`. Emits `{ sport, league? }` via
`onChange` — pure UI, no fetches.
- `web/src/components/SoccerGradeResult.tsx` — soccer-themed result
card. Parses the engine's reasoning summary into visual chips
(⚽ goals/90, 📊 xG, 🎯 penalty taker, 🏹 free-kick taker, ⛳ corner
taker, 🏔️ altitude, 🟨 referee, ⏱️ minutes discount, 🛡️ opponent
defense, 🏆 tournament pedigree). Color-coded by tone
(positive / caution / warning / neutral). Free-tier responses
(carrying `tier_gated: true`) render the chip row blurred under an
upgrade CTA; the structured grade + confidence + edge stay visible.
Kept separate from `GradeCard` so the NBA/MLB/WNBA path is
untouched.
### Files modified (frontend)
- `web/src/app/api/checkout/route.ts` — full rewrite. Was a NexaPay
payment-link creator; is now a thin proxy that forwards `{ tier,
founder_code? }` + bearer to Express `/api/stripe/checkout`.
Response remap: `checkout_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