930d526b01
The authority review found there was nothing to switch: lineage is a
write-only graph with no living permanent writer and no product consumer.
Authority theater would have been a switch on a consumer that does not exist,
reading a store that is not being written. So: make the graph live, measure it
from outside, and expose the one category it alone owns.
WRITE-PATH FAILURE SEMANTICS, TRACED FIRST. persist(:857) -> the authoritative
cacheSet snapshot:latest(:1404) -> the lineage gate(:1426) -> ledger(:1473).
Lineage failure cannot fail base retention (earlier, separate upsert), cannot
fail publication (the product write precedes the gate), cannot fail the Ledger
(lineageIndex defaults null; the ledger has its own guard), and cannot create a
new partial row (blankLineage() first, atomicity sweep after the catch). The
isolation this tranche needed already existed; only a mode was missing.
THREE MODES, ONE EVALUATOR. `lineageWriteMode` distinguishes OFF /
CANARY_LEASED / PERSISTENT_SHADOW, and the write gate and the status surface
both read it, so they cannot disagree. The canary is CONSULTED, never
converted: its <=4h absolute expiry, its dynamic evaluation and its
fail-closed parse are untouched.
AN AMBIGUOUS CONFIGURATION FAILS CLOSED. If a sport is named by both persistent
mode and an active lease, the two instructions disagree about WHEN WRITING
STOPS — the lease says 22:45, persistent says never. The dangerous reading is
the quiet one: an operator sets a bounded lease believing writing will stop
while persistent keeps it going. We cannot know which they meant, so that sport
writes nothing until the configuration says one thing. The sport allowlist is
the canary's own, so persistent mode can never widen past it.
THE OBSERVER MAY NOT ASK THE WRITER HOW IT DID. settleLedger returned
{settled:0,pending:0} — byte-identical to a healthy "nothing to settle" — while
1,444 rows sat unprocessed, and the watchdog believed it. So `lineageCoverage`
reads durable retained state only, and THE DENOMINATOR MAY NOT CONSULT
lineage_action: eligibility is "the row was published AND a natural key is
derivable from its own identity columns", neither of which the lineage path
writes. If expectation were derived from whether lineage exists, coverage would
be 100% by construction and the metric would be decoration. Zero-expected and
zero-written are kept as different answers.
THE LEGACY BOUNDARY IS OBSERVED, NOT DECLARED. `publication_id` is stamped only
by commitPublication, so the row itself says whether lineage ran. Verified on
production: 5,353 rows carry it — 4,234 complete actions plus exactly the 1,119
historical partial rows — and zero actions exist without one. No epoch constant
is invented; a date would have been a guess about when the writer was on.
publication_id NULL -> LEGACY_UNVERIFIED. Stamped but incomplete ->
LINEAGE_UNAVAILABLE, which is the honest answer for the 1,119 and is never
quietly rewritten as legacy.
THE GRADE-SHIFT BADGE IS UNTOUCHED. revised_from_grade answers "did the letter
change"; lineage answers "which published claim superseded which". Different
questions, and a test now fails if either route learns the word lineage.
Suite 397/5,496/0 · tsc 0 · 15/15 teeth.
TWO OF MY OWN TESTS WERE VACUOUS AND A TOOTH FOUND IT. Tooth 2 came back green
because the isolation tests asserted the slate was published — true whether or
not the exception propagated — while never reaching the lineage gate at all:
the fake grader never fired `onGraded`, so the collector stayed empty and
persistedRows stayed null. Fixed by firing the hook and counting the commit.
A green teeth run means the test is missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
934 lines
38 KiB
JavaScript
934 lines
38 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;
|
|
// Best-effort: the health probe must never fail because the audit could not
|
|
// run. An unavailable audit is reported as AUDIT_UNAVAILABLE, which is a
|
|
// different answer from "coverage is zero".
|
|
let lineageCoverage = null;
|
|
try {
|
|
lineageCoverage = await require('../services/lineageCoverage')
|
|
.auditLatestCohort('mlb', { now: () => new Date().toISOString() });
|
|
} catch (e) {
|
|
lineageCoverage = { audit_available: false, reason: e.message, health: 'AUDIT_UNAVAILABLE' };
|
|
}
|
|
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(),
|
|
// THE WRITE MODE, from the SAME evaluator the write gate calls. Reporting
|
|
// it from a second parse is how a status surface and a gate come to
|
|
// disagree, so there is only one.
|
|
lineage_write_mode: require('../services/lineageWriteMode').state(),
|
|
// INDEPENDENT COVERAGE. Derived from durable retained state, never from
|
|
// the writer's own counters — an observer that reads the failing writer's
|
|
// return value cannot see that writer fail.
|
|
lineage_coverage: lineageCoverage,
|
|
// 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.
|
|
*/
|
|
/**
|
|
* ADDITIVE READ ANCESTRY — non-authoritative, replaces nothing.
|
|
*
|
|
* The category lineage alone owns: which published Read came first, what
|
|
* superseded what, and whether a later observation revised or recaptured the
|
|
* same claim. It does NOT serve the grade-shift badge, which answers a
|
|
* different question from a different source and is untouched.
|
|
*
|
|
* Internal-only on purpose. Nothing user-facing consumes lineage yet, and the
|
|
* narrowest seam that exposes the truth without altering an existing response
|
|
* is a protected route.
|
|
*/
|
|
router.get('/lineage/ancestry', async (req, res) => {
|
|
const q = req.query || {};
|
|
const required = ['sport', 'game_date', 'player_key', 'stat', 'side', 'line'];
|
|
const missing = required.filter((k) => q[k] === undefined || q[k] === '');
|
|
if (missing.length) {
|
|
return res.status(400).json({ error: 'missing required identity fields', missing });
|
|
}
|
|
try {
|
|
const ancestry = require('../services/read/readAncestry');
|
|
const out = await ancestry.ancestryForRead({
|
|
sport: String(q.sport).toLowerCase(),
|
|
game_date: String(q.game_date),
|
|
player_key: String(q.player_key),
|
|
stat: String(q.stat),
|
|
side: String(q.side),
|
|
line: Number(q.line),
|
|
canonical_event_id: q.canonical_event_id ? String(q.canonical_event_id) : null,
|
|
});
|
|
return res.json(out);
|
|
} catch (e) {
|
|
return res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
/** Coverage audit for one sport, on demand — the same independent observer the
|
|
* status probe reports, addressable when a specific cohort is in question. */
|
|
router.get('/lineage/coverage/:sport', async (req, res) => {
|
|
try {
|
|
const out = await require('../services/lineageCoverage')
|
|
.auditLatestCohort(req.params.sport, { now: () => new Date().toISOString() });
|
|
return res.json(out);
|
|
} catch (e) {
|
|
return res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
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;
|