f54b0627e1
The internal one-shot route already called the SAME production runSnapshot with
the SAME default dependencies — but it passed no trigger, and runSnapshot
defaults an absent trigger to SCHEDULED. So every operator-invoked run was
recorded as though the cron had fired it. That was a lie about provenance,
present by omission, and it would have contaminated the trace of any forced
diagnostic run.
CONTROLLED_FORCED is now its own trigger. Both internal routes (`/snapshot/:sport`
and `/snapshot/all`) stamp it, along with the process generation. The scheduler
still stamps SCHEDULED, and a test asserts neither internal route can label
itself scheduled.
Trace retention moves from "scheduled only" to a named allow-list of SCHEDULED +
CONTROLLED_FORCED. INTRADAY is still refused — it runs every ~20 minutes and
would displace scheduled evidence, which is the failure the store exists to
prevent. MANUAL_API stays refused too.
THE PIPELINE IS UNTOUCHED. snapshotService, gradeSlateService, retentionService,
snapshotScheduler, oddsService and eventIdentity are all UNCHANGED. A test
asserts the route injects no dependency override — no getOdds, gradeAndCacheSlate,
retention, ledger, cacheSet/cacheGet, gameBinder, eventIdentity, mlbAdapter or
notify — so the only difference from a scheduled invocation is the label and the
absence of a scheduled hour, which a forced run genuinely does not have.
The ?limit bisect-hook invariant is preserved and tightened: the opts passed
carry exactly {trigger, processStartedAt} and never a stray limit.
Teeth, injections verified present, against a green baseline:
:sport route mislabelled SCHEDULED -> 3 fail
/all route mislabelled SCHEDULED -> 2 fail
intraday admitted to the store -> 5 fail
trigger filter removed -> 3 fail
THE FIRST TEETH RUN WAS INVALID AND IS DISCARDED: both routes live in one file,
so a single-occurrence replace hit `/snapshot/all` and left `/snapshot/:sport`
correct — the injection landed on the wrong target and the suite passed. Coverage
for `/all` was added, plus a test that the file contains exactly two
CONTROLLED_FORCED stamps and zero SCHEDULED ones, then both were re-run failing
independently.
Four stale assertions updated with the reason recorded: three pinned the
`not_scheduled` refusal string (now trigger-agnostic) and one pinned an empty
opts object on the route.
389 suites / 5,280 tests pass. web tsc exit 0. Lineage stays OFF.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
868 lines
36 KiB
JavaScript
868 lines
36 KiB
JavaScript
'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;
|