175 lines
7.4 KiB
JavaScript
175 lines
7.4 KiB
JavaScript
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;
|