'use strict'; const { canAccess } = require('../config/tiers'); /** * Snapshot tier gating (Session 67) — the model price never leaves the server * for an unentitled viewer. * * WHY THIS EXISTS: `GET /api/snapshot/:sport` is PUBLIC and unauthenticated. * Session 66 gated `model_odds` on `/api/analyze`, but the snapshot endpoint * bypassed that gate entirely and served the model price to anonymous callers * on every graded row. This closes it at the same layer as the CLV gate * (`routes/ledger.js` columnsFor/stripClv): strip on the way out, never hide * in the client. * * WHAT MUST GO, AND WHY IT IS MORE THAN `model_odds`: * model_odds the gated number itself. * p_win model_odds IS `impliedProbToAmerican(p_win)` — shipping p_win * is shipping the price in a different base. * ev_pct INVERTIBLE. ev is a function of (p_win, book_odds) and * book_odds is public, so p_win — and therefore the price — can * be recovered exactly from it. A gate that leaves ev behind is * not a gate. * value a boolean over (ev, takeable); with the band public it leaks a * takeable bound on ev. Cheap to drop, so drop them. * * WHAT STAYS ON EVERY TIER — deliberately: * book_odds, fair_odds, fair_prob, overround, devig_method. * These are MARKET facts and the de-vig of market facts. The de-vigged fair * number is the free-tier hook and the hero of the price layer: THE FAIR LEG * IS NEVER THE PAYWALL. Only VYNDR's own price gates. * * `model_price_locked` is stamped on rows that still carry a book+fair pair so * the client renders the lock teaser rather than an absent leg — a gated price * must never be mistaken for a missing one. */ // Model-derived fields. Every one of these can reconstruct the model price. const MODEL_FIELDS = Object.freeze(['model_odds', 'p_win', 'ev_pct', 'value', 'takeable']); // Market facts + their de-vig. Never stripped, on any tier. const MARKET_FIELDS = Object.freeze(['book_odds', 'fair_odds', 'fair_prob', 'overround', 'devig_method']); /** Does this tier receive VYNDR's own price? */ function entitledToModelPrice(tierName) { return canAccess(tierName, 'model_price'); } /** * stripModelPrice(grades, tierName) — returns a NEW array with the * model-derived fields removed for unentitled tiers. Entitled tiers get the * rows back untouched (same reference — no needless copying on the hot path). */ function stripModelPrice(grades, tierName) { if (!Array.isArray(grades)) return grades; if (entitledToModelPrice(tierName)) return grades; return grades.map((g) => { if (!g || typeof g !== 'object') return g; const out = { ...g }; for (const f of MODEL_FIELDS) delete out[f]; // Only flag a LOCK where there is a price story to lock a third leg onto. if (out.book_odds != null && out.fair_odds != null) out.model_price_locked = true; return out; }); } module.exports = { MODEL_FIELDS, MARKET_FIELDS, entitledToModelPrice, stripModelPrice, }; /* =========================================================================== * BUILD 1 — THE SETTLED/LIVE GATE (2026-07-31, specs/tier-redesign-spec.md) * * THE RULE: a grade is PAID while its outcome is unknown, and becomes FREE the * moment it resolves. Resolution is the flip point, and it is read ONLY from a * written outcome — never from time, game status or `gradedAt`. A game can be * final long before the settle pass runs, so "probably over" is exactly how a * live edge would leak. * * FAIL CLOSED: anything we cannot prove is resolved is treated as LIVE (paid). * A settlement failure therefore WITHHOLDS content rather than exposing it — * the same direction `resolveTierFromRequest` fails. * * THE LOCKED SHELL: an unentitled viewer still sees that tonight's reads EXIST * and their shape — player, market, line, the real book/fair prices, the stats * anyone can already get free — plus an AGGREGATE count and tier distribution. * What is withheld is the model's JUDGMENT. And the distribution is aggregate * ONLY: per-row tier is never emitted, so no one can work out WHICH prop is the A. * ========================================================================= */ /** Model JUDGMENT on a live read. Data stays; the verdict and its argument go. */ const LIVE_JUDGMENT_FIELDS = Object.freeze([ 'grade', 'confidence', 'confidence_basis', // the verdict 'reasoning', 'kill_conditions_triggered', // its argument 'projection', 'edge_pct', // our number vs the line 'matchup_grade', 'form', // derived letter/label judgments 'alt_lines', 'kelly', // Desk tools ]); /** * A grade is RESOLVED only when a real outcome is written on it. `void` and * `unrecoverable` ARE resolutions (terminal results, no live edge left). */ function isResolved(g) { if (!g || typeof g !== 'object') return false; const o = g.outcome; if (!o) return false; if (typeof o === 'string') return o.trim() !== ''; return typeof o === 'object' && typeof o.result === 'string' && o.result.trim() !== ''; } /** Does this tier get tonight's live reads? (free/anon: no) */ function entitledToLiveGrades(tierName) { return canAccess(tierName, 'reasoning_visible'); } /** * The aggregate tease: how many live reads exist and their tier shape. * Aggregate ONLY — never joined back to a row. */ function liveLockedSummary(grades) { const live = (Array.isArray(grades) ? grades : []).filter((g) => g && !isResolved(g)); const tiers = {}; for (const g of live) { const letter = String((g.grade || '')).trim().toUpperCase().charAt(0); if (!letter) continue; tiers[letter] = (tiers[letter] || 0) + 1; } return { count: live.length, tiers }; } /** * gateLiveGrades(grades, tierName) — entitled tiers pass through untouched. * Unentitled: resolved grades pass FULL (including reasoning — settled is the * proof product and costs nothing post-resolution); unresolved grades are * reduced to the shell. */ function gateLiveGrades(grades, tierName) { if (!Array.isArray(grades)) return grades; if (entitledToLiveGrades(tierName)) return grades; return grades.map((g) => { if (!g || typeof g !== 'object') return g; if (isResolved(g)) return g; // settled ⇒ free, in full const shell = { ...g }; for (const f of LIVE_JUDGMENT_FIELDS) delete shell[f]; shell.locked = true; // the card renders the unlock prompt return shell; }); } module.exports.LIVE_JUDGMENT_FIELDS = LIVE_JUDGMENT_FIELDS; module.exports.isResolved = isResolved; module.exports.entitledToLiveGrades = entitledToLiveGrades; module.exports.liveLockedSummary = liveLockedSummary; module.exports.gateLiveGrades = gateLiveGrades;