'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 (CORRECTED) — ITEMIZED GRADES ARE PAID, LIVE **AND** SETTLED. * * WHY THE RESOLUTION-FLIP WAS WRONG: settlement is nightly, so freeing a grade * at resolution turns the free tier into a ONE-DAY-DELAYED FEED OF THE WHOLE * PRODUCT. A bettor watching one cycle behind gets the entire method for free. * That exploit is why there is no per-grade flip here: an itemized grade — * tonight's or last week's — is Analyst+. * * WHAT FREE GETS INSTEAD, and it is not a crippled demo: * 1. the whole data aggregator (schedule, per-book lines, stats, streaks, hubs) * 2. the AGGREGATE track record — tier hit-rates, CLV, calibration, accuracy * over time — served by /api/accuracy + /api/ledger/accuracy, computed FROM * settled data but never itemizing the nightly slate * 3. a CAPPED, DAY-ROTATED sample of resolved calls for texture * 4. the locked shell of tonight's reads: they exist, and their shape * * THE LINE: aggregate proof is free; the itemized judgment is the product. * ========================================================================= */ /** Model JUDGMENT on ANY itemized grade. Data stays; the verdict and its argument go. */ const ITEMIZED_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 ]); /** Kept for the free SAMPLE + the aggregate: is a real outcome written on this row? */ 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 itemized grades at all? (free/anon: no) */ function entitledToItemizedGrades(tierName) { return canAccess(tierName, 'reasoning_visible'); } /** Tonight's tease — 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 }; } const FREE_SAMPLE_CAP = 3; /** * freeSample(grades, dayKey) — a TASTE, not the archive. * * Up to FREE_SAMPLE_CAP RESOLVED calls, in FULL (reasoning + outcome), chosen by * a day-derived offset so the set rotates daily and is stable within a day. The * cap is what kills the exploit: 3 rotating past calls cannot reconstruct a * nightly slate, whereas the full settled list is the feed one cycle late. */ function freeSample(grades, dayKey) { const resolved = (Array.isArray(grades) ? grades : []).filter(isResolved); if (resolved.length === 0) return []; const key = String(dayKey || ''); let h = 0; for (let i = 0; i < key.length; i += 1) h = (h * 31 + key.charCodeAt(i)) >>> 0; const start = resolved.length ? h % resolved.length : 0; const out = []; for (let i = 0; i < Math.min(FREE_SAMPLE_CAP, resolved.length); i += 1) { out.push(resolved[(start + i) % resolved.length]); } return out; } /** * gateItemizedGrades(grades, tierName) — entitled tiers pass through untouched. * Unentitled: EVERY grade (live or settled) loses its judgment and is stamped * `locked`, keeping only the free-side data so the board still reads as real. */ function gateItemizedGrades(grades, tierName) { if (!Array.isArray(grades)) return grades; if (entitledToItemizedGrades(tierName)) return grades; return grades.map((g) => { if (!g || typeof g !== 'object') return g; const shell = { ...g }; for (const f of ITEMIZED_JUDGMENT_FIELDS) delete shell[f]; shell.locked = true; return shell; }); } module.exports.ITEMIZED_JUDGMENT_FIELDS = ITEMIZED_JUDGMENT_FIELDS; module.exports.isResolved = isResolved; module.exports.entitledToItemizedGrades = entitledToItemizedGrades; module.exports.entitledToLiveGrades = entitledToItemizedGrades; // back-compat alias module.exports.liveLockedSummary = liveLockedSummary; module.exports.freeSample = freeSample; module.exports.FREE_SAMPLE_CAP = FREE_SAMPLE_CAP; module.exports.gateItemizedGrades = gateItemizedGrades;