Compare commits

...

4 Commits

Author SHA1 Message Date
builtbykev 5f5c004416 Item 2 — nightly pg_dump backup script + runbook (Supabase free tier has none)
scripts/backup-db.sh: nightly full-DB pg_dump via the direct connection string,
14-day local rotation, weekly off-box rsync copy, ntfy alert on any failure +
an undersized-dump guard (an empty dump is a silent failure). docs/BACKUP-
RUNBOOK.md: the ONE env var Kev must set (SUPABASE_DB_URL — the direct
db.<ref>.supabase.co:5432 URI, not the pooler), the cron line, the off-box
target (Hetzner Storage Box via rsync, simplest for a Hetzner box), and the
restore FINGERPRINT procedure (pg_restore into a scratch DB + count
ledger_entries — proves it's a real, restorable backup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:55:39 -04:00
builtbykev 78c19291c9 Items 1,3,4,5 — security migrations (author; apply in Supabase, then re-run advisor)
023_security_hardening.sql:
- Item 1 (CRITICAL, advisor lint 0010): founder_pricing_seats view → recreate
  with security_invoker=on so it respects RLS instead of running as definer.
  (The founder counter no longer depends on it — item 0 uses Stripe directly.)
- Item 3: waitlist write hole — drop the always-true policies, anon may INSERT
  only, update/delete/read via service role.
- Item 5: pin an explicit search_path on the flagged functions (lint 0011).

024_anon_revoke_discoverability.sql:
- Item 4: revoke anon SELECT on the advisor-named tables (accuracy_tracking,
  bets, cascade_alerts, closing_lines, coach_profiles, daily_scan) + a
  commented broad sweep. The frontend reads data via Express (service role),
  never as anon, so this is safe. REVOKE/KEEP rationale documented in the file.

These need Kev to apply (no DB access from here); fingerprint = re-run the
Security Advisor and confirm the lints clear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:55:39 -04:00
builtbykev 889e8621b4 Item 6 — activate the Stripe billing portal link from account settings
The billing portal is fully configured in Stripe (cancellations, plan
switching, invoice history) and the Express endpoint (POST /api/stripe/portal)
existed, but nothing in the UI linked to it. Added the Next proxy
(app/api/stripe/portal) and a "Manage billing →" button in the profile billing
section (paid tiers) that mints a portal session and redirects. Kev still
activates the hosted portal in the Stripe dashboard; this is the app-side link.

Dunning verification (item 6): cancel-on-exhaustion is correctly wired — Smart
Retries exhausting cancels the subscription → customer.subscription.deleted →
webhook sets a 48h grace → middleware/gracePeriod.checkGracePeriod downgrades
tier to free in both users + user_profiles after the grace expires. See the
report for one nuance (the 48h grace on the FIRST payment_failed is shorter than
Stripe's 2-week retry window — self-correcting via subscription.updated, but
worth a product decision).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:55:39 -04:00
builtbykev ae3cff9dbd Item 7 — book roster: ESPN BET → theScore Bet (PENN)
ESPN BET is defunct — PENN/ESPN terminated the deal; PENN rebranded it to
theScore Bet (Dec 1 2025) and ESPN is now exclusive with DraftKings. Removed
the ESPN BET entries from the BookChip map (web/src/lib/books.js) and added
theScore Bet (mono TS, slug thescore) as the successor. Added 'thescore' to the
backend oddsNormalizer ALLOWED_BOOKS so the feed's lines are accepted; synced
the bookWordmark test list. The ESPN references in src/config/sports.js are
ESPN's STATS API (data provider, unrelated to the sportsbook) — left untouched.

Flagged in specs/design-reference/HANDOFF.md that the design mockups' BookChip
row still shows ESPN BET and needs the same one-swap on the next refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:55:39 -04:00
10 changed files with 323 additions and 4 deletions
+61
View File
@@ -0,0 +1,61 @@
# VYNDR Backup Runbook (security follow-up item 2)
Supabase free tier has **zero** backups (no scheduled, no PITR). `scripts/backup-db.sh`
is the safety net: a nightly full-database `pg_dump`, 14 days kept locally, a
weekly copy pushed off-box, ntfy alert on any failure.
## What Kev needs to set (one env var)
**`SUPABASE_DB_URL`** — the Supabase **direct** connection string (session mode).
Supabase → Project → Settings → Database → **Connection string → URI**, the
`db.<ref>.supabase.co:5432` one (NOT the `:6543` transaction pooler — `pg_dump`
needs a real session). Paste it in Coolify as an env var; never commit it.
Optional: `BACKUP_REMOTE` (off-box rsync target for the weekly copy — see below).
## Install the cron (on the Hetzner box)
```bash
# 1. Ensure the client is present
sudo apt-get install -y postgresql-client rsync
# 2. Nightly at 03:10 UTC. Pass the env the script needs (or source an env file).
sudo crontab -e
# add:
10 3 * * * SUPABASE_DB_URL='postgresql://...' BACKUP_REMOTE='u123456@u123456.your-storagebox.de:vyndr-backups/' /path/to/vyndr/scripts/backup-db.sh >> /var/log/vyndr-backup.log 2>&1
```
(If the cron runs inside the Coolify container instead, the env vars are already
present — just schedule `scripts/backup-db.sh`.)
## Off-box target (simplest reliable pick)
**Hetzner Storage Box** over `rsync`/SSH — you're already on Hetzner, it's ~€3/mo
for 1TB, and needs no extra tooling. Create one, add the box's SSH key to it, set
`BACKUP_REMOTE=u<id>@u<id>.your-storagebox.de:vyndr-backups/`. The script pushes
the latest dump every Sunday. (Alternative: Backblaze B2 via `rclone` if you'd
rather keep it off Hetzner entirely — swap the `rsync` line for `rclone copy`.)
## Restore / FINGERPRINT (proves it's a real backup, not just a file)
A dump only counts once a restore of it succeeds. Load one into a scratch DB:
```bash
# spin a throwaway local postgres, restore the newest dump, count a known table
docker run -d --name vyndr-restore-test -e POSTGRES_PASSWORD=x -p 55432:5432 postgres:15
sleep 5
newest=$(ls -t /var/backups/vyndr/vyndr-*.dump | head -1)
pg_restore --no-owner --no-privileges -d "postgresql://postgres:x@localhost:55432/postgres" "$newest"
psql "postgresql://postgres:x@localhost:55432/postgres" -c "select count(*) from public.ledger_entries;"
docker rm -f vyndr-restore-test
```
A non-zero `ledger_entries` count from the restored dump = the backup is real and
restorable. Record the date + row count as the fingerprint.
## Alerting
Any hard failure (missing env, `pg_dump` error, empty/undersized dump) pages
`ntfy` topic `vyndr-backups-kev2026` at urgent priority. The weekly off-box push
failing (or `BACKUP_REMOTE` unset) pages at high priority but does not fail the
run — the local dump still succeeded. Subscribe the phone to that topic.
+63
View File
@@ -0,0 +1,63 @@
-- Migration 023 — Security hardening (Chrome ops session follow-up)
-- Author: security follow-up items 1, 3, 5. Apply in the Supabase SQL editor.
-- Idempotent + guarded so a re-run is safe. After applying, re-run the Security
-- Advisor to confirm lint 0010 (security_definer_view) and 0011
-- (function_search_path_mutable) are cleared.
begin;
-- ── Item 1 (CRITICAL, advisor lint 0010) ────────────────────────────────────
-- public.founder_pricing_seats is a SECURITY DEFINER view — it runs with the
-- creator's privileges and ignores RLS, exposed via the public API. Recreate
-- it as security_invoker so it runs with the CALLER's privileges + respects RLS.
-- (The founder counter no longer depends on this view — it now counts real
-- active Stripe subscriptions directly — so this is purely closing the surface.)
do $$
begin
if exists (select 1 from pg_views where schemaname = 'public' and viewname = 'founder_pricing_seats') then
execute 'alter view public.founder_pricing_seats set (security_invoker = on)';
end if;
end $$;
-- ── Item 3 — waitlist write hole ────────────────────────────────────────────
-- public.waitlist had always-true (USING(true)/WITH CHECK(true)) write policies:
-- the anon API could insert/update/delete rows. Drop ALL existing policies and
-- allow anon to INSERT only; updates/deletes/reads go through the service role
-- (which bypasses RLS). Signups are additionally rate-limited at the API layer.
alter table public.waitlist enable row level security;
do $$
declare pol record;
begin
for pol in select policyname from pg_policies where schemaname = 'public' and tablename = 'waitlist' loop
execute format('drop policy %I on public.waitlist', pol.policyname);
end loop;
end $$;
create policy waitlist_anon_insert on public.waitlist for insert to anon with check (true);
-- Remove any lingering table-level write grants from anon; keep INSERT only.
revoke update, delete, truncate on public.waitlist from anon;
revoke select on public.waitlist from anon; -- a signup list is not public
grant insert on public.waitlist to anon;
-- ── Item 5 — function search_path hardening (advisor lint 0011) ──────────────
-- Flagged functions have a mutable search_path (hijackable). Pin an explicit,
-- safe search_path (pg_catalog, public) — resolves the advisor without the
-- breakage risk of '' on functions that reference public objects unqualified.
-- Handles any overload signature.
do $$
declare fn record;
begin
for fn in
select p.oid::regprocedure as sig
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.proname in ('touch_updated_at', 'update_updated_at', 'reset_scan_count')
loop
execute format('alter function %s set search_path = pg_catalog, public', fn.sig);
end loop;
end $$;
commit;
-- NOTE: item 5 lists "and the other flagged functions". Run the Security Advisor
-- (Supabase -> Advisors -> Security, lint 0011) for the full list; add each to
-- the proname IN (...) set above and re-apply. All are the same low-risk change.
@@ -0,0 +1,46 @@
-- Migration 024 — Revoke anon table discoverability (advisor: anon GraphQL/API access)
-- Author: security follow-up item 4. Apply in the Supabase SQL editor.
--
-- ARCHITECTURE FACT this rests on: the VYNDR frontend NEVER reads app-data
-- tables with the Supabase anon key. All data flows browser -> Next proxy ->
-- Express (service role, which bypasses RLS). The anon key is used ONLY for
-- Supabase Auth (login/session). So revoking anon SELECT on app-data tables
-- does not break the app — it just closes the discovery hole the advisor flags.
--
-- REVOKE/KEEP decision (deliberate):
-- REVOKE anon SELECT — every app-data table. The "public record" surfaces
-- (model ledger, accuracy, public profiles) are served by Express under the
-- service role, so even they need no direct anon read.
-- KEEP — nothing needs direct anon table SELECT. (If a future feature reads a
-- genuinely-public view straight from the browser, grant anon SELECT on
-- that SPECIFIC view only, never a base table.)
begin;
-- Explicitly revoke on the advisor-named tables (definitely safe).
do $$
declare t text;
begin
foreach t in array array[
'accuracy_tracking', 'bets', 'cascade_alerts', 'closing_lines',
'coach_profiles', 'daily_scan'
]
loop
if exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = t) then
execute format('revoke select on public.%I from anon', t);
end if;
end loop;
end $$;
-- BROAD SWEEP (recommended — apply after a quick review). Revoke anon SELECT on
-- EVERY existing public table, then leave anon with no base-table discovery.
-- Uncomment to apply; the app does not read tables as anon so this is safe.
--
-- revoke select on all tables in schema public from anon;
-- alter default privileges in schema public revoke select on tables from anon;
--
-- After applying, re-run the Security Advisor to confirm the anon-access lint is
-- cleared. Grant anon SELECT back ONLY on a specific public VIEW if a browser
-- feature ever needs one.
commit;
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
#
# VYNDR nightly database backup (security follow-up item 2).
#
# Supabase free tier has ZERO backups (no scheduled, no PITR) — the ledger and
# everything else have no safety net. This dumps the WHOLE database nightly via
# the direct connection string, keeps 14 days locally, pushes a weekly copy
# off-box, and pages ntfy on ANY failure. Runs on the Hetzner box via cron.
#
# REQUIRED env (set on the box / in the container that runs the cron):
# SUPABASE_DB_URL the Supabase DIRECT connection string (session mode, the
# db.<ref>.supabase.co:5432 URL — NOT the :6543 pooler;
# pg_dump needs a real session). Kev pastes this in Coolify.
# OPTIONAL env:
# BACKUP_DIR local dump dir (default /var/backups/vyndr)
# BACKUP_KEEP_DAYS local retention (default 14)
# BACKUP_REMOTE off-box rsync target for the weekly copy, e.g.
# u123456@u123456.your-storagebox.de:vyndr-backups/
# (empty = skip the off-box push; a WARN is paged)
# NTFY_URL (default https://ntfy.sh)
# NTFY_TOPIC (default vyndr-backups-kev2026)
#
set -Eeuo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/vyndr}"
KEEP_DAYS="${BACKUP_KEEP_DAYS:-14}"
NTFY_URL="${NTFY_URL:-https://ntfy.sh}"
NTFY_TOPIC="${NTFY_TOPIC:-vyndr-backups-kev2026}"
STAMP="$(date -u +%Y%m%d-%H%M%S)"
DUMP="${BACKUP_DIR}/vyndr-${STAMP}.dump"
MIN_BYTES="${BACKUP_MIN_BYTES:-50000}" # a real dump of this DB is far bigger; guards an empty/failed dump
notify() { # notify <title> <priority> <message>
curl -fsS --max-time 15 \
-H "Title: ${1}" -H "Priority: ${2}" -H "Tags: floppy_disk" \
-d "${3}" "${NTFY_URL}/${NTFY_TOPIC}" >/dev/null 2>&1 || true
}
fail() { notify "VYNDR backup FAILED" "urgent" "${1}"; echo "ERROR: ${1}" >&2; exit 1; }
trap 'fail "backup script errored near line ${LINENO}"' ERR
[ -n "${SUPABASE_DB_URL:-}" ] || fail "SUPABASE_DB_URL is not set — cannot back up"
command -v pg_dump >/dev/null 2>&1 || fail "pg_dump not installed (apt-get install postgresql-client)"
mkdir -p "${BACKUP_DIR}"
# 1. Dump the whole DB in custom format (-Fc: compressed, restorable with pg_restore).
pg_dump "${SUPABASE_DB_URL}" -Fc --no-owner --no-privileges -f "${DUMP}" \
|| fail "pg_dump failed"
# 2. Sanity: a real dump is not tiny. An empty/near-empty file is a silent failure.
SIZE="$(stat -c%s "${DUMP}" 2>/dev/null || echo 0)"
[ "${SIZE}" -ge "${MIN_BYTES}" ] || fail "dump is only ${SIZE} bytes (< ${MIN_BYTES}) — treating as a failed backup"
# 3. Rotate: drop local dumps older than KEEP_DAYS.
find "${BACKUP_DIR}" -name 'vyndr-*.dump' -type f -mtime "+${KEEP_DAYS}" -delete || true
# 4. Weekly off-box copy (Sundays). A single failure of the off-box push is a
# WARN, not a hard failure — the local dump still succeeded.
if [ "$(date -u +%u)" = "7" ]; then
if [ -n "${BACKUP_REMOTE:-}" ]; then
if rsync -az --timeout=120 "${DUMP}" "${BACKUP_REMOTE}"; then
notify "VYNDR backup OK (+off-box)" "default" "Nightly dump ${STAMP} (${SIZE} bytes) + weekly off-box copy pushed."
else
notify "VYNDR off-box push FAILED" "high" "Local dump ${STAMP} is fine (${SIZE} bytes) but the weekly off-box rsync failed."
fi
else
notify "VYNDR off-box push SKIPPED" "high" "Local dump ${STAMP} OK but BACKUP_REMOTE is unset — no off-box copy this week."
fi
else
echo "backup ok: ${DUMP} (${SIZE} bytes)"
fi
exit 0
+2
View File
@@ -13,6 +13,8 @@ Two design components, both open directly in a browser. All styling is inline (n
Desktop /u profile also carries the TIER RECORD calibration strip (matches mobile screen 10 and the landing band). Desktop /u profile also carries the TIER RECORD calibration strip (matches mobile screen 10 and the landing band).
> **BOOK ROSTER NOTE (2026-07, security follow-up item 7):** ESPN BET is defunct — PENN rebranded it to **theScore Bet** (Dec 1 2025); ESPN is now exclusive with DraftKings. The product BookChip set (`web/src/lib/books.js`) was updated (ESPN BET removed, theScore Bet added). The **design mockups' BookChip row still shows ESPN BET** — it needs the same one-swap to theScore Bet (mono `TS`) whenever the design files are refreshed.
## Tokens (exact) ## Tokens (exact)
- Surfaces: `#06060B` void · `#0E0E14` card · `#14141E` elevated · row hairline `#101018` · deep panel `#0A0A10` - Surfaces: `#06060B` void · `#0E0E14` card · `#14141E` elevated · row hairline `#101018` · deep panel `#0A0A10`
- Signal green `#00D4A0` — ONE meaning: edge / active / A-tier / primary CTA. Never decorative. - Signal green `#00D4A0` — ONE meaning: edge / active / A-tier / primary CTA. Never decorative.
+2 -1
View File
@@ -5,7 +5,8 @@ const { getAbbreviation } = require('./teamMap');
// prop data through Pinnacle survives normalization. (bovada deliberately // prop data through Pinnacle survives normalization. (bovada deliberately
// left OUT — it's the canonical "not-allowed" example in the tests, and // left OUT — it's the canonical "not-allowed" example in the tests, and
// VYNDR surfaces regulated US books.) // VYNDR surfaces regulated US books.)
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle']); // 'thescore' = theScore Bet (PENN), successor to the defunct ESPN BET (item 7).
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore']);
const MARKET_MAP = { const MARKET_MAP = {
// NBA / WNBA props // NBA / WNBA props
+2 -1
View File
@@ -9,7 +9,8 @@ const path = require('path');
const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books'); const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books');
// The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline. // The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline.
const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle']; // 'thescore' = theScore Bet (PENN), successor to the defunct ESPN BET (item 7).
const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore'];
const DEFAULT_FG = '#B8BCC8'; const DEFAULT_FG = '#B8BCC8';
const REPO = path.resolve(__dirname, '../..'); const REPO = path.resolve(__dirname, '../..');
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { jsonError } from '@/lib/auth-helpers';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Stripe billing-portal proxy (security follow-up item 6) — Next → Express →
* Stripe. Forwards the browser's bearer token (Express's requireAuth verifies
* the same one) and returns the hosted portal URL. The portal is fully
* configured in Stripe (cancellations, plan switching, invoice history); this
* is the app-side link from account settings.
*/
export async function POST(req: NextRequest) {
const authHeader = req.headers.get('authorization');
if (!authHeader) return jsonError(401, 'Log in to manage billing.');
try {
const upstream = await fetch(`${BACKEND_URL}/api/stripe/portal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
});
const data = (await upstream.json().catch(() => ({}))) as { portal_url?: string; error?: string };
if (!upstream.ok || !data.portal_url) {
return NextResponse.json(
{ error: data.error || 'Billing portal is unavailable right now.' },
{ status: upstream.ok ? 502 : upstream.status },
);
}
return NextResponse.json({ portal_url: data.portal_url }, { status: 200 });
} catch {
return jsonError(503, 'Billing portal is unavailable right now.');
}
}
+33
View File
@@ -40,6 +40,26 @@ export default function ProfilePage() {
.catch(() => setProfile(null)); .catch(() => setProfile(null));
}, [user]); }, [user]);
// Item 6 — open the Stripe billing portal (payment method, invoices, plan
// switching, cancellation). The portal itself is configured in Stripe; this
// just mints a session and redirects.
const handleManageBilling = async () => {
setWorking(true);
setError('');
const token = currentAccessToken();
const res = await fetch('/api/stripe/portal', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
setWorking(false);
const body = await res.json().catch(() => ({}));
if (res.ok && body.portal_url) {
window.location.href = body.portal_url;
} else {
setError(body.error || 'Billing portal is unavailable right now.');
}
};
const handleCancel = async () => { const handleCancel = async () => {
if (!confirm('Cancel your subscription at the end of the current period?')) return; if (!confirm('Cancel your subscription at the end of the current period?')) return;
setWorking(true); setWorking(true);
@@ -153,6 +173,19 @@ export default function ProfilePage() {
</section> </section>
)} )}
{/* Manage billing — Stripe customer portal (item 6) */}
{tier !== 'free' && (
<section className="surface" style={{ padding: 20, marginBottom: 16 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Billing</h3>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
Update your payment method, switch plans, or download invoices in the secure Stripe portal.
</p>
<button onClick={handleManageBilling} disabled={working} className="btn-ghost">
{working ? 'Opening…' : 'Manage billing →'}
</button>
</section>
)}
{/* Subscription actions */} {/* Subscription actions */}
{tier !== 'free' && !profile.cancel_at_period_end && ( {tier !== 'free' && !profile.cancel_at_period_end && (
<section className="surface" style={{ padding: 20, marginBottom: 16 }}> <section className="surface" style={{ padding: 20, marginBottom: 16 }}>
+6 -2
View File
@@ -19,8 +19,12 @@ const BOOKS = {
BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, // Book roster update (security follow-up item 7): ESPN BET is defunct — PENN
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, // rebranded it to theScore Bet (Dec 1 2025); ESPN is now exclusive with
// DraftKings. theScore Bet (PENN) is the successor.
TS: { name: 'theScore Bet', mono: 'TS', slug: 'thescore', bg: '#08150F', fg: '#2FBF71', bd: '#2FBF7155' },
THESCORE: { name: 'theScore Bet', mono: 'TS', slug: 'thescore', bg: '#08150F', fg: '#2FBF71', bd: '#2FBF7155' },
'THESCORE BET': { name: 'theScore Bet', mono: 'TS', slug: 'thescore', bg: '#08150F', fg: '#2FBF71', bd: '#2FBF7155' },
// BetRivers is a blue book (its logo is blue "BetRivers"), not purple. // BetRivers is a blue book (its logo is blue "BetRivers"), not purple.
BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' }, BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' }, BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },