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: [] });
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* resolveTierFromRequest (Session 67) — best-effort tier for a PUBLIC endpoint.
|
||||
*
|
||||
* `requireAuth` is the wrong tool for a route that must keep serving anonymous
|
||||
* callers: it 401s. This resolves a tier when a bearer token happens to be
|
||||
* present and otherwise returns 'free', so the endpoint stays open while the
|
||||
* gated fields stay gated.
|
||||
*
|
||||
* FAILS CLOSED. Any error — bad token, Supabase unreachable, missing env —
|
||||
* returns 'free', which is the LEAST entitled tier. A resolution failure can
|
||||
* therefore only ever withhold the model price, never leak it.
|
||||
*
|
||||
* Deliberately dependency-light and injectable so route tests don't need a
|
||||
* Supabase client.
|
||||
*/
|
||||
|
||||
async function resolveTierFromRequest(req, opts = {}) {
|
||||
try {
|
||||
const header = req && req.headers && req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) return 'free';
|
||||
const token = header.slice(7).trim();
|
||||
if (!token) return 'free';
|
||||
|
||||
const getClient = opts.getSupabaseServiceClient
|
||||
|| require('../utils/supabase').getSupabaseServiceClient;
|
||||
const supabase = getClient();
|
||||
if (!supabase) return 'free';
|
||||
|
||||
const { data, error } = await supabase.auth.getUser(token);
|
||||
if (error || !data || !data.user) return 'free';
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('users')
|
||||
.select('tier')
|
||||
.eq('id', data.user.id)
|
||||
.single();
|
||||
|
||||
const tier = profile && profile.tier;
|
||||
return typeof tier === 'string' && tier ? tier : 'free';
|
||||
} catch {
|
||||
return 'free'; // fail closed — never leak on an error path
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { resolveTierFromRequest };
|
||||
@@ -0,0 +1,72 @@
|
||||
'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,
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/* ============================================================
|
||||
Session 67 — the model price must never leave the server for an unentitled
|
||||
viewer, and the FAIR leg must never be gated.
|
||||
|
||||
`GET /api/snapshot/:sport` is PUBLIC. Session 66 gated model_odds on
|
||||
/api/analyze; this endpoint bypassed that gate and served the model price
|
||||
(and p_win, and an invertible ev_pct) to anonymous callers on every graded
|
||||
row. These tests lock the fix shut.
|
||||
============================================================ */
|
||||
|
||||
const { stripModelPrice, MODEL_FIELDS, MARKET_FIELDS, entitledToModelPrice } = require('../../src/utils/snapshotGating');
|
||||
const { resolveTierFromRequest } = require('../../src/utils/requestTier');
|
||||
|
||||
// A real shape, from a live /api/snapshot/mlb row.
|
||||
const ROW = Object.freeze({
|
||||
player_name: 'Josh Bell', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'B',
|
||||
book_odds: -210, fair_odds: -173, fair_prob: 0.633, overround: 0.068, devig_method: 'multiplicative',
|
||||
model_odds: -311, p_win: 0.757, ev_pct: 11.7, value: false, takeable: false,
|
||||
projection: 0.8, archetype: 'DRIVER',
|
||||
});
|
||||
|
||||
describe('stripModelPrice — free/anonymous never receive the model price', () => {
|
||||
it('removes EVERY model-derived field for free', () => {
|
||||
const [out] = stripModelPrice([ROW], 'free');
|
||||
for (const f of MODEL_FIELDS) expect(out[f]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes them for anonymous / unknown / garbage tiers (fails closed)', () => {
|
||||
for (const tier of [undefined, null, '', 'anonymous', 'nonsense', 'FREE ']) {
|
||||
const [out] = stripModelPrice([ROW], tier);
|
||||
expect(out.model_odds).toBeUndefined();
|
||||
expect(out.p_win).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('strips ev_pct too — it is INVERTIBLE back to p_win', () => {
|
||||
// ev is a function of (p_win, book_odds); book_odds is public, so leaving
|
||||
// ev behind hands over the model price in a different base.
|
||||
const [out] = stripModelPrice([ROW], 'free');
|
||||
expect(out.ev_pct).toBeUndefined();
|
||||
expect(MODEL_FIELDS).toContain('ev_pct');
|
||||
});
|
||||
|
||||
it('KEEPS every market field — the fair leg is never the paywall', () => {
|
||||
const [out] = stripModelPrice([ROW], 'free');
|
||||
for (const f of MARKET_FIELDS) expect(out[f]).toBe(ROW[f]);
|
||||
expect(out.fair_odds).toBe(-173);
|
||||
});
|
||||
|
||||
it('flags the lock so a gated price is not mistaken for a missing one', () => {
|
||||
const [out] = stripModelPrice([ROW], 'free');
|
||||
expect(out.model_price_locked).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT flag a lock on a row with no price story to lock onto', () => {
|
||||
const [out] = stripModelPrice([{ ...ROW, book_odds: null, fair_odds: null }], 'free');
|
||||
expect(out.model_price_locked).toBeUndefined();
|
||||
});
|
||||
|
||||
it('analyst and desk receive the full triplet, untouched', () => {
|
||||
for (const tier of ['analyst', 'desk']) {
|
||||
const [out] = stripModelPrice([ROW], tier);
|
||||
expect(out.model_odds).toBe(-311);
|
||||
expect(out.p_win).toBe(0.757);
|
||||
expect(out.ev_pct).toBe(11.7);
|
||||
expect(out.model_price_locked).toBeUndefined();
|
||||
}
|
||||
expect(entitledToModelPrice('analyst')).toBe(true);
|
||||
expect(entitledToModelPrice('free')).toBe(false);
|
||||
});
|
||||
|
||||
it('never mutates the input rows', () => {
|
||||
const input = [{ ...ROW }];
|
||||
stripModelPrice(input, 'free');
|
||||
expect(input[0].model_odds).toBe(-311);
|
||||
});
|
||||
|
||||
it('survives junk rows without throwing', () => {
|
||||
expect(() => stripModelPrice([null, undefined, 'x', 7], 'free')).not.toThrow();
|
||||
expect(stripModelPrice(null, 'free')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTierFromRequest — a public endpoint that still gates', () => {
|
||||
const noSupabase = { getSupabaseServiceClient: () => null };
|
||||
|
||||
it('anonymous (no header) is free', async () => {
|
||||
expect(await resolveTierFromRequest({ headers: {} }, noSupabase)).toBe('free');
|
||||
});
|
||||
|
||||
it('a malformed or empty bearer is free', async () => {
|
||||
expect(await resolveTierFromRequest({ headers: { authorization: 'Basic x' } }, noSupabase)).toBe('free');
|
||||
expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer ' } }, noSupabase)).toBe('free');
|
||||
});
|
||||
|
||||
it('FAILS CLOSED — a thrown resolver yields free, never an entitled tier', async () => {
|
||||
const boom = { getSupabaseServiceClient: () => { throw new Error('supabase down'); } };
|
||||
expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer abc' } }, boom)).toBe('free');
|
||||
});
|
||||
|
||||
it('an invalid token is free', async () => {
|
||||
const bad = {
|
||||
getSupabaseServiceClient: () => ({
|
||||
auth: { getUser: async () => ({ data: null, error: new Error('bad token') }) },
|
||||
}),
|
||||
};
|
||||
expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer abc' } }, bad)).toBe('free');
|
||||
});
|
||||
|
||||
it('a valid token resolves the real tier', async () => {
|
||||
const good = {
|
||||
getSupabaseServiceClient: () => ({
|
||||
auth: { getUser: async () => ({ data: { user: { id: 'u1' } }, error: null }) },
|
||||
from: () => ({ select: () => ({ eq: () => ({ single: async () => ({ data: { tier: 'desk' } }) }) }) }),
|
||||
}),
|
||||
};
|
||||
expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer good' } }, good)).toBe('desk');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the route wiring', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'routes', 'snapshot.js'), 'utf8');
|
||||
|
||||
it('gates BOTH response paths (snapshot and the grades fallback)', () => {
|
||||
const gated = src.match(/grades: gate\(/g) || [];
|
||||
expect(gated.length).toBe(2);
|
||||
});
|
||||
|
||||
it('never shared-caches a response that varies by entitlement', () => {
|
||||
// A CDN handing a paid payload to an anonymous viewer would defeat the gate.
|
||||
expect(src).toMatch(/req\.headers\.authorization \? 'private, max-age=30' : 'public, max-age=30'/);
|
||||
// Scoped to the /:sport handler — /summary carries counts only (no price
|
||||
// data), so it stays legitimately public-cacheable.
|
||||
const bySport = src.slice(src.indexOf("router.get('/:sport'"));
|
||||
expect(bySport).not.toMatch(/res\.set\('Cache-Control', 'public, max-age=30'\)/);
|
||||
});
|
||||
|
||||
it('the browser proxy forwards the bearer token', () => {
|
||||
const proxy = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'web', 'src', 'app', 'api', 'snapshot', '[sport]', 'route.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(proxy).toMatch(/Authorization: auth/);
|
||||
expect(proxy).toMatch(/private, max-age=30/);
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user