From 2071b7945684f4b54fe417789d43b5a4f1ee6580 Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 31 Jul 2026 23:34:03 -0400 Subject: [PATCH] Order Zero Phase 1: keyed read-only PropLine verification endpoint Adds GET /api/internal/propline-verify (internal-key gated, read-only) so Phase 1 can run WHERE THE KEY LIVES. Touches no cache, no ledger, no grade; the live adapter and the live ruler are untouched. Breadth reuses proplineAdapter.fetchRaw -- the exact live request -- so what it measures is what the pipeline actually receives. Reports per sport (never pooled): books/prop from the feed vs after our own ALLOWED_BOOKS, props made INVISIBLE by that filter, reference-book presence, DFS presence reported separately, and consensus eligibility. Consensus eligibility is deliberately strict: >=2 REFERENCE books posting BOTH sides at the SAME line. A one-sided quote cannot be de-vigged, and two books at different lines are not the same market -- counting either would overstate how much of the slate can carry a real ruler. Probes the documented-but-unverified endpoints (/sports, /context, /odds/closing, /movement, /results, /exports/resolved-props for four sport keys) and classifies works/partial/no, with 403 = tier-gated and 200-but- empty = partial rather than works. Key safety is the other locked property: the key goes via axios params, never string-interpolated, and every emitted string passes scrubKeys() which removes the literal key AND any surviving apiKey= query value. A test asserts a thrown transport error carrying the key cannot escape. 13 unit tests, hermetic (no network, no key). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc --- src/routes/internal.js | 28 +++ src/services/proplineVerify.js | 314 ++++++++++++++++++++++++++++++ tests/unit/proplineVerify.test.js | 146 ++++++++++++++ 3 files changed, 488 insertions(+) create mode 100644 src/services/proplineVerify.js create mode 100644 tests/unit/proplineVerify.test.js diff --git a/src/routes/internal.js b/src/routes/internal.js index 9bdd49c..488b769 100644 --- a/src/routes/internal.js +++ b/src/routes/internal.js @@ -528,4 +528,32 @@ router.post('/outcomes/:sport', async (req, res) => { } }); +/** + * GET /api/internal/propline-verify (Order Zero, Phase 1) + * + * KEYED, READ-ONLY verification — runs where the PropLine key lives. Reports + * per-sport book breadth (feed vs after our own allow-list), exchange reality, + * consensus eligibility, and works/partial/no for the documented-but-unverified + * endpoints. Touches no cache, no ledger, no grade; the live adapter and the + * live ruler are untouched. + * + * Costs ~1 PropLine call per sport plus one per probe. Never returns the key — + * every string it emits passes through `scrubKeys`. + * + * ?sports=mlb,wnba (default: mlb,wnba) + */ +router.get('/propline-verify', async (req, res) => { + try { + const sports = String(req.query.sports || 'mlb,wnba') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean).slice(0, 6); + const { verify } = require('../services/proplineVerify'); + const out = await verify({ sports }); + res.set('Cache-Control', 'no-store'); + return res.json({ ok: true, ...out }); + } catch (err) { + const { __internals } = require('../services/proplineVerify'); + return res.status(500).json({ ok: false, error: __internals.scrubKeys(err && err.message) }); + } +}); + module.exports = router; diff --git a/src/services/proplineVerify.js b/src/services/proplineVerify.js new file mode 100644 index 0000000..e60e208 --- /dev/null +++ b/src/services/proplineVerify.js @@ -0,0 +1,314 @@ +'use strict'; + +/** + * proplineVerify — Order Zero Phase 1: KEYED, READ-ONLY verification. + * + * Runs where the PropLine key lives (prod). Answers four questions the public + * endpoints could not: + * + * 1. WNBA real book breadth — is WNBA thin AT THE FEED, or allow-list-starved + * the way MLB was? (Per sport. Never pooled.) + * 2. Exchange reality — do novig / smarkets / kalshi / matchbook / polymarket + * actually carry props on a live slate? The exchange-as-ruler is a + * HYPOTHESIS; this confirms it or forces the soft-book fallback. + * 3. Endpoint verification — /results, /exports/resolved-props, /odds/closing, + * /movement, /context: works / partial / no, FOR OUR TIER. Documented is + * not verified. + * 4. Sport coverage — /v1/sports, which rewrites sport-rollout economics. + * + * READ-ONLY. Touches no cache, no ledger, no grade. The live adapter is + * untouched — this reuses `proplineAdapter.fetchRaw` (the exact live request) + * for breadth, and issues its own direct probes for the unused endpoints. + * + * KEY SAFETY: the API key is passed via axios `params` and never interpolated + * into a logged string. Every value this module returns — including error + * messages and any echoed URL — goes through `scrubKeys()`, which replaces the + * literal key with ''. Belt and braces: an axios error carries + * `config.url` and some servers echo the query string back. + */ + +const axios = require('axios'); +const proplineAdapter = require('./adapters/proplineAdapter'); +const { __internals: PA } = proplineAdapter; + +const BASE = 'https://api.prop-line.com/v1'; +const TIMEOUT_MS = 15_000; + +// The three roles. Only REFERENCE books may ever price the ruler. +// DFS pick'em is PERMANENTLY EXCLUDED: fixed-payout shaded lines are not a +// market price, whatever their coverage. +const REFERENCE_CANDIDATES = ['pinnacle', 'novig', 'smarkets', 'matchbook', 'kalshi', 'polymarket', 'bovada']; +const DFS_PLATFORMS = ['prizepicks', 'underdog', 'sleeper', 'dabble']; + +/** Replace every configured key with '' anywhere in a string. */ +function scrubKeys(str) { + let s = String(str == null ? '' : str); + for (const k of PA.getKeys()) { + if (k && k.length > 3) s = s.split(k).join(''); + } + // Defence in depth: kill any surviving apiKey=… query value. + return s.replace(/(apiKey=)[^&\s]+/gi, '$1'); +} + +const median = (xs) => { + if (!xs.length) return null; + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +}; +const round2 = (n) => (Number.isFinite(n) ? Math.round(n * 100) / 100 : null); + +/** + * Book breadth for one sport, computed from the RAW feed (pre-filter) and again + * AFTER `oddsNormalizer.ALLOWED_BOOKS` — the delta is what our own config costs. + * + * A "prop" is (event, player, market). `invisible` = props with ZERO allowed + * books: they do not exist to VYNDR at all. + */ +function analyseBreadth(raw, allowedBooks) { + // A PROP is (event, player, market). A PRICED QUOTE is that prop at one + // book's line — and only a TWO-SIDED quote can be de-vigged, so the ruler's + // real eligibility question is "how many reference books post BOTH sides at + // the SAME line", not "how many books touched this player". + const byProp = new Map(); // propKey -> Set(book) + const byPropLine = new Map(); // propKey|line -> Set(book) (two-sided only) + const bookRows = new Map(); + let events = 0; + + for (const ev of raw || []) { + if (!ev || !Array.isArray(ev.bookmakers)) continue; + events += 1; + for (const bm of ev.bookmakers) { + if (!bm || !bm.key || !Array.isArray(bm.markets)) continue; + for (const mk of bm.markets) { + const outcomes = (mk && mk.outcomes) || []; + // Pair Over/Under exactly as oddsNormalizer does. + const pairs = new Map(); + for (const oc of outcomes) { + if (!oc || !oc.description || oc.point == null) continue; + const k = `${oc.description}::${oc.point}`; + if (!pairs.has(k)) pairs.set(k, { player: oc.description, point: oc.point }); + if (oc.name === 'Over') pairs.get(k).over = oc.price; + else if (oc.name === 'Under') pairs.get(k).under = oc.price; + } + for (const q of pairs.values()) { + const propKey = `${ev.id}|${q.player}|${mk.key}`; + if (!byProp.has(propKey)) byProp.set(propKey, new Set()); + byProp.get(propKey).add(bm.key); + bookRows.set(bm.key, (bookRows.get(bm.key) || 0) + 1); + if (q.over != null && q.under != null) { + const lk = `${propKey}|${q.point}`; + if (!byPropLine.has(lk)) byPropLine.set(lk, new Set()); + byPropLine.get(lk).add(bm.key); + } + } + } + } + } + + const feedCounts = []; + const filteredCounts = []; + let invisible = 0; + const refPresent = {}; + const dfsPresent = {}; + for (const b of REFERENCE_CANDIDATES) refPresent[b] = 0; + for (const b of DFS_PLATFORMS) dfsPresent[b] = 0; + + for (const books of byProp.values()) { + feedCounts.push(books.size); + let kept = 0; + for (const b of books) if (allowedBooks.has(b)) kept += 1; + filteredCounts.push(kept); + if (kept === 0) invisible += 1; + for (const b of REFERENCE_CANDIDATES) if (books.has(b)) refPresent[b] += 1; + for (const b of DFS_PLATFORMS) if (books.has(b)) dfsPresent[b] += 1; + } + + // Ruler eligibility, the strict version: >= 2 REFERENCE books posting BOTH + // sides at the SAME line. Anything less is a labelled single-book fallback. + let eligible2 = 0; + let eligible3 = 0; + let twoSidedGroups = 0; + const bestRefPerProp = new Map(); + for (const [lk, books] of byPropLine.entries()) { + twoSidedGroups += 1; + let r = 0; + for (const b of REFERENCE_CANDIDATES) if (books.has(b)) r += 1; + const propKey = lk.slice(0, lk.lastIndexOf('|')); + bestRefPerProp.set(propKey, Math.max(bestRefPerProp.get(propKey) || 0, r)); + } + for (const r of bestRefPerProp.values()) { + if (r >= 2) eligible2 += 1; + if (r >= 3) eligible3 += 1; + } + + const props = feedCounts.length; + const hist = (xs) => { const h = {}; for (const n of xs) h[n] = (h[n] || 0) + 1; return h; }; + const mean = (xs) => (xs.length ? round2(xs.reduce((a, b) => a + b, 0) / xs.length) : null); + const atMostOne = (xs) => (xs.length ? round2((100 * xs.filter((n) => n <= 1).length) / xs.length) : null); + const pctOf = (n) => (props ? round2((100 * n) / props) : null); + + return { + events, + props, + two_sided_prop_line_groups: twoSidedGroups, + books_in_feed: [...bookRows.entries()].sort((a, b) => b[1] - a[1]) + .map(([book, quotes]) => ({ book, quotes, admitted: allowedBooks.has(book) })), + feed: { + mean_books_per_prop: mean(feedCounts), + median_books_per_prop: median(feedCounts), + at_most_one_book_pct: atMostOne(feedCounts), + histogram: hist(feedCounts), + }, + after_allow_list: { + mean_books_per_prop: mean(filteredCounts), + median_books_per_prop: median(filteredCounts), + at_most_one_book_pct: atMostOne(filteredCounts), + histogram: hist(filteredCounts), + }, + invisible_props: invisible, + invisible_pct: pctOf(invisible), + reference_presence: Object.fromEntries( + REFERENCE_CANDIDATES.map((b) => [b, { props: refPresent[b], pct: pctOf(refPresent[b]) }]), + ), + dfs_presence_EXCLUDED_FROM_CONSENSUS: Object.fromEntries( + DFS_PLATFORMS.map((b) => [b, { props: dfsPresent[b], pct: pctOf(dfsPresent[b]) }]), + ), + // THE decision number: how much of the slate can carry a real consensus. + consensus_eligible_n2: eligible2, + consensus_eligible_n2_pct: pctOf(eligible2), + consensus_eligible_n3: eligible3, + consensus_eligible_n3_pct: pctOf(eligible3), + }; +} + +/** One read-only probe. Never throws; classifies works / partial / no. */ +async function probe(path, params, note, httpGet) { + const get = httpGet || axios.get; + const keys = PA.getKeys().filter(Boolean); + if (!keys.length) return { path, verdict: 'no', reason: 'no key configured', note }; + try { + const res = await get(`${BASE}${path}`, { + params: { apiKey: keys[0], ...params }, + timeout: TIMEOUT_MS, + validateStatus: () => true, + // CSV endpoints stream; cap what we read. + maxContentLength: 2_000_000, + }); + const status = res.status; + const body = res.data; + const isCsv = typeof body === 'string'; + const rows = isCsv ? Math.max(0, body.split('\n').filter(Boolean).length - 1) : null; + const headers = {}; + for (const h of ['x-propline-archive-starts', 'x-propline-archive-notice', 'x-propline-export-window-start']) { + if (res.headers && res.headers[h]) headers[h] = String(res.headers[h]); + } + + let verdict = 'no'; + if (status >= 200 && status < 300) { + const empty = isCsv ? rows === 0 + : (body == null || (Array.isArray(body) && !body.length) + || (typeof body === 'object' && !Object.keys(body).length)); + verdict = empty ? 'partial' : 'works'; + } else if (status === 402 || status === 403) { + verdict = 'no'; // tier-gated for us + } else if (status === 404) { + verdict = 'partial'; // reachable, nothing on file for this target + } + + return { + path, verdict, status, note, + ...(rows != null ? { csv_rows: rows } : {}), + ...(Object.keys(headers).length ? { headers } : {}), + shape: scrubKeys(summarise(body)).slice(0, 700), + }; + } catch (err) { + return { path, verdict: 'no', note, error: scrubKeys(err && err.message) }; + } +} + +/** A short, key-free description of a response body. */ +function summarise(body) { + if (body == null) return 'null'; + if (typeof body === 'string') return `csv/text: ${body.split('\n').slice(0, 2).join(' | ')}`; + if (Array.isArray(body)) { + return `array(${body.length})` + (body.length ? ` first_keys=[${Object.keys(body[0] || {}).join(',')}]` : ''); + } + if (typeof body === 'object') { + const out = [`object keys=[${Object.keys(body).join(',')}]`]; + for (const [k, v] of Object.entries(body)) { + if (Array.isArray(v)) out.push(`${k}: array(${v.length})${v.length && typeof v[0] === 'object' ? ` keys=[${Object.keys(v[0]).join(',')}]` : ''}`); + } + return out.join(' ; '); + } + return String(body); +} + +/** + * The full Phase-1 report. `deps` injectable so tests never touch the network. + */ +async function verify(opts = {}) { + const sports = opts.sports && opts.sports.length ? opts.sports : ['mlb', 'wnba']; + const fetchRaw = opts.fetchRaw || proplineAdapter.fetchRaw; + const httpGet = opts.httpGet; + const allowed = opts.allowedBooks || require('../utils/oddsNormalizer').ALLOWED_BOOKS || new Set(); + + const out = { + generated_at: new Date().toISOString(), + read_only: true, + live_adapter_untouched: true, + allow_list_size: allowed.size, + per_sport: {}, + endpoints: [], + notes: [], + }; + + if (!proplineAdapter.hasKeys()) { + out.notes.push('NO PROPLINE KEY IN THIS ENV — nothing was measured. Run where the key lives.'); + return out; + } + + // 1 + 2 — breadth and exchange reality, PER SPORT. Never pooled. + const firstEvent = {}; + for (const sport of sports) { + try { + const raw = await fetchRaw(sport); + if (!Array.isArray(raw)) { + out.per_sport[sport] = { error: 'no response / unsupported sport' }; + continue; + } + out.per_sport[sport] = analyseBreadth(raw, allowed); + const ev = raw.find((e) => e && e.id && Array.isArray(e.bookmakers) && e.bookmakers.length); + if (ev) firstEvent[sport] = { id: ev.id, key: PA.SPORT_KEYS[sport] }; + } catch (err) { + out.per_sport[sport] = { error: scrubKeys(err && err.message) }; + } + } + + // 4 — sport coverage (rewrites rollout economics if it settles NBA/soccer). + out.endpoints.push(await probe('/sports', {}, 'sport coverage — which sports PropLine carries', httpGet)); + + // 3 — the four documented-but-unverified endpoints, against a REAL event. + for (const sport of sports) { + const ev = firstEvent[sport]; + if (!ev) { + out.notes.push(`${sport}: no event with bookmakers on the live slate — endpoint probes skipped for this sport.`); + continue; + } + const markets = (PA.MARKETS[sport] || []).slice(0, 3).join(','); + const p = `/sports/${ev.key}/events/${ev.id}`; + out.endpoints.push(await probe(`${p}/context`, {}, `${sport} — free? probable pitchers / lineups / umpire / weather`, httpGet)); + out.endpoints.push(await probe(`${p}/odds/closing`, { markets }, `${sport} — canonical CLV helper`, httpGet)); + out.endpoints.push(await probe(`${p}/movement`, { markets }, `${sport} — steam across books`, httpGet)); + out.endpoints.push(await probe(`${p}/results`, { markets }, `${sport} — settled outcomes + actual values`, httpGet)); + } + + // The $19/mo question: does the bulk resolved export settle sports we can't? + for (const sportKey of ['baseball_mlb', 'basketball_wnba', 'basketball_nba', 'soccer_epl']) { + out.endpoints.push(await probe('/exports/resolved-props', { sport: sportKey }, `bulk resolved export — ${sportKey}`, httpGet)); + } + + return out; +} + +module.exports = { verify, __internals: { analyseBreadth, scrubKeys, probe, summarise, REFERENCE_CANDIDATES, DFS_PLATFORMS, BASE } }; diff --git a/tests/unit/proplineVerify.test.js b/tests/unit/proplineVerify.test.js new file mode 100644 index 0000000..fc5e99f --- /dev/null +++ b/tests/unit/proplineVerify.test.js @@ -0,0 +1,146 @@ +'use strict'; + +/** + * proplineVerify — Order Zero Phase 1 unit tests. + * + * Hermetic: no network, no key. Locks the two properties that make this tool + * trustworthy — (1) the key can never escape in any emitted string, and (2) + * consensus eligibility counts only reference books posting BOTH sides at the + * SAME line (a one-sided quote cannot be de-vigged, so it cannot rule). + */ + +const { __internals } = require('../../src/services/proplineVerify'); +const { analyseBreadth, scrubKeys, probe, summarise, REFERENCE_CANDIDATES, DFS_PLATFORMS } = __internals; + +const ALLOWED = new Set(['draftkings', 'fanduel', 'betmgm', 'betrivers', 'pinnacle']); + +/** Build a PropLine-shaped event. books = { bookKey: [{player, point, over, under}] } */ +function event(id, books) { + return { + id, + home_team: 'Home', away_team: 'Away', + bookmakers: Object.entries(books).map(([key, quotes]) => ({ + key, + markets: [{ + key: 'batter_hits', + outcomes: quotes.flatMap((q) => [ + ...(q.over != null ? [{ name: 'Over', description: q.player, point: q.point, price: q.over }] : []), + ...(q.under != null ? [{ name: 'Under', description: q.player, point: q.point, price: q.under }] : []), + ]), + }], + })), + }; +} + +describe('proplineVerify — key safety', () => { + const realKeys = process.env.PROPLINE_API_KEY_1; + afterEach(() => { process.env.PROPLINE_API_KEY_1 = realKeys; }); + + it('scrubs the configured key out of any string', () => { + process.env.PROPLINE_API_KEY_1 = 'SUPERSECRETKEY123'; + const leak = 'GET https://api.prop-line.com/v1/x?apiKey=SUPERSECRETKEY123&markets=a failed'; + const out = scrubKeys(leak); + expect(out).not.toContain('SUPERSECRETKEY123'); + expect(out).toContain(''); + }); + + it('scrubs an apiKey query value even when the key is not configured', () => { + delete process.env.PROPLINE_API_KEY_1; + expect(scrubKeys('?apiKey=abc123xyz&z=1')).toBe('?apiKey=&z=1'); + }); + + it('probe never throws and never emits the key on a transport error', async () => { + process.env.PROPLINE_API_KEY_1 = 'SUPERSECRETKEY123'; + const httpGet = async () => { throw new Error('connect ECONNREFUSED apiKey=SUPERSECRETKEY123'); }; + const r = await probe('/x', {}, 'note', httpGet); + expect(r.verdict).toBe('no'); + expect(JSON.stringify(r)).not.toContain('SUPERSECRETKEY123'); + }); +}); + +describe('proplineVerify — probe verdicts', () => { + beforeEach(() => { process.env.PROPLINE_API_KEY_1 = 'k'; }); + + const withStatus = (status, data, headers = {}) => async () => ({ status, data, headers }); + + it('200 with data = works', async () => { + expect((await probe('/x', {}, '', withStatus(200, [{ a: 1 }]))).verdict).toBe('works'); + }); + it('200 with an empty body = partial, not works', async () => { + expect((await probe('/x', {}, '', withStatus(200, []))).verdict).toBe('partial'); + }); + it('403 = no (tier-gated for us)', async () => { + expect((await probe('/x', {}, '', withStatus(403, { detail: 'upgrade' }))).verdict).toBe('no'); + }); + it('404 = partial (reachable, nothing on file)', async () => { + expect((await probe('/x', {}, '', withStatus(404, { detail: 'none' }))).verdict).toBe('partial'); + }); + it('counts CSV rows excluding the header', async () => { + const r = await probe('/x', {}, '', withStatus(200, 'h1,h2\na,b\nc,d\n')); + expect(r.csv_rows).toBe(2); + }); +}); + +describe('proplineVerify — breadth', () => { + it('reports the cost of our own allow-list, and props made invisible by it', () => { + const raw = [event('e1', { + draftkings: [{ player: 'A', point: 1.5, over: -110, under: -110 }], + novig: [{ player: 'A', point: 1.5, over: -104, under: -104 }], + kalshi: [{ player: 'A', point: 1.5, over: -103, under: -105 }], + // B exists at three books, NONE of them admitted -> invisible to VYNDR + smarkets: [{ player: 'B', point: 0.5, over: 120, under: -140 }], + prizepicks: [{ player: 'B', point: 0.5, over: -119, under: -119 }], + underdog: [{ player: 'B', point: 0.5, over: -118, under: -118 }], + })]; + const r = analyseBreadth(raw, ALLOWED); + expect(r.props).toBe(2); + expect(r.feed.mean_books_per_prop).toBe(3); + expect(r.after_allow_list.mean_books_per_prop).toBe(0.5); + expect(r.invisible_props).toBe(1); + expect(r.invisible_pct).toBe(50); + }); + + it('consensus eligibility requires TWO-SIDED quotes at the SAME line', () => { + const raw = [event('e1', { + // Prop A: two reference books, both two-sided, SAME line -> eligible + novig: [{ player: 'A', point: 1.5, over: -104, under: -104 }], + kalshi: [{ player: 'A', point: 1.5, over: -103, under: -105 }], + // Prop B: two reference books but DIFFERENT lines -> NOT eligible + smarkets: [{ player: 'B', point: 0.5, over: 100, under: -120 }], + matchbook: [{ player: 'B', point: 1.5, over: 200, under: -240 }], + // Prop C: two reference books, same line, but one is ONE-SIDED -> NOT eligible + polymarket: [{ player: 'C', point: 2.5, over: 150, under: -180 }], + bovada: [{ player: 'C', point: 2.5, over: 145 }], + })]; + const r = analyseBreadth(raw, ALLOWED); + expect(r.props).toBe(3); + expect(r.consensus_eligible_n2).toBe(1); + expect(r.consensus_eligible_n3).toBe(0); + }); + + it('DFS platforms are reported separately and are never reference candidates', () => { + for (const dfs of DFS_PLATFORMS) expect(REFERENCE_CANDIDATES).not.toContain(dfs); + const raw = [event('e1', { + prizepicks: [{ player: 'A', point: 1.5, over: -119, under: -119 }], + underdog: [{ player: 'A', point: 1.5, over: -118, under: -118 }], + sleeper: [{ player: 'A', point: 1.5, over: -120, under: -120 }], + })]; + const r = analyseBreadth(raw, ALLOWED); + // 100% DFS coverage must NOT create consensus eligibility. + expect(r.dfs_presence_EXCLUDED_FROM_CONSENSUS.prizepicks.pct).toBe(100); + expect(r.consensus_eligible_n2).toBe(0); + }); + + it('ignores game-line outcomes that carry no player description', () => { + const raw = [{ + id: 'e1', + bookmakers: [{ key: 'draftkings', markets: [{ key: 'h2h', outcomes: [{ name: 'Home', price: -130 }] }] }], + }]; + expect(analyseBreadth(raw, ALLOWED).props).toBe(0); + }); + + it('summarise never returns object internals for a null body', () => { + expect(summarise(null)).toBe('null'); + expect(summarise([{ a: 1 }])).toContain('array(1)'); + }); +});