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:
Kev
2026-07-20 19:47:42 -04:00
parent 16697a4b90
commit fbcb00b7b1
7 changed files with 373 additions and 9 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+74 -1
View File
@@ -170,7 +170,28 @@ export async function POST(req: NextRequest) {
}
}
return NextResponse.json({ ...data, scans_remaining: scansRemaining, tier: user?.tier ?? 'free' });
// Session 67 — THE PRICE LAYER for the read card.
//
// A manual scan carries no market: the request is {player, stat, line,
// direction}, so `analyzeViaEngine1` has no over/under prices to de-vig and
// book_odds / fair_odds are legitimately absent from its response. The
// prices DO exist for props the board graded, so we recover them from the
// pre-graded snapshot — the same cache-only read (no odds fetch, no quota)
// this route already performs for locked odds and team.
//
// The join is EXACT: player + stat + line + side. A user-chosen line the
// board never graded has no market attached to it, so the triplet stays
// hidden rather than borrowing a different line's price. `fair_odds` is
// side-specific (over and under de-vig to different prices), which is why
// side is part of the key and not an afterthought.
const priceLayer = await lookupSnapshotPrices(body, req.headers.get('authorization')).catch(() => ({}));
return NextResponse.json({
...data,
...priceLayer,
scans_remaining: scansRemaining,
tier: user?.tier ?? 'free',
});
} catch (err) {
console.error('[scan] backend call failed', err);
return jsonError(502, 'The engine hit a wall. Try that read again.');
@@ -186,6 +207,58 @@ export async function POST(req: NextRequest) {
* from a different line would be a fabrication, so absent beats wrong.
* Upsert on the dedupe constraint: a double-tap never duplicates.
*/
/**
* lookupSnapshotPrices — recover the market price layer for a manually-scanned
* prop from the pre-graded snapshot.
*
* Returns `{}` when there is no exact match, so the card renders the
* projection alone. NEVER returns a partial or borrowed price: if book and
* fair aren't both present on the matched row, nothing is returned at all —
* a lone book price is just the market repeated back, and a fair price
* without its book has nothing to be fair against.
*
* `model_odds` / `ev_pct` arrive already tier-stripped by the backend for
* unentitled callers (utils/snapshotGating), so the free tier's lock is
* enforced upstream of this function, not here.
*/
async function lookupSnapshotPrices(body: ScanBody, authHeader?: string | null) {
const { nameKey } = await import('@/lib/playerName');
const sport = body.sport.toLowerCase();
const playerKey = nameKey(body.player);
const wantSide = body.direction === 'under' ? 'under' : 'over';
const snap = await fetch(`${BACKEND_URL}/api/snapshot/${sport}`, {
headers: { Accept: 'application/json', ...(authHeader ? { Authorization: authHeader } : {}) },
cache: 'no-store',
}).then((r) => (r.ok ? r.json() : null));
interface SnapRow {
player?: string; player_name?: string; stat_type?: string; stat?: string;
line?: number; direction?: string; side?: string;
book_odds?: number | null; fair_odds?: number | null; model_odds?: number | null;
ev_pct?: number | null; quarantine_reason?: string | null; model_price_locked?: boolean;
}
const rows: SnapRow[] = snap?.grades || [];
const match = rows.find((g) => {
if (nameKey(g.player || g.player_name || '') !== playerKey) return false;
if (String(g.stat_type || g.stat || '').toLowerCase() !== body.stat.toLowerCase()) return false;
if (Number(g.line) !== Number(body.line)) return false;
const side = String(g.direction || g.side || 'over').toLowerCase() === 'under' ? 'under' : 'over';
return side === wantSide;
});
if (!match) return {};
if (match.book_odds == null || match.fair_odds == null) return {};
return {
book_odds: match.book_odds,
fair_odds: match.fair_odds,
model_odds: match.model_odds ?? null,
ev_pct: match.ev_pct ?? null,
quarantine_reason: match.quarantine_reason ?? null,
model_price_locked: match.model_price_locked === true,
};
}
async function writeLedgerEntry(
sb: NonNullable<ReturnType<typeof getServiceRoleSupabase>>,
userId: string,
+14 -3
View File
@@ -4,16 +4,27 @@ export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Snapshot proxy (Session 45) — forwards GET /api/snapshot/:sport (pre-graded slate). */
/**
* Snapshot proxy (Session 45) — forwards GET /api/snapshot/:sport (pre-graded slate).
*
* Session 67 — the bearer token is FORWARDED so the backend can resolve the
* caller's tier and decide whether they receive VYNDR's own price. Without
* this every request would look anonymous and paid users would lose the model
* leg. The response varies by entitlement, so it is never shared-cached here.
*/
export async function GET(_req: NextRequest, ctx: { params: Promise<{ sport: string }> }) {
const { sport } = await ctx.params;
try {
const auth = _req.headers.get('authorization');
const upstream = await fetch(`${BACKEND_URL}/api/snapshot/${encodeURIComponent(sport)}`, {
method: 'GET',
headers: { Accept: 'application/json' },
headers: { Accept: 'application/json', ...(auth ? { Authorization: auth } : {}) },
});
const data = await upstream.json().catch(() => ({ grades: [], deltas: [] }));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
return NextResponse.json(data, {
status: upstream.ok ? 200 : upstream.status,
headers: { 'Cache-Control': auth ? 'private, max-age=30' : 'public, max-age=30' },
});
} catch {
return NextResponse.json({ sport, grades: [], deltas: [] }, { status: 200 });
}