require('dotenv').config(); // Session 10 — Sentry must initialize BEFORE express is required so // the instrumentation hooks attach correctly. Graceful no-op when // SENTRY_DSN is unset. const { initSentry, Sentry } = require('./utils/sentry'); initSentry(); const express = require('express'); const cors = require('cors'); const oddsRoutes = require('./routes/odds'); const analyzeRoutes = require('./routes/analyze'); const scanRoutes = require('./routes/scan'); const movementsRoutes = require('./routes/movements'); const alertsRoutes = require('./routes/alerts'); const betsRoutes = require('./routes/bets'); const stripeRoutes = require('./routes/stripe'); const statsRoutes = require('./routes/stats'); const propsRoutes = require('./routes/props'); const waitlistRoutes = require('./routes/waitlist'); const pipelineRoutes = require('./routes/pipeline'); const shareCardRoutes = require('./routes/shareCard'); const pushRoutes = require('./routes/push'); const gradingRoutes = require('./routes/grading'); const correctionRoutes = require('./routes/corrections'); const internalRoutes = require('./routes/internal'); const { missionHeader } = require('./middleware/mission'); const app = express(); // CORS — accept the Next.js frontend on Vercel/production and localhost dev. // FRONTEND_ORIGINS overrides at deploy time (comma-separated). const defaultOrigins = [ 'http://localhost:3000', 'http://localhost:3001', 'https://vyndr.app', 'https://www.vyndr.app', ]; const envOrigins = (process.env.FRONTEND_ORIGINS || '').split(',').map((s) => s.trim()).filter(Boolean); const allowedOrigins = [...new Set([...defaultOrigins, ...envOrigins])]; app.use( cors({ origin(origin, cb) { // Allow same-origin (no Origin header) and the configured allowlist. // Also allow any *.vercel.app preview for staging. if (!origin) return cb(null, true); if (allowedOrigins.includes(origin)) return cb(null, true); if (/\.vercel\.app$/.test(new URL(origin).hostname)) return cb(null, true); return cb(new Error(`Origin ${origin} not allowed`)); }, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }) ); // Mission header on all responses app.use(missionHeader); // Stripe webhook needs raw body — must be before express.json() app.use('/api/stripe/webhook', express.raw({ type: 'application/json' })); // Body parser limit raised to 10MB to accommodate full-slate poller // payloads. The default 100KB rejected real WNBA slates with 413. // Per-route limits below can tighten or loosen this for specific paths. app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Health check — public minimal status (Coolify, uptime monitors). Detailed // adapter + Python service status only with X-VYNDR-Internal-Key. app.get('/api/health', async (req, res) => { const checks = {}; try { const { getRedisClient, isDegraded } = require('./utils/redis'); if (isDegraded()) throw new Error('degraded'); await getRedisClient().ping(); checks.redis = 'ok'; } catch { checks.redis = 'down'; } try { const { getSupabaseServiceClient } = require('./utils/supabase'); const { error } = await getSupabaseServiceClient().from('users').select('id').limit(1); checks.supabase = error ? 'error' : 'ok'; } catch { checks.supabase = 'down'; } const healthy = checks.redis === 'ok' && checks.supabase === 'ok'; const expectedKey = process.env.VYNDR_INTERNAL_KEY; const providedKey = req.headers['x-vyndr-internal-key']; if (expectedKey && providedKey === expectedKey) { try { const axios = require('axios'); const pyUrl = process.env.PYTHON_SERVICE_URL || 'http://localhost:8000'; await axios.get(`${pyUrl}/health`, { timeout: 3_000 }); checks.python = 'ok'; } catch { checks.python = 'down'; } checks.adapters = { sharpapi: require('./services/adapters/sharpApiAdapter').configured(), propodds: require('./services/adapters/propOddsAdapter').configured(), parlayapi: require('./services/adapters/parlayApiAdapter').configured(), oddspapi: require('./services/adapters/oddsPapiAdapter').configured(), cfbd: require('./services/adapters/cfbdAdapter').configured(), openrouter: require('./services/adapters/openRouterAdapter').configured(), }; checks.engine2_enabled = process.env.ENGINE2_ENABLED === 'true'; return res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'degraded', checks, version: require('../package.json').version || '1.0.0', uptime: Math.floor(process.uptime()), }); } return res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'degraded', }); }); app.use('/api/odds', oddsRoutes); app.use('/api/analyze', analyzeRoutes); app.use('/api/scan', scanRoutes); app.use('/api/movements', movementsRoutes); app.use('/api/alerts', alertsRoutes); app.use('/api/bets', betsRoutes); // Session 49 — per-user onboarding preferences (auth-gated, user_metadata). app.use('/api/preferences', require('./routes/preferences')); app.use('/api/stripe', stripeRoutes); app.use('/api/stats', statsRoutes); app.use('/api/props', propsRoutes); // Session 60 (night2/E) — the scan search box's canonical player resolver. app.use('/api/players', require('./routes/players')); // Session 60 (night2/F) — per-user utility reads (Settings scan meter). app.use('/api/user', require('./routes/user')); // Session 63 (A1-S4) — the founder's media desk (email-allowlisted). app.use('/api/desk', require('./routes/desk')); app.use('/api/waitlist', waitlistRoutes); app.use('/api/pipeline', pipelineRoutes); app.use('/api/share-card', shareCardRoutes); app.use('/api/push', pushRoutes); // Resolution payloads carry full ESPN box scores plus per-game prop // arrays. Full-slate WNBA / MLB resolves exceed 2MB in practice — keep // /api/grading aligned with the global 10MB ceiling. Correction sweep // stays small (just a window-hours integer + flags). app.use('/api/grading', express.json({ limit: '10mb' }), gradingRoutes); app.use('/api/grading', express.json({ limit: '256kb' }), correctionRoutes); const widgetRoutes = require('./routes/widget'); app.use('/api/widget', widgetRoutes); // Session 23 — all-day intelligence layer. Free/cheap content surfaces // that keep the platform alive when odds-api is empty: schedule (ESPN), // game lines (Tank01), streaks + hot lists (cached game logs), and the // stat-filtered views over all of them. const scheduleRoutes = require('./routes/schedule'); app.use('/api/schedule', scheduleRoutes); // Wave 6 — combat intelligence (MMA/UFC): fight cards + tale-of-the-tape + // best-effort ML/round-total odds. Read-only, cache-friendly, honest empty // off-card. NOT in the graded-props pipeline (no settled grades in v1). const combatRoutes = require('./routes/combat'); app.use('/api/combat', combatRoutes); app.use('/api/fight', combatRoutes.fightRouter); // Session 45 — live ticker feed (snapshot exhaust + editorial pins). Public, // cache-only, never triggers a snapshot. const tickerRoutes = require('./routes/ticker'); app.use('/api/ticker', tickerRoutes); // Session 45 — pre-graded slate read (snapshot:{sport}:latest). Public, cache-only. const snapshotReadRoutes = require('./routes/snapshot'); app.use('/api/snapshot', snapshotReadRoutes); // Session 51 — Team Hub (roster + archetypes + graded props). Public, cached. app.use('/api/team', require('./routes/team')); // A1 Session 11 — LIVE TRACKING: current box-line values for in-progress // games (free statsapi/ESPN, shared 90s cache). Grades never change in-game // — this is tracking, labeled as such. app.use('/api/live', require('./routes/live')); // Session 55 — self-learning loop: the system's rolling accuracy record // (settled snapshot grades vs real results). Public, cache-only. app.use('/api/accuracy', require('./routes/accuracy')); app.use('/api/ledger', require('./routes/ledger')); // A1 Session 10 — public ledger profiles: claim a handle, one explicit // publish toggle, and the ENTIRE settled record on a public page. app.use('/api/profiles', require('./routes/profiles')); const gameLinesRoutes = require('./routes/gameLines'); app.use('/api/gamelines', gameLinesRoutes); const streaksRoutes = require('./routes/streaks'); app.use('/api/streaks', streaksRoutes); const hotListRoutes = require('./routes/hotlist'); app.use('/api/hotlist', hotListRoutes); // Wave 2A — offseason/never-dark hub feeds. FREE ESPN news wire + quota- // disciplined championship futures (both graceful/empty; never error). app.use('/api/news', require('./routes/news')); app.use('/api/futures', require('./routes/futures')); // Session 28 — parlay builder, line-movement views, book comparison. // All three are zero-credit: parlay math is pure, lines read a Redis // snapshot history, books read the cached odds props. const parlayRoutes = require('./routes/parlay'); app.use('/api/parlay', parlayRoutes); const lineMovementRoutes = require('./routes/lineMovement'); app.use('/api/lines', lineMovementRoutes); const bookComparisonRoutes = require('./routes/bookComparison'); app.use('/api/books', bookComparisonRoutes); // Session 29 — content templates: structured social/newsletter content // generated from live data, degrading gracefully by data level. const contentRoutes = require('./routes/content'); app.use('/api/content', contentRoutes); // Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe // (forwards to the self-hosted Listmonk; graceful no-op without env). app.use('/api/newsletter', require('./routes/newsletter')); // A1 S9 — Slip Reader: OCR a bet-slip screenshot into legs (auth + // per-tier daily quota inside the router). Values are user-slip values. app.use('/api/slips', require('./routes/slips')); // Session 18 — internal ops endpoints (admin dashboard triggers, // shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from // the public surface; the Next.js admin route proxies through with // the key kept server-side. app.use('/api/internal', internalRoutes); // A1 S3 — partner attribution report. Internal-key gated (router-level // requireInternalAuth); no Next proxy on purpose — never browser-facing. app.use('/api/partners', require('./routes/partners')); // Session 10 — Sentry's Express error handler catches uncaught // errors from every route mounted above. Must come AFTER routes but // BEFORE any final express error handler. The noop client makes this // a safe no-op when SENTRY_DSN is unset. Sentry.setupExpressErrorHandler(app); module.exports = app;