Files
vyndr/src/routes/internal.js
T
builtbykev 40aba37f83 Backup: env-injected SSH key, nightly off-box push, triggerable run
Closing the backup for real. Three changes, each fixing something that
would have made the Storage Box target fail or silently rot.

1. SSH KEY COMES FROM ENV, not from the container. Generating a keypair
   inside the API container was the obvious move and it is wrong: the
   container filesystem is ephemeral, so the key dies on the next
   redeploy and the off-box push starts failing silently. backup-db.sh
   now reads BACKUP_SSH_KEY (a Coolify secret), writes it to a 0600 temp
   file per run, and removes it on exit via trap.

2. PORT 23, verified live. Hetzner Storage Box runs full OpenSSH on 23;
   port 22 answers with mod_sftp (SFTP only). Banner-checked both against
   u635423.your-storagebox.de. rsync now uses
   -e "ssh -p ${BACKUP_SSH_PORT:-23} ... -i <key>"; the old invocation had
   no -e at all and would have gone to 22.

3. OFF-BOX PUSH IS NIGHTLY, not Sundays-only. A weekly push meant up to
   six days of dumps existed ONLY inside an ephemeral container, which is
   the same as not existing. Alert copy updated to say exactly that when
   the push fails or is skipped.

Also adds POST /api/internal/backup/run (internal-key gated) so a real
backup can be TRIGGERED and OBSERVED — it returns exit code, duration,
output tail, and whether the remote + ssh key are configured. The backup
can only run where SUPABASE_DB_URL and the Supabase route live (this
container), and there was no way to fire or inspect it without a shell.

Connectivity established this session: Storage Box reachable from the dev
box on 22/23; Supabase :5432 NOT reachable from WSL2 (so the dump must
run in-container, as designed); docker IS available locally, so the
restore-verify can run against a scratch Postgres using the real dump.

Suite 278/3305 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 21:42:27 -04:00

331 lines
13 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');
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.
*/
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 results = await snapshot.runAllSnapshots();
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.
*/
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;
// Session 56 — surface the missed-cron signal in the health probe.
const mlbTs = last_snapshot.mlb && last_snapshot.mlb.updated_at;
return res.json({
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 {
const summary = await snapshot.runSnapshot(req.params.sport);
return res.json({ ok: true, 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();
return res.json({
ok: result.ok,
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 });
}
});
/**
* 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/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 });
}
});
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 });
}
});
module.exports = router;