'use strict'; /** * Internal ops endpoints (Session 18). * * Reachable only with the shared `VYNDR_INTERNAL_KEY` — never * exposed to end users. The admin dashboard wires the Tank01 * prefetch button to POST here through the Next.js server (the * key never touches a browser). * * Deviation from spec: the spec suggested `execSync('node scripts/tank01-prefetch.js')`. * We import the module instead — same behavior, but in-process and * testable. The module already exposes `main(argv)` which returns * the same summary object the spec expected to parse out of stdout. */ const express = require('express'); const { requireInternalAuth } = require('../middleware/internalAuth'); /** * The start of THIS Node process, computed ONCE so every call reports the same * instant for one process lifetime. Deriving it per request from `Date.now()` * would make it read as "now" and destroy its only use — marking a boundary. */ const PROCESS_STARTED_AT = new Date(Date.now() - Math.round(process.uptime() * 1000)).toISOString(); const tank01Prefetch = require('../../scripts/tank01-prefetch'); const quotaTracker = require('../services/quotaTracker'); const router = express.Router(); router.use(requireInternalAuth({ loopbackOnly: false })); /** * GET /api/internal/quota (Session 20) * * Snapshot of every configured provider's current quota counter. * Consumed by the admin dashboard's "Provider Quotas" tile. Cached * for 5s so a refresh-button mash doesn't flood Redis. */ /** * GET /api/internal/acquisition/:sport (scheduled acquisition trace) * * READ-ONLY. Returns the bounded history of SCHEDULED snapshot acquisition * attempts for one sport — the primary/retry/fallback decision chain that * production previously retained nothing about. Makes no provider request and * runs no part of the pipeline. Counts, statuses and sanitized error classes * only: no keys, no URLs, no payloads, no prop arrays. */ /** * GET /api/internal/pregrading/:sport (pre-grading stage trace) * * READ-ONLY. The stage-by-stage record of cohort construction between * acquisition and the first grading call — identity, admission, dedupe — for * SCHEDULED attempts, correlated to the same snapshot_attempt_id the * acquisition recorder minted. Counts and sanitized error classes only. Runs no * pipeline and makes no fetch. */ router.get('/pregrading/:sport', async (req, res) => { try { const acq = require('../services/ops/acquisitionTrace'); const attempts = await acq.pregradeHistory(req.params.sport); res.set('Cache-Control', 'no-store'); return res.json({ ok: true, sport: String(req.params.sport || '').toLowerCase(), trigger_scope: acq.TRIGGER.SCHEDULED, history_cap: acq.HISTORY_CAP, count: attempts.length, attempts, }); } catch (err) { return res.status(500).json({ ok: false, error: err && err.message }); } }); router.get('/acquisition/:sport', async (req, res) => { try { const acq = require('../services/ops/acquisitionTrace'); const attempts = await acq.history(req.params.sport); res.set('Cache-Control', 'no-store'); return res.json({ ok: true, sport: String(req.params.sport || '').toLowerCase(), trigger_scope: acq.TRIGGER.SCHEDULED, history_cap: acq.HISTORY_CAP, count: attempts.length, attempts, }); } catch (err) { return res.status(500).json({ ok: false, error: err && err.message }); } }); router.get('/quota', async (req, res) => { try { const providers = await quotaTracker.getAllQuotaStatuses(); res.set('Cache-Control', 'private, max-age=5'); return res.json({ ok: true, providers }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/quota] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * POST /api/internal/quota/test-alert (quota guard) * * Test-fires the quota pager end-to-end through the REAL opsNotify path so * "does the 80% alert actually deliver?" is verifiable on demand (the reason * the 500/500 drain went unnoticed was a counting blind spot, not delivery — * this proves the delivery leg). Sends one ntfy to vyndr-pipeline-kev2026. * Does NOT touch the real counter. Body: { pct? } (default 0.85). */ router.post('/quota/test-alert', async (req, res) => { try { const notify = require('../utils/opsNotify').notify; const pct = Number.isFinite(Number(req.body && req.body.pct)) ? Number(req.body.pct) : 0.85; const out = await notify( `TEST — odds-api quota alert delivery check (${Math.round(pct * 100)}%). If you can read this, the quota pager path works.`, { title: 'VYNDR quota (test)', priority: 'high', tags: ['warning', 'chart_decreasing'] }, ); return res.json({ ok: true, delivery: out }); } catch (err) { const message = err && err.message ? err.message : String(err); return res.status(500).json({ ok: false, error: message }); } }); /** * POST /api/internal/prefetch/tank01 * * Body (all optional): * { max?: number, sports?: string[]|string, dryRun?: boolean } * * Builds an argv array equivalent to the CLI form and hands it to * the prefetch module. Returns the module's summary on success. */ router.post('/prefetch/tank01', async (req, res) => { const body = (req.body && typeof req.body === 'object') ? req.body : {}; // Build argv. `main()` parses its own args, so all the validation // (numeric bounds, allowed sports) stays in one place — we just // translate JSON shapes into CLI flags. const argv = ['node', 'scripts/tank01-prefetch.js']; if (Number.isFinite(body.max) && body.max > 0) { argv.push(`--max=${Math.floor(body.max)}`); } if (body.dryRun === true) { argv.push('--dry-run'); } if (body.sports) { const sportsList = Array.isArray(body.sports) ? body.sports.join(',') : String(body.sports); argv.push(`--sports=${sportsList}`); } try { const summary = await tank01Prefetch.main(argv); return res.json({ ok: true, summary }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/prefetch/tank01] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * POST /api/internal/snapshot/:sport (Session 45) * * Trigger one snapshot cycle for a sport (pre-grade the slate, lock grades, * compute deltas, emit ticker events). Internal-only (requireInternalAuth at the * router root). This is what the cron / n8n schedule calls — never public, so a * bad actor can't drain the PropLine quota by spamming it. */ /** POST /api/internal/snapshot/all — every active sport, sequentially. * Registered BEFORE /snapshot/:sport so "all" isn't captured as a sport. */ router.post('/snapshot/all', async (req, res) => { const snapshot = require('../services/snapshotService'); try { const acqT = require('../services/ops/acquisitionTrace'); const results = await snapshot.runAllSnapshots({ trigger: acqT.TRIGGER.CONTROLLED_FORCED, processStartedAt: PROCESS_STARTED_AT, }); return res.json({ ok: true, results }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/snapshot/all] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * GET /api/internal/snapshot/status (Session 52) — verification probe. Reports * whether the in-process cron is armed, the freshest snapshot per sport, which * pipeline Redis keys exist, and the ticker item count. Read-only; safe to poll. * GET vs the POST /snapshot/:sport below — no route collision. * * -- RUNTIME IDENTITY (added for the MLB rollout) ------------------------- * The rollout stalled because nothing could answer "which build is running?" * or "is lineage effectively on?" except as a side effect of a scheduled * snapshot writing a row — so every state transition waited on cron. * * Two read-only fields close that: * * runtime.code_sha the SAME resolver production provenance uses * (`retentionService.codeSha`). NEVER git HEAD, never * gitea/main, never deployment intent. Unavailable * returns null rather than a guess. * runtime.started_at the start of THIS process. Deliberately not named * `deployed_at` — a restart without a deploy moves it. * lineage_canary the SAME frozen state the write gate consults * (`lineageCanaryConfig`), never a second parse. * * No raw environment value is returned: `lineage_canary.sports` is the * normalised set, and `configuration_source` says only whether the value came * from the environment or the default. * * Still strictly observational — the handler only reads Redis. */ router.get('/snapshot/status', async (req, res) => { const { cacheGet } = require('../utils/redis'); const { HOURS_UTC, isSnapshotOverdue } = require('../snapshotScheduler'); const SPORTS = ['mlb', 'nba', 'wnba']; try { const redis_keys = {}; const last_snapshot = {}; for (const sp of SPORTS) { const latestKey = `snapshot:${sp}:latest`; const prevKey = `snapshot:${sp}:previous`; const gradesKey = `grades:${sp}`; const [latest, prev, grades] = await Promise.all([cacheGet(latestKey), cacheGet(prevKey), cacheGet(gradesKey)]); redis_keys[latestKey] = !!latest; redis_keys[prevKey] = !!prev; redis_keys[gradesKey] = !!grades; if (latest) { last_snapshot[sp] = { updated_at: latest.updated_at || null, gradeCount: Array.isArray(latest.grades) ? latest.grades.length : 0, deltaCount: Array.isArray(latest.deltas) ? latest.deltas.length : 0, }; } } const ticker = await cacheGet('ticker:items'); redis_keys['ticker:items'] = !!ticker; // Same helpers the pipeline itself uses — one build identity, one canary parser. const { codeSha, lastRetention } = require('../services/retentionService'); const lineageCanary = require('../services/lineageCanaryConfig'); // Session 56 — surface the missed-cron signal in the health probe. const mlbTs = last_snapshot.mlb && last_snapshot.mlb.updated_at; return res.json({ runtime: { code_sha: codeSha(), // Computed once at module load from process.uptime(), so repeated calls // report one stable value for one process lifetime. started_at: PROCESS_STARTED_AT, }, // The effective lineage state is resolved once at module load and cannot // change without a new process, so `runtime.started_at` is a defensible // lower bound for how long this state has held. lineage_canary: lineageCanary.state(), // Latest TERMINAL retention result per sport, exactly as the persistence // function reported it. Chunked writes stop at the first failed chunk, so // rows existing under a snapshot_id does not mean the cohort is complete — // this is the only place that distinction is observable in production. // Counts and status only: no row payloads, no credentials. last_retention: lastRetention(), cron_armed: process.env.SNAPSHOT_CRON === '1', cron_hours_utc: HOURS_UTC, last_snapshot, overdue: isSnapshotOverdue(mlbTs), redis_keys, ticker_count: Array.isArray(ticker) ? ticker.length : 0, }); } catch (err) { return res.status(500).json({ ok: false, error: err.message }); } }); router.post('/snapshot/:sport', async (req, res) => { const snapshot = require('../services/snapshotService'); try { // ?limit= is a BISECT HOOK, not a production knob: it bounds the graded // slate for one run so a cap regression can be isolated by measurement // rather than guessed at. Omitted => gradeSlateService's real DEFAULT_LIMIT. const limit = Number(req.query.limit) > 0 ? Number(req.query.limit) : undefined; // TRUTHFUL PROVENANCE. runSnapshot defaults an absent trigger to SCHEDULED, // so an operator-invoked run was previously recorded as though the cron // fired it. It is now labelled for what it is. This changes ONLY the // diagnostic trace — the pipeline path, the dependencies and every // authoritative write are identical to the scheduled invocation. const acqT = require('../services/ops/acquisitionTrace'); const summary = await snapshot.runSnapshot(req.params.sport, { ...(limit ? { limit } : {}), trigger: acqT.TRIGGER.CONTROLLED_FORCED, processStartedAt: PROCESS_STARTED_AT, }); return res.json({ ok: true, ...(limit ? { limit_override: limit } : {}), summary }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/snapshot] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * POST /api/internal/outcomes/all (Session 55) — settle every sport's latest * snapshot against real results + recompute the overall accuracy record. This * is the self-learning loop's write path (the public /api/accuracy is read-only). * Registered BEFORE /outcomes/:sport so "all" isn't captured as a sport. */ router.post('/outcomes/all', async (req, res) => { const outcomes = require('../services/outcomeService'); try { const results = await outcomes.settleAllOutcomes(); return res.json({ ok: true, results }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/outcomes/all] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * POST /api/internal/ghost/drafts (Session 63 / A1-S4d) — assemble today's * desk pack and save the slate-preview + settle drafts to Ghost. DRAFTS * ONLY; Kev publishes from the Ghost admin. Env-gated no-op without Ghost. */ router.post('/ghost/drafts', async (req, res) => { try { const { assembleDeskPack } = require('../services/deskService'); const ghost = require('../services/ghostPublisher'); const pack = await assembleDeskPack(); const result = await ghost.saveDailyDrafts(pack); return res.json({ ok: result.ok, result }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/ghost/drafts] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * POST /api/internal/refresh/all (Session 60, Phase 2.5) — manual intraday * odds-only refresh: STEAM/VALUE movement, public revisions, closing * capture. Same behavior as the in-process 20-min cadence. */ router.post('/refresh/all', async (req, res) => { const refresh = require('../services/intradayRefreshService'); try { const results = await refresh.runAllIntradayRefreshes(); return res.json({ ok: true, results }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/refresh/all] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * POST /api/internal/backup/run (Session 64) — fire ONE real database backup * now: pg_dump → validate (`pg_restore --list` must contain ledger_entries) → * off-box rsync to the Storage Box. * * Exists because the backup can only run where SUPABASE_DB_URL and the Supabase * network route live — inside this container — and there was previously no way * to trigger or observe it without a shell on the box. Returns the script's * exit code and output tail so a real run can be VERIFIED, not assumed. */ router.post('/backup/run', async (req, res) => { const { runBackup, durabilityWarning } = require('../backupScheduler'); try { const started = Date.now(); const result = await runBackup(); // Session 64 Phase 2b — off-box is REQUIRED now, so its outcome is reported // SEPARATELY from the exit code. The script deliberately still exits 0 on a // failed push (a durable on-box dump must not raise a false total-failure // alarm) — so without this field a failed off-box push reads as success. const tail = result.tail || ''; const offboxOk = /OFFBOX_OK=1/.test(tail) ? true : (/OFFBOX_OK=0/.test(tail) ? false : null); // null = deferred/not attempted return res.json({ ok: result.ok, offbox_ok: offboxOk, exit_code: result.code, duration_ms: Date.now() - started, durability_warning: durabilityWarning() || null, remote_configured: !!process.env.BACKUP_REMOTE, ssh_key_configured: !!process.env.BACKUP_SSH_KEY, output_tail: result.tail || result.error || null, }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/backup/run] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * GET /api/internal/backup/verify (Session 64) — prove the newest dump on the * persistent volume actually CONTAINS the data, by counting `ledger_entries` * rows out of the archive with pg_restore. A backup nobody has read back is a * hope, not a backup. */ router.get('/backup/verify', async (req, res) => { const { latestDump, countRowsInDump } = require('../backupScheduler'); try { const dir = process.env.BACKUP_DIR || '/var/backups/vyndr'; // Report identity + writability: a mounted-but-unwritable volume is the // exact failure we hit (Coolify mounts root-owned; the container runs as // the non-root `vyndr` user), and the fix needs the real uid/gid. const fs = require('fs'); let writable = false; let dirErr = null; try { fs.accessSync(dir, fs.constants.W_OK); writable = true; } catch (e) { dirErr = e.code || e.message; } const identity = { uid: typeof process.getuid === 'function' ? process.getuid() : null, gid: typeof process.getgid === 'function' ? process.getgid() : null, backup_dir_writable: writable, backup_dir_error: dirErr, }; const dump = latestDump(dir); if (!dump) { return res.json({ ok: false, backup_dir: dir, ...identity, error: 'no dump found in BACKUP_DIR' }); } const table = String(req.query.table || 'ledger_entries'); const counted = await countRowsInDump(dump.path, table); return res.json({ ok: counted.ok, backup_dir: dir, ...identity, dump: dump.file, dump_bytes: dump.size, table, rows_in_dump: counted.rows, error: counted.error || null, }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/backup/verify] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * GET /api/internal/backup/offbox — list the dumps actually present ON the * Storage Box. Exit 0 from the backup is not proof the file landed; this reads * the remote directory back so off-box presence is verified, not inferred. */ router.get('/backup/offbox', async (req, res) => { const { listOffbox } = require('../backupScheduler'); try { const result = await listOffbox(); return res.json(result); } catch (err) { const message = err && err.message ? err.message : String(err); return res.status(500).json({ ok: false, error: message }); } }); /** * POST /api/internal/harness/run (Session 64) — induce the nightly harness run * on demand. Scheduled mechanisms are verified by INDUCING their real code * path, never by waiting for a slot to discover whether they work. */ router.post('/harness/run', async (req, res) => { try { const runner = require('../services/harnessRunner'); const out = await runner.runAndRecord(); return res.json({ ok: out.ok !== false, ...out, last_run_at: await runner.lastRunAt() }); } catch (err) { return res.status(500).json({ ok: false, error: err.message }); } }); /** * POST /api/internal/closing/capture/:sport (Session 64) — induce one closing * capture pass against the CURRENT live odds, so the mechanism is proven now * rather than discovered tomorrow. */ router.post('/closing/capture/:sport', async (req, res) => { try { const sp = String(req.params.sport || '').toLowerCase(); const closing = require('../services/closingCapture'); const odds = await require('../services/oddsService').getOdds(sp); const props = (odds && Array.isArray(odds.props)) ? odds.props : []; await require('../services/gameBinder').attachGameTimes(sp, props, {}); const windowMinutes = Number(req.query.window || 0) || undefined; const rows = closing.buildCaptureRows(sp, props, { windowMinutes }); const priced = rows.filter((r) => !r.missed_reason).length; const persisted = req.query.dry === '1' ? { skipped: true } : await closing.persist(rows); const reasons = {}; for (const r of rows) if (r.missed_reason) reasons[r.missed_reason] = (reasons[r.missed_reason] || 0) + 1; return res.json({ ok: true, sport: sp, props: props.length, rows: rows.length, priced, missed: rows.length - priced, missed_reasons: reasons, persisted, }); } catch (err) { return res.status(500).json({ ok: false, error: err.message }); } }); /** * POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the * persistent ledger (outcome + actual_value + CLV) across every sport. * Idempotent — safe to re-run; already-settled rows are never touched. */ router.post('/ledger/settle', async (req, res) => { const ledger = require('../services/ledgerService'); try { const results = await ledger.settleAllLedgers(); return res.json({ ok: true, results }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/ledger/settle] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * POST /api/internal/ledger/attach-closing[/:sport] — CLV instrument repair. * Attach the de-vigged closing probability from `closing_captures` onto locked * model-record rows. Recovers rows a truncated read / premature verdict wrongly * declared closeless (market_unavailable is a re-checkable absence, not terminal). * Idempotent: `closing_prob` is write-once; a row with a genuine close never * changes, and a row with no capture stays honestly absent. */ async function attachClosingHandler(req, res) { const ledger = require('../services/ledgerService'); const ALL = ['mlb', 'wnba', 'nba', 'soccer']; const only = String(req.params.sport || '').toLowerCase(); const sports = only ? [only] : ALL; try { const results = {}; for (const sp of sports) results[sp] = await ledger.attachClosingProb(sp, { limit: Number(req.query.limit) || undefined }); return res.json({ ok: true, results }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/ledger/attach-closing] failed:', message); return res.status(500).json({ ok: false, error: message }); } } // Express 5 (path-to-regexp v8) has no `/:sport?` optional param — register both. router.post('/ledger/attach-closing', attachClosingHandler); router.post('/ledger/attach-closing/:sport', attachClosingHandler); /** * POST /api/internal/newsletter/send (Session S7, a1) — assemble today's * VYNDR REPORT from the pipeline (snapshot signals + streak lens + the * ledger record) and send it as a Listmonk campaign to the opted-in list. * * DELIBERATELY UNSCHEDULED: nothing calls this on a timer. The operator * (or a future n8n cron, once Kev arms it) triggers the send. Env-gated — * without LISTMONK_* config it's a calm no-op; an empty report (zero * signals AND zero streaks) refuses to send. * * Body (optional): { sports?: string[] } — defaults to ['mlb', 'wnba']. */ router.post('/newsletter/send', async (req, res) => { const newsletter = require('../services/newsletterService'); const body = (req.body && typeof req.body === 'object') ? req.body : {}; const sports = Array.isArray(body.sports) && body.sports.length > 0 ? body.sports : undefined; try { const result = await newsletter.sendDailyReport({ sports }); return res.json(result); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/newsletter/send] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** POST /api/internal/gamectx/:date — induce a game-context capture on demand. */ router.post('/gamectx/:date', async (req, res) => { try { const gc = require('../services/gameContext'); const out = await gc.captureDate(req.params.date, { sport: req.query.sport || 'mlb' }); return res.status(out.ok ? 200 : 500).json(out); } catch (err) { return res.status(500).json({ ok: false, error: err.message }); } }); /** GET /api/internal/gamectx/accrual — how far from a self-derived park factor. */ router.get('/gamectx/accrual', async (req, res) => { try { const gc = require('../services/gameContext'); return res.json(await gc.accrualStatus({ sport: req.query.sport || 'mlb' })); } catch (err) { return res.status(500).json({ error: err.message }); } }); /** * POST /api/internal/statcast/refresh — INDUCE the Layer-1 mechanism refresh. * * Same call the nightly scheduler makes. Exists so the job is verifiable ON * DEMAND: we prove a refresh works by running it and reading the result, never * by waiting for the cron slot. Idempotent — running it twice is a no-op beyond * refreshing values and updated_at. */ /** * POST /api/internal/lineup-context/refresh — INDUCE the lineup + baserunner * context ingest. * * Exists for the same reason the statcast one does: a job is proven by running * it and reading the result, never by waiting for the slot it rides in. It also * makes the ingest verifiable WITHOUT a full snapshot, which now takes ~3 * minutes and 524s at the edge — so a zero row count can be diagnosed as a * wiring bug rather than an honest absence in seconds. */ router.post('/lineup-context/refresh', async (req, res) => { try { const svc = require('../services/lineupContextService'); const sb = require('../utils/supabase').getSupabaseServiceClient(); if (!sb) return res.status(500).json({ ok: false, error: 'no supabase service client' }); const out = await svc.refreshContext({ supabase: sb, sport: 'mlb', date: req.query.date || undefined, }); return res.status(out.ok ? 200 : 500).json(out); } catch (err) { console.error('[internal/lineup-context]', err.message); return res.status(500).json({ ok: false, error: err.message }); } }); router.post('/statcast/refresh', async (req, res) => { try { const agg = require('../services/statcastAggregateService'); const season = req.query.season ? Number(req.query.season) : undefined; const out = await agg.refreshSeason({ season }); const fresh = await agg.getFreshness({}); return res.status(out.ok ? 200 : 500).json({ ...out, freshness: fresh, stale: agg.isStale(fresh) }); } catch (err) { console.error('[internal/statcast]', err.message); return res.status(500).json({ ok: false, error: err.message }); } }); /** GET /api/internal/statcast/status — freshness probe for the mechanism tier. */ router.get('/statcast/status', async (req, res) => { try { const agg = require('../services/statcastAggregateService'); const fresh = await agg.getFreshness({}); return res.json({ ...fresh, stale: agg.isStale(fresh), max_age_hours: agg.MAX_AGE_HOURS, min_pa: agg.MIN_PA, min_ip: agg.MIN_IP, cron_hour_utc: Number(process.env.STATCAST_HOUR_UTC || 11), enabled: process.env.STATCAST !== '0', }); } catch (err) { return res.status(500).json({ error: err.message }); } }); router.post('/outcomes/:sport', async (req, res) => { const outcomes = require('../services/outcomeService'); try { const summary = await outcomes.settleSnapshot(req.params.sport); await outcomes.recomputeOverall(); return res.json({ ok: true, summary: { sport: summary.sport, settled: summary.settled, pending: summary.pending, accuracy: summary.accuracy } }); } catch (err) { const message = err && err.message ? err.message : String(err); console.error('[internal/outcomes] failed:', message); return res.status(500).json({ ok: false, error: message }); } }); /** * GET /api/internal/propline-verify (Order Zero, Phase 1) * * KEYED, READ-ONLY verification — runs where the PropLine key lives. Reports * per-sport book breadth (feed vs after our own allow-list), exchange reality, * consensus eligibility, and works/partial/no for the documented-but-unverified * endpoints. Touches no cache, no ledger, no grade; the live adapter and the * live ruler are untouched. * * Costs ~1 PropLine call per sport plus one per probe. Never returns the key — * every string it emits passes through `scrubKeys`. * * ?sports=mlb,wnba (default: mlb,wnba) */ router.get('/propline-verify', async (req, res) => { try { const sports = String(req.query.sports || 'mlb,wnba') .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean).slice(0, 6); const { verify } = require('../services/proplineVerify'); const out = await verify({ sports }); res.set('Cache-Control', 'no-store'); return res.json({ ok: true, ...out }); } catch (err) { const { __internals } = require('../services/proplineVerify'); return res.status(500).json({ ok: false, error: __internals.scrubKeys(err && err.message) }); } }); /** * GET /api/internal/ranking-delta (Order: rank on p_win — CHALLENGER-FIRST) * * Reads the live snapshot and reports how far the board WOULD move if the * ranking instrument changed from `rankGrades` (grade-first, edge as 4th key) * to `rankByForecast` (p_win-first, no edge term). Changes nothing — the live * ordering is untouched until this delta is reviewed. * * ?sports=mlb,wnba ?top=10 */ router.get('/ranking-delta', async (req, res) => { try { const { cacheGet } = require('../utils/redis'); const { rankingDelta } = require('../utils/gradeRanking'); const sports = String(req.query.sports || 'mlb,wnba') .split(',').map((x) => x.trim().toLowerCase()).filter(Boolean).slice(0, 6); const topN = Math.max(1, Math.min(50, parseInt(req.query.top, 10) || 10)); const out = {}; for (const sport of sports) { let grades = null; const snap = await cacheGet(`snapshot:${sport}:latest`); if (snap && Array.isArray(snap.grades)) grades = snap.grades; else { const env = await cacheGet(`grades:${sport}`); if (env && Array.isArray(env.grades)) grades = env.grades; } if (!grades || grades.length === 0) { out[sport] = { note: 'no cached grades' }; continue; } const withPWin = grades.filter((g) => g && g.p_win != null).length; out[sport] = { graded: grades.length, with_p_win: withPWin, // Honest: if p_win is absent the challenger degrades to grade order and // the delta understates. Say so rather than reporting a clean zero. p_win_coverage_pct: grades.length ? Math.round((1000 * withPWin) / grades.length) / 10 : null, ...rankingDelta(grades, topN), }; } res.set('Cache-Control', 'no-store'); return res.json({ ok: true, live_ordering_unchanged: true, per_sport: out }); } catch (err) { return res.status(500).json({ ok: false, error: err && err.message }); } }); /** * GET /api/internal/diagnose-refusals (25-cap + 72% refusal, Part 1) * * READ-ONLY diagnosis: runs the real grade path over a real slate, categorises * every refusal, and measures per-grade cost so we know what raising the cap * would actually cost. Writes nothing. * * ?sport=mlb&sample=60&concurrency=5 */ router.get('/diagnose-refusals', async (req, res) => { try { const { diagnose } = require('../services/refusalDiagnostics'); const out = await diagnose({ sport: req.query.sport || 'mlb', sample: parseInt(req.query.sample, 10) || undefined, concurrency: parseInt(req.query.concurrency, 10) || undefined, }); res.set('Cache-Control', 'no-store'); return res.json({ ok: true, ...out }); } catch (err) { return res.status(500).json({ ok: false, error: err && err.message }); } }); /** * GET /api/internal/feature-coverage (Connect-layers Step 0) * * READ-ONLY input check: before wiring any layer into the grade, measure * whether its inputs are actually populated on real props. Writes nothing. * * ?sport=mlb&sample=60 */ router.get('/feature-coverage', async (req, res) => { try { const { coverage } = require('../services/featureCoverage'); const out = await coverage({ sport: req.query.sport || 'mlb', sample: parseInt(req.query.sample, 10) || undefined, concurrency: parseInt(req.query.concurrency, 10) || undefined, }); res.set('Cache-Control', 'no-store'); return res.json({ ok: true, ...out }); } catch (err) { return res.status(500).json({ ok: false, error: err && err.message }); } }); /** * GET /api/internal/env-context-audit (matchup/park audit, Step 0) * * READ-ONLY. The arch-v1 environment and matchup axes fired on ZERO prod rows * while archetype axes fired normally, so the question is WHERE the resolution * chain drops off. This replays buildContext + contextFor against the CURRENT * cached snapshot grades and counts the drop-off at each hop, instead of * inferring it from an absence. */ router.get('/env-context-audit', async (req, res) => { try { const sport = String(req.query.sport || 'mlb').toLowerCase(); const { cacheGet } = require('../utils/redis'); const envCtx = require('../services/environmentContext'); const snap = await cacheGet(`snapshot:${sport}:latest`); const grades = (snap && Array.isArray(snap.grades) ? snap.grades : []).slice(0, 120); const ctx = await envCtx.buildContext(sport, { origin: process.env.BACKEND_SELF_ORIGIN || 'http://localhost:3000', }); const i = ctx._internals || {}; const counts = { grades_examined: grades.length, with_team_field: 0, team_resolves_to_abbr: 0, abbr_matches_a_game: 0, got_environment: 0, with_bats: 0, with_playerId: 0, opp_pitcher_known: 0, got_matchup: 0, }; const sampleTeams = []; for (const g of grades) { const team = g.team; if (team) counts.with_team_field += 1; const abbr = envCtx.abbrOf(team); if (abbr) counts.team_resolves_to_abbr += 1; if (sampleTeams.length < 8 && team) sampleTeams.push({ team, abbr: abbr || null }); if (abbr && i.gameByTeam && i.gameByTeam.has(abbr)) counts.abbr_matches_a_game += 1; if (g.bats) counts.with_bats += 1; if (g.playerId != null) counts.with_playerId += 1; if (abbr && i.oppPitcherByTeam && i.oppPitcherByTeam.get(abbr) != null) counts.opp_pitcher_known += 1; const r = await ctx.contextFor(g); if (r && r.environment) counts.got_environment += 1; if (r && r.matchup) counts.got_matchup += 1; } res.set('Cache-Control', 'no-store'); return res.json({ ok: true, read_only: true, build_context_stats: ctx.stats, internals_sizes: { gameByTeam: i.gameByTeam ? i.gameByTeam.size : null, forecastByHome: i.forecastByHome ? i.forecastByHome.size : null, oppPitcherByTeam: i.oppPitcherByTeam ? i.oppPitcherByTeam.size : null, handById: i.handById ? i.handById.size : null, }, drop_off: counts, sample_team_values: sampleTeams, }); } catch (err) { return res.status(500).json({ ok: false, error: err && err.message, stack_head: String(err && err.stack || '').split('\n')[1] || null }); } }); module.exports = router;