Close the public model-price leak; wire the read card's price layer
PHASE 0.5 GATE — the three checks, and one correction.
`fairLine` does not exist. Zero hits across src/ and web/src. Option A as
written had no referent, but it resolves better than feared: `fair_odds` is
already a real de-vigged American price on every graded snapshot row, so there
is nothing to derive.
Gate 1 (is it a price): PASS. fair_odds is American odds from
impliedProbToAmerican inside devigTwoWay; fair_prob is the probability. Both
distinct from `line`, the stat threshold.
Gate 2 (numeric match): PASS, 8/8 exact. Recomputed fair_odds and fair_prob
independently from the stored raw over/under prices; every value matched the
stored one to the integer and to 3dp. Same de-vig, same numbers the component
was proven against.
Gate 3 (poison independence): PASS, and proven on the quarantined cohort
itself. devigTwoWay's inputs are (over_odds, under_odds) — market prices
only, no model term is reachable. The 8 rows recomputed above are all
wrong_opponent_grade rows, and their fair prices reproduce exactly from the
market. The poison is in the grade, not the price. Quarantine therefore
suppresses the MODEL leg only; the fair leg stands, as designed.
THE LEAK WAS REAL AND ALREADY LIVE. GET /api/snapshot/:sport is public and
unauthenticated, and it was serving model_odds, p_win, ev_pct, value and
takeable to anonymous callers on every graded row — 25 of 25 on the live wnba
board. The Session-66 gate on /api/analyze was bypassed entirely by this
endpoint.
The strip covers more than model_odds, because model_odds is not the only way
to read the model price: p_win IS the price in another base, and ev_pct is
INVERTIBLE — ev is a function of p_win and book_odds, and book_odds is public,
so leaving ev behind hands the price over. All five model-derived fields go.
book_odds, fair_odds, fair_prob, overround and devig_method stay on every tier:
the fair leg is never the paywall. Rows that keep a book+fair pair are stamped
model_price_locked so a gated price is never mistaken for a missing one.
Tier comes from resolveTierFromRequest, which reads a bearer token when one is
present and otherwise returns 'free'. It FAILS CLOSED on every error path, so a
resolution failure can only ever withhold the price. The response now varies by
entitlement, so the /:sport handler downgrades Cache-Control to private for
authenticated callers and the browser proxy forwards the bearer token —
otherwise a CDN could hand a paid payload to an anonymous viewer, or every
request would look anonymous and paid users would lose the leg.
READ CARD — a manual scan carries no market. The request is {player, stat,
line, direction}, so the engine has no over/under prices to de-vig and
book_odds/fair_odds are legitimately absent from its response; that is why the
triplet was hidden there. lookupSnapshotPrices recovers them from the
pre-graded snapshot via the same cache-only read this route already performs
for locked odds and team. The join is exact on player + stat + line + side
(fair_odds is side-specific), and returns nothing unless book and fair are BOTH
present — a user-chosen line the board never graded has no market attached, so
the triplet stays hidden rather than borrowing another line's price.
FAIR-LEG ABSENCE, measured before shipping: 636 graded rows, 636 with book,
636 with fair, 0 one-sided. Absence rate 0.0%. The hero number is not a
sometimes-number on current data.
Tests 3556 passed / 291 suites, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
+17
-4
@@ -16,6 +16,11 @@ const { nameKey } = require('../utils/playerName');
|
||||
// S6 (A1 board) — ●●○●● last-10 vs tonight's locked line, computed from the
|
||||
// rosterlogs blob the snapshot pipeline already writes. Pure, cache-only.
|
||||
const { indexRosterLogs, attachLast10Dots } = require('../services/last10Dots');
|
||||
// Session 67 — the model price never leaves the server for an unentitled
|
||||
// viewer. This endpoint is PUBLIC, so the Session-66 gate on /api/analyze was
|
||||
// being bypassed here on every graded row. Same layer as the CLV gate.
|
||||
const { stripModelPrice } = require('../utils/snapshotGating');
|
||||
const { resolveTierFromRequest } = require('../utils/requestTier');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
@@ -93,6 +98,14 @@ router.get('/summary', async (req, res) => {
|
||||
router.get('/:sport', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
try {
|
||||
// Tier is resolved from the bearer token when one is present; anonymous
|
||||
// and free callers get the market legs only. Because the response now
|
||||
// VARIES by entitlement, the shared `public` cache directive below is
|
||||
// downgraded to `private` for authenticated callers — a CDN must never
|
||||
// hand a paid payload to an anonymous viewer.
|
||||
const tier = await resolveTierFromRequest(req);
|
||||
const gate = (grades) => stripModelPrice(grades, tier);
|
||||
const cacheHeader = req.headers.authorization ? 'private, max-age=30' : 'public, max-age=30';
|
||||
const [snap, outcomeLog, rosterBlob] = await Promise.all([
|
||||
cacheGet(`snapshot:${sport}:latest`),
|
||||
cacheGet(`outcomes:${sport}:log`),
|
||||
@@ -102,14 +115,14 @@ router.get('/:sport', async (req, res) => {
|
||||
const roster = indexRosterLogs(rosterBlob);
|
||||
const enrich = (grades) => attachLast10Dots(attachOutcomes(grades, idx), roster, sport);
|
||||
if (snap && Array.isArray(snap.grades)) {
|
||||
res.set('Cache-Control', 'public, max-age=30');
|
||||
return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: enrich(snap.grades), deltas: snap.deltas || [] });
|
||||
res.set('Cache-Control', cacheHeader);
|
||||
return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: gate(enrich(snap.grades)), deltas: snap.deltas || [] });
|
||||
}
|
||||
// Fallback: the grades envelope (no deltas yet).
|
||||
const env = await cacheGet(`grades:${sport}`);
|
||||
const grades = env && Array.isArray(env.grades) ? env.grades : [];
|
||||
res.set('Cache-Control', 'public, max-age=30');
|
||||
return res.json({ sport, updated_at: env && env.updated_at, grades: enrich(grades), deltas: [] });
|
||||
res.set('Cache-Control', cacheHeader);
|
||||
return res.json({ sport, updated_at: env && env.updated_at, grades: gate(enrich(grades)), deltas: [] });
|
||||
} catch (err) {
|
||||
console.error('[snapshot]', err.message);
|
||||
return res.status(200).json({ sport, grades: [], deltas: [] });
|
||||
|
||||
Reference in New Issue
Block a user