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); app.use('/api/stripe', stripeRoutes); app.use('/api/stats', statsRoutes); app.use('/api/props', propsRoutes); 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); 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); // 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 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); // 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;