Files
vyndr/src/app.js
T
builtbykev e5b20b0509 Software watches coverage now, and ancestry becomes a product contract
Three closeout items, traced before building.

THE OBSERVER WAS DIAGNOSTICS, NOT MONITORING. Traced from the deployed tree:
exactly two callsites, both manual internal routes, and nothing in the scheduler
or ops path consumed it. A persistent lineage failure could have sat unnoticed
until a human asked.

The monitor now runs on the scheduler's per-minute tick, throttled to 30
minutes. It is placed there rather than after a snapshot on purpose: an
in-snapshot audit structurally cannot report that no snapshot ran, which is the
failure mode that matters most, and it would run under peak write contention
where the audit already demonstrably times out. It reads only the durable
observer and never `attachLineage`'s counters, and every failure path is
swallowed — a monitor that can take down the pipeline it watches is worse than
no monitor.

HEALTHY IS SILENCE; EVERYTHING ELSE SPEAKS. `coverageAlarm` is pure, so the
policy is testable and cannot drift into the scheduler. AUDIT_UNAVAILABLE says
"could not be measured — the audit did not run", deliberately worded so it can
never be read as "coverage is zero": those are different claims and collapsing
them is how a monitor starts lying in the reassuring direction. Alerts dedupe on
(health, cohort) so a standing fault states itself once and a NEW cohort with
the same fault speaks again.

ANCESTRY BECOMES A PRODUCT CONTRACT. It was internal-only. `GET
/api/ancestry/ledger/:id` (requireAuth, rate-limited) plus the Next proxy that
makes it browser-reachable, keyed on the LEDGER ROW ID — a stable identifier the
ledger API already returns — rather than a raw natural key exposed because it
was convenient. Its own router, so `routes/ledger.js` stays free of lineage
entirely and the grade-badge guard keeps its teeth. Every response declares
`authority: LOCAL, authority_scope: ANCESTRY_ONLY`.

THREE TEETH CAME BACK GREEN AND ALL THREE WERE MY TESTS, NOT SAFE DEFECTS.
The badge guard was CASE-SENSITIVE, so `LINEAGE_ANCESTRY` and `readAncestry`
walked straight past it. `try/finally` is valid JavaScript, so removing the
monitor's catch produced no load error and nothing asserted the containment.
And the multi-date cohort check was a grep for `.gte('game_date'` that the
head query satisfied on its own. All three replaced with behavioural tests,
including a scheduler double whose fake client HONOURS its filters — a
pass-through would have made a narrowed cohort walk look correct.

Suite 398/5,522/0 · tsc 0 · web build 0 · teeth 14/14 and 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
2026-08-30 23:44:46 -04:00

240 lines
11 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);
// Session 49 — per-user onboarding preferences (auth-gated, user_metadata).
app.use('/api/preferences', require('./routes/preferences'));
// ADDITIVE ancestry contract — lineage is locally authoritative for Read
// ancestry and nothing else. Its own router so the ledger router stays free of
// lineage entirely, which is what keeps the grade-shift badge guard meaningful.
app.use('/api/ancestry', require('./routes/ancestry'));
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'));
// E12 — The Report archive. Public, read-only; every issue carries its own
// day record, because the archive is a ledger too.
app.use('/api/report', require('./routes/report'));
// 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);
// Content STUDIO — finished posts (copy + card + fact-contract) for review and,
// later, for an autonomous poster. Distinct from /api/content (Session 29),
// which serves structured content objects by data level.
app.use('/api/content-studio', require('./routes/contentStudio'));
// 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'));
app.use('/api/founders', require('./routes/founders'));
app.use('/api/hero-prop', require('./routes/heroProp'));
app.use('/api/desk-showcase', require('./routes/deskShowcase'));
// 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;