Compare commits

...

19 Commits

Author SHA1 Message Date
builtbykev 219167eebf A1: migration 021 — partner attribution (pre-apply commit, per docs/PARTNERS.md)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:54:16 -04:00
builtbykev 1d46b446c9 Merge S10 (a1): public ledger profiles v1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	BUILD-STATE.md
#	CLAUDE.md
2026-07-11 19:40:43 -04:00
builtbykev caf09840d5 Merge S9 (a1): slip reader — zero-API OCR
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:39:56 -04:00
builtbykev c18dd42067 S5 fix: viability JSDoc type (JS default-param inference broke the build)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:39:21 -04:00
builtbykev 02c17a65c3 S5 (a1): prop viability — lineups, injury wire, date navigation
- lineupService: statsapi hydrate=lineups (live shape verified) →
  CONFIRMED (batting slot) / NOT_IN (team posted without the player) /
  PROJECTED (not posted). 10-min cache, pure parser, injectable.
- NOT_IN visibly KILLS the grade on the slate: struck through + NOT IN
  LINEUP chip, parlay/book actions suppressed. The locked ledger read is
  untouched — honesty is showing the read is dead, not deleting it.
- injuryService: ESPN injuries feed → OUT/GTD/PROB chips (unknown status
  → no chip, never invented). Chips on slate strips via ViabilityChips.
- Date navigation on the Slate: YESTERDAY (results surface — finals +
  THE SETTLE panel of that date's settled reads w/ outcome + CLV chips,
  via new ?date= filter on /api/ledger/model) / TODAY / TOMORROW
  (schedule until lines post). Odds/grades/pitcher layers are TODAY's
  and never fake other dates; 60s poll only refreshes today.
- Routes /api/schedule/:sport/lineups + /injuries + Next proxies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:38:37 -04:00
builtbykev b20145c215 S10 (a1): public ledger profiles v1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:22 -04:00
builtbykev 4e49ee0990 S9 (a1): slip reader — zero-API OCR
tesseract.js (self-hosted WASM, Apache-2.0) + pure per-book layout
parsers (DK/FD/MGM/Caesars) with per-field confidence and needs_review
honesty — the reader never guesses. POST /api/slips/parse (auth, free
1/day paid 10/day, 4MB cap) + Next proxy. Gated /slip page: upload or
paste, manual-correct UI, per-leg grades through the normal engine
(refusals render honestly), add-all to Parlay Lab, share card. Vision
model upgrade logged post-revenue. 2574 -> 2608 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:17 -04:00
builtbykev aaafc3e0f2 Merge S8 (a1): ops — the product watches itself
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	BUILD-STATE.md
#	CLAUDE.md
#	src/snapshotScheduler.js
2026-07-11 14:38:06 -04:00
builtbykev 841e1c9475 Post-merge: hermetic newsletter test (dev .env Supabase creds leaked into the suite)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:36:29 -04:00
builtbykev 044941d406 S8 (a1): ops — the product watches itself
Settlement alarm (settle-pass THROW pages high; morning zero-settle alarm
keyed off the Postgres ledger settle results, once per ET date, never on an
empty yesterday), per-sport 3-consecutive-slot failure pager (pure
opsWatch.createFailureTracker, pages once per losing streak), odds-api >=80%
quota alert (once per day, Redis-deduped), systemHealth (statfs + os mem,
disk>85 / mem>90 pages), daily 9 AM ET pulse (ONE notification: ledger rows
yesterday via ledgerService.countRowsForDate, settles 24h, quota, disk/mem,
desk line), docs/OPS-RUNBOOK.md (Uptime Kuma monitors, Coolify
deploy-failure -> ntfy, phone subscription). All copy VOICE v1.1 — deadpan,
numbers, no exclamation points (tests lint for it). 2398 -> 2437 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:34:45 -04:00
builtbykev a6a81f5d42 Merge S2 (a1): compliance + approval pack
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	BUILD-STATE.md
#	web/src/app/about/page.tsx
2026-07-11 14:33:18 -04:00
builtbykev fc0c7bfd18 Merge S7 (a1): newsletter — THE VYNDR REPORT
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	BUILD-STATE.md
#	CLAUDE.md
2026-07-11 14:32:18 -04:00
builtbykev 48eb420f9e Merge S3 (a1): affiliate + partner plumbing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:31:53 -04:00
builtbykev 0e7871ac0e S4 (a1): the media engine — VOICE templates, /desk, Ghost drafts
4a VOICE v1.1 committed (board start); lint is EXECUTABLE — banned list +
   no-exclamation law enforced in the engine (throws in test, drops in
   prod) and locked by tests. Curly-apostrophe variants covered.
4b mediaEngine: deterministic templates (MORNING WIRE, SIGNAL, STREAK
   WATCH, THE SETTLE, RECEIPTS, ARCHETYPE WATCH, LINE DISPATCH) filled
   ONLY from snapshot/ledger/streaks JSON. Record percentages never render
   under n>=20 (counts + 'Record building' below). Stark layer = curated
   committed library (content/stark-lines.json), day-rotated selection —
   selected, never generated.
4c /desk (founder-only: requireAuth + DESK_OWNERS email allowlist,
   deny-by-default): all formats as text + <=280-char pre-segmented tweets
   with per-tweet copy buttons + char counts, wire/numbers-only variants,
   DATA BRIEF block (structured day numbers) with copy-for-claude.ai.
   ntfy ping after the day's first snapshot: 'Desk pack ready'.
4d ghostPublisher: DRAFTS ONLY (status:'draft' test-locked), env-gated
   no-op, HS256 JWT via node crypto (zero new deps). POST
   /api/internal/ghost/drafts saves slate preview + settle drafts.
   Nothing anywhere auto-posts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:31:35 -04:00
builtbykev 4b262fdd65 S2 (a1): compliance + approval pack
- /responsible-gambling rebuilt sincerely: 21+, 1-800-GAMBLER primary,
  17-state resource list, warning signs, self-exclusion guidance, links
  to the existing /settings surfaces. No marketing adjacency.
- /terms + /privacy honest drafts with entity placeholders ([ENTITY NAME],
  [STATE OF FORMATION], [ARBITRATION VENUE], [CONTACT EMAIL]); privacy
  sub-processors match reality (adds Sentry, honest PostHog description).
- NEW /methodology: pipeline -> engine -> letter grades, refusals,
  VYNDR Originals, ledger settle + CLV + n>=20, why misses are public.
- /about audited to North Star framing (THE PROOF card, methodology link).
- Footer: 1-800-GAMBLER + Methodology link; compliance audit found zero
  pages suppressing the global footer.
- 5 Ghost seed articles in content/articles/ + docs/GHOST-PUBLISHING.md
  manual runbook (no Ghost access used).
- tests/unit/compliancePages.test.js (repo source-text style). 2429 tests
  green, web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:29:31 -04:00
builtbykev 32d7200571 S7 (a1): newsletter — THE VYNDR REPORT
Email capture + daily report assembly + operator-triggered Listmonk send.

- NewsletterCapture (dark terminal, mono) on landing (below FAQ) + /welcome
  (the real signup success surface); double-opt-in note; 'Signups open soon'
  when Listmonk env is unset.
- POST /api/newsletter/subscribe: public, 10/min IP limit, honeypot,
  server-side email validation, forwards to Listmonk subscribers API with
  preconfirm_subscriptions:false (Listmonk sends the confirmation).
  No env -> calm 200 { ok:false, reason:'not configured' }. Next proxy
  web/src/app/api/newsletter/subscribe/route.ts (S25 rule).
- newsletterService.buildDailyReport: signals from snapshot:{sport}:latest,
  STREAK WATCH via rosterLogs -> streaksService -> streakLens, THE RECORD via
  ledgerService.getModelAggregate (percentage only when hit_pct != null —
  n>=20 gate — else 'RECORD BUILDING · N pending'). RG footer (21+,
  1-800-GAMBLER, Listmonk-native {{ UnsubscribeURL }}) in html + text.
  VOICE v1.1 lint locked by tests: no '!', no banned vocabulary, numbers
  only from injected pipeline data.
- sendDailyReport: creates + starts a Listmonk campaign; env-gated no-op;
  refuses an empty report. Deliberately UNSCHEDULED — only
  POST /api/internal/newsletter/send (internal key) triggers it.
- docs/NEWSLETTER.md: box-side Listmonk runbook (install, double-opt-in
  list, API user, Coolify env, test-send).
- Spec: specs/feature-a1-s7-newsletter.md.

Tests 2398 -> 2429 (207 suites, all green); next build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:29:22 -04:00
builtbykev 0996320bd1 S3 (a1): affiliate + partner plumbing
Zero out-of-pocket; everything config-flip-ready but DISABLED/organic.

- BOOK IT deep links: web/src/lib/bookLinks.js + affiliateConfig.js
  (all books enabled:false, Impact/Partnerize param shapes documented,
  empty params skipped). Wired into StatStrip BookItTeaser (real anchor
  now) + scan hand-off links. Every book anchor renders
  rel="sponsored noopener noreferrer" (BOOK_LINK_REL).
- Best-price marker: slateAdapter.detectBestBook (only when >=2 books
  post the SAME line and prices differ — absent beats wrong) + subtle
  signal-green dot in StatStrip. Slate.groupByGame threads the grouped
  per-book rows (books[]) onto PropRowProp instead of discarding them.
- Partner refs: ?ref=CODE -> vyndr_ref cookie (90d, first-touch,
  PartnerRefCapture in layout) -> signup metadata partner_ref ->
  internal GET /api/partners/report/:code (requireInternalAuth; honest
  zeros + note until the TODO migration in docs/PARTNERS.md adds
  user_profiles.partner_ref — NOT run). Stripe promo-code convention:
  partner code == promotion code, verbatim.
- Tests: +41 (2398 -> 2439, 209 suites); bookItTeaser + vyndrCoreScreens
  invariants updated to the new (stronger) rel contract. Web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:28:18 -04:00
builtbykev d242b11b4b S1 (a1): feature-promise audit — no claim survives unverified
PROMISE-AUDIT.md: every /pricing claim → verified/built/reworded.
BUILT (was vapor): alt line ladder + edge ranking (same-features regrade
at shifted lines, Desk-gated at the API), quarter-Kelly (engine quantile
P(win) x real captured odds — either missing → no sizing), free-tier
kill-condition locked previews. FIXED (was false): analyst 15/day cap vs
the Founder 'Unlimited reads' promise → analyst unlimited; every '40+
factors' claim (real count: 22 named features) reworded truthfully in 7
files. VERIFIED: cascade alerts (real, wired), phi correlation,
leg history, cross-book comparison, WC soccer, real-time feed.
Locked by tests/unit/promiseAudit.test.js. Jest now ignores
.claude/worktrees (parallel agents' suites no longer leak into runs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:24:41 -04:00
builtbykev e4d2e79f95 A1 board: commit NORTH STAR v1.1 + VOICE v1.1 to /specs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:21:20 -04:00
120 changed files with 9469 additions and 170 deletions
+26
View File
@@ -206,3 +206,29 @@ The system's track record — settled grades vs real results. Written by
`GET /api/snapshot/:sport` grades may carry `outcome: { result:'hit'|'miss'|
'push', actual:number }` once the game is final. `AccuracyBadge` +
`StatStrip.OutcomeChip` render these. Frontend proxy: `web/src/app/api/accuracy`.
## 8. Public ledger profiles (`src/routes/profiles.js`, A1 Session 10)
Strava-for-betting v1. Table `public_profiles` (migration 022 — apply
before deploy). PRIVATE BY DEFAULT; one explicit publish toggle in Settings.
### `GET /api/profiles/:handle` (public, cached 60s)
Published only. Unknown AND unpublished return the SAME 404 body
(`{ error: 'Profile not found' }`) — no existence leak.
```
{
handle: string,
aggregate: <getModelAggregate shape, scoped to the user via userId>,
entries: [<same columns as /api/ledger, SETTLED rows only, newest 50>],
min_sample: 20 // below this, RECORD BUILDING — never a %
}
```
### `GET|POST /api/profiles/me` (requireAuth)
GET → `{ profile: { handle, published, created_at } | null }`.
POST `{ handle, published }` → upsert own row (service role). Handle must
match `^[a-z0-9_]{3,20}$` (400); a handle owned by another user → 409.
`published` flips ONLY on boolean `true`.
Frontend: `/u/[handle]` (OPEN route, server shell + client record + OG card,
Node runtime) via proxies `web/src/app/api/profiles/me|[handle]`.
+143 -1
View File
@@ -1,7 +1,121 @@
# VYNDR — Build State
## Last Updated
2026-07-10
2026-07-11
## Session S9 (a1 board, 2026-07-11) — Slip Reader: zero-API OCR ✅
Spec: `specs/a1-s9-slip-reader.md`. +34 tests (24 unit + 10 integration).
- **slipReader service** — tesseract.js (self-hosted WASM, Apache-2.0, zero
API spend) + PURE per-book layout parsers (DraftKings, FanDuel, BetMGM,
Caesars) on OCR-text fixtures. Per-field confidence; below 0.6 → field
null + `needs_review` — the reader never guesses. Stats normalize to the
scan-route vocabulary; names through `playerName`.
- **POST /api/slips/parse** — auth, free 1/day paid 10/day (`slips:{user}:
{day}` Redis + memory mirror), 4MB image cap, text paste path. Next proxy
added. Mounted in app.js.
- **/slip page** (gated) — upload/paste screenshot → manual-correct UI
(amber = uncertain) → per-leg grade via existing /api/scan (honest
refusals) → add-all to Parlay Lab → "VYNDR read my slip" share card.
- **Acceptance: FULL image path** — synthetic DK slip PNG (sharp SVG→PNG)
→ real tesseract.js OCR → parser → 3/3 complete legs. Untested on a real
phone screenshot yet — grab one post-deploy.
- Vision-model upgrade logged post-revenue in specs/vyndr-roadmap.md.
## Session S10 (a1 board, 2026-07-11) — Public Ledger Profiles v1 ✅
Stage-3 seed (Strava for betting), zero out-of-pocket. Branch off
day1/a1-board (aaafc3e). 2574 → **2612 tests** (220 suites), web build
exit 0. Spec: `specs/a1-s10-public-profiles.md`.
- **Migration `022_public_profiles.sql`** — COMMITTED, NOT APPLIED (founder
applies migrations). `public_profiles` (user_id PK → auth.users, handle
UNIQUE + regex CHECK, `published` DEFAULT FALSE). RLS: published rows
readable by anyone, own row by owner, writes service-role only.
- **API** — `/api/profiles`: `GET/POST /me` (requireAuth; handle regex 400,
taken handle 409, published only flips on explicit boolean true) and
public `GET /:handle` (published → user-scoped 30d aggregate + settled
rows newest 50, same columns as /api/ledger; unknown AND unpublished →
byte-identical 404 — no existence leak). `getModelAggregate` gained a
`userId` option (public `.is('user_id', null)` default untouched).
- **Frontend** — `/u/[handle]` (PUBLIC, in OPEN_ROUTES): server shell with
"CLV-verified record — @handle · VYNDR" metadata + client record (hit% +
beat-close% at n≥20 else RECORD BUILDING; outcome + CLV chips) + OG card
(Node runtime, never edge). Settings PUBLIC PROFILE section: claim handle
+ one explicit publish toggle with the private-by-default copy.
- **Next**: profile OG card could embed the live record once n≥20 profiles
exist; user-scan settlement coverage beyond MLB is the limiting factor.
## Session S3 (a1 board, 2026-07-11) — Affiliate + Partnership Plumbing ✅
Zero out-of-pocket; everything config-flip-ready but DISABLED/organic.
2398 → **2439 tests** (209 suites), web build exit 0.
- **BOOK IT deep links** — `web/src/lib/bookLinks.js` + `affiliateConfig.js`
(every book `enabled:false`; Impact/Partnerize param shapes documented).
StatStrip BOOK IT is a real anchor now (organic); scan hand-off links moved
to the builder. Every book anchor renders `rel="sponsored noopener noreferrer"`.
- **Best-price marker** — `slateAdapter.detectBestBook` (≥2 books, SAME line,
differing prices, else null) + green dot in StatStrip; `books[]` (the grouped
odds rows) now threads Slate → strips instead of being discarded by pickLine.
- **Partner refs** — `?ref=CODE` → 90d first-party `vyndr_ref` cookie
(first-touch, `PartnerRefCapture` in layout) → signup metadata `partner_ref`
→ internal `GET /api/partners/report/:code` (zeros + note until the TODO
migration in docs/PARTNERS.md adds `user_profiles.partner_ref`; NOT run).
- **docs/PARTNERS.md** — ref link spec, Stripe promo-code mapping convention
(partner code == promotion code), TODO migration SQL.
## Session S7 (a1 board, 2026-07-11) — Newsletter: THE VYNDR REPORT ✅
Branch off day1/a1-board (e4d2e79). 2398 → **2429 tests** (207 suites),
web build exit 0. Spec: `specs/feature-a1-s7-newsletter.md`. Runbook:
`docs/NEWSLETTER.md`.
- **Capture**: `NewsletterCapture` (dark terminal, mono data) on the landing
(below FAQ) + `/welcome` (the real signup success surface — the signup
"done" card auto-redirects in 1.5s, useless for a form). Double-opt-in
note under the field; "Signups open soon" when Listmonk env is unset.
- **Backend**: `POST /api/newsletter/subscribe` (public, 10/min IP limit,
honeypot, server-side email validation) → Listmonk subscribers API with
`preconfirm_subscriptions:false` (Listmonk sends the confirmation).
No env → calm 200 `{ ok:false, reason:'not configured' }`. Next proxy
`web/src/app/api/newsletter/subscribe/route.ts` (S25 rule).
- **Assembly**: `newsletterService.buildDailyReport` — signals from
`snapshot:{sport}:latest`, STREAK WATCH via rosterLogs → streaksService →
streakLens (one row per player), THE RECORD via `getModelAggregate`
(percentage ONLY when hit_pct != null — the n≥20 gate — else
"RECORD BUILDING · N pending"). RG footer (21+, 1-800-GAMBLER,
`{{ UnsubscribeURL }}`) in html + text. VOICE lint locked by tests:
no `!`, no banned vocabulary, numbers only from injected pipeline data.
- **Send**: `sendDailyReport` creates + starts a Listmonk campaign;
operator-triggered ONLY via `POST /api/internal/newsletter/send`
(internal key). NOT scheduled. Empty report (0 signals + 0 streaks)
refuses to send.
- **Env for Coolify** (all four required, else graceful no-op):
`LISTMONK_URL`, `LISTMONK_USER`, `LISTMONK_TOKEN`, `LISTMONK_LIST_ID`.
## Session S2 (a1 board, 2026-07-11) — BUILT ✅ COMPLIANCE + APPROVAL PACK
Branch worktree off `day1/a1-board`. 2429 tests (206 suites), web build
exit 0 (`next build --webpack`). No backend routes touched.
- **/responsible-gambling REBUILT sincerely** — 21+, 1-800-GAMBLER
(1-800-426-2537) as the primary helpline, 17-state resource list,
warning signs, state self-exclusion guidance, links to the EXISTING
/settings Responsible Play + Danger Zone surfaces. Zero marketing
adjacency (no pricing/scan/upgrade links — a test enforces it).
- **/terms + /privacy redrafted** as honest approval-pack drafts (marked
DRAFT: JULY 2026). Entity details are placeholder tokens Kev must fill:
[ENTITY NAME], [STATE OF FORMATION], [ARBITRATION VENUE],
[CONTACT EMAIL]. Privacy sub-processors now match reality (Stripe,
Supabase, Resend, Sentry; PostHog described honestly — autocapture off,
banner is disclosure-only, NOT consent-gated).
- **/methodology NEW** (server component + metadata, footer COMPANY link,
OPEN_ROUTES): pipeline → engine → 11-step letter grades, refusals
(insufficient_data), VYNDR Originals (41), Ledger settle (locked at
grade time, box-score settle, CLV vs captured close, n≥20, public
revisions), why misses are public.
- **/about audited** to North Star framing: data intelligence platform,
THE PROOF card (Ledger + n≥20 + refusals + methodology link). Kept the
"give it back" h1 (Phase-E test) + Wordmark (QA.5).
- **Footer**: helpline updated 1-800-522-4700 → 1-800-GAMBLER; Methodology
link added. Compliance audit: footer is global in the root layout, no
nested layout or CSS suppresses it on any page — zero pages missing it.
- **content/articles/** — 5 Ghost-ready seed drafts (front matter, VOICE
v1.1, zero exclamation points, test-enforced) + docs/GHOST-PUBLISHING.md
manual runbook (no Ghost credentials used; nothing auto-posts).
- Tests: tests/unit/compliancePages.test.js (31 assertions, source-text
style).
## Current Phase
SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum,
@@ -9,6 +123,34 @@ work-order 1.6 closed (canonical player keys + slate join invariant),
Phase 2 slate UX, Phase 3 mobile P0. DEPLOY GATE: Coolify by Sat 10 AM ET;
first snapshot locks against freshly posted lines (manual internal trigger).
## Session 8 (A1 board, 2026-07-11) — SHIPPED ✅ OPS: THE PRODUCT WATCHES ITSELF
Branch off day1/a1-board tip. 2398 → 2437 tests (208 suites). Backend-only.
- **Settlement alarm** (`snapshotScheduler`): outcome/ledger settle pass
THROW → ntfy high. Morning zero-settle alarm: ledger settle found
pre-today Postgres rows but settled none → page once per ET date
(signal = settleLedger's own return values — survives the Redis-TTL
failure mode that silently killed morning settles in S60; documented in
`opsWatch.zeroSettleAlarm`). Genuinely empty yesterday never alerts.
- **Persistent-failure pager** (`opsWatch.createFailureTracker`, pure):
3 consecutive error / skipped-'no props' slots for a sport pages once
(high); any good slot resets and re-arms. Single-slot misses stay quiet.
- **Quota** (`opsWatch.checkQuotaDaily`): odds-api ≥80% after a snapshot
run → one alert/day, Redis-deduped (`ops:quota_day:{provider}:{date}`).
- **Box health** (`services/systemHealth`, pure + injectable): fs.statfs
+ os mem → `{disk_pct, mem_pct}`; pages high at disk>85 / mem>90.
- **Daily pulse** 13:00 UTC (9 AM EDT; `PULSE_HOUR_UTC` to move), ONE
notification: ledger rows yesterday (`ledgerService.countRowsForDate`,
'n/a' when Supabase off — never a fabricated 0), settles last 24h,
quota pct, disk/mem, 'desk pack: see /desk'. Dedupe: in-process date +
`ops:pulse:{date}`.
- **docs/OPS-RUNBOOK.md**: Uptime Kuma monitors (vyndr.app 200, api
/api/health keyword 'healthy', /api/snapshot/summary), Coolify
deploy-failure webhook → ntfy, ntfy phone-subscription steps.
- Live acceptance alert POSTed to ntfy.sh/vyndr-pipeline-kev2026.
- All alert copy VOICE v1.1 — deadpan, numbers, zero exclamation points
(tests lint for `!`). Spec: `specs/session-8-ops-watch.md`.
## Session 60 (night2/full-board, 2026-07-11) — BUILT ✅ THE WHOLE BOARD (awaiting "merge the train")
Branch `night2/full-board` off d10bb4c — ZERO pushes to main. 2352 →
+133
View File
@@ -801,6 +801,139 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and
schedule-derived waiting copy. Keep them green.
## Affiliate + Partner Plumbing (A1 S3 — non-obvious)
- **Every sportsbook link goes through `web/src/lib/bookLinks.js`**
(`buildBookLink` → `{url, tracking}`; unknown book → null). The affiliate
layer is `web/src/lib/affiliateConfig.js` — ALL books `enabled:false` (no
program approved); flipping a book + filling params produces tracked URLs
with NO component changes. Empty param values are SKIPPED (a half-filled
config degrades to organic, never a fabricated id). Anchors MUST render
`rel={BOOK_LINK_REL}` ("sponsored noopener noreferrer") — tests assert it.
- **Best-price dot honesty rule:** `slateAdapter.detectBestBook(rows, side,
refLine)` returns a book ONLY when ≥2 books post the SAME line for the side
and prices differ — never compare odds across different lines, never mark a
lone price "best". The per-book rows reach the browser as `lines[]` on each
grouped prop; `Slate.groupByGame` now threads them as `books` on PropRowProp
(pickLine still picks the single displayed line).
- **Partner attribution:** `?ref=CODE` → `vyndr_ref` cookie (90d, FIRST-touch,
`lib/partnerRef.js` + `PartnerRefCapture` in layout) → `signUp` metadata
`partner_ref` → internal `GET /api/partners/report/:code`. The
`user_profiles.partner_ref` column does NOT exist yet — the endpoint returns
zeros + note until the TODO migration in `docs/PARTNERS.md` runs (metadata
lands in auth.users.raw_user_meta_data, which PostgREST can't query). OAuth
signups carry no metadata (known gap). Partner code == Stripe promotion code,
verbatim (docs/PARTNERS.md §3).
- **Worktree build gotcha:** `web/node_modules` isn't shared into git worktrees
and Turbopack REJECTS a symlink pointing outside the project root — use
`cp -al` (hardlink copy) from the main repo's web/node_modules.
## Newsletter — THE VYNDR REPORT (Session S7, a1 — non-obvious)
- **`newsletterService`** is the whole layer: `buildDailyReport(sports, deps)`
(deterministic VOICE v1.1 template over snapshot grades + streak lens +
`getModelAggregate`; every dep injectable), `subscribe(email)` and
`sendDailyReport(opts)` (Listmonk, self-hosted, zero out-of-pocket). ALL
Listmonk paths are env-gated on `LISTMONK_URL/USER/TOKEN/LIST_ID` — any
missing → `{ ok:false, reason:'not configured' }`, and the capture UI shows
"Signups open soon". Auth is the API-user scheme `Authorization: token
user:token`, NOT basic auth.
- **Double opt-in is Listmonk's job:** subscribe sends
`preconfirm_subscriptions:false`; the list itself must be created as
double-opt-in (runbook: `docs/NEWSLETTER.md`). The email template embeds the
LITERAL `{{ UnsubscribeURL }}` — Listmonk substitutes per-recipient; never
"fix" it. A 409 (already subscribed) is returned as ok — no enumeration.
- **The send is deliberately unscheduled.** Only `POST
/api/internal/newsletter/send` (internal key) triggers it; an empty report
(0 signals AND 0 streaks) refuses to send. The record line renders a
percentage ONLY when `hit_pct != null` (n≥20 gate upstream in
`getModelAggregate`), else "RECORD BUILDING · N pending".
- **VOICE lint is executable:** `tests/unit/newsletterService.test.js` fails on
any `!`, banned vocabulary (word-boundary regex), or hype emoji in
subject/html/text. The RG disclaimer says "No outcome is promised" — the word
"guarantee" trips the lint even in a disclaimer, keep it out.
- **Capture placement:** the signup "done" card auto-redirects after 1.5s
(`setTimeout(router.replace)`) — useless for a form. The free-signup success
surface is `/welcome`. Landing capture sits below FAQ. Subscribe route is
10/min IP-limited → keep `tests/integration/newsletterRoute.test.js` under 10
subscribe requests (same lesson as analyze.test.js).
## Ops Self-Watch (Session 8, A1 board — non-obvious)
- **Pure alarm logic lives in `src/services/opsWatch.js`** (failure tracker,
zero-settle signal, quota daily check, pulse assembly) + `src/services/
systemHealth.js` (statfs/os, injectable). `snapshotScheduler` only WIRES
them — keep new alarm logic in opsWatch so it stays unit-testable.
- **Zero-settle signal = the ledger settle's own return values** (Postgres
`game_date < today AND outcome IS NULL`), NOT `snapshot:{sport}:previous` —
the snapshot key expires in exactly the failure mode the alarm exists to
catch (the S60 SNAP_TTL bug). Scoped to `SETTLEABLE_SPORTS` (mlb);
NBA/WNBA rows legitimately stay pending and must not page daily. Morning
slot only (`morningHourUtc` = first configured hour ≥ 06 UTC).
- **Failure pager pages ONCE per losing streak** (exactly at count 3), not
once per failing slot; `status:'skipped', reason:'no grades'` is NOT a
failure (props arrived, grader refused) and resets the counter.
- **Daily pulse** fires at `PULSE_HOUR_UTC` (default 13 = 9 AM EDT), one
notification, dual dedupe (in-process date + Redis `ops:pulse:{date}`).
Missing data renders 'n/a' — a pulse never fabricates a zero (Data
Semantics Rule applies to ops copy). All alert copy: no exclamation
points, ever (VOICE v1.1) — tests lint for it.
- **Single-suite jest runs of scheduler tests can hang at exit** when redis
is down (ioredis reconnect timer keeps the process alive) — PRE-EXISTING
at baseline, full-suite runs are unaffected. Don't chase it as a leak in
new code; inject cacheGet/cacheSet in tests to avoid creating a client.
- Box-side ops (Uptime Kuma, Coolify deploy-failure webhook, ntfy phone
setup) = `docs/OPS-RUNBOOK.md`.
## Slip Reader (Session 9, A1 board — non-obvious)
- **`src/services/slipReader.js`** — PURE per-book OCR-text layout parsers
(DK/FD/MGM/Caesars) + `detectBook` + `parseSlipText(text, bookHint)`. The
NEVER-GUESS contract: every field carries a confidence; below
`CONFIDENCE_THRESHOLD` (0.6) the field is NULL + the leg `needs_review:true`.
Values are USER-SLIP values (`source:'user_slip'`) — never written to any
market cache. Stat labels normalize via `STAT_ALIASES` → the scan-route
vocabulary (a unit test cross-checks every alias against VALID_STAT_TYPES);
players via `playerName` normalizeName/nameKey.
- **Caesars head split is alias-anchored:** "Pete Alonso Home Runs Over 0.5"
is regex-ambiguous (greedy name eats "Home" → stat "runs" = WRONG-stat
fabrication). `splitPlayerStat` matches the LONGEST known stat alias the
head ENDS with; no alias → stat null AND player low-confidence (boundary
unknowable). BetMGM legs carry "@ -115" — don't pre-filter its lines with
the game-row `@` heuristic; the Over/Under grammar is the filter.
- **OCR = tesseract.js v7 (Apache-2.0), lazy-required** in `recognizeImage`
so the parsers load without WASM. ~46MB on disk (core WASM); eng
traineddata (~11MB) downloads ONCE at first recognize and caches at
`TESSERACT_CACHE_PATH` (default `.tesseract-cache/`) — first prod OCR call
needs outbound network, then it's fully offline.
- **`POST /api/slips/parse`** (requireAuth): base64/data-URL image ≤4MB
decoded OR raw `text` (paste path, also what hermetic route tests use).
Daily quota `slips:{user}:{YYYY-MM-DD}` — free 1/day, paid 10/day; Redis
counter with in-memory mirror (max of both enforces). Validation runs
BEFORE the quota is burned. Injectable via `router.__internals.setDeps`.
Next proxy: `web/src/app/api/slips/parse/route.ts` (S25 rule).
- **`/slip` page** (added to GATED_ROUTES): upload/paste → editable leg rows
(nulled fields amber; editing clears the flag) → each leg graded via the
EXISTING `POST /api/scan` (refusals render "NO GRADE — insufficient data")
→ `useParlay().addLeg` per graded leg → share card is copy-text (no OG
plumbing). Sport selector is MLB/NBA/WNBA (ParlayLeg's sport union).
- **Vision-model upgrade is POST-REVENUE** (specs/vyndr-roadmap.md) — the
route contract is the stable interface; only the extraction engine swaps.
## Public Ledger Profiles v1 (Session 10, A1 board — non-obvious)
- **`public_profiles` (migration 022) is PRIVATE BY DEFAULT** — `published`
DEFAULT FALSE, one explicit toggle in Settings, service-role writes only.
`GET /api/profiles/:handle` returns the SAME 404 body for unknown AND
unpublished handles (no existence leak) — keep them byte-identical; a
test diffs the two responses. A malformed handle 404s WITHOUT a DB query.
- **`getModelAggregate({ userId })`** swaps `.is('user_id', null)` for
`.eq('user_id', uid)` on BOTH the settled and pending queries — the same
30d window + n≥20 gate over one user's ledger. The no-userId default is
the public model record and must stay untouched.
- **The user's public entries are SETTLED rows only** (`.not('outcome',
'is', null)`, newest 50, same columns as /api/ledger) — pending reads are
not public until they settle, and nothing is curated: misses included.
- **`/u` is in OPEN_ROUTES on purpose** — the share surface must load
anonymous; the privacy gate is the API's 404, never the router.
- **Jest from a worktree gotcha:** the repo config's `testPathIgnorePatterns`
includes `/.claude/`, which matches EVERY path inside
`.claude/worktrees/...` → "No tests found". Run with
`npx jest --testPathIgnorePatterns "/node_modules/"` from a worktree
(CLI replaces the config array).
## Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
+35
View File
@@ -0,0 +1,35 @@
# PROMISE-AUDIT — every /pricing claim, verified / built / removed
### Session 62 (A1 board, Session 1). No claim survives unverified. Locked by tests/unit/promiseAudit.test.js.
| Claim (pricing page) | Verdict | Evidence |
|---|---|---|
| Free: 3 reads per day | **VERIFIED** | `tiers.js free.scans_per_day=3`; limiter enforces; Settings meter reads the same store |
| Free: Grade letter + projection | **VERIFIED** | Grade + real MODEL projection (S58 — never the line); refusals when no projection |
| Free: Cross-book line comparison | **VERIFIED** | GAME LINES 8-book table + BestLinesPanel; per-prop best-book marker shipping in S3 |
| Free: Confidence indicator | **VERIFIED** | Confidence % on card, strips, leaders |
| Free: Factor analysis (blurred) | **BUILT/REWORDED** | Copy now "3-signal preview" (the truth); free gets exactly 3 signals |
| Free: Kill conditions (blurred) | **BUILT** | Copy now "locked preview"; free NOW renders code-only teaser chips ("TRAP — upgrade to see details") instead of silence — `gradeAdapter` + tierGating locked entries |
| Free: Alt line ladder (locked) | **VERIFIED** | Desk-gated at the API (new `alt_line_ladder` capability); free card shows the DESK-tagged section only when data present (never for free) |
| Africa: 10 reads per day | **VERIFIED** | `tiers.js africa.scans_per_day=10` |
| Analyst: Full factor analysis (40+ signals) | **REWORDED** | Real named-feature count is 22 (+trap/consistency signals) — "40+" was unverifiable. Now "every signal the model weighs" in ALL 7 places (pricing, help, about, upgrade, both cards). Numbers only when true |
| Analyst: Kill conditions surfaced inline | **VERIFIED** | tierGating `kill_conditions_detail`; card renders reasons |
| Analyst: Grade + reasoning visible | **VERIFIED** | tierGating passthrough |
| Analyst: World Cup soccer intelligence | **VERIFIED** | `/api/odds/soccer/wc`, soccer tab, soccer in ACTIVE_SPORTS (July = WC window) |
| Analyst: Cascade alerts | **VERIFIED/REWORDED** | REAL: `cascadeService.detectScratches` runs on every odds fetch → `cascade_alerts` → NotificationBell. Copy tightened to "Scratch cascade alerts when players are pulled" (what it actually does; lineup-shift regrades are a future board) |
| Analyst: Parlay leg history with grades | **VERIFIED** | Parlay scans persist pick rows w/ grades; `/api/props/joint-history` (analyst+) serves joint outcomes |
| Analyst: Sportsbook deep links | **VERIFIED** | Scan hand-off links (noopener); S3 upgrades to per-book BOOK IT builder |
| Founder: Unlimited reads | **BUILT** | Was FALSE (analyst capped 15/day while card + Nav ∞ badge promised unlimited). `analyst.scans_per_day → Infinity`; Desk differentiates on capability, not rationing |
| Desk: Alt line ladder + edge ranking | **BUILT** | Was VAPOR (frontend existed, no producer). `analyzeViaEngine1` now grades the SAME feature vector at ±0.5/±1 lines (zero extra I/O), each rung carries grade + edge%, sorted by edge = the ranking. API-gated Desk-only |
| Desk: Quarter-Kelly sizing | **BUILT** | Was VAPOR (marketing copy only). `src/utils/kelly.js`: quarter-Kelly from the engine's quantile P(win) (game-log based) × the REAL captured book odds — either missing → no sizing, never a default. Rendered on the Desk card ("QTR-KELLY 1.4% of bankroll · at -115") |
| Desk: Real-time intelligence feed | **VERIFIED** | Ticker (snapshot exhaust) + 20-min intraday refresh (STEAM/VALUE/MOVE) + live slate strip |
| Desk: Parlay correlation analysis (phi) | **VERIFIED** | `parlayService` numeric correlation + S28 phi matrix; joint-history endpoint |
| Desk: Consensus vs model comparison | **VERIFIED (thin)** | MODEL vs LINE vs multi-book board on every read; `lineDiscrepancyDetector` exists server-side. Honest note: a labeled multi-book consensus average is a small future upgrade, the comparison itself is real |
## Deviations made to keep the page honest
1. All "40+ factors" claims removed product-wide (7 files) — replaced with wording that is true at any factor count.
2. Analyst reads: 15/day → unlimited (the promise wins; capacity cost is nil — reads are cached compute).
3. "Cascade alerts" reworded to the shipped behavior (scratch cascades), not the aspirational one.
4. Free kill-conditions changed from hidden → locked preview to match "(blurred/locked)" copy.
## What a paying user gets that didn't exist this morning
Alt line ladder with edge ranking · quarter-Kelly sizing on real prices · truly unlimited analyst reads · free-tier kill-condition previews.
+35
View File
@@ -0,0 +1,35 @@
---
title: How Streaks Lie
slug: how-streaks-lie
excerpt: Most hot streaks are soft schedules wearing a costume. How to read a streak against what it was built on, the way the VYNDR lens does.
tags: [streaks, the-read-room]
status: draft
---
Most hot streaks are soft schedules wearing a costume.
A 7-game hit streak against 5.2 ERA pitching is not form. It is a calendar. The player did his job — but the streak's real author was the schedule maker, and tonight the schedule hands him a different assignment. Here is exactly how the trick works, and how to see through it.
## The raw streak is the industry's favorite lie
A streak, stated alone — "8 straight games over 19.5 points," "hits in 7 straight" — carries an implication: this will continue. Books and touts both love the raw number, for opposite reasons. Touts sell it as momentum. Books price it, correctly, as bait, and shade the line into the streak because they know the public buys continuation.
The number itself is true. The implication is the lie. Whether a streak continues has almost nothing to do with its length and almost everything to do with what it was built against and what it faces tonight.
## The three questions that expose a streak
**1. What was it built against?** Pull the opponents inside the streak window from the game log. A scoring streak assembled against bottom-ten defenses is a description of the schedule. The same streak built through two top-five defenses is information about the player.
**2. What does tonight look like?** A hit streak fed on soft right-handed pitching meets a 2.80 ERA starter tonight. The streak is 7 games old; the thing that produced it is not in the building. In MLB this is unusually concrete — you know the opposing starter by name, with his numbers, hours before first pitch.
**3. Has the price already eaten it?** By game 6 of a public streak, the line has moved. You are no longer betting on the player continuing; you are betting on him continuing at a worse number than anyone got last week. Streak buzz is negative closing line value sold as a story.
## The lens rule
On VYNDR, no raw streak ever renders alone. That is a hard rule in the code, not an editorial habit. Every streak row carries the opponents it was built against, tonight's matchup — including the opposing starter and his ERA for MLB bats — a difficulty rating of tonight versus the streak's diet, and a one-line read composing the three. When we cannot rate the matchup honestly, the row says less. We do not invent context to fill a box.
Sometimes the lens confirms the streak: built against real pitching, facing more of the same, line still fair. Those exist, and they are worth something precisely because most streaks fail the checks.
## The habit
Next time a streak headline reaches you, ask the three questions before the number impresses you. Who built it. Who is here tonight. What does it cost now. Magicians hate this. So do the books.
@@ -0,0 +1,37 @@
---
title: How VYNDR Grades a Prop
slug: how-vyndr-grades-a-prop
excerpt: From the line feed to the letter. What actually happens inside the pipeline, including the part where the model refuses.
tags: [methodology, the-read-room]
status: draft
---
Most grading products are a vibe wearing a letter. Here is what ours actually does.
## The pipeline, start to finish
Grades on VYNDR are produced by a scheduled pipeline, several times a day. Nothing is graded on demand, and nothing is graded twice at two different lines without both showing on the record.
The run starts with the current prop lines from sportsbook feeds — real numbers, captured with a timestamp. For each prop, the system builds a feature vector: recent form (last-5 and last-20 averages, and the variance between them), opponent strength against that exact stat, pace, home or away, rest days, schedule density, batter-versus-pitcher history for MLB, per-90 rates and expected-goals context for soccer.
Then the engine does the only thing that matters: it compares the model's projection to the posted line. In both directions. The over and the under are graded separately, and the side with more conviction survives. The output is an 11-step letter, F through A+, with a confidence score behind it.
## What a letter means
A letter grade is a probabilistic assessment of one side of one line at one moment. An A does not mean the bet wins. It means the gap between our projection and the book's number was wide, in our favor, at the timestamp on the entry.
The model is wrong regularly. Our public ledger shows exactly how regularly, misses listed by name in the same format as the wins. That is not a disclaimer we were forced to write. It is the design.
## The part nobody else advertises: refusals
If the pipeline cannot build a real projection — not enough recent games, no baseline rate, a data source down — the model refuses to grade. No letter renders. The prop shows an absent state, and a free read is not consumed.
This matters more than it sounds. A grade with nothing behind it still looks like a grade. It would fill the page, and it would be a lie with good typography. So the rule across the whole platform is simple: market numbers are captured, never computed. Model numbers are labeled as model output. Where the data is missing, the surface says less. Absent beats wrong.
## After the grade
Every grade is locked to the ledger at the moment it is made — line, odds, timestamp. After the games end, it settles against the real box score. Hit, miss, or push, recorded once. Alongside the result we track closing line value: whether the market moved toward our side or away from it between our timestamp and the close. That number, not the win-loss column, is the standard evidence of real edge.
And no percentage renders anywhere on VYNDR until at least 20 grades have settled. Below that the surface says RECORD BUILDING, because a percentage on 6 settles is a marketing number, and we do not publish marketing numbers.
The full methodology lives at vyndr.app/methodology. The record lives at vyndr.app/ledger. Draw your own conclusion.
+37
View File
@@ -0,0 +1,37 @@
---
title: "The VYNDR Originals: 41 Archetypes and Why They Exist"
slug: the-vyndr-originals
excerpt: TORCH, BOMBER, GHOST, CONDUCTOR. What the archetype system is, how a player earns one, and why it changes which props you should trust.
tags: [archetypes, the-read-room]
status: draft
---
Every player profile on VYNDR carries a word like TORCH or BOMBER. This is not decoration. It is the compressed answer to a question the box score never asks: how does this player produce, and which of his props can you actually trust.
## What an archetype is
An archetype is a classification of production pattern, scored from a player's real season statistics. There are 41 VYNDR Originals across four sports — 15 for the NBA, 5 built specifically for the WNBA, 15 for MLB, and 6 for soccer. A player earns one by his numbers, not by reputation, and he re-earns it as the season moves. Nothing is assigned once and left to rot.
A few examples, so the vocabulary means something:
- **TORCH** — high-usage, shot-dependent scorer. His points track his touches almost one to one.
- **CONDUCTOR** — the offense runs through him, for other people. Assists are the stable prop; his own scoring swings with shot selection.
- **BOMBER** — the power bat. Home runs and total bases behave differently for him than for a contact hitter, and his strikeout risk rides along with the power.
- **GHOST** — the speed threat. He beats you on the bases, in the gaps, on the margins the slow numbers miss.
- **SURGE** — the usage sponge. His baseline is ordinary until a star sits, and then his points prop is a different prop entirely.
## Why it changes the read
Different archetypes have different reliable props and different volatile ones. Points for a TORCH is a stable leg because it is tied to a stable input, his usage. Assists for that same player float with game script. For a CONDUCTOR it is exactly reversed.
This is the practical content of the system: a 7.5 assist line means one thing hanging on a CONDUCTOR and another thing entirely hanging on a scorer who happened to have two good passing nights. Same number, different prop. The archetype tells you which one you are looking at.
Most players are not one thing, so a classification carries a primary archetype, an optional secondary, and a weighted blend. A player can be 70 percent TORCH and 30 percent CONDUCTOR, and his prop behavior will sit exactly where that mix predicts.
## Where it comes from
The classification runs on the same stats pipeline that feeds the grades — season averages, usage, rate stats, all from league data. When the model grades a prop for a BOMBER, the archetype is context the engine already holds, not a label bolted on afterward.
The names are ours. The industry has spent decades on labels like "volume scorer" and "floor general," which describe a player politely and tell you nothing about his prop sheet. The VYNDR Originals were named for the thing that matters: what the production pattern does to the numbers you are about to bet on.
Learn the vocabulary once and the slate starts reading differently. That is the point.
+31
View File
@@ -0,0 +1,31 @@
---
title: "CLV: The Only Number That Proves an Edge"
slug: what-clv-is
excerpt: Closing line value in plain language. Why beating the close matters more than winning the bet, and how VYNDR captures it on every grade.
tags: [clv, methodology, the-read-room]
status: draft
---
You can win a bet with a bad read and lose one with a great read. That is variance, and it is why the win-loss column — the number everyone stares at — is the slowest, noisiest possible way to judge a model. There is a faster, cleaner number. The industry calls it CLV: closing line value.
## The idea in one paragraph
The closing line is the book's number at the moment the game starts. It is the sharpest price of the day, because by then the market has absorbed every injury report, every lineup, and every dollar of smart money. If you consistently bet numbers that are better than where the line closes, you were consistently ahead of the market's final opinion — and over time, that is what winning is made of. Beating the close is the standard evidence of real edge. One result proves nothing. A season of positive CLV proves the process.
## A concrete example
VYNDR grades a total-bases over at a line of 1.5, odds -120, at 2:14 PM. By first pitch the same over is priced at -145. The market spent the afternoon agreeing with the read — it moved 25 cents toward our side after we posted. That is positive CLV, and it is positive whether the player finishes with 3 total bases or 0.
Now reverse it. The read settles as a win, but the close drifted to -105 — the market moved away from us and we happened to cash anyway. The result column says we were right. The CLV column says we were lucky. Over 20 bets you cannot tell those apart by results. Over 200, CLV has already told you.
## How VYNDR captures it
Every grade is locked to the ledger at grade time: line, odds, timestamp. From then until the game starts, the pipeline keeps re-capturing the book's current number on every pass, and the last capture before first pitch stands as the close. Real numbers, from the feed, at a timestamp — never estimated, never backfilled.
At settlement the ledger computes CLV signed by side: for an over, a close that moved lower means the market came toward the graded side, which counts as beating the close. For an under, the inverse. Each entry records it, and the aggregate renders only after the same rule every percentage on VYNDR obeys — at least 20 settled entries, or the surface says RECORD BUILDING.
## Why we lead with it
A results record can be dressed up. A timestamped CLV record cannot — it is the market itself grading our timing, entry by entry, in public. When the line moves three minutes after the wire posts, someone is watching. Good.
If you take one habit from this piece: judge any model — ours included — by whether it beats the close over a real sample. Everything else is a story about a hot week.
@@ -0,0 +1,33 @@
---
title: Why Our Misses Are Public
slug: why-our-misses-are-public
excerpt: Every miss stays on the Ledger, by name, in the same format as the wins, permanently. The two reasons, and why nobody else in the industry can copy this.
tags: [the-ledger, methodology]
status: draft
---
Open the VYNDR Ledger on any morning and you will find last night's misses listed by name, in the same format, the same placement, and the same energy as the wins. Wilson over 23.5 settled at 19. It is on the record. It stays on the record.
This is the strangest thing about the product to people who know the industry, so it deserves a straight explanation.
## Reason one: a curated record is not a record
The entire value of a grading model rests on its record being real. Not the highlight reel — the record. The moment you delete one bad night, the whole thing becomes marketing, and every number on the page inherits the doubt.
So the mechanics make curation impossible rather than merely discouraged. Every grade is written to the ledger at the moment it is made, locked with the line, the odds, and a timestamp. Settled entries are never edited and never deleted. If the market moves hard against a grade during the day and the model revises, the original letter stays on the record, struck through, next to the revision. Nothing is revised silently.
And no percentage renders anywhere until at least 20 grades have settled. A hot 5-1 start is a coin flip wearing a suit. We do not publish it.
## Reason two: nobody else can afford to
The standard industry model is a performance. Post the wins loud, let the losses expire in the feed, and rebuild the persona every time the variance runs cold. It works, for a while, because most audiences never audit.
That model has one structural weakness: it cannot survive a public, timestamped, append-only record. Ours is built on one. Publishing the misses is not bravery — it is the one competitive position the performance accounts cannot follow us into. Their numbers do not survive daylight. Ours are built to live in it.
## What a miss actually is
A miss is not a scandal. The model grades probabilistically, which means it is wrong regularly, on purpose, as a condition of being right more often than the market's price implies. A 60 percent proposition misses 4 times in 10. The question that matters is not whether misses happen — it is whether the process that produced them was sound, and the number that answers that is closing line value, tracked on every entry, not the win-loss column.
When a high-profile read misses, we post it the same way we post everything: the number, the reason the read existed, and the result. No apology thread. No celebration either. The format is the apology and the flex at once.
The record talks. We don't have to.
+34
View File
@@ -0,0 +1,34 @@
{
"_comment": "THE STARK LAYER library (VOICE v1.1). Curated by Kev + Claude, committed like code. Templates SELECT from these — they never generate wit. One line per post max, only beside a number. No exclamation points.",
"morning": [
"The model collects debts.",
"The slate doesn't know we're watching. It will.",
"Quants get floors. We got a terminal.",
"The board posted. So did we."
],
"settle_winning": [
"The Ledger doesn't gloat. It just doesn't delete.",
"Same format tomorrow, whatever it says.",
"The record talks. We just post it."
],
"settle_losing": [
"It's on the Ledger like everything else.",
"Same format as the wins. That's the point.",
"The model logs its misses. The industry deletes theirs."
],
"steam": [
"Someone's watching. Good.",
"The market chased the read.",
"Three minutes after the wire posted. Noted."
],
"streak": [
"Most hot streaks are soft schedules wearing a costume.",
"The lens checks what the streak was built on. Calendars lie.",
"A streak is a claim. The matchup is the cross-examination."
],
"identity": [
"One terminal, built in Detroit.",
"Garage-built. Out-reading the tower.",
"The books employ floors full of quants. Seems fair."
]
}
+66
View File
@@ -0,0 +1,66 @@
# Ghost Publishing Runbook — blog.vyndr.app
How to move the seed articles in `content/articles/` into Ghost as drafts.
Manual by design: **nothing auto-posts, anywhere, ever** (NORTH STAR — Kev is
the publisher of everything). Claude Code has no Ghost credentials and never
touches the Ghost admin; this runbook is the entire publishing pipeline.
## What lives where
- Source of truth: `content/articles/*.md` in this repo. Edit here first,
then re-paste into Ghost — never edit only in Ghost, or the repo copy rots.
- Each file has YAML front matter (`title`, `slug`, `excerpt`, `tags`,
`status: draft`). The front matter is metadata for YOU to key into Ghost's
fields — do not paste the front matter block into the post body.
## Per-article steps (about 3 minutes each)
1. Open `https://blog.vyndr.app/ghost/` and sign in (your credentials).
2. Click **New post**.
3. Title: copy the `title` from the front matter into the title field.
4. Body: in the editor type `/` and choose the **Markdown** card (or type
```/markdown```). Paste everything BELOW the closing `---` of the front
matter into the card. Ghost renders the `##` headings, bold, and lists.
- Do not paste into the plain editor surface — straight pasting can
flatten headings depending on Ghost version. The Markdown card is exact.
5. Open **Post settings** (gear icon, top right):
- **Post URL**: set to the front-matter `slug` (e.g. `what-clv-is`).
- **Excerpt**: paste the front-matter `excerpt`.
- **Tags**: add the front-matter tags (e.g. `methodology`, `the-read-room`).
6. Leave the post as a **draft** — do NOT click Publish during seeding.
7. Preview in Ghost. Checklist before it ever goes live:
- Zero exclamation points (VOICE v1.1 — deadpan, no exceptions).
- No banned vocabulary: "lock", "tail", "fade" as calls to action,
guarantee language, hype emoji. If it sounds like the industry, cut it.
- Every record/percentage claim obeys the product's n≥20 gate — if the
live Ledger cannot back a number in the article, remove the number.
- Links point at `vyndr.app/...` production paths (`/methodology`,
`/ledger`), not localhost or staging.
## The five seed articles (in publish order)
| File | Title |
| --- | --- |
| `how-vyndr-grades-a-prop.md` | How VYNDR Grades a Prop |
| `why-our-misses-are-public.md` | Why Our Misses Are Public |
| `what-clv-is.md` | CLV: The Only Number That Proves an Edge |
| `the-vyndr-originals.md` | The VYNDR Originals: 41 Archetypes and Why They Exist |
| `how-streaks-lie.md` | How Streaks Lie |
Suggested cadence: one per week, methodology first (it is the page every
other article links back to conceptually). Publishing is your call, on your
schedule — this table is an ordering, not a scheduler.
## Updating a published article
1. Edit the markdown in `content/articles/` and commit.
2. In Ghost, open the post, delete the old Markdown card, insert a fresh one,
paste the updated body. Keep the same Post URL — never change a slug after
publish (dead links).
## Explicitly out of scope
- No Ghost Admin API integration, no automation, no scheduled posts. If that
ever changes it goes through a spec in `specs/` first and Kev still presses
the button.
- No exclamation points. Still.
+112
View File
@@ -0,0 +1,112 @@
# THE VYNDR REPORT — Listmonk runbook (Session S7, a1)
The daily newsletter: the slate, the signals, the settle. Assembly is code
(`src/services/newsletterService.js` — numbers only from the pipeline, VOICE
v1.1, RG footer baked in). Delivery is Listmonk, self-hosted on the same
Hetzner box — zero out-of-pocket. Without the env vars below, every
newsletter path is a graceful no-op: the subscribe endpoint answers
`{ ok: false, reason: 'not configured' }` and the capture UI says
"Signups open soon."
Nothing is scheduled. The send is operator-triggered via the internal route
until Kev arms a cron.
## 1. Deploy Listmonk on the box (Coolify)
Listmonk is a single Go binary + Postgres. In Coolify: new service →
Docker image `listmonk/listmonk:latest` + a Postgres 16 resource
(or reuse an existing Postgres with a fresh `listmonk` database).
Minimum container env:
```
LISTMONK_app__address=0.0.0.0:9000
LISTMONK_db__host=<postgres host>
LISTMONK_db__port=5432
LISTMONK_db__user=listmonk
LISTMONK_db__password=<generate>
LISTMONK_db__database=listmonk
```
First boot needs the schema install: run `./listmonk --install` once
(Coolify: one-off command on the container), then start normally. Log in at
the service URL, change the default `listmonk` admin password immediately.
Configure SMTP under Settings → SMTP (any relay you already have; without a
working SMTP block the double-opt-in confirmations never leave the box —
test this first).
## 2. Create the list
Admin UI → Lists → New:
- Name: `THE VYNDR REPORT`
- Type: **private**
- Opt-in: **double** ← required. The API subscribe call sends
`preconfirm_subscriptions: false`; Listmonk then emails the confirmation.
Nothing lands on the list until the reader clicks it.
Note the numeric list id shown in the UI/URL — that is `LISTMONK_LIST_ID`.
## 3. Create an API user (never use the admin login for the app)
Admin UI → Settings → Users → New:
- Username: `vyndr-api`
- Type: API user → Listmonk generates a token. Copy it once; it is not shown again.
- Role: needs `subscribers:manage` + `campaigns:manage` + `campaigns:get`
(or a role scoped to exactly those).
The app authenticates with `Authorization: token vyndr-api:<token>`.
## 4. Env vars for the VYNDR backend (Coolify → the Express app)
```
LISTMONK_URL=http://<listmonk container name>:9000 # same box — use the internal network, not the public URL
LISTMONK_USER=vyndr-api
LISTMONK_TOKEN=<the API token from step 3>
LISTMONK_LIST_ID=<numeric id from step 2>
```
All four are required; any missing → the whole layer no-ops (deploy-safe).
## 5. Template note — one-click unsubscribe
The assembled email embeds the literal `{{ UnsubscribeURL }}` placeholder in
its RG footer. Listmonk substitutes it per-recipient at send time — do not
"fix" it in the service. The RG footer (21+, 1-800-GAMBLER, unsubscribe note)
is baked into `newsletterService` and asserted by
`tests/unit/newsletterService.test.js`; every send carries it.
## 6. Test-send steps
1. Subscribe yourself through the product: the landing-page capture (or
`curl -X POST https://vyndr.app/api/newsletter/subscribe -H 'Content-Type: application/json' -d '{"email":"kevdevelops@gmail.com"}'`).
2. Confirm the double-opt-in email arrives and click it — you should show as
`confirmed` on the list in the Listmonk UI.
3. Fire a real send (internal key, operator-triggered):
```
curl -X POST https://<backend>/api/internal/newsletter/send \
-H "x-internal-key: $VYNDR_INTERNAL_KEY" \
-H 'Content-Type: application/json' \
-d '{"sports":["mlb","wnba"]}'
```
Responses:
- `{ ok: true, campaignId, subject, counts }` — sent; check the inbox +
Listmonk → Campaigns for delivery stats.
- `{ ok: false, reason: 'empty report' }` — zero signals AND zero streaks at
send time (off-hours / cold caches). Deliberate: an empty email never sends.
Run after the morning snapshot (14:00 UTC) so the slate is graded.
- `{ ok: false, reason: 'not configured' }` — env missing on the backend.
4. Verify the received email: mono layout, signals match `/api/snapshot/mlb`,
the record line matches `/api/ledger/accuracy` gating (a percentage only at
n≥20, otherwise `RECORD BUILDING · N pending`), RG footer present, and the
unsubscribe link is a working per-recipient Listmonk URL.
## 7. Scheduling (NOT armed)
When Kev decides to arm the daily send, point an n8n cron (or the box
crontab) at the internal route above — e.g. daily at 15:30 UTC, after the
14:00 UTC snapshot has graded the slate. There is deliberately no in-process
scheduler for this; email to subscribers stays operator-controlled until then.
+106
View File
@@ -0,0 +1,106 @@
# VYNDR OPS RUNBOOK — the product watches itself (Session 8, A1 board)
The in-app half (settle alarms, failure pager, quota check, box health, daily
pulse) lives in `src/snapshotScheduler.js` + `src/services/opsWatch.js` +
`src/services/systemHealth.js` and pushes to ntfy via `src/utils/opsNotify.js`.
This runbook is the BOX-SIDE half: the monitors and hooks that catch the cases
the app cannot report on itself — a dead container, a failed deploy, a dead box.
Channel: `https://ntfy.sh/vyndr-pipeline-kev2026`
(override with `NTFY_URL` / `NTFY_TOPIC`; both default in `opsNotify.js`).
Zero out-of-pocket: ntfy.sh is free, Uptime Kuma is already on the box.
---
## 1. Phone subscription (do this first — alerts nobody reads are logs)
Android / iOS:
1. Install **ntfy** (Play Store, App Store, or F-Droid).
2. Open the app → **+ Subscribe to topic**.
3. Server: `https://ntfy.sh` (default). Topic: `vyndr-pipeline-kev2026`.
4. Android: in the subscription's settings enable **Instant delivery**
(foreground service) and exempt ntfy from battery optimization
(Settings → Apps → ntfy → Battery → Unrestricted). Without this,
Android can delay high-priority pages by hours.
5. Send yourself a probe and confirm the phone buzzes:
`curl -s -d "probe" https://ntfy.sh/vyndr-pipeline-kev2026`
Desktop (optional): open `https://ntfy.sh/vyndr-pipeline-kev2026` in a browser
tab and allow notifications.
Note: the topic name is the only secret. Anyone who knows it can read and
write the channel. If it ever leaks, rotate via `NTFY_TOPIC` on the container
and re-subscribe the phone.
## 2. Uptime Kuma monitors (external heartbeat — catches a dead container)
Uptime Kuma already runs on the box. Add THREE monitors
(**Add New Monitor** for each):
| # | Name | Type | URL | Pass condition | Interval |
|---|------|------|-----|----------------|----------|
| 1 | `vyndr web` | HTTP(s) | `https://vyndr.app` | status 200 | 60s, retries 3 |
| 2 | `vyndr api health` | HTTP(s) — **Keyword** | `https://api.vyndr.app/api/health` | keyword `healthy` present | 60s, retries 3 |
| 3 | `vyndr snapshot summary` | HTTP(s) | `https://api.vyndr.app/api/snapshot/summary` | status 200 | 300s, retries 3 |
Why keyword on #2: `/api/health` answers 200 `"status":"healthy"` only when
Redis AND Supabase check out; degraded mode returns 503 `"degraded"`. The
keyword check fails on BOTH a dead container and a degraded one.
Monitor #3 is the public cache-only snapshot read — it proves the pipeline's
output surface is serving, and it can never drain provider quota.
Wire Kuma to the pager:
1. **Settings → Notifications → Setup Notification → ntfy**.
2. Server URL `https://ntfy.sh`, topic `vyndr-pipeline-kev2026`,
priority: default 5 (Kuma sends its own priority on down events).
3. Check **Default enabled** so future monitors inherit it, then attach the
notification to all three monitors above.
## 3. Coolify deploy-failure -> ntfy
Coolify owns deploys, so the app can't report its own failed deploy. Two
options, in order of preference:
**A. Native notification channel (Coolify v4):**
1. Coolify → **Notifications** (team level).
2. If your Coolify version lists **ntfy**: server `https://ntfy.sh`, topic
`vyndr-pipeline-kev2026`, enable only the **Deployment Failed** (and
optionally **Deployment Success**) events.
3. If it doesn't list ntfy, use the generic **Webhook** channel pointed at
`https://ntfy.sh/vyndr-pipeline-kev2026`. ntfy accepts any POST body as
the message text — a JSON payload arrives readable, just ugly.
**B. Per-app webhook (any Coolify version):**
1. App → **Webhooks** → add `https://ntfy.sh/vyndr-pipeline-kev2026` for the
deployment-failed event.
2. Verify by triggering a deploy of a branch that fails its build; the phone
should receive the payload within seconds.
Either way, send one manual probe to prove the path before trusting it:
`curl -s -d "coolify webhook path test" https://ntfy.sh/vyndr-pipeline-kev2026`
## 4. What the app already pages on its own (for reference — do not duplicate)
| Alert | Source | Priority |
|-------|--------|----------|
| Snapshot success / 0-props / hard failure | `snapshotService` (S56) | default / low / high |
| Missed cron slot (per-minute watchdog) | `snapshotScheduler` (S56) | high |
| Settlement pass THREW (outcomes or ledger) | `snapshotScheduler` (S8) | high |
| Morning settle closed with 0 settles while Postgres held pre-today pending rows | `snapshotScheduler` + `opsWatch.zeroSettleAlarm` (S8) | high |
| Sport produced nothing 3 consecutive slots | `opsWatch.createFailureTracker` (S8) | high |
| odds-api >= 80% quota (once per day) | `opsWatch.checkQuotaDaily` (S8) | high |
| Disk > 85% or memory > 90% | `systemHealth` via daily pulse (S8) | high |
| Daily pulse, 9 AM ET (13:00 UTC, `PULSE_HOUR_UTC` to move) | `snapshotScheduler` (S8) | default |
Kill switches: `PIPELINE_ALERTS=0` silences everything app-side;
`NODE_ENV=test` is silent by design.
## 5. Weekly 5-minute sanity pass
1. Phone got yesterday's 9 AM pulse. If not: check `ops:pulse:*` in Redis and
the container log for `[opsWatch] armed`.
2. `GET /api/internal/snapshot/status` (header `x-internal-key`) shows
`cron_armed: true`, fresh `last_snapshot`, `overdue: false`.
3. Uptime Kuma dashboard: three greens.
4. Disk line in the pulse trending up week over week means log rotation or
Redis growth needs a look before the 85% page ever fires.
+119
View File
@@ -0,0 +1,119 @@
# VYNDR Partner + Affiliate Plumbing (A1 Session 3)
Everything here ships DISABLED / organic by default. No affiliate program is
approved yet; nothing spends money and no link carries tracking params until an
operator flips config. Data semantics apply to attribution too — the report
endpoint returns zeros before it returns invented numbers.
## 1. Partner ref links
A partner shares:
```
https://vyndr.app/?ref=CODE
```
- `CODE` — A-Z 0-9 dash underscore, max 32 chars, case-insensitive
(canonicalized to UPPERCASE everywhere).
- On first visit `PartnerRefCapture` (mounted in the layout) sets the
first-party cookie `vyndr_ref` for 90 days (SameSite=Lax, Path=/).
- Attribution is FIRST TOUCH: an existing cookie is never overwritten.
- On email/password signup, `AuthContext.signUp` forwards the cookie value
into Supabase signup metadata as `partner_ref`
(`auth.users.raw_user_meta_data.partner_ref`).
- Known limitation: OAuth signups (Google, etc.) do not carry signup
metadata — those signups are not attributed yet.
## 2. Partner report endpoint (internal)
```
GET /api/partners/report/:code
Header: x-internal-key: $VYNDR_INTERNAL_KEY
```
Response:
```json
{ "ok": true, "code": "CODE", "signups": 0, "conversions": 0, "mrr_attributed": 0 }
```
- `signups` — user_profiles rows with `partner_ref = CODE`
- `conversions` — those currently `subscription_status = 'active'` on a paid tier
- `mrr_attributed` — sum of the real monthly price per active profile
(analyst 19.99 / 14.99 founder, desk 49.99 / 34.99 founder)
### TODO migration (documented, NOT run)
`user_profiles` has no `partner_ref` column today, and signup metadata lives in
`auth.users.raw_user_meta_data`, which PostgREST cannot query. Until the
migration below is applied, the endpoint returns zeros with a `note`. Once
applied, real numbers flow with no code change.
```sql
-- TODO migration: 021_partner_ref.sql (do not run until reviewed)
alter table public.user_profiles
add column if not exists partner_ref text;
create index if not exists idx_user_profiles_partner_ref
on public.user_profiles(partner_ref)
where partner_ref is not null;
-- Copy the signup metadata field on profile creation
-- (extend the existing handle_new_user trigger):
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.user_profiles (id, email, partner_ref)
values (
new.id,
new.email,
upper(nullif(new.raw_user_meta_data->>'partner_ref', ''))
)
on conflict (id) do nothing;
return new;
end;
$$ language plpgsql security definer;
-- One-time backfill for signups that predate the column:
update public.user_profiles p
set partner_ref = upper(u.raw_user_meta_data->>'partner_ref')
from auth.users u
where u.id = p.id
and p.partner_ref is null
and nullif(u.raw_user_meta_data->>'partner_ref', '') is not null;
```
Note: migration 004 (`referral_codes` / `referral_conversions`) is the separate
USER-referral system (codes owned by auth users). Partner codes are external
strings and deliberately do not depend on those tables.
## 3. Stripe promo-code mapping convention
One code, three systems, zero lookup tables:
- The partner's ref code IS their Stripe promotion code, verbatim.
Partner "HOOPSPOD" → `?ref=HOOPSPOD` link → Stripe promotion code
`HOOPSPOD` on checkout.
- Codes are created in Stripe as promotion codes on a shared partner coupon;
set promotion code metadata `partner: <CODE>` so Stripe reporting can be
cross-checked against `/api/partners/report/:code`.
- Cookie-attributed signups and promo-code redemptions are reconciled by the
shared code string — a signup that used the promo but arrived without the
cookie still attributes through Stripe's side.
- Founder codes (VYNDR, BETONBLK, `FOUNDER_CODE_EXPIRY` in stripeService) are
NOT partner codes — do not issue a partner code that collides with a founder
code.
## 4. Sportsbook affiliate config (BOOK IT deep links)
- `web/src/lib/bookLinks.js` builds every sportsbook deep link (StatStrip
BOOK IT + scan hand-off). Organic shape: `https://{host}/?search={player}`.
- `web/src/lib/affiliateConfig.js` is the single flip point: per book
`{ enabled: false, params: {} }`. Param shapes (Impact: irclickid /
sharedid / wpsrc; Partnerize-style: btag / afid / siteid) are documented in
that file. Empty param values are skipped — a half-filled config degrades to
organic, never a fabricated id.
- Every book anchor renders `rel="sponsored noopener noreferrer"`
(`BOOK_LINK_REL`) — required for affiliate compliance, harmless organic.
- BetRivers accepts an optional two-letter `state` for its state subdomain
(`mi.betrivers.com`); anything else falls back to `www`.
+115
View File
@@ -21,6 +21,7 @@
"postgres": "^3.4.8",
"sharp": "^0.34.5",
"stripe": "^20.4.1",
"tesseract.js": "^7.0.0",
"web-push": "^3.6.7"
},
"devDependencies": {
@@ -2540,6 +2541,12 @@
"node": ">=6.0.0"
}
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
@@ -4043,6 +4050,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/idb-keyval": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz",
"integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==",
"license": "Apache-2.0"
},
"node_modules/import-in-the-middle": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.2.tgz",
@@ -4185,6 +4198,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -5230,6 +5249,26 @@
"node": ">= 0.6"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -5337,6 +5376,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -5730,6 +5778,12 @@
"node": ">=4"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -6365,6 +6419,30 @@
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -6442,6 +6520,12 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -6609,6 +6693,12 @@
"makeerror": "1.0.12"
}
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
@@ -6628,6 +6718,12 @@
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -6662,6 +6758,16 @@
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6917,6 +7023,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
}
}
}
+7
View File
@@ -40,10 +40,17 @@
"postgres": "^3.4.8",
"sharp": "^0.34.5",
"stripe": "^20.4.1",
"tesseract.js": "^7.0.0",
"web-push": "^3.6.7"
},
"devDependencies": {
"jest": "^30.3.0",
"supertest": "^7.2.2"
},
"jest": {
"testPathIgnorePatterns": [
"/node_modules/",
"/.claude/"
]
}
}
+91
View File
@@ -0,0 +1,91 @@
# VYNDR — VOICE.md v1.1
### The house voice. Commit to /specs. Templates obey it; Kev applies it directly. v1.1 adds THE STARK LAYER.
## THE CONCEPT: THE WIRE
Every betting account performs emotion about numbers. VYNDR takes the empty lane: **numbers with no performance at all** — a wire service, an intelligence desk with a public feed, a Bloomberg terminal that got a Twitter account. The confidence lives in the deadpan; excitement is what people do when they're not sure.
## SEVEN LAWS
1. **The record talks. We don't.** Numbers first. A post that needs "insane" doesn't have the numbers. Ours do.
2. **Deadpan is the brand.** No exclamation points, ever. Zero exceptions.
3. **Misses lead.** The Settle posts losses in the same format, placement, and energy as wins. Nobody else in the industry can afford this. That's why we do it.
4. **Everything is a receipt.** Timestamps on every claim; every read settles in public. Post the read pre-game, the result post-game, and let the gap do the marketing.
5. **Our vocabulary, never theirs.** Reads, not picks. The slate. The settle. The Ledger. The wire. Signals. Steam. BOMBER, GHOST, TORCH. Speak VYNDR until the audience does — the day a stranger asks "what's the read on Judge," the brand walks on its own.
6. **The adversary is the house.** Never bettors, never other cappers, no feuds, no names. Wire services don't argue with bloggers.
7. **Teach in public.** Read Room threads make the audience sharper. Sharper audiences stay.
## THE STARK LAYER (v1.1)
The wire stays deadpan — the Stark layer is what the deadpan is allowed to smirk about. A mastermind runs this desk and occasionally lets it show.
- **Wit budget: one dry line per post, maximum, optional** — and only in a post that already carries a number. The joke never travels without a receipt.
- **Swagger targets the machine or the house.** "We built this" energy, engineering pride, playful menace at the books. Never at bettors, never at cappers, never down.
- **The flex is capability stated as fact,** followed by a shrug. Not "we're the best" — "here's the number, draw your own conclusion."
- **Still no exclamation points.** Stark doesn't shout. He raises an eyebrow.
- **Identity beat (use sparingly):** one terminal, built in Detroit, against floors full of quants. Garage-built and out-reading the tower is the whole mythology.
## STYLE MECHANICS
Short declaratives. Line breaks are the rhythm section. A number in every post — no number, not a VYNDR post. ET timestamps on anything time-sensitive ("Posted 2:14 PM" is a flex no emoji matches). Sentence case; all-caps only for format headers (THE WIRE, THE SETTLE). Emoji budget: ✅ ❌ in results only, as data marks. ▲▼ for movement, o/u for sides. Aligned short lines over prose. Never apologize for a miss; never celebrate one. State it — the format is the apology and the flex at once.
## BANNED — TEMPLATE LINTING ASSERTS THESE NEVER APPEAR
"lock"/"locks"/🔒 · 🔥💰🚀💯 and all hype emoji · "we're so back"/"it's so over" · "tail"/"fade" as calls to action · slip-flex/cash-app energy · guarantee language ("can't lose," "free money" — also affiliate compliance) · fake urgency · engagement bait ("RT if…," "who's tailing") · all-caps hype · apology threads · dunking on losses · capper slang ("eating," "cooking," "GOAT"). If it sounds like the industry, it's banned.
## THE FORMATS
**THE MORNING WIRE** — slate brief: what's on, loudest signal, one lens observation, link.
**SIGNAL** — one read: player, stat, line, side, grade, archetype, one structural line, card attached.
**STREAK WATCH** — the streak + what it was built against + tonight's matchup. The lens is the content.
**THE SETTLE** — nightly: A-tier record, all-grades record, every miss by name. Same format forever.
**RECEIPTS** — timestamped pre-game post beside the close and the result. CLV in one image.
**ARCHETYPE WATCH** — "BOMBER watch: 3 on tonight's slate." Teaches the vocabulary while listing signals.
**THE READ ROOM** — weekly educational thread. One concept, plain language. (Freeform — written by Kev + Claude from the /desk data brief.)
**LINE DISPATCH** — steam/value as it happens, timestamped.
## EXAMPLES (calibration set — Stark layer active)
**Morning Wire:**
> THE WIRE — Sat, Jul 11
> 15 MLB. 3 WNBA.
> Loudest signal: a BOMBER read in a park that owes him one. The model collects debts.
> Slate's graded. 3 free reads a day.
> vyndr.app
**Signal:**
> Suzuki — Total Bases o1.5 · A
> TORCH read. Fourth straight soft right-hander; the lens says the streak is real, not schedule.
> Graded 2:41 PM ET at -118.
**The Settle:**
> THE SETTLE — Jul 11
> A-tier: 6-2
> All grades: 13-8
> Misses, by name: Wilson PTS o23.5 ❌ · Realmuto TB o1.5 ❌
> Both live on the Ledger. Permanently. That's the point.
> vyndr.app/ledger
**Receipt:**
> Posted 2:14 PM: Judge TB o1.5 · A · -120
> Close: -145. Market chased the read.
> Result: 2 TB ✅
> Line moved three minutes after the wire posted. Someone's watching. Good.
**Read Room (opener):**
> Most hot streaks are soft schedules wearing a costume.
> A 7-game hit streak against 5.2 ERA pitching isn't form — it's a calendar.
> Here's exactly how the trick works. Magicians hate this; so do the books:
**Identity post (rare, earned):**
> The books employ floors full of quants.
> We built one terminal in Detroit.
> A-tier last 30 days: 61%, misses included, receipts public.
> Seems fair.
**A high-profile miss, standalone:**
> Wilson o23.5 settled ❌. 19 points.
> The read was usage against a bottom-five defense; the minutes never came.
> It's on the Ledger like everything else.
## FOR THE TEMPLATE ENGINE (Session 4 notes)
- Formats above are deterministic templates filled from snapshot/ledger JSON — numbers come ONLY from the pipeline, never composed. (Data Semantics Rule extends to marketing.)
- Template linting enforces the banned list + no-exclamation rule at build time.
- Threads arrive in /desk pre-segmented ≤280 chars/tweet with per-tweet copy buttons.
- Record claims obey the product's n≥20 gate. The voice never brags on a sample the Ledger wouldn't render.
- Stark-layer lines live in a curated rotating library (written by Kev + Claude, committed like code) — templates select, never generate.
— VOICE v1.1 · the wire is open —
+40
View File
@@ -0,0 +1,40 @@
# VYNDR — NORTH STAR v1.1
### The map every session builds against. Lives in /specs. Updated only deliberately. (v1.1: kill list corrected per founder ruling; zero-out-of-pocket rule added; media model corrected.)
## WHAT VYNDR IS
A sports data intelligence platform. **FREE = the complete picture** — schedule, stats, game logs, streaks, hot/cold, matchup context, line comparison — always populated, always interpreted through the VYNDR lens. **PAID = the read** — grades, archetypes, correlation parlays, factor analysis, CLV intelligence: the complete synthesized picture behind the paywall. **PROOF = the Ledger** — the CLV-verified public record, misses included. Free answers "what's happening." Paid answers "what it means, all together, and what to do." The platform is NEVER empty at any hour.
## THE THREE ASSETS (protect above all features)
1. **The record** — time-gated moat; never fabricate, never curate, never render under n≥20 (enforced at the aggregate API).
2. **The dataset** — every settle trains the model; every scan is labeled data; nothing pollutes it (no anon writes, no fabricated values, refusals over hollow grades).
3. **The habit + audience** — daily free excellence, the wire (VOICE v1.1), Kev's media engine, partner network.
## THE FOUR STAGES
**Stage 1 — The Tool ($0→$100K/mo MRR).** Subscriptions; ~2,000 Desk-equivalent subs. Arcs 12 + the A1 board. Funds all reinvestment.
**Stage 2 — The Affiliate Layer.** Sportsbook CPA ($250500/depositing signup) via BOOK IT + best-line + editorial. The free tier becomes a profit center; at scale affiliate can out-earn subscriptions 3:1.
**Stage 3 — The Network ($10M→$100M ARR).** User ledgers: every bettor gets a CLV-verified public record — Strava for betting. Leaving VYNDR = abandoning your receipts. Needs capital + small team.
**Stage 4 — The Platform (the billions).** From strength, choose: B2B data/API licensing (Sportradar path, low regulatory drag) or Operator (contest rake, PrizePicks path, heavy regulatory lift, where $5B valuations live).
**Market honesty:** subscription tools alone exit at ~$80240M. The billions require ascending the stack. Tool = wedge, record = moat, audience = leverage.
## FREE-LAYER DOCTRINE
1. Free is genuinely excellent — never crippled (crippled free = no traffic = dead flywheel = no affiliate revenue).
2. The lens applies to free data; the GRADE and synthesis are paid.
3. The tease is structural: locked grade badges on free surfaces; 3 free reads/day.
4. Free must be a standalone daily habit: never empty, faster than ESPN, better organized than anyone.
5. Every free surface has a share path — free users are distribution.
6. Every free user has affiliate value independent of subscription intent.
## THE COMPANY SHAPE
Kev = media + partnerships, full time, publisher of everything (nothing auto-posts, anywhere, ever). The product self-operates (autonomy loop + ntfy alerting) and self-arms Kev daily (/desk copy-paste arsenal + DATA BRIEF for freeform writing with Claude on the Max sub). Claude Code = the build force; Claude in Chrome = ops verification runbooks; the Hetzner box runs everything — nothing depends on a local machine.
## OPERATING RULES (permanent)
- **ZERO OUT-OF-POCKET:** no new paid APIs, services, or dependencies. Existing infra + Max subscription only. Revenue unlocks upgrades — the reinvestment loop is the only budget (Kev's standing rule: money made reinvests into tech + tools).
- No pricing claim the product doesn't deliver; no feature the pricing page doesn't honestly describe.
- One meaning per color; all data in mono; glow = A-tier only; the dark terminal IS the brand.
- Absent beats wrong; refusal beats hollow; real-and-small beats fake-and-big.
- Density is unlimited under fixed grammar (ROW-GRAMMAR.md); mobile physics respected.
- The voice is VOICE v1.1 — the wire, deadpan, misses lead, Stark layer smirks only with a receipt. No exclamation points, anywhere, including the product UI.
- Killed permanently: AI chat assistant, light mode. Everything else earns scheduling, not exile.
- Responsible gaming built sincerely. The company that lasts is the one users don't resent in five years.
— NORTH STAR v1.1 · July 2026 —
+77
View File
@@ -0,0 +1,77 @@
# A1 Session 10 — Public Ledger Profiles v1
Stage-3 seed (NORTH STAR): every bettor gets a CLV-verified public record —
Strava for betting. v1 is the minimum honest version: a handle, one explicit
publish toggle, and the user's ENTIRE settled record on a public page.
## Operating rules
- Zero out-of-pocket. Existing Supabase + Express + Next only.
- PRIVATE BY DEFAULT. `published` defaults false; publishing is one explicit
toggle with copy that says exactly what it does.
- Same n-gating as the model record: no percentage under 20 settles
(`ledgerService.MIN_AGG_SAMPLE`).
- Data semantics: only real settled ledger rows. Nothing curated — a public
profile shows ALL settled reads including misses. That is the point.
- No existence leak: unknown handle and unpublished handle return the SAME
404 body.
## Data
Migration `supabase/migrations/022_public_profiles.sql` (committed, NOT
applied — the founder applies migrations):
```
public_profiles (
user_id uuid PK REFERENCES auth.users ON DELETE CASCADE,
handle text UNIQUE NOT NULL CHECK (handle ~ '^[a-z0-9_]{3,20}$'),
published boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
)
```
RLS: anon + authenticated SELECT where `published = true`; owner SELECTs own
row; NO client write policies — all writes via the service role.
## Endpoints (`src/routes/profiles.js`, mounted at /api/profiles)
- `GET /api/profiles/me` (requireAuth) → `{ profile: {handle, published,
created_at} | null }`.
- `POST /api/profiles/me` (requireAuth) → upsert own `{ handle, published }`.
Handle validated against `^[a-z0-9_]{3,20}$` (400 on invalid); a handle
owned by another user → 409. Service-role write.
- `GET /api/profiles/:handle` (public, 60/min) → published profile:
`{ handle, aggregate, entries, min_sample }` where
- `aggregate` = `ledgerService.getModelAggregate({ userId })` — a new
`userId` option swaps the `.is('user_id', null)` public scoping for
`.eq('user_id', uid)`. The public default is untouched.
- `entries` = that user's SETTLED rows, newest 50, same columns as
/api/ledger.
Unknown OR unpublished → 404 with the identical body (no existence leak).
Next proxies: `web/src/app/api/profiles/me/route.ts` (GET/POST, forwards
Authorization) and `web/src/app/api/profiles/[handle]/route.ts` (GET).
## Frontend
- `/u/[handle]` — PUBLIC (never gated). Server component shell with metadata
`CLV-verified record — @handle · VYNDR` + client content: record header
(hit% + beat-close% at n≥20, else RECORD BUILDING), settled rows with
outcome + CLV chips (ledger card patterns), OG card via
`opengraph-image.tsx` in the segment (Node runtime — NEVER edge, S53 rule).
- `/settings` PUBLIC PROFILE section: claim handle + publish toggle. Copy
makes public-by-choice explicit: "Publishing puts your ENTIRE settled
record on a public page — wins and misses. Private by default."
## Acceptance criteria
1. Migration committed, unapplied, documented in the report.
2. `getModelAggregate` default scope unchanged; `userId` scope unit-tested
both ways.
3. Unpublished and unknown handles return byte-identical 404 bodies.
4. Handle regex enforced at API AND database CHECK.
5. `/u/...` reachable anonymous; aggregate renders no % under 20 settles.
6. Full jest suite green; `web` build exit 0.
## Test plan
- `tests/unit/ledgerAggregateScope.test.js` — public vs userId scoping.
- `tests/integration/profilesRoutes.test.js` — handle validation, 409 on
taken handle, publish flow, 404-no-leak (mock supabase per
ledgerRoutes.test.js pattern).
- `tests/unit/publicProfilePage.test.js` — page/OG/settings source
assertions (metadata string, no edge runtime, RECORD BUILDING state,
private-by-default copy, /u stays ungated).
+72
View File
@@ -0,0 +1,72 @@
# A1 Session 3 — Affiliate + Partnership Plumbing
## Operating constraints
- ZERO out-of-pocket. No affiliate program is approved yet — everything ships
config-flip-ready but DISABLED / organic by default.
- Data semantics: never fabricate market values. A "best price" claim renders
only when genuinely multiple books post the same line — absent beats wrong.
- No exclamation points in any copy (VOICE v1.1).
## 1. BOOK IT deep-link builder
- `web/src/lib/bookLinks.js` (CommonJS, unit-testable)
- `SUPPORTED_BOOKS`: draftkings, fanduel, betmgm, caesars, betrivers
(hosts sourced from the existing SPORTSBOOKS list in scan/page.tsx +
lib/books.js brand map).
- `buildBookLink({ book, player, sport, state? }, config?) → { url, tracking } | null`
- Unknown book → null (absent beats wrong).
- Organic base: `https://{host}/?search={player}` (existing deep-link shape).
- Affiliate layer: reads `web/src/lib/affiliateConfig.js` — per book
`{ enabled: false, params: {} }` with Impact/Partnerize-style param
shapes documented in comments (irclickid / sharedid / afid / btag).
- `tracking: true` only when the book is enabled AND at least one
non-empty param was appended. Disabled → clean organic link.
- `BOOK_LINK_REL = 'sponsored noopener noreferrer'` — every book anchor
renders this rel.
- Wiring:
- `StatStrip.tsx` BookItTeaser → real `<a>` deep link (best-price book when
known, else the prop's book, else DraftKings). Organic until config flips.
- `scan/page.tsx` sportsbook hand-off anchors → `buildBookLink` +
`BOOK_LINK_REL` (replaces the local SPORTSBOOKS/deepLink pair).
## 2. Best-line highlight (cheap version)
- Finding (documented in the session report): the grouped odds proxy already
ships per-book rows to the browser as `lines[]` per player+stat;
`Slate.groupByGame`/`pickLine` collapse to ONE line and discard the rest.
- Ship: thread `books` (the raw `lines[]`) onto each PropRowProp;
`slateAdapter.detectBestBook(rows, side, refLine)` returns
`{ book, odds }` ONLY when ≥2 books post the SAME line for the side and
prices differ; `buildPlayerStripsFromProps` attaches `bestBook` + `book`
to each strip prop; StatStrip renders a subtle signal-green dot
(one meaning: best available price).
## 3. Partner ref system
- (a) `web/src/lib/partnerRef.js` (CommonJS) — parse `?ref=CODE`, sanitize
(AZ 09 - _, ≤32, uppercased), first-party cookie `vyndr_ref` (90d,
SameSite=Lax, Path=/), first-touch (never overwrites an existing cookie).
`components/vyndr/PartnerRefCapture.tsx` mounted in the layout
(GlobalHosts pattern).
- (b) `src/routes/partners.js``GET /api/partners/report/:code` behind
`requireInternalAuth``{ signups, conversions, mrr_attributed }` read
from `user_profiles` filtered by `partner_ref`. The column does NOT exist
yet: the endpoint degrades to zeros + a note, and the TODO migration is
documented in docs/PARTNERS.md (NOT run).
- (c) Signup: `AuthContext.signUp` forwards the `vyndr_ref` cookie into
Supabase signup metadata as `partner_ref`.
- (d) `docs/PARTNERS.md` — Stripe promo-code mapping convention + the
TODO migration.
## Acceptance criteria
- All book links organic by default; flipping `enabled` + params in
affiliateConfig produces a tracked URL with the params appended.
- Best-price dot never renders on single-book or unequal-line data.
- `?ref=CODE` first visit sets `vyndr_ref` for 90 days; signup metadata
carries `partner_ref`; report endpoint 401s without the internal key.
- Full `npx jest` green; `cd web && npx next build` exit 0.
## Test plan
- tests/unit/bookLinks.test.js — organic default, enabled param shapes,
aliases, betrivers state, unknown book, rel constant + source assertions.
- tests/unit/slateAdapterBestBook.test.js — detectBestBook honesty rules +
strip attachment.
- tests/unit/partnerRef.test.js — sanitize/parse/cookie/first-touch.
- tests/unit/partnersRoute.test.js — report math + internal-auth mount.
+110
View File
@@ -0,0 +1,110 @@
# A1 Session 9 — Slip Reader (zero-API OCR)
## What
Upload a sportsbook bet-slip screenshot. VYNDR reads it (Tesseract OCR,
self-hosted, free), extracts the legs, lets the user correct anything the
OCR was unsure about, grades each leg through the normal engine (refusals
apply), and hands the legs to the Parlay Lab. "VYNDR read my slip."
## Zero out-of-pocket
- OCR = `tesseract.js` (npm, Apache-2.0). Bundles the WASM core — no
external OCR service, no per-call cost. English traineddata (~11MB) is
fetched once on first recognize and cached on disk (`.tesseract-cache/`).
- No other new dependencies. Image decode for the acceptance harness uses
the existing `sharp` dependency.
## Data semantics
Extracted values are USER-SLIP values — the line/odds the user's book
printed on their slip, labeled as such in the UI. They are never written
into any market cache. Grading goes through the normal engine
(`POST /api/scan` per leg); insufficient-data refusals render honestly.
NEVER guess: a field the parser cannot read above threshold is returned
`null` and the leg carries `needs_review: true`. Absent beats wrong.
## Endpoints
### POST /api/slips/parse (Express) — auth required (`requireAuth`)
Body (JSON):
- `image`: base64 or data-URL image (≤ 4MB decoded), OR
- `text`: raw OCR/slip text (skips OCR — used by tests + paste-text path)
- `book` (optional hint): `draftkings | fanduel | betmgm | caesars`
Response 200:
```json
{
"book": "draftkings",
"legs": [
{
"player": "Aaron Judge", "player_key": "aaron judge",
"stat": "total_bases", "stat_label": "Total Bases",
"line": 1.5, "side": "over", "odds": -115,
"confidence": { "player": 0.9, "stat": 0.95, "line": 0.95, "side": 0.95, "odds": 0.9 },
"needs_review": false
}
],
"needs_review": false,
"source": "user_slip"
}
```
Errors: 400 (no image/text, image too large, undecodable), 401 (no auth),
429 (daily limit), 503 (OCR engine unavailable).
Rate limit: Redis counter `slips:{userId}:{YYYY-MM-DD}` (UTC day, 48h TTL),
free = 1/day, paid (analyst/desk) = 10/day. In-memory fallback when Redis
is degraded. 429 carries `used`/`limit`.
### Next proxy
`web/src/app/api/slips/parse/route.ts``${BACKEND_URL}/api/slips/parse`,
forwards the Authorization header (S25 rule).
## Parser design (`src/services/slipReader.js`)
- PURE text parsers per book layout: `parseDraftKings`, `parseFanDuel`,
`parseBetMGM`, `parseCaesars` + `detectBook(text)` + `parseSlipText(text,
bookHint)`. Unit-tested on realistic OCR-text fixtures. No I/O.
- Stat names normalize through `STAT_ALIASES` → the existing stat
vocabulary (`VALID_STAT_TYPES` in src/routes/scan.js / STAT_LABELS in
web/src/lib/gradeAdapter.js). Unknown stat → `stat: null`,
`needs_review: true`.
- Player names normalize through `src/utils/playerName` (`normalizeName`
display + `nameKey`).
- Side grammar per book: `Over 1.5` / `Under 1.5`, FanDuel `To Record 2+
Total Bases` (2+ → line 1.5, side over), `Alt` prefixes stripped.
- Per-field confidence in [0,1]; `CONFIDENCE_THRESHOLD = 0.6`. A field
below threshold is nulled and flags the leg `needs_review`.
- `recognizeImage(buffer)` lazy-requires tesseract.js (parsers stay
loadable in tests without WASM).
## Frontend `/slip` (gated route)
- Added to `GATED_ROUTES` in web/src/lib/routes.js.
- Upload or paste a screenshot → POST /api/slips/parse → editable leg rows
(each field pre-filled; needs_review fields amber). "User-slip values"
label on the extracted numbers.
- Confirm → each leg graded via existing `POST /api/scan` (per leg);
refusals render as "NO GRADE — insufficient data".
- "Add all to Parlay Lab" → `useParlay().addLeg` per graded leg.
- Shareable result card ("VYNDR read my slip") — DOM card in existing
VYNDR card styling + copy-to-clipboard share text. No new OG plumbing.
## Acceptance criteria
1. DK/FD/MGM/Caesars OCR-text fixtures parse to correct legs (player,
stat, line, side, odds) with confidences; garbage text → no legs, never
fabricated ones.
2. A leg with an unreadable field returns that field null +
`needs_review: true`.
3. Route: 401 unauthenticated; free tier blocked at 2nd parse in a day
(429); paid tier allows 10.
4. End-to-end: DK fixture → parse → normalized legs → the exact
`POST /api/scan` request shape (`{sport, player, stat, line,
direction}`). If a synthetic PNG can be OCR'd in this environment
(SVG→PNG via sharp → tesseract.js), run the image path too; otherwise
the parser acceptance stands, honestly labeled.
5. Full jest suite green; `web` build exit 0.
## Test plan
- `tests/unit/slipReader.test.js` — per-book fixtures, alias
normalization, threshold/needs_review, never-guess, detectBook.
- `tests/integration/slipsRoute.test.js` — auth 401, text-path parse,
free 1/day + paid 10/day limits, 4MB cap, bad payloads.
## Post-revenue upgrade
Vision-model slip reading (Claude/GPT-4V-class) replaces layout parsers —
logged in specs/vyndr-roadmap.md as a post-revenue item.
+84
View File
@@ -0,0 +1,84 @@
# Feature A1-S7 — Newsletter: THE VYNDR REPORT
## Goal
A daily email — the slate, the signals, the settle — for opted-in subscribers.
Zero out-of-pocket: Listmonk is self-hosted on the same Hetzner box. VOICE v1.1
governs every line. Nothing auto-posts; the send is operator-triggered through
an internal route until Kev arms a schedule.
## Endpoints
### POST /api/newsletter/subscribe (public, rate-limited 10/min per IP)
Body: `{ email: string, website?: string }` (`website` = honeypot; bots filled → silent `{ ok: true }`).
- Valid email + Listmonk env set → forward to Listmonk subscriber API with
`preconfirm_subscriptions: false` (Listmonk sends the double-opt-in
confirmation). Response: `{ ok: true }` (also on Listmonk 409 = already
subscribed — idempotent, no enumeration).
- Env missing → HTTP 200 `{ ok: false, reason: 'not configured' }` (the UI
says "signups open soon" — never a scary error).
- Invalid email → 400 `{ ok: false, error: 'Enter a valid email.' }`.
Env: `LISTMONK_URL`, `LISTMONK_USER`, `LISTMONK_TOKEN`, `LISTMONK_LIST_ID`.
Auth header: `Authorization: token USER:TOKEN` (Listmonk API-user scheme).
### POST /api/internal/newsletter/send (requireInternalAuth)
Body: `{ sports?: string[] }` (default `['mlb', 'wnba']`). Assembles the daily
report and creates + starts a Listmonk campaign. Returns the assembly summary.
Refuses (`ok: false, reason: 'empty report'`) when there are zero signals AND
zero streaks — an empty email never sends. NOT scheduled anywhere; the operator
(or a future n8n cron) calls this.
### Next proxy: web/src/app/api/newsletter/subscribe/route.ts
S25 rule — forwards POST to `${BACKEND_URL}/api/newsletter/subscribe`. On
upstream failure returns 200 `{ ok: false, reason: 'not configured' }` so the
visitor sees the graceful "soon" state.
## Data shapes
`newsletterService.buildDailyReport(sports, deps)``{ subject, html, text, counts: { signals, streaks, sports } }`
- Signals: `cacheGet('snapshot:{sport}:latest')` → grades, top 5 per sport by
confidence (A-tier first). Numbers only from the pipeline.
- STREAK WATCH: `loadRosterLogs``streaksService.computeStreaks`
`streakLens.applyLens` (lens ctx = cached schedule only; absent = say less).
Top 3 across sports by streak length.
- THE RECORD: `ledgerService.getModelAggregate()` — render the record line
ONLY when `hit_pct != null` (the n≥20 gate lives in the aggregate);
otherwise `RECORD BUILDING · N pending`.
- All deps injectable; unit tests use fixtures, no network, no Redis.
`sendDailyReport(opts)` — env-gated (`ok:false, reason:'not configured'`
without Listmonk env), POSTs `/api/campaigns` then PUT status `running`.
`fetchImpl` injectable.
## VOICE / compliance invariants (asserted by tests)
- No `!` anywhere in subject, html, or text.
- Banned vocabulary never appears: "lock", "tail"/"fade" as CTAs, hype emoji.
- Record percentage renders only under the n≥20 gate (hit_pct null → building line).
- RG footer in both html + text: 21+, 1-800-GAMBLER, unsubscribe note.
- One-click unsub is Listmonk-native: literal `{{ UnsubscribeURL }}` placeholder
in the template (Listmonk substitutes at send time).
## Frontend
`web/src/components/NewsletterCapture.tsx` — dark-terminal styled, mono data
font. Copy: "THE VYNDR REPORT — the slate, the signals, the settle. Daily.
Free." Double-opt-in note under the field. States: idle / busy / sent
("Confirmation sent. Check your inbox.") / soon ("Signups open soon.").
Mounted on the landing page (after FAQ, near the footer) and on the signup
success card (the free-signup success path).
## Acceptance criteria
1. POST /api/newsletter/subscribe with no env → 200 `{ ok:false, reason:'not configured' }`.
2. With env (mocked fetch) → Listmonk called with `preconfirm_subscriptions:false`, list id from env, `token` auth header; 409 treated as ok.
3. Invalid email → 400; honeypot → silent ok.
4. buildDailyReport output passes the VOICE lint (no `!`, no banned words) and includes the RG footer + `{{ UnsubscribeURL }}`.
5. Record line gated: hit_pct null → "RECORD BUILDING · N pending"; hit_pct present → the percentage renders.
6. sendDailyReport without env = no-op; empty report never sends.
7. Full `npx jest` green; `cd web && npx next build` exit 0.
## Test plan
- `tests/unit/newsletterService.test.js` — fixtures for snapshot grades,
roster logs, aggregate; VOICE lint; gates; RG footer; send no-op + campaign
create/start via injected fetch; empty-report refusal.
- `tests/integration/newsletterRoute.test.js` — supertest against app with
mocked global fetch + redis mock; ≤10 requests total (the 10/min limiter has
no reset hook — same lesson as analyze.test.js).
+83
View File
@@ -0,0 +1,83 @@
# Session 8 (A1 board) — Ops: the product watches itself
## Problem
The pipeline alerts on snapshot success/failure and missed cron slots (S56),
but the record can still die silently: a settlement pass that throws or
quietly settles nothing, a sport erroring slot after slot, quota creeping to
the block threshold, a full disk. Kev must be paged by the product, not
discover it days later.
## Scope (extends existing scheduler/services — nothing rebuilt)
1. **Settlement alarm** (`snapshotScheduler.js`)
- Settle pass (outcomes or ledger) THROWS -> ntfy priority `high`.
- Zero-settle alarm: at the MORNING slot only (first configured hour
>= 06 UTC; 14 UTC default = 10 AM ET, the first slot after overnight
completions), alert when the ledger settle pass found rows from before
today but settled none. **Chosen signal (documented): the ledger settle
pass's own return values** (`settleLedger` fetches
`game_date < today AND outcome IS NULL` from Postgres). It is the
cheapest (zero extra reads) and the most reliable: Postgres survives the
Redis TTL expiry that silently killed morning settles in Session 60 —
a `snapshot:{sport}:previous` signal would vanish in exactly that
failure mode. Scoped to SETTLEABLE sports (mlb) because NBA/WNBA/soccer
rows legitimately stay pending until their settle feed exists.
A genuinely empty yesterday fetches 0 rows -> no alarm. Deduped once
per ET date via Redis `ops:settle_zero:{date}`.
2. **Persistent snapshot failure** (`opsWatch.createFailureTracker`, pure)
- Per-sport consecutive count of bad outcomes (`status==='error'` or
`skipped` with reason `'no props'`) across cron slots (in-process Map).
- Pages ONCE (priority high) when a sport reaches 3 consecutive bad
slots; stays quiet at 4+; any good outcome resets and re-arms.
3. **Quota alert** (`opsWatch.checkQuotaDaily`)
- After each snapshot run: odds-api usage >= 80% -> one alert per day
(Redis dedupe `ops:quota_day:{provider}:{YYYY-MM-DD}`). Complements the
once-per-MONTH quotaTracker warn.
4. **Box health** (`src/services/systemHealth.js`, pure + injectable)
- `getSystemHealth({fsImpl, osImpl})` -> `{disk_pct, mem_pct}` via
`fs.promises.statfs('/')` + `os.freemem/totalmem`.
- `healthIssues()` flags disk > 85% / mem > 90%; checked in the daily
pulse; issues alert at priority high.
5. **Daily pulse** (scheduler, 13:00 UTC = 9 AM EDT, dedupe by date
in-process + Redis `ops:pulse:{date}`), ONE notification:
ledger rows written yesterday (Supabase count via
`ledgerService.countRowsForDate`, `n/a` when unconfigured), settles in
the last 24h (from `outcomes:{sport}:log`), odds-api quota pct,
disk/mem, `desk pack: see /desk` placeholder.
6. **Runbook**`docs/OPS-RUNBOOK.md`: Uptime Kuma monitors, Coolify
deploy-failure webhook -> ntfy, phone subscription steps.
## Data shapes
- `createFailureTracker(threshold=3).record(sport, result)` ->
`{ sport, count, shouldPage }`
- `zeroSettleAlarm(ledgerResults, settleable=['mlb'])` ->
`{ alarm, settled, pending }`
- `checkQuotaDaily(deps)` -> `{ alerted, deduped?, pct }`
- `getSystemHealth()` -> `{ disk_pct: 0-100|null, mem_pct: 0-100|null }`
- `buildPulseMessage(pieces)` -> single multi-line string, VOICE v1.1
(deadpan, numbers, zero exclamation points).
## Acceptance criteria
- Settle-pass throw -> one high-priority ntfy; zero-settle morning alarm
fires only when Postgres held pre-today unsettled rows; empty yesterday
never alerts.
- 3 consecutive bad slots page exactly once per losing streak; success
resets.
- Quota >= 80% alerts at most once per day.
- Pulse is ONE notification, fields present, `n/a` when Supabase off,
no `!` anywhere in any alert copy.
- One real test alert POSTed to the live ntfy channel (HTTP result in the
session report). Full jest green.
## Test plan
- `tests/unit/opsWatch.test.js` — tracker (increment/page-once/reset,
per-sport isolation), zero-settle signal, morning-hour resolution, quota
dedupe (per-day, injected now), pulse assembly (fields + no-`!` lint),
recent-settle counting.
- `tests/unit/systemHealth.test.js` — injected fs/os math, statfs failure
degrades to null, threshold flags at 85/90 boundaries.
- `tests/unit/opsScheduler.test.js` — scheduler wiring: settle-throw alert,
3-slot page via ticks, pulse fires once at 13:00 with Redis dedupe.
## Zero out-of-pocket
ntfy.sh (existing channel) + Uptime Kuma (already on the box) + node
built-ins (`fs.statfs`, `os`). No new dependency, no paid service.
+17
View File
@@ -131,6 +131,23 @@ this is an aggregate + UI change only.
---
## Slip Reader — vision-model upgrade (POST-REVENUE)
Session 9 (A1 board) shipped the zero-API Slip Reader: tesseract.js OCR
(self-hosted WASM, free) + rigid per-book layout parsers (DK/FD/MGM/Caesars)
with per-field confidence and needs_review honesty. That architecture is
deliberately conservative — it reads clean screenshots of the four big books
and refuses everything else.
The upgrade, when revenue funds it (zero-out-of-pocket rule): a vision model
(Claude-class multimodal) replaces the OCR+layout-parser pair — one call reads
ANY book, any theme, any crop, and returns structured legs with real
confidence. Costs per-call money, so it is gated on paid tiers paying for
themselves. The route contract (`POST /api/slips/parse``{ legs,
needs_review, source: 'user_slip' }`) is the stable interface; only the
extraction engine behind it swaps. Keep the never-guess rule: model output
below confidence threshold still nulls the field.
## Operating invariants (do not regress)
- Three stat_type whitelists stay in sync: `routes/analyze.js`, `routes/scan.js`,
`python/utils/validation.py`.
+14
View File
@@ -132,6 +132,8 @@ app.use('/api/props', propsRoutes);
app.use('/api/players', require('./routes/players'));
// Session 60 (night2/F) — per-user utility reads (Settings scan meter).
app.use('/api/user', require('./routes/user'));
// Session 63 (A1-S4) — the founder's media desk (email-allowlisted).
app.use('/api/desk', require('./routes/desk'));
app.use('/api/waitlist', waitlistRoutes);
app.use('/api/pipeline', pipelineRoutes);
app.use('/api/share-card', shareCardRoutes);
@@ -163,6 +165,9 @@ app.use('/api/team', require('./routes/team'));
// (settled snapshot grades vs real results). Public, cache-only.
app.use('/api/accuracy', require('./routes/accuracy'));
app.use('/api/ledger', require('./routes/ledger'));
// A1 Session 10 — public ledger profiles: claim a handle, one explicit
// publish toggle, and the ENTIRE settled record on a public page.
app.use('/api/profiles', require('./routes/profiles'));
const gameLinesRoutes = require('./routes/gameLines');
app.use('/api/gamelines', gameLinesRoutes);
const streaksRoutes = require('./routes/streaks');
@@ -182,11 +187,20 @@ app.use('/api/books', bookComparisonRoutes);
// generated from live data, degrading gracefully by data level.
const contentRoutes = require('./routes/content');
app.use('/api/content', contentRoutes);
// Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe
// (forwards to the self-hosted Listmonk; graceful no-op without env).
app.use('/api/newsletter', require('./routes/newsletter'));
// A1 S9 — Slip Reader: OCR a bet-slip screenshot into legs (auth +
// per-tier daily quota inside the router). Values are user-slip values.
app.use('/api/slips', require('./routes/slips'));
// Session 18 — internal ops endpoints (admin dashboard triggers,
// shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from
// the public surface; the Next.js admin route proxies through with
// the key kept server-side.
app.use('/api/internal', internalRoutes);
// A1 S3 — partner attribution report. Internal-key gated (router-level
// requireInternalAuth); no Next proxy on purpose — never browser-facing.
app.use('/api/partners', require('./routes/partners'));
// Session 10 — Sentry's Express error handler catches uncaught
// errors from every route mounted above. Must come AFTER routes but
+16 -1
View File
@@ -33,6 +33,9 @@ const TIERS = Object.freeze({
engine2: false,
stat_dashboard: true,
api_access: false,
// Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises).
alt_line_ladder: false,
kelly_sizing: false,
}),
// Session 12 — $4.99/mo Africa tier. Slotted between free and analyst.
// Same intelligence surface as analyst (reasoning + kill conditions
@@ -49,9 +52,16 @@ const TIERS = Object.freeze({
engine2: false,
stat_dashboard: true,
api_access: false,
// Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises).
alt_line_ladder: false,
kelly_sizing: false,
}),
analyst: Object.freeze({
scans_per_day: 15,
// Session 62 (A1-S1) — the Founder Access card promises "Unlimited
// reads" and the Nav shows an ∞ badge for every paid tier; the 15/day
// cap made both claims false. Analyst is unlimited; Desk differentiates
// on capabilities (alt ladder, Kelly, feed), not on rationing reads.
scans_per_day: Infinity,
grade_visible: true,
reasoning_visible: true,
kill_conditions_detail: true,
@@ -61,6 +71,9 @@ const TIERS = Object.freeze({
engine2: false,
stat_dashboard: true,
api_access: false,
// Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises).
alt_line_ladder: false,
kelly_sizing: false,
}),
desk: Object.freeze({
scans_per_day: Infinity,
@@ -73,6 +86,8 @@ const TIERS = Object.freeze({
engine2: true, // LLM deep analysis
stat_dashboard: true,
api_access: false,
alt_line_ladder: true,
kelly_sizing: true,
}),
});
+41
View File
@@ -0,0 +1,41 @@
'use strict';
/**
* /api/desk (Session 63 / A1-S4) — Kev's copy-paste arsenal.
*
* GET /pack — the assembled daily media pack + DATA BRIEF.
* Auth: requireAuth + email allowlist (env DESK_OWNERS, comma-separated).
* Unset allowlist → 403 for everyone (deny by default; the desk is the
* founder's surface, not a tier feature).
*/
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { createRateLimit } = require('../middleware/rateLimit');
const { assembleDeskPack } = require('../services/deskService');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 20 }));
function isOwner(req) {
const owners = String(process.env.DESK_OWNERS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const email = String(req.user?.email || '').toLowerCase();
return owners.length > 0 && email && owners.includes(email);
}
router.get('/pack', requireAuth, async (req, res) => {
if (!isOwner(req)) {
return res.status(403).json({ error: 'The desk is the publishers surface. Set DESK_OWNERS to grant access.' });
}
try {
const pack = await assembleDeskPack();
res.set('Cache-Control', 'private, max-age=60');
return res.json(pack);
} catch (err) {
console.error('[desk/pack]', err.message);
return res.status(200).json({ generated_at: null, formats: {}, data_brief: null, error: 'assembly failed' });
}
});
module.exports = router;
+45
View File
@@ -180,6 +180,25 @@ router.post('/outcomes/all', async (req, res) => {
}
});
/**
* POST /api/internal/ghost/drafts (Session 63 / A1-S4d) — assemble today's
* desk pack and save the slate-preview + settle drafts to Ghost. DRAFTS
* ONLY; Kev publishes from the Ghost admin. Env-gated no-op without Ghost.
*/
router.post('/ghost/drafts', async (req, res) => {
try {
const { assembleDeskPack } = require('../services/deskService');
const ghost = require('../services/ghostPublisher');
const pack = await assembleDeskPack();
const result = await ghost.saveDailyDrafts(pack);
return res.json({ ok: result.ok, result });
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/ghost/drafts] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
/**
* POST /api/internal/refresh/all (Session 60, Phase 2.5) — manual intraday
* odds-only refresh: STEAM/VALUE movement, public revisions, closing
@@ -214,6 +233,32 @@ router.post('/ledger/settle', async (req, res) => {
}
});
/**
* POST /api/internal/newsletter/send (Session S7, a1) — assemble today's
* VYNDR REPORT from the pipeline (snapshot signals + streak lens + the
* ledger record) and send it as a Listmonk campaign to the opted-in list.
*
* DELIBERATELY UNSCHEDULED: nothing calls this on a timer. The operator
* (or a future n8n cron, once Kev arms it) triggers the send. Env-gated —
* without LISTMONK_* config it's a calm no-op; an empty report (zero
* signals AND zero streaks) refuses to send.
*
* Body (optional): { sports?: string[] } — defaults to ['mlb', 'wnba'].
*/
router.post('/newsletter/send', async (req, res) => {
const newsletter = require('../services/newsletterService');
const body = (req.body && typeof req.body === 'object') ? req.body : {};
const sports = Array.isArray(body.sports) && body.sports.length > 0 ? body.sports : undefined;
try {
const result = await newsletter.sendDailyReport({ sports });
return res.json(result);
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/newsletter/send] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
router.post('/outcomes/:sport', async (req, res) => {
const outcomes = require('../services/outcomeService');
try {
+4
View File
@@ -96,6 +96,10 @@ router.get('/model', async (req, res) => {
q = applyFilters(q, req);
// Session 60 (night2/E) — PRIOR READS: a player's own public history.
if (req.query.player) q = q.eq('player_key', nameKey(String(req.query.player).slice(0, 60)));
// Session 64 (A1-S5) — the Yesterday results surface: rows for one game date.
if (req.query.date && /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.date))) {
q = q.eq('game_date', String(req.query.date));
}
const { data, error } = await q
.order('graded_at', { ascending: false })
.limit(limit);
+54
View File
@@ -0,0 +1,54 @@
'use strict';
/**
* THE VYNDR REPORT — public subscribe endpoint (Session S7, a1 board).
*
* POST /api/newsletter/subscribe { email, website? }
*
* Forwards to the self-hosted Listmonk's subscriber API with double opt-in
* (preconfirm_subscriptions: false → Listmonk sends the confirmation email;
* nothing lands in the list until the visitor clicks it).
*
* Grace notes:
* - Without LISTMONK_* env this is a calm HTTP 200
* { ok: false, reason: 'not configured' } — the UI renders
* "signups open soon", never a scary error.
* - `website` is a honeypot (same trick as /api/waitlist): bots fill the
* hidden field, humans don't. Filled → silent { ok: true }.
* - Listmonk 409 (already subscribed) is { ok: true } — idempotent, and the
* response never reveals whether an address exists.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const newsletterService = require('../services/newsletterService');
const router = express.Router();
// Public + writes upstream → tight throttle (10/min per IP).
router.use(createRateLimit({ windowMs: 60_000, max: 10 }));
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
router.post('/subscribe', async (req, res) => {
const body = (req.body && typeof req.body === 'object') ? req.body : {};
// Honeypot — silently accept and drop.
if (body.website) return res.json({ ok: true });
const email = typeof body.email === 'string' ? body.email.trim() : '';
if (!email || email.length > 254 || !EMAIL_RE.test(email)) {
return res.status(400).json({ ok: false, error: 'Enter a valid email.' });
}
const result = await newsletterService.subscribe(email);
if (result.ok) return res.json({ ok: true });
if (result.reason === 'not configured') {
return res.json({ ok: false, reason: 'not configured' });
}
// Upstream hiccup — log it, keep the visitor's response calm.
console.error('[newsletter/subscribe] listmonk error:', result.reason);
return res.json({ ok: false, reason: 'unavailable' });
});
module.exports = router;
+117
View File
@@ -0,0 +1,117 @@
'use strict';
/**
* Partner attribution report (A1 Session 3).
*
* GET /api/partners/report/:code — internal-only (requireInternalAuth,
* same key as the snapshot pipeline; never browser-reachable, no Next
* proxy on purpose). Returns per-partner attribution:
*
* { ok, code, signups, conversions, mrr_attributed, note? }
*
* Data source: public.user_profiles filtered by `partner_ref` — the
* code a signup carried in its metadata (set by the web PartnerRefCapture
* → AuthContext.signUp flow).
*
* IMPORTANT — the `partner_ref` column does NOT exist on user_profiles
* yet. Signup metadata lands in auth.users.raw_user_meta_data, which
* PostgREST can't query. Until the TODO migration in docs/PARTNERS.md
* is applied (adds the column + copies it from the signup metadata in
* handle_new_user), the select errors and this endpoint HONESTLY
* degrades to zeros with a `note` — it never fabricates attribution.
* Once the migration runs, real numbers flow with no code change here.
*
* MRR attribution uses the REAL tier prices (founder_pricing-aware) on
* currently-active paid profiles. It is monthly recurring revenue at
* today's price book — not lifetime value, not a projection.
*/
const express = require('express');
const { requireInternalAuth } = require('../middleware/internalAuth');
const { getSupabaseServiceClient } = require('../utils/supabase');
const router = express.Router();
router.use(requireInternalAuth({ loopbackOnly: false }));
// Tier price book (matches stripeService / pricing page).
const TIER_MRR = {
analyst: { standard: 19.99, founder: 14.99 },
desk: { standard: 49.99, founder: 34.99 },
};
/** Same convention as web/src/lib/partnerRef.js — A-Z 0-9 - _, ≤32, uppercase. */
function sanitizePartnerCode(raw) {
const s = String(raw == null ? '' : raw).trim().toUpperCase();
if (!s || s.length > 32) return null;
return /^[A-Z0-9_-]+$/.test(s) ? s : null;
}
/**
* Pure report math over user_profiles rows
* ({ tier, subscription_status, founder_pricing }).
* signups — every profile attributed to the code
* conversions — profiles currently on an active paid tier
* mrr_attributed — sum of those profiles' monthly price (founder-aware)
*/
function buildPartnerReport(rows) {
const list = Array.isArray(rows) ? rows : [];
let conversions = 0;
let mrr = 0;
for (const r of list) {
if (!r) continue;
const tier = String(r.tier || 'free').toLowerCase();
const active = String(r.subscription_status || '') === 'active';
const prices = TIER_MRR[tier];
if (!prices || !active) continue;
conversions += 1;
mrr += r.founder_pricing === true ? prices.founder : prices.standard;
}
return {
signups: list.length,
conversions,
mrr_attributed: Math.round(mrr * 100) / 100,
};
}
// Injectable for tests (no network / no Supabase env needed).
let _getClient = getSupabaseServiceClient;
function _setClientForTests(fn) {
_getClient = typeof fn === 'function' ? fn : getSupabaseServiceClient;
}
router.get('/report/:code', async (req, res) => {
const code = sanitizePartnerCode(req.params.code);
if (!code) {
return res.status(400).json({ ok: false, error: 'invalid partner code' });
}
try {
const supabase = _getClient();
const { data, error } = await supabase
.from('user_profiles')
.select('tier, subscription_status, founder_pricing')
.eq('partner_ref', code);
if (error) {
// Column not migrated yet (docs/PARTNERS.md TODO) — zeros, never invented.
return res.json({
ok: true,
code,
signups: 0,
conversions: 0,
mrr_attributed: 0,
note: 'partner_ref is not queryable yet — apply the TODO migration in docs/PARTNERS.md to activate attribution',
});
}
return res.json({ ok: true, code, ...buildPartnerReport(data) });
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[partners/report] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
module.exports = router;
module.exports.sanitizePartnerCode = sanitizePartnerCode;
module.exports.buildPartnerReport = buildPartnerReport;
module.exports._setClientForTests = _setClientForTests;
+134
View File
@@ -0,0 +1,134 @@
'use strict';
/**
* /api/profiles — public ledger profiles v1 (A1 Session 10).
*
* Stage-3 seed: a user claims a handle and publishes their ENTIRE settled
* ledger record — wins and misses, nothing curated. Strava for betting.
*
* GET /me (auth) — own public_profiles row (or null).
* POST /me (auth) — upsert own { handle, published }. Handle must
* match ^[a-z0-9_]{3,20}$; a handle owned by
* another user → 409. Service-role write only
* (the table has no client write policies).
* GET /:handle (public) — the published record: the user-scoped 30d
* aggregate (same n≥20 gate as the model record)
* + settled rows, newest 50, same columns as
* /api/ledger.
*
* PRIVACY: PRIVATE BY DEFAULT — `published` only flips via the explicit
* toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle
* return the byte-identical 404 body.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const { requireAuth } = require('../middleware/auth');
const ledgerService = require('../services/ledgerService');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
const HANDLE_RE = /^[a-z0-9_]{3,20}$/;
// Same 404 body for unknown AND unpublished — never confirm a handle exists.
const NOT_FOUND = { error: 'Profile not found' };
// Same columns as /api/ledger (routes/ledger.js ROW_COLUMNS).
const ROW_COLUMNS = 'id, player_key, player_name, sport, stat, line, side, locked_odds, book, grade, edge, confidence, model_value, graded_at, game_id, game_date, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at, revised_from_grade';
const ENTRY_LIMIT = 50;
function sbOrNull() {
try {
if (!ledgerService.__internals.isConfigured()) return null;
return require('../utils/supabase').getSupabaseServiceClient();
} catch { return null; }
}
// Own row — the owner sees it published or not.
router.get('/me', requireAuth, async (req, res) => {
const sb = sbOrNull();
if (!sb) return res.json({ profile: null });
try {
const { data, error } = await sb.from('public_profiles')
.select('handle, published, created_at')
.eq('user_id', req.user.id)
.maybeSingle();
if (error) throw new Error(error.message);
return res.json({ profile: data || null });
} catch (err) {
console.error('[profiles/me]', err.message);
return res.status(200).json({ profile: null });
}
});
// Claim/update the handle + the ONE explicit publish toggle.
router.post('/me', requireAuth, async (req, res) => {
const sb = sbOrNull();
if (!sb) return res.status(503).json({ error: 'Profiles are unavailable right now' });
try {
const body = req.body || {};
const handle = String(body.handle || '').trim().toLowerCase();
if (!HANDLE_RE.test(handle)) {
return res.status(400).json({ error: 'Handle must be 3-20 characters: a-z, 0-9, underscore' });
}
// PRIVATE BY DEFAULT: published only flips when the body says true.
const published = body.published === true;
const { error } = await sb.from('public_profiles')
.upsert({ user_id: req.user.id, handle, published }, { onConflict: 'user_id' });
if (error) {
// Unique violation on `handle` → someone else owns it.
if (error.code === '23505' || /duplicate|unique/i.test(error.message || '')) {
return res.status(409).json({ error: 'That handle is taken' });
}
throw new Error(error.message);
}
return res.json({ ok: true, profile: { handle, published } });
} catch (err) {
console.error('[profiles/post]', err.message);
return res.status(503).json({ error: 'Could not save profile' });
}
});
// The public record. Cache lightly; the settle pass updates rows a few
// times a day, not per-second.
router.get('/:handle', async (req, res) => {
const handle = String(req.params.handle || '').trim().toLowerCase();
// Invalid shape can't exist (DB CHECK) → same 404, no query needed.
if (!HANDLE_RE.test(handle)) return res.status(404).json(NOT_FOUND);
const sb = sbOrNull();
if (!sb) return res.status(404).json(NOT_FOUND);
try {
const { data: row, error } = await sb.from('public_profiles')
.select('user_id, handle, published')
.eq('handle', handle)
.maybeSingle();
if (error) throw new Error(error.message);
// Unknown and unpublished are indistinguishable from outside.
if (!row || row.published !== true) return res.status(404).json(NOT_FOUND);
// Same aggregate machinery as the model record — same window, same
// n≥20 gate — scoped to this user's own rows.
const aggregate = await ledgerService.getModelAggregate({ userId: row.user_id, sb });
// ALL settled reads, misses included — nothing curated.
const { data: entries, error: entriesErr } = await sb.from('ledger_entries')
.select(ROW_COLUMNS)
.eq('user_id', row.user_id)
.not('outcome', 'is', null)
.order('graded_at', { ascending: false })
.limit(ENTRY_LIMIT);
if (entriesErr) throw new Error(entriesErr.message);
res.set('Cache-Control', 'public, max-age=60');
return res.json({
handle: row.handle,
aggregate,
entries: entries || [],
min_sample: ledgerService.MIN_AGG_SAMPLE,
});
} catch (err) {
console.error('[profiles/handle]', err.message);
return res.status(404).json(NOT_FOUND);
}
});
module.exports = router;
+32
View File
@@ -49,6 +49,38 @@ router.get('/:sport/pitchers', async (req, res) => {
}
});
// Session 64 (A1-S5) — MLB lineup confirmation. { byPlayer, postedTeams };
// consumers resolve CONFIRMED / NOT_IN / PROJECTED via the posted-team rule.
router.get('/:sport/lineups', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
if (sport !== 'mlb') return res.set(MISSION_HEADER).json({ sport, byPlayer: {}, postedTeams: [] });
const date = req.query.date || scheduleService.todayET();
try {
const { fetchLineups } = require('../services/lineupService');
const parsed = await fetchLineups(date);
res.set('Cache-Control', 'public, max-age=300');
return res.set(MISSION_HEADER).json({ sport, date, ...parsed });
} catch (err) {
console.error('[schedule/lineups]', err.message);
return res.set(MISSION_HEADER).json({ sport, date, byPlayer: {}, postedTeams: [] });
}
});
// Session 64 (A1-S5) — the injury wire (ESPN feed): { byPlayer: { key:
// { status: OUT|GTD|PROB, detail, team } } }. Accurate chips, no cascades yet.
router.get('/:sport/injuries', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
try {
const { fetchInjuries } = require('../services/injuryService');
const byPlayer = await fetchInjuries(sport);
res.set('Cache-Control', 'public, max-age=600');
return res.set(MISSION_HEADER).json({ sport, byPlayer });
} catch (err) {
console.error('[schedule/injuries]', err.message);
return res.set(MISSION_HEADER).json({ sport, byPlayer: {} });
}
});
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
const date = req.query.date || scheduleService.todayET();
+147
View File
@@ -0,0 +1,147 @@
'use strict';
/**
* A1 Session 9 — POST /api/slips/parse (Slip Reader).
*
* Auth required. Accepts a base64/data-URL slip screenshot (≤4MB decoded)
* OR raw slip `text` (paste path + hermetic tests — skips OCR entirely).
* OCR is tesseract.js (self-hosted WASM, zero API spend); parsing is
* `slipReader.parseSlipText` — extracted values are USER-SLIP values,
* never written into any market cache.
*
* Rate limit: `slips:{user}:{YYYY-MM-DD}` (UTC day) — free 1/day,
* paid (analyst/desk) 10/day. Redis counter with an in-memory mirror so
* a degraded Redis still enforces the cap within this process.
*/
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { cacheGet, cacheSet } = require('../utils/redis');
const slipReader = require('../services/slipReader');
const router = express.Router();
const MAX_IMAGE_BYTES = 4 * 1024 * 1024; // 4MB decoded
const MAX_TEXT_CHARS = 20_000;
const COUNTER_TTL_S = 48 * 3600; // outlives the UTC day it counts
const MEM_MAX = 20_000;
const mem = new Map(); // dayKey → count (in-memory mirror / fallback)
function dailyLimit(tier) {
return tier === 'free' ? 1 : 10;
}
function dayKey(userId, now = new Date()) {
return `slips:${userId}:${now.toISOString().slice(0, 10)}`;
}
/**
* Consume one parse slot for the user's UTC day. Uses max(redis, memory)
* as the used count so neither store resetting under-counts alone.
*/
async function takeSlot(user, deps = {}) {
const get = deps.cacheGet || cacheGet;
const set = deps.cacheSet || cacheSet;
const limit = dailyLimit(user.tier || 'free');
const key = dayKey(user.id, deps.now);
let redisUsed = 0;
try {
const v = await get(key);
redisUsed = Number(v) || 0;
} catch { /* degraded redis → memory mirror carries it */ }
const used = Math.max(redisUsed, mem.get(key) || 0);
if (used >= limit) return { ok: false, used, limit };
if (mem.size > MEM_MAX) {
const oldest = mem.keys().next().value;
if (oldest !== undefined) mem.delete(oldest);
}
mem.set(key, used + 1);
try { await set(key, used + 1, COUNTER_TTL_S); } catch { /* best effort */ }
return { ok: true, used: used + 1, limit };
}
/** Decode a base64 / data-URL image field into a Buffer, or null. */
function decodeImage(image) {
if (typeof image !== 'string' || !image) return null;
const b64 = image.replace(/^data:image\/[a-z+.-]+;base64,/i, '').replace(/\s/g, '');
if (!/^[A-Za-z0-9+/=]+$/.test(b64)) return null;
try {
const buf = Buffer.from(b64, 'base64');
return buf.length > 0 ? buf : null;
} catch {
return null;
}
}
router.post('/parse', requireAuth, async (req, res) => {
const { image, text, book } = req.body || {};
// ── Validate input BEFORE burning a daily slot ──
let buffer = null;
let rawText = null;
if (typeof text === 'string' && text.trim()) {
if (text.length > MAX_TEXT_CHARS) {
return res.status(400).json({ error: 'Slip text too long.' });
}
rawText = text;
} else if (image != null) {
buffer = decodeImage(image);
if (!buffer) {
return res.status(400).json({ error: 'Could not decode the image. Send base64 or a data URL.' });
}
if (buffer.length > MAX_IMAGE_BYTES) {
return res.status(400).json({ error: 'Image too large. 4MB max.' });
}
} else {
return res.status(400).json({ error: 'Send an image (base64) or slip text.' });
}
// ── Daily quota ──
const slot = await takeSlot(req.user, router.__deps || {});
if (!slot.ok) {
res.set('X-Slips-Used', String(slot.used));
res.set('X-Slips-Limit', String(slot.limit));
return res.status(429).json({
error: slot.limit === 1
? 'One slip read per day on the free tier. Upgrade for ten.'
: 'Daily slip-read limit reached.',
used: slot.used,
limit: slot.limit,
tier: req.user.tier || 'free',
});
}
// ── OCR (image path only) ──
if (rawText == null) {
try {
const recognize = (router.__deps && router.__deps.recognizeImage) || slipReader.recognizeImage;
rawText = await recognize(buffer);
} catch (err) {
console.error('[slips] OCR failed:', err.message);
return res.status(503).json({ error: 'The reader could not process this image. Try again or paste the slip text.' });
}
}
// ── Parse. Values are user-slip values; unreadable fields stay null. ──
const result = slipReader.parseSlipText(rawText, book);
res.set('X-Slips-Used', String(slot.used));
res.set('X-Slips-Limit', String(slot.limit));
return res.json(result);
});
// Test hooks — injectable deps + counter reset (same pattern as scanLimit).
router.__internals = {
mem,
dayKey,
takeSlot,
dailyLimit,
decodeImage,
resetForTests: () => mem.clear(),
setDeps: (deps) => { router.__deps = deps; },
};
module.exports = router;
+126
View File
@@ -0,0 +1,126 @@
'use strict';
/**
* deskService — assembles Kev's daily copy-paste arsenal (Session 63 / A1-S4).
*
* Pulls the day's REAL pipeline data (snapshots, streaks+lens, ledger
* aggregate + settled rows) and fills the VOICE v1.1 templates. Output is
* the /desk pack: every format as text + pre-segmented tweets (≤280) +
* 2 copy variants (with/without the Stark line) + the DATA BRIEF (structured
* numbers Kev pastes into claude.ai for freeform writing).
*
* NOTHING auto-posts. This service only assembles.
*/
const media = require('./mediaEngine');
const SPORTS = ['mlb', 'wnba', 'nba', 'soccer'];
function variants(text) {
if (!text) return [];
// Variant B: the same post without its Stark line (pure numbers). The
// Stark lines are the only non-data lines, all sourced from the library.
const starkSet = new Set(Object.values(media.__internals.STARK).flat());
const stripped = text.split('\n').filter((l) => !starkSet.has(l)).join('\n');
const out = [{ label: 'wire', text, tweets: media.segmentThread(text) }];
if (stripped !== text) out.push({ label: 'numbers only', text: stripped, tweets: media.segmentThread(stripped) });
return out;
}
/**
* Assemble the pack. deps injectable: cacheGet, loadRosterLogs,
* computeStreaks/computeFormHeat/applyLens, ledger (getModelAggregate + sb
* reads), now.
*/
async function assembleDeskPack(opts = {}) {
const deps = {
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
loadRosterLogs: opts.loadRosterLogs || require('./rosterLogs').loadRosterLogs,
streaks: opts.streaks || require('./streaksService'),
lens: opts.lens || require('./streakLens'),
ledger: opts.ledger || require('./ledgerService'),
now: opts.now || (() => new Date().toISOString()),
};
const nowIso = deps.now();
// 1. Snapshots → counts, loudest signal, signals, archetype watch, dispatches.
const counts = {};
let allGrades = [];
for (const sp of SPORTS) {
const snap = await deps.cacheGet(`snapshot:${sp}:latest`);
const grades = snap && Array.isArray(snap.grades) ? snap.grades : [];
counts[sp] = grades.length;
allGrades = allGrades.concat(grades.map((g) => ({ ...g, sport: sp })));
}
const graded = allGrades.filter((g) => g.grade && !g.outcome);
graded.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
const loudest = graded[0] || null;
// 2. Streaks through the lens (MLB primary; extendable).
let streakRows = [];
try {
const roster = await deps.loadRosterLogs('mlb');
const rows = [
...deps.streaks.computeStreaks(roster, 'mlb', { limit: 6 }),
...deps.streaks.computeFormHeat(roster, 'mlb', { limit: 3 }),
];
const sched = await deps.cacheGet(`schedule:mlb:${nowIso.slice(0, 10)}`);
streakRows = deps.lens.applyLens(rows, { scheduleGames: Array.isArray(sched) ? sched : [], pitcherGames: [] });
} catch { /* streak section degrades to absent */ }
// 3. Ledger — aggregate + yesterday's settled rows for the Settle/Receipts.
const aggregate = await deps.ledger.getModelAggregate({});
let settledRows = [];
try {
if (opts.fetchSettled) settledRows = await opts.fetchSettled();
else {
const { getSupabaseServiceClient } = require('../utils/supabase');
if (deps.ledger.__internals.isConfigured()) {
const sb = getSupabaseServiceClient();
const { data } = await sb.from('ledger_entries')
.select('player_name, stat, line, side, grade, graded_at, locked_odds, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at')
.is('user_id', null).not('outcome', 'is', null)
.order('settled_at', { ascending: false }).limit(30);
settledRows = data || [];
}
}
} catch { /* receipts degrade to absent */ }
const misses = settledRows.filter((r) => r.outcome === 'miss');
// 4. Fill the formats. Absent data → absent format, never filler.
const dispatches = graded
.filter((g) => g.movement && (g.movement.kind === 'steam' || g.movement.kind === 'value'))
.slice(0, 4)
.map((g) => media.lineDispatch(g, nowIso))
.filter(Boolean);
const pack = {
generated_at: nowIso,
formats: {
morning_wire: variants(media.morningWire({ dateIso: nowIso, counts, loudest })),
signals: graded.slice(0, 3).map((g) => variants(media.signal(g))).filter((v) => v.length),
streak_watch: variants(media.streakWatch(streakRows, nowIso)),
settle: variants(media.theSettle({ aggregate, misses, dateIso: nowIso })),
receipts: settledRows.slice(0, 3).map((r) => variants(media.receipt(r))).filter((v) => v.length),
archetype_watch: variants(media.archetypeWatch(graded, nowIso)),
line_dispatches: dispatches.map((d) => variants(d)),
},
// 5. DATA BRIEF — the day's structured numbers for freeform writing.
data_brief: {
date: nowIso,
slate_counts: counts,
top_signals: graded.slice(0, 8).map((g) => ({
player: g.player || g.player_name, sport: g.sport, stat: g.stat_type || g.stat,
line: g.line, side: g.direction, grade: g.grade, confidence: g.confidence,
archetype: g.archetype || null, odds: (g.gradedAt && g.gradedAt.odds) || null,
movement: g.movement || null,
})),
streaks: streakRows.slice(0, 8).map((r) => ({ player: r.player, team: r.team, read: r.lens && r.lens.read })),
record: aggregate,
settled_today: settledRows.slice(0, 20),
},
};
return pack;
}
module.exports = { assembleDeskPack, __internals: { variants, SPORTS } };
+77
View File
@@ -0,0 +1,77 @@
'use strict';
/**
* ghostPublisher (Session 63 / A1-S4d) — saves DRAFTS to the self-hosted
* Ghost instance. DRAFTS ONLY — nothing anywhere auto-posts; Kev publishes.
*
* Zero new dependencies: the Ghost Admin API JWT is HS256 built with node
* crypto. Env-gated (GHOST_URL + GHOST_ADMIN_API_KEY 'id:secret') — without
* env every call is a documented no-op.
*/
const crypto = require('crypto');
const b64url = (buf) => Buffer.from(buf).toString('base64url');
/** Ghost Admin API JWT: HS256, kid = key id, aud '/admin/', 5-min expiry. */
function ghostJwt(adminKey, nowSec = Math.floor(Date.now() / 1000)) {
const [id, secret] = String(adminKey || '').split(':');
if (!id || !secret) return null;
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: id }));
const payload = b64url(JSON.stringify({ iat: nowSec, exp: nowSec + 300, aud: '/admin/' }));
const sig = crypto.createHmac('sha256', Buffer.from(secret, 'hex'))
.update(`${header}.${payload}`).digest('base64url');
return `${header}.${payload}.${sig}`;
}
function isConfigured() {
return Boolean(process.env.GHOST_URL && process.env.GHOST_ADMIN_API_KEY);
}
/** Save one draft. Returns { ok, id? } — never throws. */
async function saveDraft({ title, html, tags = [] }, opts = {}) {
if (!opts.force && !isConfigured()) return { ok: false, reason: 'ghost not configured' };
const fetchImpl = opts.fetchImpl || fetch;
const base = (opts.url || process.env.GHOST_URL || '').replace(/\/$/, '');
const token = ghostJwt(opts.adminKey || process.env.GHOST_ADMIN_API_KEY, opts.nowSec);
if (!token) return { ok: false, reason: 'bad admin key' };
try {
const res = await fetchImpl(`${base}/ghost/api/admin/posts/?source=html`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Ghost ${token}` },
body: JSON.stringify({ posts: [{ title, html, status: 'draft', tags }] }),
});
if (!res.ok) return { ok: false, reason: `ghost ${res.status}` };
const data = await res.json().catch(() => ({}));
return { ok: true, id: data.posts && data.posts[0] && data.posts[0].id };
} catch (e) {
return { ok: false, reason: e.message };
}
}
/** Daily slate preview + weekly record review drafts from the desk pack. */
async function saveDailyDrafts(pack, opts = {}) {
if (!pack || !pack.formats) return { ok: false, reason: 'no pack' };
const results = [];
const wire = pack.formats.morning_wire && pack.formats.morning_wire[0];
if (wire) {
results.push(await saveDraft({
title: `The Slate — ${new Date(pack.generated_at).toDateString()}`,
html: `<pre>${escapeHtml(wire.text)}</pre>`,
tags: ['slate'],
}, opts));
}
const settle = pack.formats.settle && pack.formats.settle[0];
if (settle) {
results.push(await saveDraft({
title: `The Settle — ${new Date(pack.generated_at).toDateString()}`,
html: `<pre>${escapeHtml(settle.text)}</pre>`,
tags: ['settle'],
}, opts));
}
return { ok: results.every((r) => r.ok), results };
}
const escapeHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
module.exports = { saveDraft, saveDailyDrafts, ghostJwt, isConfigured };
+4
View File
@@ -55,6 +55,10 @@ async function gradeBestSide(grade, prop, sport) {
line: prop.line,
sport,
book: prop.book,
// Session 62 (A1-S1) — real book odds ride into the engine so the
// quarter-Kelly sizing can compute from actual prices (or not at all).
over_odds: prop.over_odds ?? null,
under_odds: prop.under_odds ?? null,
};
const sides = await Promise.all([
Promise.resolve()
+74
View File
@@ -0,0 +1,74 @@
'use strict';
/**
* injuryService — the real injury wire (Session 64 / A1-S5).
*
* FREE ESPN injuries feed per sport → per-player status chips:
* OUT / GTD (day-to-day, questionable) / PROB. Day 1 is ACCURATE CHIPS on
* player pages + prop rows; cascade auto-regrades are a future board.
* Cache 15 min. Pure parser + injectable fetch → unit-tested offline.
*/
const { nameKey } = require('../utils/playerName');
const TTL = 900;
const HTTP_TIMEOUT_MS = 10_000;
const FEEDS = {
mlb: 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/injuries',
wnba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/injuries',
nba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/injuries',
};
/** ESPN status text → the three honest chips (unknown → null, never guessed). */
function chipFor(statusText) {
const s = String(statusText || '').toLowerCase();
if (!s) return null;
if (s.includes('out') || s.includes('injured list') || s.includes('il-') || s === 'suspension') return 'OUT';
if (s.includes('day-to-day') || s.includes('questionable') || s.includes('doubtful') || s === 'gtd') return 'GTD';
if (s.includes('probable')) return 'PROB';
return null;
}
/** Pure: ESPN injuries JSON → { nameKey: { status, detail, team } } */
function parseInjuries(json) {
const byPlayer = {};
for (const teamBlock of (json && json.injuries) || []) {
const team = teamBlock.displayName || null;
for (const inj of teamBlock.injuries || []) {
const athlete = inj.athlete || {};
const name = athlete.displayName || athlete.fullName;
if (!name) continue;
const chip = chipFor(inj.status);
if (!chip) continue;
byPlayer[nameKey(name)] = {
status: chip,
detail: (inj.details && inj.details.type) || inj.shortComment || null,
team,
};
}
}
return byPlayer;
}
async function fetchInjuries(sport, opts = {}) {
const sp = String(sport || '').toLowerCase();
const url = FEEDS[sp];
if (!url) return {};
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
const key = `injuries:${sp}`;
const cached = await cacheGet(key);
if (cached) return cached;
const axios = opts.axios || require('axios');
try {
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
const parsed = parseInjuries(res.data);
await cacheSet(key, parsed, TTL);
return parsed;
} catch (e) {
console.warn(`[injuries] ${sp} fetch failed:`, e.message);
return {};
}
}
module.exports = { fetchInjuries, parseInjuries, chipFor, __internals: { TTL, FEEDS } };
@@ -406,6 +406,50 @@ async function analyzeViaEngine1(rawProp = {}) {
// ledger model_value and rendered under the MODEL label on the card.
legacy.projection = projection;
// Session 62 (A1-S1) — ALT LINE LADDER + EDGE RANKING. The pricing page
// sold it; the adapter/card were already built for it; nothing produced
// it. Re-grade the SAME feature vector at shifted lines (zero extra I/O)
// and rank by edge. Attached for every graded result; the API tier gate
// strips it below Desk.
try {
const baseLine = Number(prop.line);
if (Number.isFinite(baseLine)) {
const ladder = [-1, -0.5, 0, 0.5, 1]
.map((shift) => Math.round((baseLine + shift) * 100) / 100)
.filter((ln) => ln > 0)
.map((ln) => {
const shiftedProp = { ...prop, line: ln };
const g = engine1.gradeProp({ features, trap, consistency, prop: shiftedProp });
const adapted = toLegacyShape(g, {
player: rawProp.player, stat_type: rawProp.stat_type,
line: ln, direction: prop.direction, book: rawProp.book || 'unknown', sport: meta.sport,
}, { edgePct: edgePctFor(features, { ...shiftedProp, stat_type: rawProp.stat_type, direction: prop.direction }) });
return { line: ln, grade: adapted.grade, edge_pct: adapted.edge_pct, base: ln === baseLine };
})
.sort((a, b) => (Number(b.edge_pct) || 0) - (Number(a.edge_pct) || 0));
if (ladder.length > 1) legacy.alt_lines = ladder;
}
} catch { /* the ladder is additive — never breaks the read */ }
// Session 62 (A1-S1) — QUARTER-KELLY. Real probability (quantile estimator
// over the actual game logs) × real book odds, or nothing. Never derived
// from confidence, never a default vig.
try {
const { estimateProbability } = require('./probabilityEstimator');
const est = estimateProbability({ gameLogs: meta.gameLogs, line: prop.line, statType: rawProp.stat_type, features });
const pWin = String(prop.direction || 'over').toLowerCase() === 'under'
? (Number.isFinite(est.p_over) ? 1 - est.p_over : null)
: (Number.isFinite(est.p_over) ? est.p_over : null);
if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000;
const sideOdds = String(prop.direction || 'over').toLowerCase() === 'under'
? rawProp.under_odds : rawProp.over_odds;
if (pWin != null && sideOdds != null) {
const { quarterKelly } = require('../../utils/kelly');
const k = quarterKelly(pWin, sideOdds);
if (k) legacy.kelly = { ...k, odds: String(sideOdds) };
}
} catch { /* sizing is additive — absent beats wrong */ }
return legacy;
}
+33 -5
View File
@@ -361,10 +361,36 @@ async function defaultGetPlayerStats(name, sport) {
return { found: false }; // no free settled-result feed yet → pending
}
/**
* Session 8 (A1 board, ops) — count of ledger rows for one game_date (the
* daily pulse's "rows written yesterday"). Returns null (NOT 0) when Supabase
* isn't configured or the count fails — the pulse renders "n/a", never a
* fabricated zero.
*/
async function countRowsForDate(gameDate, opts = {}) {
if (!gameDate) return null;
if (!opts.sb && !isConfigured()) return null;
try {
const sb = opts.sb || defaultClient();
const { count, error } = await sb.from('ledger_entries')
.select('id', { count: 'exact', head: true })
.eq('game_date', gameDate);
if (error) return null;
return count || 0;
} catch {
return null;
}
}
/**
* 30-day aggregate over the PUBLIC model record (user_id NULL): hit rate,
* beat-the-close rate, pending count. Percentages are null below
* MIN_AGG_SAMPLE — the UI must show "record building" instead.
*
* A1 Session 10 — `opts.userId` swaps the public `.is('user_id', null)`
* scoping for `.eq('user_id', uid)`: the SAME aggregate (same window, same
* n≥20 gate) over one user's own ledger, powering public profiles. The
* public default is untouched.
*/
async function getModelAggregate(opts = {}) {
const empty = {
@@ -379,8 +405,9 @@ async function getModelAggregate(opts = {}) {
const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
let settledQ = sb.from('ledger_entries')
.select('outcome, clv_result, player_key, grade')
.is('user_id', null)
.select('outcome, clv_result, player_key, grade');
settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null);
settledQ = settledQ
.not('outcome', 'is', null)
.gte('game_date', since)
.limit(AGG_FETCH_LIMIT);
@@ -391,9 +418,9 @@ async function getModelAggregate(opts = {}) {
if (error) return { ...empty, error: error.message };
let pendingQ = sb.from('ledger_entries')
.select('id', { count: 'exact', head: true })
.is('user_id', null)
.is('outcome', null);
.select('id', { count: 'exact', head: true });
pendingQ = opts.userId ? pendingQ.eq('user_id', opts.userId) : pendingQ.is('user_id', null);
pendingQ = pendingQ.is('outcome', null);
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
if (opts.team) pendingQ = pendingQ.eq('team', opts.team);
@@ -453,6 +480,7 @@ module.exports = {
settleLedger,
settleAllLedgers,
applyRevision,
countRowsForDate,
getModelAggregate,
MIN_AGG_SAMPLE,
__internals: {
+82
View File
@@ -0,0 +1,82 @@
'use strict';
/**
* lineupService — MLB lineup confirmation (Session 64 / A1-S5).
*
* FREE statsapi `schedule?hydrate=lineups`: once a team posts its lineup the
* feed carries homePlayers/awayPlayers in batting order. From that, per
* player (nameKey):
* CONFIRMED (with batting slot) — the player is in a posted lineup.
* NOT_IN — his team's lineup IS posted and he isn't in it. This visibly
* kills the grade on the slate (struck through + chip). The
* locked ledger read is untouched — honesty means SHOWING the
* read is dead, not deleting it.
* (absent) — his team hasn't posted yet → the UI renders PROJECTED.
*
* Cache 10 min (lineups post in waves pre-game). Pure parser + injectable
* fetch → unit-tested on the real feed shape with zero network.
*/
const { nameKey } = require('../utils/playerName');
const TTL = 600; // 10 min
const BASE = 'https://statsapi.mlb.com/api/v1';
const HTTP_TIMEOUT_MS = 10_000;
/**
* Pure: statsapi schedule JSON → {
* byPlayer: { nameKey: { status:'confirmed', slot, team } },
* postedTeams: [team names whose lineup is up],
* }
* Players NOT in byPlayer whose team IS in postedTeams are NOT_IN —
* resolved by statusFor().
*/
function parseLineups(scheduleJson) {
const byPlayer = {};
const postedTeams = [];
const games = ((scheduleJson || {}).dates || [])[0]?.games || [];
for (const g of games) {
const lu = g.lineups || {};
for (const side of ['home', 'away']) {
const players = lu[`${side}Players`];
if (!Array.isArray(players) || players.length === 0) continue;
const team = g.teams?.[side]?.team?.name || null;
if (team) postedTeams.push(team);
players.forEach((p, i) => {
if (!p || !p.fullName) return;
byPlayer[nameKey(p.fullName)] = { status: 'confirmed', slot: i + 1, team };
});
}
}
return { byPlayer, postedTeams };
}
/** Resolve one player's viability given parsed lineups + his team. */
function statusFor(player, team, parsed) {
if (!parsed) return { status: 'projected' };
const hit = parsed.byPlayer[nameKey(player)];
if (hit) return hit;
const token = (n) => String(n || '').toLowerCase().split(/\s+/).pop();
const posted = team && parsed.postedTeams.some((t) => t === team || token(t) === token(team));
return posted ? { status: 'not_in', team } : { status: 'projected' };
}
async function fetchLineups(date, opts = {}) {
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
const key = `lineups:mlb:${date}`;
const cached = await cacheGet(key);
if (cached) return cached;
const axios = opts.axios || require('axios');
try {
const res = await axios.get(`${BASE}/schedule?sportId=1&date=${date}&hydrate=lineups`, { timeout: HTTP_TIMEOUT_MS });
const parsed = parseLineups(res.data);
await cacheSet(key, parsed, TTL);
return parsed;
} catch (e) {
console.warn('[lineups] fetch failed:', e.message);
return { byPlayer: {}, postedTeams: [] };
}
}
module.exports = { fetchLineups, parseLineups, statusFor, __internals: { TTL, BASE } };
+258
View File
@@ -0,0 +1,258 @@
'use strict';
/**
* mediaEngine — VOICE v1.1 template engine (Session 63 / A1-S4).
*
* Deterministic templates for the wire formats (MORNING WIRE, SIGNAL,
* STREAK WATCH, THE SETTLE, RECEIPTS, ARCHETYPE WATCH, LINE DISPATCH),
* filled ONLY from pipeline JSON (snapshot/ledger/streaks). Templates never
* compose numbers — the Data Semantics Rule extends to marketing. Record
* claims obey the product's n≥20 gate. Stark-layer lines are SELECTED from
* the curated committed library (content/stark-lines.json), never generated.
*
* Every emitted string passes lintVoice() — the banned list + the
* no-exclamation law are enforced at build time by tests and at runtime by
* assembly (a template that fails lint throws in dev, drops the line in prod).
*
* NOTHING here posts anywhere. Output goes to /desk for Kev to publish.
*/
const fs = require('fs');
const path = require('path');
const STARK = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'content', 'stark-lines.json'), 'utf8'));
// ---- VOICE lint -----------------------------------------------------------
const BANNED_PATTERNS = [
/!/, // no exclamation points, ever
/\block(s|ed)?\b/i, /🔒/,
/🔥/, /💰/, /🚀/, /💯/,
/we[']?re so back/i, /it[']?s so over/i,
/\btail\b/i, /\bfade\b/i, // as calls to action — banned outright
/can[']?t lose/i, /free money/i, /guarantee/i,
/RT if/i, /who[']?s tailing/i, /\btailing\b/i,
/\beating\b/i, /\bcooking\b/i, /\bGOAT\b/,
];
/** Returns [] when clean, else the matched patterns (as strings). */
function lintVoice(text) {
const hits = [];
for (const re of BANNED_PATTERNS) {
if (re.test(String(text || ''))) hits.push(re.source);
}
return hits;
}
function assertVoice(text, context) {
const hits = lintVoice(text);
if (hits.length > 0) {
const msg = `[mediaEngine] VOICE lint failed (${context}): ${hits.join(', ')}`;
if (process.env.NODE_ENV === 'test') throw new Error(msg);
console.warn(msg);
return false;
}
return true;
}
// ---- helpers ---------------------------------------------------------------
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'u' : 'o');
function etTime(iso, withDay = false) {
const d = iso ? new Date(iso) : new Date();
if (Number.isNaN(d.getTime())) return '';
const opts = withDay
? { timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric' }
: { timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit' };
return new Intl.DateTimeFormat('en-US', opts).format(d);
}
/** Deterministic stark selection: day-of-year rotation, never random
* (Data Semantics: reproducible output for the same inputs + date). */
function starkLine(category, dateIso) {
const lines = STARK[category] || [];
if (lines.length === 0) return null;
const d = dateIso ? new Date(dateIso) : new Date();
const doy = Math.floor((d.getTime() - Date.UTC(d.getUTCFullYear(), 0, 0)) / 86_400_000);
return lines[doy % lines.length];
}
/** Segment a post into ≤280-char tweets on line boundaries. */
function segmentThread(text, limit = 280) {
const lines = String(text || '').split('\n');
const tweets = [];
let cur = '';
for (const line of lines) {
const candidate = cur ? `${cur}\n${line}` : line;
if (candidate.length > limit && cur) {
tweets.push(cur);
cur = line;
} else {
cur = candidate;
}
}
if (cur.trim()) tweets.push(cur);
return tweets;
}
// ---- formats ---------------------------------------------------------------
/** THE MORNING WIRE. data = { dateIso, counts: {mlb, wnba, ...}, loudest: grade row|null } */
function morningWire(data = {}) {
const parts = Object.entries(data.counts || {})
.filter(([, n]) => n > 0)
.map(([sp, n]) => `${n} ${sp.toUpperCase()}.`);
const lines = [`THE WIRE — ${etTime(data.dateIso, true)}`];
if (parts.length > 0) lines.push(parts.join(' '));
if (data.loudest && data.loudest.grade) {
const l = data.loudest;
const arch = l.archetype ? `a ${l.archetype} read` : `an ${l.grade} read`;
lines.push(`Loudest signal: ${arch}${l.player} ${l.stat_type || l.stat} ${sideCh(l.direction)}${l.line} · ${l.grade}.`);
}
const stark = starkLine('morning', data.dateIso);
if (stark) lines.push(stark);
lines.push('Slates graded. 3 free reads a day.', 'vyndr.app');
const text = lines.join('\n');
assertVoice(text, 'morningWire');
return text;
}
/** SIGNAL. g = one graded snapshot row (must carry gradedAt). */
function signal(g = {}) {
if (!g.player || !g.grade) return null;
const lines = [
`${g.player}${humanStat(g.stat_type || g.stat)} ${sideCh(g.direction)}${g.line} · ${g.grade}`,
];
if (g.archetype) lines.push(`${g.archetype} read.`);
const odds = g.gradedAt && g.gradedAt.odds != null ? ` at ${g.gradedAt.odds}` : '';
const ts = g.gradedAt && g.gradedAt.timestamp ? `Graded ${etTime(g.gradedAt.timestamp)} ET${odds}.` : null;
if (ts) lines.push(ts);
const text = lines.join('\n');
assertVoice(text, 'signal');
return text;
}
/** STREAK WATCH. rows = lens-applied streak rows. */
function streakWatch(rows = [], dateIso) {
const real = rows.filter((r) => r && r.lens && r.lens.read).slice(0, 4);
if (real.length === 0) return null;
const lines = [`STREAK WATCH — ${etTime(dateIso, true)}`];
for (const r of real) lines.push(`${r.player}: ${r.lens.read}.`);
const stark = starkLine('streak', dateIso);
if (stark) lines.push(stark);
lines.push('vyndr.app/explore');
const text = lines.join('\n');
assertVoice(text, 'streakWatch');
return text;
}
/**
* THE SETTLE. agg = ledger model aggregate; misses = settled miss rows.
* Record percentages ONLY at n≥20 (the aggregate already nulls them) —
* below that, counts only, honestly labeled.
*/
function theSettle({ aggregate, misses = [], dateIso } = {}) {
const a = aggregate || {};
const lines = [`THE SETTLE — ${etTime(dateIso, true)}`];
const tierA = a.by_tier && a.by_tier['A'];
if (tierA && tierA.settled > 0) lines.push(`A-tier: ${tierA.hits}-${tierA.misses}`);
if (a.settled > 0) lines.push(`All grades: ${a.hits}-${a.misses}${a.pushes ? ` (${a.pushes} push)` : ''}`);
else lines.push('Nothing settled today.');
if (a.hit_pct == null && a.settled > 0) {
lines.push(`Record building — ${a.settled} settled, ${a.pending || 0} pending. Percentages post at 20.`);
}
const missRows = misses.filter((m) => m && m.player_name).slice(0, 6);
if (missRows.length > 0) {
lines.push(`Misses, by name: ${missRows.map((m) => `${m.player_name} ${humanStat(m.stat)} ${sideCh(m.side)}${m.line}`).join(' · ')}`);
}
const stark = starkLine(missRows.length > 0 ? 'settle_losing' : 'settle_winning', dateIso);
if (stark) lines.push(stark);
lines.push('vyndr.app/ledger');
const text = lines.join('\n');
assertVoice(text, 'theSettle');
return text;
}
/** RECEIPTS. row = settled ledger row with lock + close + outcome. */
function receipt(row = {}) {
if (!row.player_name || !row.outcome) return null;
const s = sideCh(row.side);
const lines = [
`Posted ${etTime(row.graded_at)} ET: ${row.player_name} ${humanStat(row.stat)} ${s}${row.line} · ${row.grade}${row.locked_odds ? ` · ${row.locked_odds}` : ''}`,
];
if (row.closing_line != null && row.clv_result) {
const clvNote = row.clv_result === 'beat' ? 'Market chased the read.'
: row.clv_result === 'faded' ? 'Market went the other way.' : 'Close held.';
lines.push(`Close: ${s}${row.closing_line}${row.closing_odds ? ` at ${row.closing_odds}` : ''}. ${clvNote}`);
}
const mark = row.outcome === 'hit' ? '✅' : row.outcome === 'miss' ? '❌' : 'push';
lines.push(`Result: ${row.actual_value != null ? `${row.actual_value} ` : ''}${mark}`);
if (row.clv_result === 'beat' && row.outcome === 'hit') {
const stark = starkLine('steam', row.settled_at);
if (stark) lines.push(stark);
}
const text = lines.join('\n');
assertVoice(text, 'receipt');
return text;
}
/** ARCHETYPE WATCH. grades = snapshot rows. */
function archetypeWatch(grades = [], dateIso) {
const byArch = {};
for (const g of grades) {
if (!g.archetype || !g.grade) continue;
byArch[g.archetype] = byArch[g.archetype] || [];
byArch[g.archetype].push(g);
}
const top = Object.entries(byArch).sort((a, b) => b[1].length - a[1].length)[0];
if (!top || top[1].length < 2) return null;
const [arch, rows] = top;
const lines = [
`${arch} watch: ${rows.length} on tonights slate.`,
...rows.slice(0, 4).map((g) => `${g.player} ${humanStat(g.stat_type || g.stat)} ${sideCh(g.direction)}${g.line} · ${g.grade}`),
'vyndr.app',
];
const text = lines.join('\n');
assertVoice(text, 'archetypeWatch');
return text;
}
/** LINE DISPATCH. move = intraday movement row on a graded prop. */
function lineDispatch(g = {}, nowIso) {
const m = g.movement;
if (!m || m.delta == null) return null;
const s = sideCh(g.direction);
const locked = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line;
const arrow = m.currentLine > locked ? '▲' : '▼';
const kind = m.kind === 'steam' ? 'Steam.' : m.kind === 'value' ? 'Value — the read holds at the new number.' : 'Line moved.';
const lines = [
`${g.player} ${humanStat(g.stat_type || g.stat)}: ${s}${locked}${s}${m.currentLine} ${arrow}`,
`${kind} ${etTime(nowIso)} ET.`,
];
const text = lines.join('\n');
assertVoice(text, 'lineDispatch');
return text;
}
const STAT_HUMAN = {
total_bases: 'TB', home_runs: 'HR', hits: 'Hits', rbi: 'RBI', runs: 'Runs',
strikeouts: 'Ks', stolen_bases: 'SB', earned_runs: 'ER', innings_pitched: 'IP',
points: 'PTS', rebounds: 'REB', assists: 'AST', threes: '3PT', outs: 'Outs', doubles: '2B',
};
function humanStat(stat) {
const k = String(stat || '').toLowerCase();
return STAT_HUMAN[k] || k.replace(/_/g, ' ');
}
module.exports = {
lintVoice,
segmentThread,
starkLine,
morningWire,
signal,
streakWatch,
theSettle,
receipt,
archetypeWatch,
lineDispatch,
__internals: { BANNED_PATTERNS, etTime, humanStat, assertVoice, STARK },
};
+337
View File
@@ -0,0 +1,337 @@
'use strict';
/**
* THE VYNDR REPORT (Session S7, a1 board) — daily newsletter assembly + send.
*
* Assembly is deterministic templating over EXISTING pipeline data (the Data
* Semantics Rule extends to marketing — numbers come only from the pipeline,
* never composed):
* - Signals: snapshot:{sport}:latest (the locked pre-graded slate)
* - STREAK WATCH: rosterLogs → streaksService → streakLens (the lens IS the content)
* - THE RECORD: ledgerService.getModelAggregate() — the n≥20 gate lives in
* the aggregate (hit_pct is null below MIN_AGG_SAMPLE); this
* template renders a percentage ONLY when hit_pct != null.
*
* VOICE v1.1: deadpan, no exclamation points, misses-included record, o/u for
* sides, ET timestamps. Template linting (unit tests) asserts the banned list.
*
* Send is Listmonk (self-hosted, same box — zero out-of-pocket), env-gated:
* without LISTMONK_* env every send path is a graceful no-op. One-click unsub
* is Listmonk-native — the literal {{ UnsubscribeURL }} placeholder below is
* substituted by Listmonk at send time, not by us.
*
* NOTHING here is scheduled. The send is operator-triggered via
* POST /api/internal/newsletter/send until Kev arms a cron.
*/
const { cacheGet } = require('../utils/redis');
const DEFAULT_SPORTS = ['mlb', 'wnba'];
const SIGNALS_PER_SPORT = 5;
const STREAKS_TOTAL = 3;
// ---------------------------------------------------------------- helpers
function listmonkConfig(env = process.env) {
const url = (env.LISTMONK_URL || '').replace(/\/+$/, '');
const user = env.LISTMONK_USER || '';
const token = env.LISTMONK_TOKEN || '';
const listId = parseInt(env.LISTMONK_LIST_ID || '', 10);
if (!url || !user || !token || !Number.isFinite(listId)) return null;
return { url, user, token, listId };
}
function authHeaders(cfg) {
// Listmonk API-user scheme (v2.4+): Authorization: token user:token
return {
'Content-Type': 'application/json',
Authorization: `token ${cfg.user}:${cfg.token}`,
};
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** "total_bases" → "total bases" — display form for stat keys. */
function statLabel(stat) {
return String(stat || '').toLowerCase().replace(/_/g, ' ').trim();
}
const sideChar = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'u' : 'o');
function etDateParts(now) {
const d = now instanceof Date ? now : new Date(now);
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric',
});
return fmt.format(d); // e.g. "Sat, Jul 11"
}
function etDateKey(now) {
const d = now instanceof Date ? now : new Date(now);
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
// ---------------------------------------------------------------- assembly
const GRADE_ORDER = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9 };
const gradeRank = (g) => (GRADE_ORDER[String(g || '').toUpperCase()] ?? 99);
/** One signal line: "Judge — total bases o1.5 · A+ · BOMBER" */
function signalLine(g) {
const stat = statLabel(g.stat_type || g.stat);
const side = sideChar(g.direction);
const bits = [`${g.player || g.player_name}${stat} ${side}${g.line}`, String(g.grade || '').toUpperCase()];
if (g.archetype) bits.push(String(g.archetype).toUpperCase());
return bits.join(' · ');
}
/** Pick each sport's top graded reads (best grade first, confidence tiebreak). */
function topSignals(grades, cap = SIGNALS_PER_SPORT) {
const rows = (Array.isArray(grades) ? grades : []).filter((g) => g && g.grade && (g.player || g.player_name));
rows.sort((a, b) => (gradeRank(a.grade) - gradeRank(b.grade)) || ((b.confidence || 0) - (a.confidence || 0)));
return rows.slice(0, cap);
}
/** One streak line, lens included: the streak + what it was built against + tonight. */
function streakLine(row) {
const read = row.lens && row.lens.read ? row.lens.read : row.description;
return `${row.player}${read}`;
}
/** THE RECORD line. The n≥20 gate is upstream (hit_pct null below sample). */
function recordLine(agg) {
if (!agg) return 'RECORD BUILDING · 0 pending';
if (agg.hit_pct == null) return `RECORD BUILDING · ${agg.pending || 0} pending`;
const decided = (agg.hits || 0) + (agg.misses || 0);
let line = `Last ${agg.window_days || 30} days: ${agg.hits}-${agg.misses} (${agg.hit_pct}%), misses included, n=${decided}`;
if (agg.beat_close_pct != null) line += ` · beat the close ${agg.beat_close_pct}%`;
return line;
}
/** RG + unsubscribe footer — baked into every send, html and text. */
const RG_TEXT_FOOTER = [
'21+. Gambling problem? Call or text 1-800-GAMBLER.',
'VYNDR is a data and analysis tool, not a sportsbook. No outcome is promised.',
'You are receiving this because you confirmed a subscription at vyndr.app.',
'One click to leave, no questions: {{ UnsubscribeURL }}',
].join('\n');
function rgHtmlFooter() {
return `
<div style="margin-top:28px;padding-top:14px;border-top:1px solid #1c2a26;color:#5b6f68;font-size:11px;line-height:1.7;">
21+. Gambling problem? Call or text 1-800-GAMBLER.<br/>
VYNDR is a data and analysis tool, not a sportsbook. No outcome is promised.<br/>
You are receiving this because you confirmed a subscription at vyndr.app.<br/>
One click to leave, no questions: <a href="{{ UnsubscribeURL }}" style="color:#5b6f68;">unsubscribe</a>
</div>`;
}
function sectionHtml(title, lines) {
if (!lines.length) return '';
const rows = lines.map((l) => `<div style="padding:3px 0;">${esc(l)}</div>`).join('\n');
return `
<div style="margin-top:22px;">
<div style="color:#00d4a0;font-size:11px;letter-spacing:0.14em;margin-bottom:8px;">${esc(title)}</div>
${rows}
</div>`;
}
/**
* Assemble the daily report from pipeline data. Every dependency is
* injectable; fixtures drive the unit tests, no network, no Redis.
*
* Returns { subject, html, text, counts: { signals, streaks, sports } }.
*/
async function buildDailyReport(sports = DEFAULT_SPORTS, deps = {}) {
const d = {
cacheGet,
loadRosterLogs: (sp) => require('./rosterLogs').loadRosterLogs(sp),
computeStreaks: (roster, sp, opts) => require('./streaksService').computeStreaks(roster, sp, opts),
applyLens: (rows, ctx) => require('./streakLens').applyLens(rows, ctx),
getModelAggregate: (opts) => require('./ledgerService').getModelAggregate(opts),
now: () => new Date(),
...deps,
};
const now = d.now();
const dateLabel = etDateParts(now);
// --- Signals: the locked snapshot per sport (cache-only, never triggers a run).
const signalSections = [];
let signalCount = 0;
const slateCounts = [];
for (const sport of sports) {
const sp = String(sport).toLowerCase();
let snap = null;
try { snap = await d.cacheGet(`snapshot:${sp}:latest`); } catch { snap = null; }
const grades = (snap && Array.isArray(snap.grades)) ? snap.grades : [];
if (grades.length > 0) slateCounts.push(`${grades.length} ${sp.toUpperCase()}`);
const top = topSignals(grades);
if (top.length > 0) {
signalCount += top.length;
signalSections.push({ title: `SIGNALS — ${sp.toUpperCase()}`, lines: top.map(signalLine) });
}
}
// --- STREAK WATCH: pure engines over cached logs, interpreted through the lens.
// Lens context is cache-only (schedule) — absent context = the lens says less.
let streakRows = [];
for (const sport of sports) {
const sp = String(sport).toLowerCase();
try {
const roster = await d.loadRosterLogs(sp);
const streaks = d.computeStreaks(roster, sp, { stat: 'all' });
let scheduleGames = [];
try {
const sched = await d.cacheGet(`schedule:${sp}:${etDateKey(now)}`);
if (Array.isArray(sched)) scheduleGames = sched;
} catch { /* say less */ }
streakRows = streakRows.concat(d.applyLens(streaks, { scheduleGames, pitcherGames: [] }));
} catch { /* an empty sport is a valid state */ }
}
streakRows.sort((a, b) => (b.currentStreak || 0) - (a.currentStreak || 0));
// One row per player (their longest streak wins) — signal-dense, no repeats.
const seenPlayers = new Set();
const streakLines = [];
for (const row of streakRows) {
const p = String(row.player || '').toLowerCase();
if (!p || seenPlayers.has(p)) continue;
seenPlayers.add(p);
streakLines.push(streakLine(row));
if (streakLines.length >= STREAKS_TOTAL) break;
}
// --- THE RECORD: n≥20 gate enforced upstream (hit_pct null below sample).
let agg = null;
try { agg = await d.getModelAggregate({}); } catch { agg = null; }
const record = recordLine(agg);
// --- Compose. Subject carries a number only when the pipeline gave us one.
const subject = signalCount > 0
? `THE VYNDR REPORT — ${dateLabel} · ${signalCount} signals`
: `THE VYNDR REPORT — ${dateLabel}`;
const wireLine = slateCounts.length > 0
? `THE WIRE — ${dateLabel}. ${slateCounts.join('. ')}.`
: `THE WIRE — ${dateLabel}. No graded slate at send time.`;
const textParts = [wireLine];
for (const s of signalSections) textParts.push('', s.title, ...s.lines);
if (streakLines.length) textParts.push('', 'STREAK WATCH', ...streakLines);
textParts.push('', 'THE RECORD', record, '', 'The slate, the signals, the settle. vyndr.app', '', RG_TEXT_FOOTER);
const text = textParts.join('\n');
const html = `
<div style="background:#07100d;color:#c9d8d2;font-family:'JetBrains Mono',ui-monospace,Menlo,monospace;font-size:13px;line-height:1.6;padding:28px 24px;max-width:560px;margin:0 auto;">
<div style="color:#e8fff4;font-size:15px;letter-spacing:0.18em;">THE VYNDR REPORT</div>
<div style="color:#5b6f68;font-size:11px;margin-top:2px;">${esc(wireLine)}</div>
${signalSections.map((s) => sectionHtml(s.title, s.lines)).join('\n')}
${sectionHtml('STREAK WATCH', streakLines)}
${sectionHtml('THE RECORD', [record])}
<div style="margin-top:22px;">
<a href="https://vyndr.app" style="color:#00d4a0;">The slate, the signals, the settle. vyndr.app</a>
</div>
${rgHtmlFooter()}
</div>`;
return {
subject,
html,
text,
counts: { signals: signalCount, streaks: streakLines.length, sports: sports.length },
};
}
// ------------------------------------------------------------------- send
/**
* Subscribe one email through Listmonk with double opt-in
* (preconfirm_subscriptions: false → Listmonk sends the confirmation).
* Env-gated: no LISTMONK_* env → { ok: false, reason: 'not configured' }.
* A 409 (already subscribed) is ok — idempotent, no enumeration.
*/
async function subscribe(email, opts = {}) {
const cfg = listmonkConfig(opts.env);
if (!cfg) return { ok: false, reason: 'not configured' };
const fetchImpl = opts.fetchImpl || globalThis.fetch;
try {
const res = await fetchImpl(`${cfg.url}/api/subscribers`, {
method: 'POST',
headers: authHeaders(cfg),
body: JSON.stringify({
email: String(email).toLowerCase().trim(),
name: '',
status: 'enabled',
lists: [cfg.listId],
preconfirm_subscriptions: false,
}),
});
if (res.ok || res.status === 409) return { ok: true };
return { ok: false, reason: `listmonk ${res.status}` };
} catch (err) {
return { ok: false, reason: err && err.message ? err.message : 'listmonk unreachable' };
}
}
/**
* Assemble today's report and send it as a Listmonk campaign (create, then
* start). Refuses to send an empty report — absent beats hollow. Env-gated
* no-op without Listmonk config. NOT scheduled anywhere; the internal route
* is the only caller until Kev arms a cron.
*/
async function sendDailyReport(opts = {}) {
const cfg = listmonkConfig(opts.env);
if (!cfg) return { ok: false, reason: 'not configured' };
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const report = await buildDailyReport(opts.sports || DEFAULT_SPORTS, opts.deps || {});
if (report.counts.signals === 0 && report.counts.streaks === 0) {
return { ok: false, reason: 'empty report', report: { subject: report.subject, counts: report.counts } };
}
try {
const createRes = await fetchImpl(`${cfg.url}/api/campaigns`, {
method: 'POST',
headers: authHeaders(cfg),
body: JSON.stringify({
name: report.subject,
subject: report.subject,
lists: [cfg.listId],
type: 'regular',
content_type: 'html',
body: report.html,
altbody: report.text,
}),
});
if (!createRes.ok) return { ok: false, reason: `listmonk create ${createRes.status}` };
const created = await createRes.json().catch(() => ({}));
const campaignId = created && created.data && created.data.id;
if (!campaignId) return { ok: false, reason: 'listmonk create: no campaign id' };
const startRes = await fetchImpl(`${cfg.url}/api/campaigns/${campaignId}/status`, {
method: 'PUT',
headers: authHeaders(cfg),
body: JSON.stringify({ status: 'running' }),
});
if (!startRes.ok) return { ok: false, reason: `listmonk start ${startRes.status}`, campaignId };
return { ok: true, campaignId, subject: report.subject, counts: report.counts };
} catch (err) {
return { ok: false, reason: err && err.message ? err.message : 'listmonk unreachable' };
}
}
module.exports = {
buildDailyReport,
sendDailyReport,
subscribe,
__internals: {
listmonkConfig, authHeaders, statLabel, signalLine, topSignals,
streakLine, recordLine, etDateParts, etDateKey, RG_TEXT_FOOTER,
DEFAULT_SPORTS,
},
};
+181
View File
@@ -0,0 +1,181 @@
'use strict';
/**
* opsWatch — pure ops-alarm logic for the scheduler (Session 8, A1 board).
*
* The record must never die silently. This module holds the TESTABLE pieces
* the scheduler wires to ntfy (via opsNotify):
*
* 1. createFailureTracker — per-sport CONSECUTIVE snapshot-failure counter.
* Single-slot errors are normal before books post lines; 3 in a row is a
* broken pipeline. Pages exactly once per losing streak (at the
* threshold crossing), re-arms on any good outcome.
* 2. zeroSettleAlarm — "settle pass finished but the record did not
* advance." SIGNAL CHOICE (documented): the ledger settle pass's own
* return values. settleLedger fetches `game_date < today AND outcome IS
* NULL` from POSTGRES, so the signal survives the Redis TTL expiry that
* silently killed morning settles in Session 60 — a
* snapshot:{sport}:previous read would vanish in exactly that failure
* mode, and it costs zero extra reads. Scoped to SETTLEABLE sports (mlb)
* because NBA/WNBA/soccer rows legitimately stay pending until they have
* a settled-result feed. A genuinely empty yesterday fetches 0 rows ->
* settled 0 / pending 0 -> no alarm, ever.
* 3. checkQuotaDaily — odds-api >= 80% -> one alert per day (Redis dedupe
* by date key). Complements quotaTracker's once-per-MONTH warn.
* 4. buildPulseMessage / countRecentSettles — the 9 AM ET daily pulse, one
* notification, VOICE v1.1: deadpan, numbers, no exclamation points.
*
* Everything here is pure or fully injectable — no requires of redis/ntfy.
*/
/** Sports with a real settled-result feed (mlb game logs). Keep in sync with
* outcomeService/ledgerService's MLB-only settlement until Phase 4.5. */
const SETTLEABLE_SPORTS = ['mlb'];
const PAGE_THRESHOLD = 3;
/** A snapshot result that means "the pipeline produced nothing": a hard error
* or an empty odds feed. 'no grades' (props arrived, grader refused) and 'ok'
* are NOT failures for this counter. */
function isBadSnapshotResult(result) {
if (!result) return false;
if (result.status === 'error') return true;
return result.status === 'skipped' && String(result.reason || '') === 'no props';
}
/**
* Per-sport consecutive-failure counter (in-process; a restart re-arms, which
* is acceptable — the missed-cron watchdog covers a crashed scheduler).
* record() returns { sport, count, shouldPage }: shouldPage is true ONLY at
* the exact threshold crossing, so a sport that keeps failing pages once per
* losing streak, not once per slot.
*/
function createFailureTracker(threshold = PAGE_THRESHOLD) {
const counts = new Map();
return {
record(sport, result) {
const sp = String(sport || '').toLowerCase();
const bad = isBadSnapshotResult(result);
const count = bad ? (counts.get(sp) || 0) + 1 : 0;
counts.set(sp, count);
return { sport: sp, count, shouldPage: bad && count === threshold };
},
count(sport) { return counts.get(String(sport || '').toLowerCase()) || 0; },
threshold,
};
}
/** The daily "close the book" slot: the first configured UTC hour >= 06
* (hours 0-5 UTC are late-night ET slots of the PREVIOUS ET day). Falls back
* to the smallest configured hour when nothing is >= 06. */
function morningHourUtc(hoursUtc) {
const hours = (hoursUtc || []).filter((h) => Number.isInteger(h));
if (hours.length === 0) return 14;
const daytime = hours.filter((h) => h >= 6);
return (daytime.length > 0 ? daytime : hours).sort((a, b) => a - b)[0];
}
/**
* Evaluate the zero-settle signal from settleAllLedgers results.
* alarm === true only when settleable-sport rows EXISTED for settlement
* (fetched from Postgres: settled + pending > 0) and none settled.
*/
function zeroSettleAlarm(ledgerResults, settleable = SETTLEABLE_SPORTS) {
const rows = (Array.isArray(ledgerResults) ? ledgerResults : [])
.filter((r) => r && settleable.includes(String(r.sport || '').toLowerCase()));
const settled = rows.reduce((n, r) => n + (r.settled || 0), 0);
const pending = rows.reduce((n, r) => n + (r.pending || 0), 0);
return { alarm: settled === 0 && pending > 0, settled, pending };
}
/** ET calendar date (YYYY-MM-DD) of a Date; UTC fallback if Intl is absent. */
function dateET(d = new Date()) {
try {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
} catch {
return d.toISOString().slice(0, 10);
}
}
/**
* Quota alert: odds-api usage >= thresholdPct -> alert once per UTC day
* (Redis dedupe key ops:quota_day:{provider}:{date}). All deps injectable:
* { getStatus, cacheGet, cacheSet, notify, now, providerId, thresholdPct }.
* Never throws — an ops check must not break the snapshot run.
*/
async function checkQuotaDaily(deps = {}) {
const providerId = deps.providerId || 'odds-api';
const thresholdPct = deps.thresholdPct != null ? deps.thresholdPct : 0.8;
try {
const status = await deps.getStatus(providerId);
const pct = status && Number.isFinite(status.pct) ? status.pct : null;
if (pct == null || pct < thresholdPct) return { alerted: false, pct };
const day = (deps.now ? deps.now() : new Date()).toISOString().slice(0, 10);
const key = `ops:quota_day:${providerId}:${day}`;
if (await deps.cacheGet(key)) return { alerted: false, deduped: true, pct };
await deps.cacheSet(key, '1', 2 * 24 * 3600);
await deps.notify(
`${providerId} at ${Math.round(pct * 100)}% of ${status.quotaType || 'period'} quota `
+ `(${status.used}/${status.limit}). Blocks at 95. PropLine stays primary; `
+ `the backup path is thinning.`,
{ title: 'VYNDR quota', priority: 'high', tags: ['warning', 'chart_decreasing'] },
);
return { alerted: true, pct };
} catch (err) {
return { alerted: false, error: err.message };
}
}
/** Count settled outcomes across logs whose settledAt falls inside the last
* windowHours (default 24). Pure — feed it the outcomes:{sport}:log arrays. */
function countRecentSettles(logs, nowMs, windowHours = 24) {
const cutoff = nowMs - windowHours * 3600 * 1000;
let n = 0;
for (const log of logs || []) {
for (const o of Array.isArray(log) ? log : []) {
const t = o && o.settledAt ? new Date(o.settledAt).getTime() : NaN;
if (Number.isFinite(t) && t >= cutoff) n += 1;
}
}
return n;
}
/**
* Assemble the ONE daily-pulse notification body. VOICE v1.1: deadpan,
* numbers, no exclamation points. Missing data renders as "n/a" — a pulse
* never fabricates a zero.
* pieces: { dateEt, ledgerRows (number|null), settles24h (number|null),
* quota ({pct, used, limit}|null), health ({disk_pct, mem_pct}|null) }
*/
function buildPulseMessage(pieces = {}) {
const na = (v) => (v == null ? 'n/a' : String(v));
const quota = pieces.quota && Number.isFinite(pieces.quota.pct)
? `${Math.round(pieces.quota.pct * 100)}% (${pieces.quota.used}/${pieces.quota.limit})`
: 'n/a';
const h = pieces.health || {};
const disk = Number.isFinite(h.disk_pct) ? `${h.disk_pct}%` : 'n/a';
const mem = Number.isFinite(h.mem_pct) ? `${h.mem_pct}%` : 'n/a';
return [
`VYNDR pulse — ${pieces.dateEt || dateET()}`,
`ledger rows yesterday: ${na(pieces.ledgerRows)}`,
`settles last 24h: ${na(pieces.settles24h)}`,
`odds-api quota: ${quota}`,
`disk ${disk} · mem ${mem}`,
'desk pack: see /desk',
].join('\n');
}
module.exports = {
createFailureTracker,
isBadSnapshotResult,
zeroSettleAlarm,
morningHourUtc,
checkQuotaDaily,
countRecentSettles,
buildPulseMessage,
dateET,
SETTLEABLE_SPORTS,
PAGE_THRESHOLD,
};
+462
View File
@@ -0,0 +1,462 @@
'use strict';
/**
* A1 Session 9 — Slip Reader (zero-API OCR).
*
* Parses sportsbook bet-slip OCR text into legs the grading engine can
* consume. Layout parsers are PURE (text in → legs out, no I/O) so they
* unit-test on raw OCR-text fixtures; the tesseract.js image path is a
* thin wrapper (`recognizeImage`) that lazy-requires the WASM engine.
*
* DATA SEMANTICS: everything extracted here is a USER-SLIP value — the
* line/odds the user's book printed on their slip. It is never written
* into any market cache and is labeled `source: 'user_slip'` upstream.
*
* NEVER GUESS: a field the parser cannot read above CONFIDENCE_THRESHOLD
* is returned null and the leg carries `needs_review: true`. Absent
* beats wrong (North Star operating rule).
*/
const { normalizeName, nameKey } = require('../utils/playerName');
const CONFIDENCE_THRESHOLD = 0.6;
const MAX_LEGS = 12; // mirrors the scan route's parlay cap
/* ── Stat vocabulary ─────────────────────────────────────────────
Slip market labels → the canonical stat_type vocabulary shared by
src/routes/scan.js VALID_STAT_TYPES + web gradeAdapter STAT_LABELS.
Keys are lowercased, space-collapsed slip labels. */
const STAT_ALIASES = {
// MLB batters
'total bases': 'total_bases', 'tb': 'total_bases',
'hits': 'hits', 'hit': 'hits',
'home runs': 'home_runs', 'home run': 'home_runs', 'hr': 'home_runs',
'any time home run': 'home_runs', 'anytime home run': 'home_runs',
'to hit a home run': 'home_runs',
'rbi': 'rbi', 'rbis': 'rbi', 'runs batted in': 'rbi',
'runs': 'runs', 'runs scored': 'runs', 'run scored': 'runs',
'stolen bases': 'stolen_bases', 'stolen base': 'stolen_bases',
'doubles': 'doubles',
'walks': 'walks', 'batter walks': 'walks',
// MLB pitchers
'strikeouts': 'strikeouts', 'strikeouts thrown': 'strikeouts',
'pitcher strikeouts': 'strikeouts', 'ks': 'strikeouts', 'k s': 'strikeouts',
'earned runs': 'earned_runs', 'earned runs allowed': 'earned_runs',
'hits allowed': 'hits_allowed',
'innings pitched': 'innings_pitched',
'outs': 'outs', 'outs recorded': 'outs',
// NBA / WNBA
'points': 'points', 'pts': 'points',
'rebounds': 'rebounds', 'reb': 'rebounds', 'rebs': 'rebounds',
'assists': 'assists', 'ast': 'assists', 'asts': 'assists',
'threes': 'threes', '3 pointers made': 'threes', 'three pointers made': 'threes',
'3 pt made': 'threes', 'threes made': 'threes', 'made threes': 'threes', '3pm': 'threes',
'blocks': 'blocks', 'blk': 'blocks',
'steals': 'steals', 'stl': 'steals',
'turnovers': 'turnovers', 'to': 'turnovers',
'pts + reb + ast': 'pra', 'points + rebounds + assists': 'pra',
'pts rebs asts': 'pra', 'pts reb ast': 'pra', 'pra': 'pra',
// Soccer
'goals': 'goals', 'goal': 'goals', 'any time goalscorer': 'goals',
'anytime goalscorer': 'goals',
'shots': 'shots', 'shots on target': 'shots_on_target',
'shots on goal': 'shots_on_target',
'tackles': 'tackles', 'cards': 'cards', 'corners': 'corners',
'saves': 'saves', 'goalkeeper saves': 'saves',
'goals conceded': 'goals_conceded', 'passes': 'passes',
'clean sheet': 'clean_sheet',
};
// Display labels (mirror web/src/lib/gradeAdapter.js STAT_LABELS style).
function statLabel(stat) {
if (!stat) return null;
const special = { pra: 'P+R+A', rbi: 'RBI', threes: '3-Pointers' };
if (special[stat]) return special[stat];
return stat.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Normalize a slip market label to the canonical stat_type, or null. */
function normalizeStat(rawLabel) {
if (!rawLabel) return null;
let s = String(rawLabel).toLowerCase()
.replace(/[|]/g, '')
.replace(/\balt\b/g, '') // "Alt Total Bases" → "Total Bases"
.replace(/\bo\/u\b/g, '') // "Total Bases O/U"
.replace(/[()]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (STAT_ALIASES[s]) return STAT_ALIASES[s];
const underscored = s.replace(/ /g, '_');
if (Object.values(STAT_ALIASES).includes(underscored)) return underscored;
return null;
}
/* ── Shared token grammar ──────────────────────────────────────── */
// "Aaron Judge", "Ronald Acuña Jr.", "A.J. Ewing" — 23 capitalized tokens.
const NAME_PART = "[A-Z][A-Za-z'.\\u00C0-\\u017F-]+";
const NAME_RE = new RegExp(
`(${NAME_PART}(?:\\s${NAME_PART}){1,2}(?:\\s(?:Jr|Sr|II|III|IV)\\.?)?)`,
);
// Odds token: "+320", "-115", OCR unicode minus "115" / dash "115".
const ODDS_RE = /(?:^|[\s(@])([+\-−–]\s?\d{2,4})(?:[\s).]|$)/;
function parseOdds(token) {
if (!token) return null;
const n = parseInt(String(token).replace(/[−–]/, '-').replace(/\s/g, ''), 10);
if (!Number.isFinite(n)) return null;
if (Math.abs(n) < 100 || Math.abs(n) > 9999) return null; // not american odds
return n;
}
function parseLine(token) {
const n = Number(token);
return Number.isFinite(n) && n >= 0 && n <= 500 ? n : null;
}
/** Confidence for an extracted player-name candidate. */
function nameConfidence(raw) {
if (!raw) return 0;
const parts = raw.trim().split(/\s+/);
if (parts.length < 2) return 0.3; // single token — too weak
const capitalized = parts.every((p) => /^[A-Z]/.test(p));
return capitalized ? 0.9 : 0.5;
}
/** Build a finalized leg: normalize, threshold-null, flag needs_review. */
function buildLeg(fields) {
const conf = {
player: fields.player != null ? (fields.playerConfidence ?? nameConfidence(fields.player)) : 0,
stat: 0,
line: fields.line != null ? (fields.lineConfidence ?? 0.95) : 0,
side: fields.side != null ? (fields.sideConfidence ?? 0.95) : 0,
odds: fields.odds != null ? (fields.oddsConfidence ?? 0.9) : 0,
};
const stat = normalizeStat(fields.statLabel);
if (stat) conf.stat = 0.95;
const playerOk = conf.player >= CONFIDENCE_THRESHOLD;
const norm = playerOk ? normalizeName(fields.player) : null;
const leg = {
player: playerOk ? norm.display : null,
player_key: playerOk ? nameKey(fields.player) : null,
stat: conf.stat >= CONFIDENCE_THRESHOLD ? stat : null,
stat_label: conf.stat >= CONFIDENCE_THRESHOLD ? statLabel(stat) : null,
raw_stat_label: fields.statLabel || null,
line: conf.line >= CONFIDENCE_THRESHOLD ? fields.line : null,
side: conf.side >= CONFIDENCE_THRESHOLD ? fields.side : null,
odds: conf.odds >= CONFIDENCE_THRESHOLD ? fields.odds : null,
confidence: {
player: round2(conf.player), stat: round2(conf.stat), line: round2(conf.line),
side: round2(conf.side), odds: round2(conf.odds),
},
needs_review: false,
};
leg.needs_review = leg.player == null || leg.stat == null
|| leg.line == null || leg.side == null || leg.odds == null;
return leg;
}
function round2(n) { return Math.round(n * 100) / 100; }
function cleanLines(text) {
return String(text || '')
.split(/\r?\n/)
.map((l) => l.replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
// Slip chrome that is never a leg (wager rows, headers, game rows).
const NOISE_RE = /^(wager|to win|total payout|bet id|placed|cash out|share|profit boost|same game parlay|sgp|parlay|\d+ leg|odds|stake|returns|potential|today|tomorrow|final|\$)/i;
const GAME_ROW_RE = /(@| at | vs\.? | v )/i;
/* ── DraftKings ──────────────────────────────────────────────────
Layout: "<Player> Over 1.5" / market label on the NEXT line /
odds on the line after ("-115"). */
function parseDraftKings(text) {
const lines = cleanLines(text);
const legs = [];
const headRe = new RegExp(`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)$`, 'i');
for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
const m = lines[i].match(headRe);
if (!m) continue;
const [, player, sideRaw, lineRaw] = m;
// Market label = next non-noise, non-game line.
let statLabelRaw = null;
let odds = null;
for (let j = i + 1; j <= i + 3 && j < lines.length; j += 1) {
const l = lines[j];
if (NOISE_RE.test(l)) break;
const oddsMatch = l.match(/^([+\-−–]\s?\d{2,4})$/);
if (oddsMatch) { odds = parseOdds(oddsMatch[1]); break; }
if (!statLabelRaw && !GAME_ROW_RE.test(l) && !headRe.test(l)) statLabelRaw = l;
}
legs.push(buildLeg({
player,
statLabel: statLabelRaw,
line: parseLine(lineRaw),
side: sideRaw.toLowerCase(),
odds,
}));
}
return legs;
}
/* ── FanDuel ─────────────────────────────────────────────────────
Layouts: "<Player> To Record 2+ Total Bases" (2+ → line 1.5 over),
"<Player> Any Time Home Run", "<Player> Over 7.5 Strikeouts".
SGP legs often carry no per-leg odds → odds null + needs_review. */
function parseFanDuel(text) {
const lines = cleanLines(text);
const legs = [];
const recordRe = new RegExp(`^${NAME_RE.source}\\s+To Record\\s+(\\d+)\\+\\s+(.+)$`, 'i');
const anytimeRe = new RegExp(`^${NAME_RE.source}\\s+((?:Any\\s?Time|Anytime) (?:Home Run|Goalscorer))$`, 'i');
const ouRe = new RegExp(`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)\\s+(.+)$`, 'i');
for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
const line = lines[i];
if (NOISE_RE.test(line) || GAME_ROW_RE.test(line)) continue;
// Trailing odds on the same line, else a bare odds token on the next.
let body = line;
let odds = null;
const trailing = body.match(/\s([+\-−–]\s?\d{2,4})$/);
if (trailing) { odds = parseOdds(trailing[1]); body = body.slice(0, trailing.index).trim(); }
if (odds == null && lines[i + 1] && /^([+\-−–]\s?\d{2,4})$/.test(lines[i + 1])) {
odds = parseOdds(lines[i + 1]);
}
let m = body.match(recordRe);
if (m) {
const threshold = parseInt(m[2], 10);
legs.push(buildLeg({
player: m[1], statLabel: m[3],
line: Number.isFinite(threshold) ? threshold - 0.5 : null,
lineConfidence: 0.9,
side: 'over', sideConfidence: 0.85,
odds,
}));
continue;
}
m = body.match(anytimeRe);
if (m) {
legs.push(buildLeg({
player: m[1], statLabel: m[2],
line: 0.5, lineConfidence: 0.9,
side: 'over', sideConfidence: 0.85,
odds,
}));
continue;
}
m = body.match(ouRe);
if (m) {
legs.push(buildLeg({
player: m[1], statLabel: m[4],
line: parseLine(m[3]),
side: m[2].toLowerCase(),
odds,
}));
}
}
return legs;
}
/* ── BetMGM ──────────────────────────────────────────────────────
Layout: "<Player> Over 1.5 Total Bases @ -115" (inline), or the
odds trailing without "@". */
function parseBetMGM(text) {
const lines = cleanLines(text);
const legs = [];
const re = new RegExp(
`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)\\s+(.+?)(?:\\s*@\\s*([+\\-−–]\\s?\\d{2,4}))?$`, 'i',
);
for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
const line = lines[i];
if (NOISE_RE.test(line)) continue;
// NOTE: no game-row pre-skip here — MGM leg lines carry "@ -115",
// which the game-row heuristic would eat. The grammar (Over/Under)
// is the filter; game rows never match it.
const m = line.match(re);
if (!m) continue;
let [, player, sideRaw, lineRaw, statRaw, oddsRaw] = m;
// Odds sometimes trail the stat label without "@".
if (!oddsRaw) {
const trailing = statRaw.match(/\s([+\-−–]\s?\d{2,4})$/);
if (trailing) { oddsRaw = trailing[1]; statRaw = statRaw.slice(0, trailing.index).trim(); }
}
legs.push(buildLeg({
player,
statLabel: statRaw,
line: parseLine(lineRaw),
side: sideRaw.toLowerCase(),
odds: parseOdds(oddsRaw),
}));
}
return legs;
}
/* ── Caesars ─────────────────────────────────────────────────────
Layout: "<Player> Total Bases Over 1.5 (-115)" — stat between the
name and the side, odds parenthesized or bare at the end.
The player/stat boundary inside the head segment is ambiguous to a
regex ("Pete Alonso Home Runs" — is "Home" a name token?), so we
split on the LONGEST known stat alias the head ends with. No alias
match → stat null + needs_review; the split NEVER invents a stat. */
// Alias keys, longest (most words, then chars) first, for suffix scans.
const ALIAS_KEYS_BY_LEN = Object.keys(STAT_ALIASES)
.sort((a, b) => b.split(' ').length - a.split(' ').length || b.length - a.length);
function splitPlayerStat(head) {
const low = String(head || '').toLowerCase().replace(/\s+/g, ' ').trim();
for (const alias of ALIAS_KEYS_BY_LEN) {
if (low.length > alias.length && low.endsWith(' ' + alias)) {
const player = head.slice(0, head.length - alias.length)
.trim()
.replace(/\s+alt$/i, ''); // "Judge Alt Total Bases" → player "Judge"
return { player, statLabel: head.slice(head.length - alias.length).trim() };
}
}
// No known stat suffix — the player/stat boundary is unknown too, so
// the name candidate is low-confidence (below threshold → nulled).
const nameMatch = String(head || '').match(new RegExp(`^${NAME_RE.source}`));
return { player: nameMatch ? nameMatch[1] : null, statLabel: null, uncertain: true };
}
function parseCaesars(text) {
const lines = cleanLines(text);
const legs = [];
const re = /^(.+?)\s+(Over|Under)\s+(\d+(?:\.\d+)?)\s*(?:\(?([+\-−–]\s?\d{2,4})\)?)?$/i;
for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
const line = lines[i];
if (NOISE_RE.test(line)) continue;
const m = line.match(re);
if (!m) continue;
const [, head, sideRaw, lineRaw, oddsRaw] = m;
const { player, statLabel, uncertain } = splitPlayerStat(head);
legs.push(buildLeg({
player,
statLabel,
...(uncertain ? { playerConfidence: 0.5 } : {}),
line: parseLine(lineRaw),
side: sideRaw.toLowerCase(),
odds: parseOdds(oddsRaw),
}));
}
return legs;
}
/* ── Book detection + dispatch ─────────────────────────────────── */
const BOOK_MARKERS = [
['draftkings', /draft\s?kings|dksb/i],
['fanduel', /fan\s?duel/i],
['betmgm', /bet\s?mgm/i],
['caesars', /caesars|czr/i],
];
const PARSERS = {
draftkings: parseDraftKings,
fanduel: parseFanDuel,
betmgm: parseBetMGM,
caesars: parseCaesars,
};
function detectBook(text) {
const t = String(text || '');
for (const [book, re] of BOOK_MARKERS) {
if (re.test(t)) return book;
}
return null;
}
/**
* Parse OCR slip text. Uses the hinted/detected book's layout parser;
* unknown book → try every layout, keep the best result (most legs,
* tie-broken by fewest needs_review). Layout SELECTION is heuristic;
* field VALUES never are — unparseable fields stay null.
*/
function parseSlipText(text, bookHint) {
const hinted = bookHint && PARSERS[String(bookHint).toLowerCase()]
? String(bookHint).toLowerCase() : null;
const detected = hinted || detectBook(text);
if (detected) {
const legs = PARSERS[detected](text);
return finalize(detected, legs);
}
let best = { book: null, legs: [] };
for (const [book, parser] of Object.entries(PARSERS)) {
const legs = parser(text);
const better = legs.length > best.legs.length
|| (legs.length === best.legs.length && legs.length > 0
&& countReview(legs) < countReview(best.legs));
if (better) best = { book, legs };
}
return finalize(best.legs.length ? best.book : null, best.legs);
}
function countReview(legs) { return legs.filter((l) => l.needs_review).length; }
function finalize(book, legs) {
return {
book,
legs,
needs_review: legs.length === 0 || legs.some((l) => l.needs_review),
source: 'user_slip',
};
}
/* ── OCR (tesseract.js — self-hosted WASM, zero API) ───────────── */
/**
* Run OCR on an image buffer. Lazy-requires tesseract.js so the pure
* parsers stay loadable without the WASM engine (tests, cold paths).
* English traineddata (~11MB) downloads once and caches on disk.
*/
async function recognizeImage(buffer, opts = {}) {
const { createWorker } = opts.tesseract || require('tesseract.js');
const worker = await createWorker('eng', 1, {
cachePath: opts.cachePath || process.env.TESSERACT_CACHE_PATH || '.tesseract-cache',
// Silence per-job progress logging.
logger: () => {},
});
try {
const { data } = await worker.recognize(buffer);
return data.text || '';
} finally {
await worker.terminate();
}
}
module.exports = {
parseSlipText,
detectBook,
recognizeImage,
CONFIDENCE_THRESHOLD,
__internals: {
parseDraftKings,
parseFanDuel,
parseBetMGM,
parseCaesars,
normalizeStat,
splitPlayerStat,
statLabel,
parseOdds,
buildLeg,
STAT_ALIASES,
NOISE_RE,
},
};
+60
View File
@@ -0,0 +1,60 @@
'use strict';
/**
* systemHealth — box vitals (Session 8, A1 board — ops).
*
* Pure + injectable: disk usage via fs.promises.statfs('/'), memory via
* os.freemem/os.totalmem. Returns percentages (0-100, rounded) or null when a
* probe fails — the caller renders "n/a", never a fabricated number (Data
* Semantics Rule applies to ops copy too). Zero dependencies, zero cost.
*/
const fsp = require('fs').promises;
const os = require('os');
/** Alerting thresholds — checked by the daily pulse. */
const THRESHOLDS = { disk_pct: 85, mem_pct: 90 };
const roundPct = (frac) => Math.min(100, Math.max(0, Math.round(frac * 100)));
/**
* { disk_pct, mem_pct } — each null when its probe fails.
* opts: { fsImpl (statfs), osImpl (freemem/totalmem), path }.
*/
async function getSystemHealth(opts = {}) {
const fsImpl = opts.fsImpl || fsp;
const osImpl = opts.osImpl || os;
const out = { disk_pct: null, mem_pct: null };
try {
const s = await fsImpl.statfs(opts.path || '/');
if (s && Number.isFinite(s.blocks) && s.blocks > 0 && Number.isFinite(s.bavail)) {
out.disk_pct = roundPct(1 - s.bavail / s.blocks);
}
} catch { /* disk_pct stays null */ }
try {
const total = osImpl.totalmem();
const free = osImpl.freemem();
if (Number.isFinite(total) && total > 0 && Number.isFinite(free)) {
out.mem_pct = roundPct(1 - free / total);
}
} catch { /* mem_pct stays null */ }
return out;
}
/**
* Deadpan issue lines for anything over threshold (strictly >). Empty array =
* healthy. Copy carries the numbers; no punctuation theatrics.
*/
function healthIssues(health, thresholds = THRESHOLDS) {
const issues = [];
if (!health) return issues;
if (Number.isFinite(health.disk_pct) && health.disk_pct > thresholds.disk_pct) {
issues.push(`disk at ${health.disk_pct}% (threshold ${thresholds.disk_pct}%)`);
}
if (Number.isFinite(health.mem_pct) && health.mem_pct > thresholds.mem_pct) {
issues.push(`memory at ${health.mem_pct}% (threshold ${thresholds.mem_pct}%)`);
}
return issues;
}
module.exports = { getSystemHealth, healthIssues, THRESHOLDS };
+108 -3
View File
@@ -60,9 +60,19 @@ function startSnapshotScheduler(opts = {}) {
const settleLedgers = opts.settleAllLedgers || require('./services/ledgerService').settleAllLedgers;
const notify = opts.notify || require('./utils/opsNotify').notify;
const cacheGet = opts.cacheGet || require('./utils/redis').cacheGet;
const cacheSet = opts.cacheSet || require('./utils/redis').cacheSet;
const now = opts.now || (() => new Date());
// Session 8 (A1 board) — ops: the product watches itself. Pure logic lives
// in services/opsWatch + services/systemHealth; this file only wires it.
const opsWatch = require('./services/opsWatch');
const getSystemHealth = opts.getSystemHealth || require('./services/systemHealth').getSystemHealth;
const healthIssues = opts.healthIssues || require('./services/systemHealth').healthIssues;
const getQuotaStatus = opts.getQuotaStatus || require('./services/quotaTracker').getQuotaStatus;
const countLedgerRows = opts.countLedgerRows || require('./services/ledgerService').countRowsForDate;
const failureTracker = opts.failureTracker || opsWatch.createFailureTracker();
let lastFiredSlot = null;
let lastOverdueSlot = null;
let lastPulseDate = null;
// Session 56 — missed-cron watchdog. Runs every minute (independent of the
// fire schedule): if a scheduled slot came and went without a snapshot, alert
@@ -83,8 +93,55 @@ function startSnapshotScheduler(opts = {}) {
} catch { /* watchdog must never throw */ }
};
// Session 8 — daily 9 AM ET pulse (13:00 UTC), ONE notification. Dedupe is
// belt-and-braces: in-process date + a Redis key that survives a restart
// inside the same day. Runs on the per-minute cadence, independent of slots.
const PULSE_HOUR_UTC = Number.parseInt(process.env.PULSE_HOUR_UTC || '13', 10);
const pulseTick = async () => {
try {
const d = now();
if (d.getUTCHours() !== PULSE_HOUR_UTC || d.getUTCMinutes() !== 0) return;
const dayKey = d.toISOString().slice(0, 10);
if (dayKey === lastPulseDate) return;
lastPulseDate = dayKey;
if (await cacheGet(`ops:pulse:${dayKey}`)) return; // already sent today (pre-restart)
await cacheSet(`ops:pulse:${dayKey}`, '1', 2 * 24 * 3600);
const yesterdayEt = opsWatch.dateET(new Date(d.getTime() - 24 * 3600 * 1000));
let ledgerRows = null;
try { ledgerRows = await countLedgerRows(yesterdayEt); } catch { /* n/a */ }
let settles24h = null;
try {
const logs = [];
for (const sp of ['mlb', 'nba', 'wnba', 'soccer']) {
const raw = await cacheGet(`outcomes:${sp}:log`);
logs.push(Array.isArray(raw) ? raw : (raw && raw.log) || []);
}
settles24h = opsWatch.countRecentSettles(logs, d.getTime());
} catch { /* n/a */ }
let quota = null;
try { quota = await getQuotaStatus('odds-api'); } catch { /* n/a */ }
let health = null;
try { health = await getSystemHealth(); } catch { /* n/a */ }
await notify(
opsWatch.buildPulseMessage({ dateEt: opsWatch.dateET(d), ledgerRows, settles24h, quota, health }),
{ title: 'VYNDR daily pulse', tags: ['newspaper'] },
);
// Box health pages separately at high priority — the pulse is a read,
// the threshold breach is an action item.
const issues = healthIssues(health);
if (issues.length > 0) {
await notify(`Box health: ${issues.join('; ')}. The record does not fit on a full disk.`, {
title: 'VYNDR box', priority: 'high', tags: ['warning'],
});
}
} catch { /* the pulse must never break the scheduler */ }
};
const tick = async () => {
await checkOverdue();
await pulseTick();
const d = now();
if (d.getUTCMinutes() !== 0) return;
const h = d.getUTCHours();
@@ -92,27 +149,73 @@ function startSnapshotScheduler(opts = {}) {
const slot = `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}-${h}`;
if (slot === lastFiredSlot) return; // fire once per slot
lastFiredSlot = slot;
// Session 8 — a settle pass that THROWS is a paged event, not a log line.
// Settlement failing quietly is how the record dies.
try {
const settled = await settleAll();
const totalSettled = settled.reduce((n, r) => n + (r.settled || 0), 0);
console.log(`[outcomes] cron fired ${h}:00 UTC — ${totalSettled} props settled vs real results`);
} catch (e) {
console.warn('[outcomes] settle run failed:', e.message);
await notify(`Outcome settlement THREW at ${h}:00 UTC — ${e.message}. Yesterday's grades are unsettled until the next slot.`, {
title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'],
});
}
let ledgerResults = null;
try {
const ledger = await settleLedgers();
const n = ledger.reduce((t, r) => t + (r.settled || 0), 0);
ledgerResults = await settleLedgers();
const n = ledgerResults.reduce((t, r) => t + (r.settled || 0), 0);
console.log(`[ledger] settle pass — ${n} entries settled (outcome + CLV)`);
} catch (e) {
console.warn('[ledger] settle run failed:', e.message);
await notify(`Ledger settlement THREW at ${h}:00 UTC — ${e.message}. The public record did not advance.`, {
title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'],
});
}
// Session 8 — zero-settle alarm, MORNING slot only (the book-closing pass).
// Signal = the ledger settle's own Postgres-backed return values (see
// opsWatch.zeroSettleAlarm for why not snapshot:{sport}:previous). Deduped
// once per ET date; a genuinely empty yesterday (0 rows fetched) never fires.
try {
if (h === opsWatch.morningHourUtc(HOURS_UTC) && ledgerResults) {
const z = opsWatch.zeroSettleAlarm(ledgerResults);
if (z.alarm) {
const dk = `ops:settle_zero:${opsWatch.dateET(d)}`;
if (!(await cacheGet(dk))) {
await cacheSet(dk, '1', 2 * 24 * 3600);
await notify(`Morning settle pass closed with 0 settles — ${z.pending} ledger rows from before today are still pending. Yesterday had graded rows; the record did not advance.`, {
title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'],
});
}
}
}
} catch { /* alarm evaluation must never break the tick */ }
try {
const results = await runAll();
const ok = results.filter((r) => r.status === 'ok');
console.log(`[snapshot] cron fired ${h}:00 UTC — ${ok.length}/${results.length} sports graded`);
// Session 63 (A1-S4) — after the day's FIRST slot, tell Kev the media
// pack is ready. One ping, only when something actually graded.
if (h === HOURS_UTC[0] && ok.length > 0) {
const total = results.reduce((n, r) => n + (r.gradeCount || 0), 0);
await notify(`Desk pack ready — ${total} props graded across ${ok.length} sports. vyndr.app/desk`, { title: 'VYNDR desk', tags: ['newspaper'] });
}
// Session 8 — persistent-failure pager: 3+ CONSECUTIVE erroring slots for
// a sport pages once (single-slot errors are normal before lines post).
for (const r of results) {
const t = failureTracker.record(r.sport, r);
if (t.shouldPage) {
await notify(`${String(r.sport).toUpperCase()} snapshot has produced nothing for ${t.count} consecutive slots (latest: ${r.status}${r.reason ? `${r.reason}` : ''}). One empty slot is a quiet book; ${t.count} is a broken pipe.`, {
title: 'VYNDR pipeline', priority: 'high', tags: ['rotating_light'],
});
}
}
} catch (e) {
console.warn('[snapshot] cron run failed:', e.message);
}
// Session 8 — quota check after each snapshot run: odds-api >= 80% alerts
// once per day (Redis-deduped). Never throws (guarded inside checkQuotaDaily).
await opsWatch.checkQuotaDaily({ getStatus: getQuotaStatus, cacheGet, cacheSet, notify, now: () => now() });
};
// Session 60 (night2/D) — Phase 2.5 intraday refresh. Every
@@ -152,7 +255,9 @@ function startSnapshotScheduler(opts = {}) {
// was invisible at boot, which made "is settlement scheduled?" unanswerable
// from logs. This line makes it verifiable forever.
console.log(`[settlement] armed — outcomes + ledger settle pass runs FIRST at each snapshot slot (${HOURS_UTC.join(',')} UTC), idempotent re-runs`);
return { interval, tick, refreshTick };
// Session 8 — same verifiability rule: every watchdog states itself at boot.
console.log(`[opsWatch] armed — settle alarms (throw + morning zero-settle), failure pager (${failureTracker.threshold} consecutive), quota daily check, pulse ${PULSE_HOUR_UTC}:00 UTC`);
return { interval, tick, refreshTick, pulseTick };
}
module.exports = { startSnapshotScheduler, HOURS_UTC, mostRecentExpectedSlot, isSnapshotOverdue };
+41
View File
@@ -0,0 +1,41 @@
'use strict';
/**
* Quarter-Kelly sizing (Session 62 / A1-S1 — the pricing page promised it;
* now it exists). Pure math, no I/O.
*
* DATA SEMANTICS: Kelly needs a REAL win probability and REAL book odds.
* p comes from the engine's quantile probability estimator (game-log based);
* odds come from the captured book row. Either missing → null — a sizing
* recommendation is never fabricated from confidence scores or defaults.
*/
function americanToDecimal(american) {
const a = Number(american);
if (!Number.isFinite(a) || a === 0) return null;
return a > 0 ? 1 + a / 100 : 1 + 100 / Math.abs(a);
}
/**
* quarterKelly(p, americanOdds) →
* { full, quarter, pct } — fractions of bankroll (pct = quarter × 100,
* rounded to 0.1) — or null when p/odds are unusable or the edge is
* non-positive (Kelly says: no bet).
*/
function quarterKelly(p, americanOdds) {
const prob = Number(p);
if (!Number.isFinite(prob) || prob <= 0 || prob >= 1) return null;
const dec = americanToDecimal(americanOdds);
if (dec == null || dec <= 1) return null;
const b = dec - 1;
const full = (b * prob - (1 - prob)) / b;
if (!Number.isFinite(full) || full <= 0) return null;
const quarter = full / 4;
return {
full: Math.round(full * 1000) / 1000,
quarter: Math.round(quarter * 1000) / 1000,
pct: Math.round(quarter * 1000) / 10, // % of bankroll at quarter-Kelly
};
}
module.exports = { quarterKelly, americanToDecimal };
+12 -3
View File
@@ -46,14 +46,23 @@ function lockKillConditions(list) {
*/
function applyTierGating(result, tierName) {
if (!result || typeof result !== 'object') return result;
// Session 62 (A1-S1) — Desk-only intelligence is stripped at the API, not
// just the adapter: alt line ladder + quarter-Kelly never leave the server
// for lower tiers. (The pricing page sells them as Desk-only; the gate
// makes that true at the wire.)
const deskGated = { ...result };
if (!canAccess(tierName, 'alt_line_ladder')) delete deskGated.alt_lines;
if (!canAccess(tierName, 'kelly_sizing')) delete deskGated.kelly;
if (canAccess(tierName, 'reasoning_visible')) {
// Paid tier — pass through unchanged.
return result;
// Paid tier — everything else passes through unchanged.
return deskGated;
}
// Free tier: keep grade + confidence + edge_pct (the hook), redact
// reasoning + kill condition explanations.
return {
...result,
...deskGated,
reasoning: lockReasoning(result.reasoning),
kill_conditions_triggered: lockKillConditions(result.kill_conditions_triggered),
tier_gated: true,
+46
View File
@@ -0,0 +1,46 @@
-- ---------------------------------------------------------------
-- 021 — partner attribution (A1 Session 3, per docs/PARTNERS.md).
--
-- Adds user_profiles.partner_ref (the external partner code captured from
-- the ?ref= cookie at signup and stored in auth metadata), extends
-- handle_new_user to copy it on profile creation (verified against the
-- LIVE function definition — it inserts (id, email) only; this adds one
-- column and preserves SECURITY DEFINER + search_path), and backfills
-- rows whose auth metadata already carries a ref.
--
-- Distinct from migration 004's USER-referral system: partner codes are
-- external strings; no dependency on referral_codes tables.
-- ---------------------------------------------------------------
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS partner_ref text;
CREATE INDEX IF NOT EXISTS idx_user_profiles_partner_ref
ON public.user_profiles(partner_ref)
WHERE partner_ref IS NOT NULL;
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
begin
insert into public.user_profiles (id, email, partner_ref)
values (
new.id,
new.email,
upper(nullif(new.raw_user_meta_data->>'partner_ref', ''))
)
on conflict (id) do nothing;
return new;
end;
$function$;
-- One-time backfill for signups that predate the column.
UPDATE public.user_profiles p
SET partner_ref = upper(u.raw_user_meta_data->>'partner_ref')
FROM auth.users u
WHERE u.id = p.id
AND p.partner_ref IS NULL
AND nullif(u.raw_user_meta_data->>'partner_ref', '') IS NOT NULL;
@@ -0,0 +1,35 @@
-- ---------------------------------------------------------------
-- 022 — public_profiles (A1 Session 10, public ledger profiles v1).
--
-- Stage-3 seed: a user may claim a handle and publish their ENTIRE settled
-- ledger record on a public page — wins and misses. Strava for betting.
--
-- PRIVACY: PRIVATE BY DEFAULT. `published` defaults false and only flips
-- via one explicit toggle in Settings. The profile API returns the SAME
-- 404 for an unknown handle and an unpublished one (no existence leak).
--
-- All writes go through the service role (the /api/profiles/me route).
-- Clients only read: published rows (anyone) + their own row (owner).
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.public_profiles (
user_id uuid PRIMARY KEY REFERENCES auth.users (id) ON DELETE CASCADE,
handle text UNIQUE NOT NULL CHECK (handle ~ '^[a-z0-9_]{3,20}$'),
published boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.public_profiles ENABLE ROW LEVEL SECURITY;
-- Anyone (anon + authenticated) reads PUBLISHED profiles only.
CREATE POLICY public_profiles_select_published ON public.public_profiles
FOR SELECT TO anon, authenticated
USING (published = true);
-- The owner reads their own row (published or not).
CREATE POLICY public_profiles_select_own ON public.public_profiles
FOR SELECT TO authenticated
USING (user_id = auth.uid());
-- No INSERT/UPDATE/DELETE policies: client-side writes are impossible.
-- The API writes via the service role, which bypasses RLS.
+10
View File
@@ -0,0 +1,10 @@
BetMGM
PARLAY
Aaron Judge Over 1.5 Total Bases @ -115
NY Yankees at BOS Red Sox
Shohei Ohtani Under 7.5 Strikeouts @ +105
LA Dodgers at SD Padres
Mookie Betts Over 1.5 Hits +140
LA Dodgers at SD Padres
Stake: $10.00
Potential returns: $68.20
+10
View File
@@ -0,0 +1,10 @@
Caesars Sportsbook
Bet ID 8823941
Aaron Judge Total Bases Over 1.5 (-115)
NY Yankees vs BOS Red Sox
Pete Alonso Home Runs Over 0.5 (+340)
NY Mets vs ATL Braves
Freddie Freeman Hits Under 1.5 (-105)
LA Dodgers vs SD Padres
Wager $10.00
To Win $61.50
+20
View File
@@ -0,0 +1,20 @@
DraftKings Sportsbook
3 Leg Parlay
+575
Aaron Judge Over 1.5
Total Bases
-115
NY Yankees @ BOS Red Sox
Today 7:05PM
Shohei Ohtani Over 7.5
Strikeouts Thrown
-130
LA Dodgers @ SD Padres
Today 9:40PM
Juan Soto Over 0.5
Home Runs
+320
NY Mets @ ATL Braves
Today 7:20PM
Wager: $10.00
To Win: $57.50
+10
View File
@@ -0,0 +1,10 @@
FanDuel Sportsbook
SAME GAME PARLAY
+650
Aaron Judge To Record 2+ Total Bases
Giancarlo Stanton Any Time Home Run
Gerrit Cole Over 6.5 Strikeouts
NY Yankees @ BOS Red Sox
7:05 PM ET
$10 bet to win $65.00
Total payout $75.00
+152
View File
@@ -0,0 +1,152 @@
'use strict';
// Session S7 (a1) — THE VYNDR REPORT routes. Redis is mocked (app.js pulls it
// in transitively); Listmonk is a mocked global fetch. NOTE: the subscribe
// route sits behind a 10/min IP limiter with no reset hook (same lesson as
// analyze.test.js) — this file stays well under 10 subscribe requests.
const request = require('supertest');
let mockStore = {};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => ({}),
cacheGet: async (k) => (k in mockStore ? mockStore[k] : null),
cacheSet: async (k, v) => { mockStore[k] = v; return true; },
cacheDel: async () => true,
isDegraded: () => false,
}));
const app = require('../../src/app');
const LISTMONK_ENV = {
LISTMONK_URL: 'http://127.0.0.1:9000',
LISTMONK_USER: 'vyndr-api',
LISTMONK_TOKEN: 'tok123',
LISTMONK_LIST_ID: '3',
};
const ENV_KEYS = [...Object.keys(LISTMONK_ENV), 'VYNDR_INTERNAL_KEY',
// Hermetic: a developer .env with real Supabase creds must not make
// ledgerService.getModelAggregate reach for the network in this suite.
'SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_SERVICE_KEY'];
const savedEnv = {};
beforeAll(() => { for (const k of ENV_KEYS) savedEnv[k] = process.env[k]; });
afterAll(() => {
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});
const realFetch = globalThis.fetch;
beforeEach(() => {
mockStore = {};
for (const k of ENV_KEYS) delete process.env[k];
});
afterEach(() => { globalThis.fetch = realFetch; });
describe('POST /api/newsletter/subscribe', () => {
test('no Listmonk env → calm HTTP 200 not-configured (never a scary error)', async () => {
globalThis.fetch = jest.fn();
const res = await request(app)
.post('/api/newsletter/subscribe')
.send({ email: 'kev@example.com' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: false, reason: 'not configured' });
expect(globalThis.fetch).not.toHaveBeenCalled();
});
test('honeypot filled → silent ok, nothing forwarded', async () => {
Object.assign(process.env, LISTMONK_ENV);
globalThis.fetch = jest.fn();
const res = await request(app)
.post('/api/newsletter/subscribe')
.send({ email: 'bot@example.com', website: 'http://spam.example' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(globalThis.fetch).not.toHaveBeenCalled();
});
test('invalid email → 400 with a plain message', async () => {
const res = await request(app)
.post('/api/newsletter/subscribe')
.send({ email: 'not-an-email' });
expect(res.status).toBe(400);
expect(res.body.ok).toBe(false);
expect(res.body.error).toBe('Enter a valid email.');
});
test('env set → forwards to Listmonk with double opt-in (preconfirm false)', async () => {
Object.assign(process.env, LISTMONK_ENV);
globalThis.fetch = jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }));
const res = await request(app)
.post('/api/newsletter/subscribe')
.send({ email: 'Kev@Example.com' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, opts] = globalThis.fetch.mock.calls[0];
expect(url).toBe('http://127.0.0.1:9000/api/subscribers');
expect(opts.headers.Authorization).toBe('token vyndr-api:tok123');
const body = JSON.parse(opts.body);
expect(body.email).toBe('kev@example.com');
expect(body.lists).toEqual([3]);
expect(body.preconfirm_subscriptions).toBe(false);
});
test('Listmonk 409 (already subscribed) → still ok, no enumeration', async () => {
Object.assign(process.env, LISTMONK_ENV);
globalThis.fetch = jest.fn(async () => ({ ok: false, status: 409, json: async () => ({}) }));
const res = await request(app)
.post('/api/newsletter/subscribe')
.send({ email: 'kev@example.com' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
test('Listmonk down → calm HTTP 200 unavailable', async () => {
Object.assign(process.env, LISTMONK_ENV);
globalThis.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
const res = await request(app)
.post('/api/newsletter/subscribe')
.send({ email: 'kev@example.com' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: false, reason: 'unavailable' });
});
});
describe('POST /api/internal/newsletter/send', () => {
test('requires the internal key', async () => {
process.env.VYNDR_INTERNAL_KEY = 'shh';
const res = await request(app).post('/api/internal/newsletter/send').send({});
expect(res.status).toBe(401);
});
test('with key but no Listmonk env → graceful no-op (operator sees why)', async () => {
process.env.VYNDR_INTERNAL_KEY = 'shh';
globalThis.fetch = jest.fn();
const res = await request(app)
.post('/api/internal/newsletter/send')
.set('x-internal-key', 'shh')
.send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: false, reason: 'not configured' });
expect(globalThis.fetch).not.toHaveBeenCalled();
});
test('with key + env but an empty pipeline → refuses to send an empty report', async () => {
process.env.VYNDR_INTERNAL_KEY = 'shh';
Object.assign(process.env, LISTMONK_ENV);
globalThis.fetch = jest.fn();
// mockStore is empty → no snapshots, no roster logs → empty report.
const res = await request(app)
.post('/api/internal/newsletter/send')
.set('x-internal-key', 'shh')
.send({ sports: ['mlb'] });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(false);
expect(res.body.reason).toBe('empty report');
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});
+219
View File
@@ -0,0 +1,219 @@
// A1 Session 10 — /api/profiles: claim handle + publish toggle (auth) and the
// public record read. Supabase + auth are mocked (ledgerRoutes.test.js
// pattern); under test: handle validation, 409 on a taken handle,
// private-by-default writes, and the no-existence-leak 404 (unknown and
// unpublished handles are byte-identical).
const express = require('express');
const request = require('supertest');
jest.mock('../../src/middleware/auth', () => ({
requireAuth: (req, res, next) => {
if (!req.headers.authorization) return res.status(401).json({ error: 'auth required' });
req.user = { id: 'u1', tier: 'analyst' };
return next();
},
}));
// Table-aware Supabase stub. Configure via mockState; every query records
// its filters so scoping is assertable.
const mockState = {
profileRow: null, // what public_profiles maybeSingle resolves to
upsertError: null,
upserts: [],
ledgerRows: [],
filters: [], // [table, filters[]]
};
function mockChain(table) {
const b = { _filters: [] };
const rec = (op) => (...args) => { b._filters.push([op, ...args]); return b; };
b.select = () => b;
b.eq = rec('eq');
b.is = rec('is');
b.not = rec('not');
b.gte = rec('gte');
b.order = () => b;
b.maybeSingle = () => {
mockState.filters.push([table, b._filters]);
return Promise.resolve({ data: mockState.profileRow, error: null });
};
b.limit = () => {
mockState.filters.push([table, b._filters]);
return Promise.resolve({ data: mockState.ledgerRows, error: null, count: 0 });
};
b.then = (resolve, reject) => {
mockState.filters.push([table, b._filters]);
return Promise.resolve({ data: mockState.ledgerRows, error: null, count: 0 }).then(resolve, reject);
};
b.upsert = (row, opts) => {
mockState.upserts.push({ table, row, opts });
return Promise.resolve({ error: mockState.upsertError });
};
return b;
}
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({ from: (table) => mockChain(table) }),
}));
process.env.SUPABASE_URL = 'https://test.supabase.co';
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key';
function mountApp() {
delete require.cache[require.resolve('../../src/routes/profiles')];
const routes = require('../../src/routes/profiles');
const app = express();
app.use(express.json());
app.use('/api/profiles', routes);
return app;
}
beforeEach(() => {
mockState.profileRow = null;
mockState.upsertError = null;
mockState.upserts.length = 0;
mockState.ledgerRows.length = 0;
mockState.filters.length = 0;
});
describe('GET /api/profiles/me', () => {
test('401 without auth', async () => {
const res = await request(mountApp()).get('/api/profiles/me');
expect(res.status).toBe(401);
});
test('returns the own row scoped to the authenticated user', async () => {
mockState.profileRow = { handle: 'kev', published: false, created_at: '2026-07-10' };
const res = await request(mountApp())
.get('/api/profiles/me')
.set('Authorization', 'Bearer t');
expect(res.status).toBe(200);
expect(res.body.profile).toEqual({ handle: 'kev', published: false, created_at: '2026-07-10' });
const [table, filters] = mockState.filters[0];
expect(table).toBe('public_profiles');
expect(filters).toContainEqual(['eq', 'user_id', 'u1']);
});
test('no row yet → profile null (not an error)', async () => {
const res = await request(mountApp())
.get('/api/profiles/me')
.set('Authorization', 'Bearer t');
expect(res.status).toBe(200);
expect(res.body.profile).toBeNull();
});
});
describe('POST /api/profiles/me', () => {
test('401 without auth', async () => {
const res = await request(mountApp()).post('/api/profiles/me').send({ handle: 'kev' });
expect(res.status).toBe(401);
});
test.each(['ab', 'way_too_long_for_a_handle', 'has space', 'bad-dash', 'dot.dot', ''])(
'rejects invalid handle %j with 400 and writes nothing',
async (handle) => {
const res = await request(mountApp())
.post('/api/profiles/me')
.set('Authorization', 'Bearer t')
.send({ handle });
expect(res.status).toBe(400);
expect(mockState.upserts).toHaveLength(0);
},
);
test('upserts own row; PRIVATE BY DEFAULT — published false unless explicitly true', async () => {
const res = await request(mountApp())
.post('/api/profiles/me')
.set('Authorization', 'Bearer t')
.send({ handle: 'Kev_2026' }); // no published flag at all
expect(res.status).toBe(200);
expect(res.body.profile).toEqual({ handle: 'kev_2026', published: false });
expect(mockState.upserts).toHaveLength(1);
expect(mockState.upserts[0].table).toBe('public_profiles');
expect(mockState.upserts[0].row).toEqual({ user_id: 'u1', handle: 'kev_2026', published: false });
expect(mockState.upserts[0].opts).toEqual({ onConflict: 'user_id' });
});
test('published only flips on an explicit boolean true', async () => {
const res = await request(mountApp())
.post('/api/profiles/me')
.set('Authorization', 'Bearer t')
.send({ handle: 'kev', published: true });
expect(res.status).toBe(200);
expect(mockState.upserts[0].row.published).toBe(true);
// truthy-but-not-true must NOT publish
const res2 = await request(mountApp())
.post('/api/profiles/me')
.set('Authorization', 'Bearer t')
.send({ handle: 'kev', published: 'yes' });
expect(res2.status).toBe(200);
expect(mockState.upserts[1].row.published).toBe(false);
});
test('handle owned by another user → 409', async () => {
mockState.upsertError = { code: '23505', message: 'duplicate key value violates unique constraint "public_profiles_handle_key"' };
const res = await request(mountApp())
.post('/api/profiles/me')
.set('Authorization', 'Bearer t')
.send({ handle: 'taken_one' });
expect(res.status).toBe(409);
});
});
describe('GET /api/profiles/:handle (public)', () => {
test('published profile → handle + user-scoped aggregate + settled entries', async () => {
mockState.profileRow = { user_id: 'u2', handle: 'kev', published: true };
mockState.ledgerRows.push({ id: 'r1', player_name: 'Judge', outcome: 'hit', grade: 'A' });
const res = await request(mountApp()).get('/api/profiles/kev');
expect(res.status).toBe(200);
expect(res.body.handle).toBe('kev');
expect(res.body.min_sample).toBe(20);
expect(res.body.aggregate).toBeTruthy();
expect(res.body.aggregate.hit_pct).not.toBeUndefined();
expect(Array.isArray(res.body.entries)).toBe(true);
// Every ledger query is scoped to THAT user, never the public record.
const ledgerQueries = mockState.filters.filter(([t]) => t === 'ledger_entries');
expect(ledgerQueries.length).toBeGreaterThan(0);
for (const [, filters] of ledgerQueries) {
expect(filters).toContainEqual(['eq', 'user_id', 'u2']);
expect(filters.some((f) => f[0] === 'is' && f[1] === 'user_id')).toBe(false);
}
// The entries list is settled rows only.
const entriesQ = ledgerQueries.find(([, f]) => f.some((x) => x[0] === 'not' && x[1] === 'outcome'));
expect(entriesQ).toBeTruthy();
});
test('NO EXISTENCE LEAK — unknown and unpublished return the identical 404', async () => {
// Unknown handle: no row at all.
mockState.profileRow = null;
const unknown = await request(mountApp()).get('/api/profiles/ghost_handle');
// Unpublished handle: row exists, published false.
mockState.profileRow = { user_id: 'u3', handle: 'private_kev', published: false };
const unpublished = await request(mountApp()).get('/api/profiles/private_kev');
expect(unknown.status).toBe(404);
expect(unpublished.status).toBe(404);
expect(unknown.body).toEqual(unpublished.body);
});
test('an unpublished profile leaks NO ledger data', async () => {
mockState.profileRow = { user_id: 'u3', handle: 'private_kev', published: false };
mockState.ledgerRows.push({ id: 'r1', player_name: 'Judge', outcome: 'hit', grade: 'A' });
const res = await request(mountApp()).get('/api/profiles/private_kev');
expect(res.status).toBe(404);
expect(res.body.entries).toBeUndefined();
expect(res.body.aggregate).toBeUndefined();
// The ledger table was never even queried.
expect(mockState.filters.filter(([t]) => t === 'ledger_entries')).toHaveLength(0);
});
test('a malformed handle shape gets the same 404 without touching the database', async () => {
const res = await request(mountApp()).get('/api/profiles/NOT%20A%20HANDLE');
expect(res.status).toBe(404);
expect(mockState.filters).toHaveLength(0);
});
});
+179
View File
@@ -0,0 +1,179 @@
// A1 S9 — POST /api/slips/parse route contract: auth, tier-aware daily
// quota (free 1/day, paid 10/day), payload validation, text-path parse.
// Auth + redis are mocked (hermetic — no supabase, no ioredis client).
const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
// requireAuth stub: Authorization present → user from header knobs; else 401.
jest.mock('../../src/middleware/auth', () => ({
requireAuth: (req, res, next) => {
if (!req.headers.authorization) return res.status(401).json({ error: 'Authentication required' });
req.user = {
id: req.headers['x-test-user'] || 'u1',
tier: req.headers['x-test-tier'] || 'free',
};
return next();
},
}));
// Redis stub — in-memory kv so no ioredis client is ever created (single-suite
// runs hang at exit otherwise; see Session 8 note).
const mockKv = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: jest.fn(async (k) => (mockKv.has(k) ? mockKv.get(k) : null)),
cacheSet: jest.fn(async (k, v) => { mockKv.set(k, v); }),
cacheDel: jest.fn(async (k) => { mockKv.delete(k); }),
getRedisClient: jest.fn(() => null),
isDegraded: jest.fn(() => false),
}));
const slipsRouter = require('../../src/routes/slips');
const dkFixture = fs.readFileSync(
path.join(__dirname, '..', 'fixtures', 'slips', 'draftkings.txt'), 'utf8',
);
function mountApp() {
const app = express();
app.use(express.json({ limit: '10mb' }));
app.use('/api/slips', slipsRouter);
return app;
}
describe('POST /api/slips/parse', () => {
let app;
beforeEach(() => {
mockKv.clear();
slipsRouter.__internals.resetForTests();
slipsRouter.__internals.setDeps({});
app = mountApp();
});
it('401s without auth', async () => {
const res = await request(app).post('/api/slips/parse').send({ text: dkFixture });
expect(res.status).toBe(401);
});
it('400s with neither image nor text', async () => {
const res = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.send({});
expect(res.status).toBe(400);
});
it('400s an undecodable image without burning the daily slot', async () => {
const bad = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.send({ image: '!!!not-base64!!!' });
expect(bad.status).toBe(400);
// The slot was not consumed — a valid parse still works.
const ok = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.send({ text: dkFixture });
expect(ok.status).toBe(200);
});
it('400s an image over 4MB', async () => {
// ~4.5MB of zeros, base64-encoded (~6MB payload — under the 10mb body cap).
const big = Buffer.alloc(4.5 * 1024 * 1024).toString('base64');
const res = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.send({ image: big });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/4MB/);
});
it('parses slip text into legs (user_slip semantics)', async () => {
const res = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.send({ text: dkFixture });
expect(res.status).toBe(200);
expect(res.body.book).toBe('draftkings');
expect(res.body.source).toBe('user_slip');
expect(res.body.legs).toHaveLength(3);
expect(res.body.legs[0]).toMatchObject({
player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', odds: -115,
});
});
it('free tier: 1 parse/day, second 429s with quota headers', async () => {
const first = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.send({ text: dkFixture });
expect(first.status).toBe(200);
expect(first.headers['x-slips-limit']).toBe('1');
const second = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.send({ text: dkFixture });
expect(second.status).toBe(429);
expect(second.body.limit).toBe(1);
expect(second.body.tier).toBe('free');
});
it('paid tier: 10 parses/day, 11th 429s', async () => {
for (let i = 0; i < 10; i += 1) {
const res = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.set('x-test-tier', 'analyst')
.send({ text: dkFixture });
expect(res.status).toBe(200);
}
const eleventh = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.set('x-test-tier', 'analyst')
.send({ text: dkFixture });
expect(eleventh.status).toBe(429);
expect(eleventh.body.limit).toBe(10);
});
it('quota is per-user (a second user still parses)', async () => {
await request(app).post('/api/slips/parse')
.set('Authorization', 'Bearer t').send({ text: dkFixture });
const other = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.set('x-test-user', 'u2')
.send({ text: dkFixture });
expect(other.status).toBe(200);
});
it('image path runs the injected OCR then parses', async () => {
slipsRouter.__internals.setDeps({
recognizeImage: jest.fn(async () => dkFixture),
});
const png = Buffer.from('fake-png-bytes').toString('base64');
const res = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.send({ image: `data:image/png;base64,${png}` });
expect(res.status).toBe(200);
expect(res.body.legs).toHaveLength(3);
});
it('503s when OCR blows up (honest failure, no empty fabrication)', async () => {
slipsRouter.__internals.setDeps({
recognizeImage: jest.fn(async () => { throw new Error('wasm sad'); }),
});
const png = Buffer.from('fake-png-bytes').toString('base64');
const res = await request(app)
.post('/api/slips/parse')
.set('Authorization', 'Bearer t')
.send({ image: png });
expect(res.status).toBe(503);
});
});
+11 -5
View File
@@ -1,20 +1,26 @@
// Session 52 — Push-to-Book "Coming Soon" teaser (source-asserted).
// Session 52 shipped BOOK IT as a "coming soon" teaser. A1 Session 3
// made it a REAL sportsbook deep link (organic until affiliateConfig
// flips a book on). GradeResultCard's PUSH-TO-BOOK (account linking /
// bet-slip push) remains a genuine teaser — that feature is unbuilt.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('BOOK IT teaser', () => {
it('StatStrip renders the "BOOK IT" teaser on graded props', () => {
describe('BOOK IT deep link (was the S52 teaser)', () => {
it('StatStrip renders BOOK IT as a real anchor built by bookLinks', () => {
const src = read('components/vyndr/StatStrip.tsx');
expect(src).toContain('BookItTeaser');
expect(src).toContain('BOOK IT ⟶');
expect(src).toContain('Push-to-Book coming soon');
expect(src).toContain('<BookItTeaser p={p} />');
expect(src).toContain('buildBookLink({ book: target, player, sport })');
expect(src).toContain('rel={BOOK_LINK_REL}');
// the dead-span teaser copy is gone
expect(src).not.toContain('Push-to-Book coming soon');
});
it('GradeResultCard renders "PUSH-TO-BOOK · COMING SOON"', () => {
it('GradeResultCard still renders the honest "PUSH-TO-BOOK · COMING SOON" teaser', () => {
const src = read('components/vyndr/GradeResultCard.tsx');
expect(src).toContain('PUSH-TO-BOOK · COMING SOON');
expect(src).toContain('Connect DraftKings, FanDuel, BetMGM');
+114
View File
@@ -0,0 +1,114 @@
// A1 Session 3 — BOOK IT deep-link builder. Organic by default; the
// affiliate layer only fires when a book is explicitly enabled with
// real params. Every anchor renders rel="sponsored noopener noreferrer".
const fs = require('fs');
const path = require('path');
const { buildBookLink, resolveBookId, SUPPORTED_BOOKS, BOOK_LINK_REL } = require('../../web/src/lib/bookLinks');
const { AFFILIATE_CONFIG } = require('../../web/src/lib/affiliateConfig');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('buildBookLink — organic by default', () => {
it('builds a clean organic link for every supported book (default config)', () => {
for (const b of SUPPORTED_BOOKS) {
const out = buildBookLink({ book: b.id, player: 'Aaron Judge', sport: 'mlb' });
expect(out).not.toBeNull();
expect(out.tracking).toBe(false);
expect(out.url).toContain(`https://${b.host}/`);
expect(out.url).toContain('search=Aaron+Judge');
// no affiliate params leak into an organic link
expect(out.url).not.toMatch(/irclickid|sharedid|btag|afid|siteid/);
}
});
it('ships EVERY book disabled in the checked-in config', () => {
for (const b of SUPPORTED_BOOKS) {
expect(AFFILIATE_CONFIG[b.id]).toBeDefined();
expect(AFFILIATE_CONFIG[b.id].enabled).toBe(false);
}
});
it('resolves book aliases (DK / DraftKings / draftkings)', () => {
expect(resolveBookId('DK')).toBe('draftkings');
expect(resolveBookId('DraftKings')).toBe('draftkings');
expect(resolveBookId('fanduel')).toBe('fanduel');
expect(resolveBookId('MGM')).toBe('betmgm');
expect(resolveBookId('CZR')).toBe('caesars');
expect(resolveBookId('BR')).toBe('betrivers');
});
it('returns null for unknown books and missing player (absent beats wrong)', () => {
expect(buildBookLink({ book: 'bovada', player: 'Aaron Judge' })).toBeNull();
expect(buildBookLink({ book: 'prizepicks', player: 'Aaron Judge' })).toBeNull();
expect(buildBookLink({ book: 'draftkings', player: '' })).toBeNull();
expect(buildBookLink({})).toBeNull();
});
it('betrivers routes by two-letter state subdomain, else www', () => {
const mi = buildBookLink({ book: 'betrivers', player: 'Judge', state: 'MI' });
expect(mi.url).toContain('https://mi.betrivers.com/');
const bad = buildBookLink({ book: 'betrivers', player: 'Judge', state: 'Michigan' });
expect(bad.url).toContain('https://www.betrivers.com/');
const none = buildBookLink({ book: 'betrivers', player: 'Judge' });
expect(none.url).toContain('https://www.betrivers.com/');
});
});
describe('buildBookLink — affiliate layer (config-flip)', () => {
const enabled = {
draftkings: { enabled: true, params: { irclickid: 'IRC123', sharedid: 'vyndr', wpsrc: 'vyndr-slate' } },
betmgm: { enabled: true, params: { btag: 'BT-42', afid: '' } },
caesars: { enabled: false, params: { btag: 'SHOULD-NOT-APPEAR' } },
};
it('appends Impact-style params and reports tracking when enabled', () => {
const out = buildBookLink({ book: 'draftkings', player: 'Aaron Judge' }, enabled);
expect(out.tracking).toBe(true);
expect(out.url).toContain('irclickid=IRC123');
expect(out.url).toContain('sharedid=vyndr');
expect(out.url).toContain('wpsrc=vyndr-slate');
expect(out.url).toContain('search=Aaron+Judge');
});
it('skips empty param values (never emits a fabricated id)', () => {
const out = buildBookLink({ book: 'betmgm', player: 'Judge' }, enabled);
expect(out.tracking).toBe(true);
expect(out.url).toContain('btag=BT-42');
expect(out.url).not.toContain('afid=');
});
it('enabled with ONLY empty params stays organic (tracking false)', () => {
const out = buildBookLink({ book: 'fanduel', player: 'Judge' }, { fanduel: { enabled: true, params: { irclickid: ' ' } } });
expect(out.tracking).toBe(false);
expect(out.url).not.toContain('irclickid');
});
it('disabled book with params configured stays organic', () => {
const out = buildBookLink({ book: 'caesars', player: 'Judge' }, enabled);
expect(out.tracking).toBe(false);
expect(out.url).not.toContain('SHOULD-NOT-APPEAR');
});
});
describe('rel="sponsored noopener noreferrer" on every book anchor', () => {
it('exports the exact rel contract', () => {
expect(BOOK_LINK_REL).toBe('sponsored noopener noreferrer');
});
it('StatStrip BOOK IT anchor uses buildBookLink + BOOK_LINK_REL', () => {
const src = read('components/vyndr/StatStrip.tsx');
expect(src).toContain('buildBookLink');
expect(src).toContain('rel={BOOK_LINK_REL}');
expect(src).toContain('BOOK IT ⟶');
});
it('scan page hand-off anchors use buildBookLink + BOOK_LINK_REL', () => {
const src = read('app/scan/page.tsx');
expect(src).toContain('SUPPORTED_BOOKS');
expect(src).toContain('rel={BOOK_LINK_REL}');
// the old hardcoded organic builder is gone
expect(src).not.toContain('const deepLink =');
});
});
+189
View File
@@ -0,0 +1,189 @@
// Session 2 (A1 board) — compliance + approval pack. The legal/trust surfaces
// are asserted against their source text (plain-JS Jest config, no TS
// transform — same pattern as vyndrAppShell.test.js).
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const WEB = path.join(ROOT, 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const readRoot = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const exists = (rel) => fs.existsSync(path.join(WEB, rel));
describe('S2 — /methodology page', () => {
it('exists as a server component with metadata', () => {
expect(exists('app/methodology/page.tsx')).toBe(true);
const src = read('app/methodology/page.tsx');
expect(src).not.toContain("'use client'");
expect(src).toContain('export const metadata');
});
const src = read('app/methodology/page.tsx');
it('covers the pipeline → engine → letter grades', () => {
expect(src).toContain('The pipeline');
expect(src).toContain('feature vector');
expect(src).toMatch(/11-step letter grade/);
expect(src).toContain('A+');
});
it('covers refusals when the model has no projection', () => {
expect(src).toContain('When the model refuses');
expect(src).toContain('refuses to grade');
expect(src).toContain('Absent beats wrong');
});
it('covers the VYNDR Originals archetype system', () => {
expect(src).toContain('VYNDR Originals');
expect(src).toContain('41');
['TORCH', 'BOMBER', 'GHOST', 'CONDUCTOR'].forEach((a) => expect(src).toContain(a));
});
it('covers ledger settlement: lock at grade time, box-score settle, CLV, n≥20', () => {
expect(src).toContain('How the Ledger settles');
expect(src).toContain('locked with the exact line, the odds, and a timestamp');
expect(src).toContain('real box score');
expect(src).toContain('closing line');
expect(src).toContain('n≥20');
expect(src).toContain('RECORD BUILDING');
});
it('covers why misses are public', () => {
expect(src).toContain('Why the misses are public');
expect(src).toContain('same format');
});
it('states the not-a-sportsbook position and links the ledger', () => {
expect(src).toContain('not a sportsbook');
expect(src).toContain('/ledger');
});
it('is registered as an open (ungated) route and linked from the footer', () => {
const routes = require('../../web/src/lib/routes');
expect(routes.OPEN_ROUTES).toContain('/methodology');
expect(routes.isGatedRoute('/methodology')).toBe(false);
expect(read('components/Footer.tsx')).toContain("href: '/methodology'");
});
});
describe('S2 — /terms and /privacy drafts', () => {
it('terms page exists with the non-negotiable disclosures', () => {
const src = read('app/terms/page.tsx');
expect(src).toContain('not a sportsbook');
expect(src).toContain('21 years of age');
expect(src).toContain('Stripe');
expect(src).toContain('1-800-GAMBLER');
});
it('privacy page exists with the real sub-processor list', () => {
const src = read('app/privacy/page.tsx');
expect(src).toContain('never sell');
['Stripe', 'Supabase', 'PostHog', 'Resend', 'Sentry'].forEach((p) => expect(src).toContain(p));
});
it('entity details are placeholder tokens, not invented facts', () => {
const terms = read('app/terms/page.tsx');
const privacy = read('app/privacy/page.tsx');
['[ENTITY NAME]', '[STATE OF FORMATION]', '[ARBITRATION VENUE]', '[CONTACT EMAIL]'].forEach((t) =>
expect(terms).toContain(t),
);
['[ENTITY NAME]', '[CONTACT EMAIL]'].forEach((t) => expect(privacy).toContain(t));
// The previous draft asserted an unverified LLC + emails — keep them out
// until Kev fills the placeholders.
[terms, privacy].forEach((src) => {
expect(src).not.toContain('VYNDR Intelligence LLC');
expect(src).not.toContain('legal@vyndr.app');
expect(src).not.toContain('privacy@vyndr.app');
});
});
});
describe('S2 — /responsible-gambling (sincere rebuild)', () => {
const src = read('app/responsible-gambling/page.tsx');
it('carries 1-800-GAMBLER as the primary helpline and the 21+ mark', () => {
expect(src).toContain('1-800-GAMBLER');
expect(src).toContain('tel:18004262537');
expect(src).toContain('21+');
});
it('lists state resources for the major legal states', () => {
['Michigan', 'New Jersey', 'New York', 'Pennsylvania', 'Ohio', 'Illinois'].forEach((s) =>
expect(src).toContain(s),
);
});
it('links the existing self-exclusion / settings surfaces', () => {
expect(src).toContain('/settings');
expect(src).toContain('Self-exclusion');
});
it('links national support resources', () => {
['ncpgambling.org', 'gamblersanonymous.org', 'gamtalk.org'].forEach((r) => expect(src).toContain(r));
});
it('has no marketing adjacency (no pricing/upgrade/scan funnels)', () => {
['/pricing', '/upgrade', '/scan', '__goPaywall'].forEach((bad) => expect(src).not.toContain(bad));
});
});
describe('S2 — compliance footer (global)', () => {
it('the root layout mounts the single global Footer', () => {
expect(read('app/layout.tsx')).toContain('<Footer />');
});
it('the footer carries 21+, the RG link, and not-a-sportsbook', () => {
const src = read('components/Footer.tsx');
expect(src).toContain('21+');
expect(src).toContain("href: '/responsible-gambling'");
expect(src).toContain('not a sportsbook');
expect(src).toContain('1-800-GAMBLER');
});
it('no nested layout replaces the root layout (footer cannot be bypassed)', () => {
// The only nested layout (player/[name]) must render children straight
// through — it exists for metadata only.
const nested = read('app/player/[name]/layout.tsx');
expect(nested).toContain('return children');
expect(nested).not.toContain('<html');
});
});
describe('S2 — seed articles + Ghost runbook', () => {
const ARTICLES = [
'content/articles/how-vyndr-grades-a-prop.md',
'content/articles/the-vyndr-originals.md',
'content/articles/why-our-misses-are-public.md',
'content/articles/what-clv-is.md',
'content/articles/how-streaks-lie.md',
];
it.each(ARTICLES)('%s exists with draft front matter', (rel) => {
const src = readRoot(rel);
expect(src.startsWith('---')).toBe(true);
expect(src).toContain('title:');
expect(src).toContain('status: draft');
});
it.each(ARTICLES)('%s obeys VOICE v1.1 — zero exclamation points', (rel) => {
expect(readRoot(rel)).not.toContain('!');
});
it('the new user-facing pages obey VOICE v1.1 — zero exclamation points', () => {
[
'app/methodology/page.tsx',
'app/responsible-gambling/page.tsx',
'app/about/page.tsx',
'app/terms/page.tsx',
'app/privacy/page.tsx',
].forEach((rel) => expect(read(rel)).not.toContain('!'));
});
it('the Ghost runbook exists and stays manual (no auto-posting)', () => {
const src = readRoot('docs/GHOST-PUBLISHING.md');
expect(src).toContain('blog.vyndr.app');
expect(src).toContain('draft');
expect(src).toContain('nothing auto-posts');
});
});
+70
View File
@@ -0,0 +1,70 @@
// A1 Session 10 — getModelAggregate scoping. The public default MUST stay
// `.is('user_id', null)` (the model record); the new `userId` option swaps
// it for `.eq('user_id', uid)` (a user's own public-profile aggregate).
// Same window, same n≥20 gate in both scopes.
const ledgerService = require('../../src/services/ledgerService');
function makeSb(captured, rows) {
return {
from() {
const b = { _filters: [] };
const rec = (op) => (...args) => { b._filters.push([op, ...args]); return b; };
b.select = () => b;
b.eq = rec('eq');
b.is = rec('is');
b.not = rec('not');
b.gte = rec('gte');
b.order = () => b;
b.limit = () => { captured.push(b._filters); return Promise.resolve({ data: rows, error: null, count: 0 }); };
b.then = (resolve, reject) => {
captured.push(b._filters);
return Promise.resolve({ data: rows, error: null, count: 2 }).then(resolve, reject);
};
return b;
},
};
}
describe('getModelAggregate scoping', () => {
test('default (no userId) scopes BOTH queries to the public record (user_id IS NULL)', async () => {
const captured = [];
const agg = await ledgerService.getModelAggregate({ sb: makeSb(captured, []) });
expect(captured.length).toBe(2); // settled + pending
for (const filters of captured) {
expect(filters).toContainEqual(['is', 'user_id', null]);
expect(filters.some((f) => f[0] === 'eq' && f[1] === 'user_id')).toBe(false);
}
expect(agg.hit_pct).toBeNull();
expect(agg.min_sample).toBe(ledgerService.MIN_AGG_SAMPLE);
});
test('userId scopes BOTH queries to that user and never touches the public scope', async () => {
const captured = [];
await ledgerService.getModelAggregate({ sb: makeSb(captured, []), userId: 'u-42' });
expect(captured.length).toBe(2);
for (const filters of captured) {
expect(filters).toContainEqual(['eq', 'user_id', 'u-42']);
expect(filters.some((f) => f[0] === 'is' && f[1] === 'user_id')).toBe(false);
}
});
test('user scope keeps the n≥20 gate — no percentage on a small sample', async () => {
const rows = Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat', grade: 'A' }));
const agg = await ledgerService.getModelAggregate({ sb: makeSb([], rows), userId: 'u-42' });
expect(agg.settled).toBe(5);
expect(agg.hit_pct).toBeNull();
expect(agg.beat_close_pct).toBeNull();
});
test('user scope renders percentages at n≥20 like the public record', async () => {
const rows = [
...Array.from({ length: 14 }, () => ({ outcome: 'hit', clv_result: 'beat', grade: 'A' })),
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded', grade: 'B' })),
];
const agg = await ledgerService.getModelAggregate({ sb: makeSb([], rows), userId: 'u-42' });
expect(agg.settled).toBe(21);
expect(agg.hit_pct).toBe(Math.round((14 / 21) * 100));
expect(agg.beat_close_pct).toBe(Math.round((14 / 21) * 100));
});
});
+179
View File
@@ -0,0 +1,179 @@
// Session 63 (A1-S4) — the VOICE v1.1 template engine. Numbers only from
// pipeline JSON; lint enforces the banned list + no-exclamation law; record
// claims obey n≥20; Stark lines are SELECTED from the committed library.
const media = require('../../src/services/mediaEngine');
const { lintVoice, segmentThread, starkLine, morningWire, signal, theSettle, receipt, streakWatch, archetypeWatch, lineDispatch } = media;
describe('lintVoice — the banned list is law', () => {
test.each([
['This one is a lock!', 2], // "lock" + exclamation
['🔥 free money, whos tailing', 3],
['Were so back 🚀', 2],
])('%s → %i violations', (text, n) => {
expect(lintVoice(text).length).toBeGreaterThanOrEqual(n);
});
test('clean wire copy passes', () => {
expect(lintVoice('THE SETTLE — Jul 11\nA-tier: 6-2\nvyndr.app/ledger')).toEqual([]);
});
});
describe('every template output passes its own lint', () => {
const NOW = '2026-07-12T14:05:00.000Z';
const GRADE = {
player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over',
grade: 'A', confidence: 78, archetype: 'BOMBER',
gradedAt: { line: 1.5, odds: -120, timestamp: NOW },
};
test('morning wire: real counts, stark from the library, no bans', () => {
const text = morningWire({ dateIso: NOW, counts: { mlb: 15, wnba: 3, nba: 0 }, loudest: GRADE });
expect(text).toContain('15 MLB.');
expect(text).toContain('3 WNBA.');
expect(text).not.toContain('0 NBA'); // zero sports never listed
expect(text).toContain('Aaron Judge');
expect(text).toContain('vyndr.app');
expect(lintVoice(text)).toEqual([]);
});
test('signal: player + line + grade + ET timestamp with real odds', () => {
const text = signal(GRADE);
expect(text).toContain('Aaron Judge — TB o1.5 · A');
expect(text).toContain('BOMBER read.');
expect(text).toContain('at -120');
expect(lintVoice(text)).toEqual([]);
});
test('the settle: counts always, percentages NEVER under n≥20', () => {
const agg = { settled: 8, hits: 5, misses: 3, pushes: 0, hit_pct: null, pending: 16, by_tier: { A: { settled: 4, hits: 3, misses: 1, hit_pct: null } } };
const misses = [{ player_name: 'Aja Wilson', stat: 'points', line: 23.5, side: 'over' }];
const text = theSettle({ aggregate: agg, misses, dateIso: NOW });
expect(text).toContain('All grades: 5-3');
expect(text).toContain('A-tier: 3-1');
expect(text).not.toContain('%'); // no percentage below the gate
expect(text).toContain('Record building — 8 settled');
expect(text).toContain('Wilson PTS o23.5 ❌'); // misses BY NAME
expect(lintVoice(text)).toEqual([]);
});
test('receipt: lock beside close beside result', () => {
const row = {
player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A',
graded_at: NOW, locked_odds: '-120', closing_line: 1.5, closing_odds: '-145',
clv: 0, clv_result: 'flat', outcome: 'hit', actual_value: 2, settled_at: NOW,
};
const text = receipt(row);
expect(text).toContain('Posted');
expect(text).toContain('Judge TB o1.5 · A · -120');
expect(text).toContain('Close: o1.5 at -145');
expect(text).toContain('Result: 2 ✅');
expect(lintVoice(text)).toEqual([]);
});
test('streak watch: lens reads only, absent lens rows dropped', () => {
const rows = [
{ player: 'Brice Turang', lens: { read: '12-game on-base streak; tonight @ Pittsburgh Pirates' } },
{ player: 'No Lens Guy', lens: { read: null } },
];
const text = streakWatch(rows, NOW);
expect(text).toContain('Turang');
expect(text).not.toContain('No Lens Guy');
expect(lintVoice(text)).toEqual([]);
});
test('archetype watch needs ≥2 of a kind, else null', () => {
expect(archetypeWatch([GRADE], NOW)).toBeNull();
const two = archetypeWatch([GRADE, { ...GRADE, player: 'Shohei Ohtani' }], NOW);
expect(two).toContain('BOMBER watch: 2');
expect(lintVoice(two)).toEqual([]);
});
test('line dispatch from a real movement row', () => {
const text = lineDispatch({ ...GRADE, movement: { kind: 'steam', delta: 1, currentLine: 2.5 } }, NOW);
expect(text).toContain('o1.5 → o2.5 ▲');
expect(text).toContain('Steam.');
expect(lintVoice(text)).toEqual([]);
});
});
describe('stark layer — selected, never generated; deterministic', () => {
test('same date → same line; comes from the committed library', () => {
const a = starkLine('morning', '2026-07-12T10:00:00Z');
const b = starkLine('morning', '2026-07-12T22:00:00Z');
expect(a).toBe(b);
expect(media.__internals.STARK.morning).toContain(a);
});
test('the whole library passes lint', () => {
for (const lines of Object.values(media.__internals.STARK)) {
if (!Array.isArray(lines)) continue;
for (const l of lines) expect(lintVoice(l)).toEqual([]);
}
});
});
describe('segmentThread — ≤280 per tweet on line boundaries', () => {
test('splits long threads and preserves every line', () => {
const long = Array.from({ length: 30 }, (_, i) => `Line ${i} of the settle with some padding text`).join('\n');
const tweets = segmentThread(long);
expect(tweets.length).toBeGreaterThan(1);
for (const t of tweets) expect(t.length).toBeLessThanOrEqual(280);
expect(tweets.join('\n')).toBe(long);
});
});
describe('deskService — pack assembly from pipeline data only', () => {
const { assembleDeskPack } = require('../../src/services/deskService');
test('assembles formats + data brief from injected caches', async () => {
const NOW = '2026-07-12T14:05:00.000Z';
const store = {
'snapshot:mlb:latest': { grades: [
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A', confidence: 80, archetype: 'BOMBER', gradedAt: { line: 1.5, odds: -120, timestamp: NOW } },
{ player: 'Shohei Ohtani', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'A', confidence: 76, archetype: 'BOMBER', gradedAt: { line: 0.5, odds: 130, timestamp: NOW } },
] },
};
const pack = await assembleDeskPack({
cacheGet: async (k) => store[k] ?? null,
loadRosterLogs: async () => [],
ledger: { getModelAggregate: async () => ({ settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null, pending: 24 }), __internals: { isConfigured: () => false } },
fetchSettled: async () => [],
now: () => NOW,
});
expect(pack.formats.morning_wire[0].text).toContain('2 MLB.');
expect(pack.formats.signals.length).toBe(2);
expect(pack.formats.archetype_watch[0].text).toContain('BOMBER watch: 2');
// variants: wire + numbers-only when a stark line was present
expect(pack.formats.morning_wire.length).toBeGreaterThanOrEqual(1);
expect(pack.data_brief.top_signals[0].player).toBe('Aaron Judge');
expect(pack.data_brief.record.pending).toBe(24);
// every emitted text passes lint
const all = JSON.stringify(pack.formats);
expect(all).not.toContain('!');
});
});
describe('ghostPublisher — drafts only, env-gated, no deps', () => {
const ghost = require('../../src/services/ghostPublisher');
test('JWT is a valid HS256 three-parter with the key id', () => {
const t = ghost.ghostJwt('abc123:6465616462656566', 1_752_000_000);
const [h, p] = t.split('.');
const header = JSON.parse(Buffer.from(h, 'base64url').toString());
const payload = JSON.parse(Buffer.from(p, 'base64url').toString());
expect(header.kid).toBe('abc123');
expect(payload.aud).toBe('/admin/');
expect(payload.exp - payload.iat).toBe(300);
});
test('unconfigured → no-op, never a throw', async () => {
const r = await ghost.saveDraft({ title: 't', html: '<p>x</p>' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('ghost not configured');
});
test('posts DRAFT status only', async () => {
let body = null;
const r = await ghost.saveDraft({ title: 't', html: '<p>x</p>' }, {
force: true, url: 'https://blog.test', adminKey: 'id:6162',
fetchImpl: async (url, opts) => { body = JSON.parse(opts.body); return { ok: true, json: async () => ({ posts: [{ id: 'p1' }] }) }; },
});
expect(r.ok).toBe(true);
expect(body.posts[0].status).toBe('draft'); // NOTHING auto-publishes
});
});
+275
View File
@@ -0,0 +1,275 @@
'use strict';
// Session S7 (a1) — THE VYNDR REPORT assembly + send. Everything injected:
// fixtures stand in for the snapshot cache, the streaks engine inputs, and
// the ledger aggregate. No network, no Redis.
const {
buildDailyReport,
sendDailyReport,
subscribe,
__internals,
} = require('../../src/services/newsletterService');
// ---------------------------------------------------------------- fixtures
const SNAPSHOT_MLB = {
sport: 'mlb',
updated_at: '2026-07-11T14:00:00Z',
grades: [
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A+', confidence: 82, archetype: 'BOMBER' },
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 64 },
{ player: 'Luis Castillo', stat_type: 'pitcher_strikeouts', line: 6.5, direction: 'under', grade: 'A', confidence: 71, archetype: 'WHIFF' },
],
deltas: [],
};
const ROSTER_MLB = [
{
name: 'Seiya Suzuki',
team: 'Chicago Cubs',
games: [
{ date: '2026-07-10', hits: 2, opponent: 'STL' },
{ date: '2026-07-09', hits: 1, opponent: 'STL' },
{ date: '2026-07-08', hits: 3, opponent: 'PIT' },
{ date: '2026-07-07', hits: 1, opponent: 'PIT' },
{ date: '2026-07-06', hits: 2, opponent: 'CIN' },
{ date: '2026-07-05', hits: 0, opponent: 'CIN' },
],
},
];
const AGG_BUILDING = { window_days: 30, min_sample: 20, settled: 4, hits: 3, misses: 1, pushes: 0, hit_pct: null, pending: 17, beat_close_pct: null };
const AGG_REAL = { window_days: 30, min_sample: 20, settled: 25, hits: 15, misses: 9, pushes: 1, hit_pct: 63, pending: 6, beat_close_pct: 58 };
function deps(overrides = {}) {
return {
cacheGet: async (key) => (key === 'snapshot:mlb:latest' ? SNAPSHOT_MLB : null),
loadRosterLogs: async (sport) => (sport === 'mlb' ? ROSTER_MLB : []),
computeStreaks: require('../../src/services/streaksService').computeStreaks,
applyLens: require('../../src/services/streakLens').applyLens,
getModelAggregate: async () => AGG_BUILDING,
now: () => new Date('2026-07-11T15:00:00Z'),
...overrides,
};
}
// VOICE v1.1 lint — the banned list the templates must never emit.
// "lock"/"tail"/"fade" as words (not substrings — "blocked"/"detail" are fine).
const BANNED_WORDS = /\b(lock|locks|tail|tails|fade|fades|guarantee[ds]?|free money|can't lose)\b/i;
const HYPE_EMOJI = /[\u{1F525}\u{1F4B0}\u{1F680}\u{1F4AF}\u{1F512}]/u;
describe('buildDailyReport — VOICE lint', () => {
let report;
beforeAll(async () => {
report = await buildDailyReport(['mlb'], deps());
});
test('no exclamation points anywhere — subject, html, text', () => {
expect(report.subject).not.toContain('!');
expect(report.text).not.toContain('!');
// html may contain !important? we never use it — assert none at all
expect(report.html).not.toContain('!');
});
test('no banned vocabulary, no hype emoji', () => {
for (const part of [report.subject, report.text, report.html]) {
expect(part).not.toMatch(BANNED_WORDS);
expect(part).not.toMatch(HYPE_EMOJI);
}
});
test('subject carries the pipeline signal count and the ET date', () => {
expect(report.subject).toBe('THE VYNDR REPORT — Sat, Jul 11 · 3 signals');
});
test('signals come only from the snapshot fixture, o/u for sides', () => {
expect(report.text).toContain('Aaron Judge — total bases o1.5 · A+ · BOMBER');
expect(report.text).toContain('Luis Castillo — pitcher strikeouts u6.5 · A · WHIFF');
expect(report.counts.signals).toBe(3);
});
test('A+ sorts ahead of B regardless of array order', () => {
const judge = report.text.indexOf('Aaron Judge');
const betts = report.text.indexOf('Mookie Betts');
expect(judge).toBeGreaterThan(-1);
expect(betts).toBeGreaterThan(judge);
});
test('STREAK WATCH renders the real computed streak through the lens', () => {
expect(report.text).toContain('STREAK WATCH');
expect(report.text).toContain('Seiya Suzuki');
// Lens: built-vs opponents from the game log — real, not composed.
expect(report.text).toMatch(/built vs/);
expect(report.counts.streaks).toBeGreaterThan(0);
});
test('RG footer present in html AND text: 21+, 1-800-GAMBLER, unsubscribe', () => {
for (const part of [report.text, report.html]) {
expect(part).toContain('21+');
expect(part).toContain('1-800-GAMBLER');
expect(part).toContain('{{ UnsubscribeURL }}');
}
});
});
describe('buildDailyReport — the record gate (n≥20 lives upstream)', () => {
test('hit_pct null → RECORD BUILDING with the pending count, never a %', async () => {
const report = await buildDailyReport(['mlb'], deps());
expect(report.text).toContain('RECORD BUILDING · 17 pending');
expect(report.text).not.toMatch(/\d+%.*misses included/);
});
test('hit_pct present → the real record line, misses included', async () => {
const report = await buildDailyReport(['mlb'], deps({ getModelAggregate: async () => AGG_REAL }));
expect(report.text).toContain('Last 30 days: 15-9 (63%), misses included, n=24');
expect(report.text).toContain('beat the close 58%');
expect(report.text).not.toContain('RECORD BUILDING');
});
test('aggregate throws → building line, never a crash', async () => {
const report = await buildDailyReport(['mlb'], deps({ getModelAggregate: async () => { throw new Error('supabase down'); } }));
expect(report.text).toContain('RECORD BUILDING · 0 pending');
});
});
describe('buildDailyReport — empty pipeline is a valid state', () => {
test('no snapshot, no roster → honest empty wire, zero counts', async () => {
const report = await buildDailyReport(['mlb'], deps({
cacheGet: async () => null,
loadRosterLogs: async () => [],
}));
expect(report.counts.signals).toBe(0);
expect(report.counts.streaks).toBe(0);
expect(report.text).toContain('No graded slate at send time');
expect(report.subject).toBe('THE VYNDR REPORT — Sat, Jul 11'); // no invented number
// Compliance footer still present even on an empty report.
expect(report.text).toContain('1-800-GAMBLER');
});
});
describe('sendDailyReport — env-gated, empty-refusing', () => {
const ENV = {
LISTMONK_URL: 'http://127.0.0.1:9000',
LISTMONK_USER: 'vyndr-api',
LISTMONK_TOKEN: 'tok123',
LISTMONK_LIST_ID: '3',
};
test('no env → graceful no-op, no fetch', async () => {
const fetchImpl = jest.fn();
const res = await sendDailyReport({ env: {}, fetchImpl, deps: deps() });
expect(res).toEqual({ ok: false, reason: 'not configured' });
expect(fetchImpl).not.toHaveBeenCalled();
});
test('empty report → refuses to send even with env set', async () => {
const fetchImpl = jest.fn();
const res = await sendDailyReport({
env: ENV, fetchImpl,
deps: deps({ cacheGet: async () => null, loadRosterLogs: async () => [] }),
});
expect(res.ok).toBe(false);
expect(res.reason).toBe('empty report');
expect(fetchImpl).not.toHaveBeenCalled();
});
test('creates then starts the campaign with the token auth scheme', async () => {
const calls = [];
const fetchImpl = jest.fn(async (url, opts) => {
calls.push({ url, opts });
if (url.endsWith('/api/campaigns')) {
return { ok: true, status: 200, json: async () => ({ data: { id: 42 } }) };
}
return { ok: true, status: 200, json: async () => ({}) };
});
const res = await sendDailyReport({ env: ENV, fetchImpl, deps: deps() });
expect(res.ok).toBe(true);
expect(res.campaignId).toBe(42);
const create = calls[0];
expect(create.url).toBe('http://127.0.0.1:9000/api/campaigns');
expect(create.opts.headers.Authorization).toBe('token vyndr-api:tok123');
const body = JSON.parse(create.opts.body);
expect(body.lists).toEqual([3]);
expect(body.subject).toContain('THE VYNDR REPORT');
expect(body.body).toContain('{{ UnsubscribeURL }}');
expect(body.altbody).toContain('1-800-GAMBLER');
const start = calls[1];
expect(start.url).toBe('http://127.0.0.1:9000/api/campaigns/42/status');
expect(JSON.parse(start.opts.body)).toEqual({ status: 'running' });
});
test('listmonk create failure → honest error, no start call', async () => {
const fetchImpl = jest.fn(async () => ({ ok: false, status: 500, json: async () => ({}) }));
const res = await sendDailyReport({ env: ENV, fetchImpl, deps: deps() });
expect(res.ok).toBe(false);
expect(res.reason).toBe('listmonk create 500');
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
});
describe('subscribe — double opt-in forwarding', () => {
const ENV = {
LISTMONK_URL: 'http://127.0.0.1:9000/',
LISTMONK_USER: 'vyndr-api',
LISTMONK_TOKEN: 'tok123',
LISTMONK_LIST_ID: '3',
};
test('no env → not configured, no fetch', async () => {
const fetchImpl = jest.fn();
const res = await subscribe('kev@example.com', { env: {}, fetchImpl });
expect(res).toEqual({ ok: false, reason: 'not configured' });
expect(fetchImpl).not.toHaveBeenCalled();
});
test('forwards with preconfirm_subscriptions:false (Listmonk sends the confirmation)', async () => {
const fetchImpl = jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }));
const res = await subscribe('KEV@Example.com ', { env: ENV, fetchImpl });
expect(res).toEqual({ ok: true });
const [url, opts] = fetchImpl.mock.calls[0];
expect(url).toBe('http://127.0.0.1:9000/api/subscribers'); // trailing slash trimmed
const body = JSON.parse(opts.body);
expect(body.email).toBe('kev@example.com');
expect(body.preconfirm_subscriptions).toBe(false);
expect(body.lists).toEqual([3]);
expect(body.status).toBe('enabled');
});
test('409 already-subscribed is ok (idempotent, no enumeration)', async () => {
const fetchImpl = jest.fn(async () => ({ ok: false, status: 409, json: async () => ({}) }));
const res = await subscribe('kev@example.com', { env: ENV, fetchImpl });
expect(res).toEqual({ ok: true });
});
test('network failure → calm reason, never throws', async () => {
const fetchImpl = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
const res = await subscribe('kev@example.com', { env: ENV, fetchImpl });
expect(res.ok).toBe(false);
expect(res.reason).toBe('ECONNREFUSED');
});
});
describe('__internals', () => {
test('listmonkConfig requires all four env vars', () => {
expect(__internals.listmonkConfig({})).toBeNull();
expect(__internals.listmonkConfig({ LISTMONK_URL: 'x', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't' })).toBeNull();
expect(__internals.listmonkConfig({ LISTMONK_URL: 'x', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't', LISTMONK_LIST_ID: 'nope' })).toBeNull();
expect(__internals.listmonkConfig({ LISTMONK_URL: 'http://x/', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't', LISTMONK_LIST_ID: '7' }))
.toEqual({ url: 'http://x', user: 'u', token: 't', listId: 7 });
});
test('recordLine gates on hit_pct, not on settled count', () => {
expect(__internals.recordLine(null)).toBe('RECORD BUILDING · 0 pending');
expect(__internals.recordLine({ hit_pct: null, pending: 12 })).toBe('RECORD BUILDING · 12 pending');
expect(__internals.recordLine({ hit_pct: 61, hits: 14, misses: 9, window_days: 30, beat_close_pct: null }))
.toBe('Last 30 days: 14-9 (61%), misses included, n=23');
});
test('statLabel humanizes stat keys', () => {
expect(__internals.statLabel('total_bases')).toBe('total bases');
expect(__internals.statLabel('PITCHER_STRIKEOUTS')).toBe('pitcher strikeouts');
});
});
+207
View File
@@ -0,0 +1,207 @@
'use strict';
// Session 8 (A1 board) — scheduler ops wiring: settle alarms, failure pager,
// quota check, daily pulse. All deps injected; zero network, zero redis.
const { startSnapshotScheduler } = require('../../src/snapshotScheduler');
function memCache(seed = {}) {
const store = { ...seed };
return {
store,
cacheGet: async (k) => (k in store ? store[k] : null),
cacheSet: async (k, v) => { store[k] = v; return true; },
};
}
function collector() {
const msgs = [];
return { msgs, notify: async (m, o = {}) => { msgs.push({ m, o }); return { sent: true }; } };
}
// Base deps: quiet pipeline, everything healthy, injectable clock.
function baseDeps(cache, notes, dRef) {
return {
...cache,
notify: notes.notify,
now: () => dRef.d,
runAllSnapshots: async () => [{ sport: 'mlb', status: 'ok', gradeCount: 5 }],
settleAllOutcomes: async () => [{ sport: 'mlb', settled: 3, pending: 0 }],
settleAllLedgers: async () => [{ sport: 'mlb', settled: 3, pending: 1 }],
getQuotaStatus: async () => ({ pct: 0.1, used: 50, limit: 500 }),
getSystemHealth: async () => ({ disk_pct: 40, mem_pct: 50 }),
countLedgerRows: async () => 42,
};
}
function makeSched(overrides = {}, startIso = '2026-07-11T14:00:00Z') {
process.env.SNAPSHOT_CRON = '1';
const cache = memCache();
const notes = collector();
const dRef = { d: new Date(startIso) };
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const sched = startSnapshotScheduler({ ...baseDeps(cache, notes, dRef), ...overrides });
clearInterval(sched.interval);
logSpy.mockRestore();
warnSpy.mockRestore();
return { sched, cache, notes, dRef };
}
afterEach(() => { delete process.env.SNAPSHOT_CRON; });
describe('settlement alarm', () => {
test('outcome settle THROW pages high priority', async () => {
const { sched, notes } = makeSched({
settleAllOutcomes: async () => { throw new Error('mlb stats 500'); },
});
await sched.tick();
const alert = notes.msgs.find((x) => x.m.includes('Outcome settlement THREW'));
expect(alert).toBeTruthy();
expect(alert.o.priority).toBe('high');
expect(alert.m).toContain('mlb stats 500');
expect(alert.m).not.toContain('!');
});
test('ledger settle THROW pages high priority', async () => {
const { sched, notes } = makeSched({
settleAllLedgers: async () => { throw new Error('supabase timeout'); },
});
await sched.tick();
const alert = notes.msgs.find((x) => x.m.includes('Ledger settlement THREW'));
expect(alert).toBeTruthy();
expect(alert.o.priority).toBe('high');
});
test('morning zero-settle alarm fires once per day, only when rows were pending', async () => {
const { sched, notes, cache } = makeSched({
settleAllLedgers: async () => [{ sport: 'mlb', settled: 0, pending: 12 }],
});
await sched.tick(); // 14:00 UTC — the morning slot
const alarms = notes.msgs.filter((x) => x.m.includes('0 settles'));
expect(alarms).toHaveLength(1);
expect(alarms[0].m).toContain('12 ledger rows');
expect(alarms[0].o.priority).toBe('high');
expect(cache.store['ops:settle_zero:2026-07-11']).toBe('1');
expect(alarms[0].m).not.toContain('!');
});
test('redis dedupe suppresses a second zero-settle alarm the same ET day', async () => {
const { sched, notes } = makeSched({
settleAllLedgers: async () => [{ sport: 'mlb', settled: 0, pending: 12 }],
cacheGet: async (k) => (k === 'ops:settle_zero:2026-07-11' ? '1' : null),
});
await sched.tick();
expect(notes.msgs.filter((x) => x.m.includes('0 settles'))).toHaveLength(0);
});
test('no alarm at a non-morning slot even with zero settles', async () => {
const { sched, notes } = makeSched({
settleAllLedgers: async () => [{ sport: 'mlb', settled: 0, pending: 12 }],
}, '2026-07-11T19:00:00Z');
await sched.tick();
expect(notes.msgs.filter((x) => x.m.includes('0 settles'))).toHaveLength(0);
});
test('genuinely empty yesterday (0 fetched rows) never alarms', async () => {
const { sched, notes } = makeSched({
settleAllLedgers: async () => [{ sport: 'mlb', settled: 0, pending: 0 }],
});
await sched.tick();
expect(notes.msgs.filter((x) => x.m.includes('0 settles'))).toHaveLength(0);
});
});
describe('persistent snapshot-failure pager', () => {
test('three consecutive failing slots page exactly once; success re-arms', async () => {
let result = { sport: 'mlb', status: 'skipped', reason: 'no props', gradeCount: 0 };
const { sched, notes, dRef } = makeSched({
runAllSnapshots: async () => [result],
});
const slots = ['2026-07-11T14:00:00Z', '2026-07-11T19:00:00Z', '2026-07-11T22:00:00Z', '2026-07-12T01:00:00Z'];
for (const iso of slots.slice(0, 2)) { dRef.d = new Date(iso); await sched.tick(); }
expect(notes.msgs.filter((x) => x.m.includes('consecutive'))).toHaveLength(0);
dRef.d = new Date(slots[2]); await sched.tick(); // third consecutive
let pages = notes.msgs.filter((x) => x.m.includes('consecutive'));
expect(pages).toHaveLength(1);
expect(pages[0].o.priority).toBe('high');
expect(pages[0].m).toContain('MLB');
expect(pages[0].m).toContain('3 consecutive');
expect(pages[0].m).not.toContain('!');
dRef.d = new Date(slots[3]); await sched.tick(); // fourth — no re-page
expect(notes.msgs.filter((x) => x.m.includes('consecutive'))).toHaveLength(1);
// success resets, then three more failures page again
result = { sport: 'mlb', status: 'ok', gradeCount: 4 };
dRef.d = new Date('2026-07-12T03:00:00Z'); await sched.tick();
result = { sport: 'mlb', status: 'error', reason: 'down', gradeCount: 0 };
for (const iso of ['2026-07-12T14:00:00Z', '2026-07-12T19:00:00Z', '2026-07-12T22:00:00Z']) {
dRef.d = new Date(iso); await sched.tick();
}
expect(notes.msgs.filter((x) => x.m.includes('consecutive'))).toHaveLength(2);
});
});
describe('quota daily check', () => {
test('>= 80% after a snapshot run alerts once per day via redis dedupe', async () => {
const { sched, notes, dRef, cache } = makeSched({
getQuotaStatus: async () => ({ pct: 0.84, used: 420, limit: 500, quotaType: 'monthly' }),
});
await sched.tick();
dRef.d = new Date('2026-07-11T19:00:00Z');
await sched.tick(); // second slot, same day — deduped
const alerts = notes.msgs.filter((x) => x.o.title === 'VYNDR quota');
expect(alerts).toHaveLength(1);
expect(alerts[0].m).toContain('84%');
expect(cache.store['ops:quota_day:odds-api:2026-07-11']).toBe('1');
});
});
describe('daily pulse', () => {
test('fires ONE notification at 13:00 UTC with the assembled fields', async () => {
const { sched, notes } = makeSched({}, '2026-07-11T13:00:00Z');
await sched.tick();
await sched.tick(); // same minute — in-process dedupe
const pulses = notes.msgs.filter((x) => x.o.title === 'VYNDR daily pulse');
expect(pulses).toHaveLength(1);
expect(pulses[0].m).toContain('ledger rows yesterday: 42');
expect(pulses[0].m).toContain('odds-api quota: 10% (50/500)');
expect(pulses[0].m).toContain('disk 40% · mem 50%');
expect(pulses[0].m).toContain('desk pack: see /desk');
expect(pulses[0].m).not.toContain('!');
});
test('redis date key suppresses a duplicate pulse after a restart', async () => {
const { sched, notes } = makeSched({
cacheGet: async (k) => (k === 'ops:pulse:2026-07-11' ? '1' : null),
}, '2026-07-11T13:00:00Z');
await sched.tick();
expect(notes.msgs.filter((x) => x.o.title === 'VYNDR daily pulse')).toHaveLength(0);
});
test('does not fire off the pulse hour', async () => {
const { sched, notes } = makeSched({}, '2026-07-11T12:00:00Z');
await sched.tick();
expect(notes.msgs.filter((x) => x.o.title === 'VYNDR daily pulse')).toHaveLength(0);
});
test('box health over threshold pages separately at high priority', async () => {
const { sched, notes } = makeSched({
getSystemHealth: async () => ({ disk_pct: 92, mem_pct: 50 }),
}, '2026-07-11T13:00:00Z');
await sched.tick();
const box = notes.msgs.filter((x) => x.o.title === 'VYNDR box');
expect(box).toHaveLength(1);
expect(box[0].o.priority).toBe('high');
expect(box[0].m).toContain('disk at 92%');
expect(box[0].m).not.toContain('!');
});
test('supabase unconfigured renders n/a, never a fabricated zero', async () => {
const { sched, notes } = makeSched({
countLedgerRows: async () => null,
}, '2026-07-11T13:00:00Z');
await sched.tick();
const pulse = notes.msgs.find((x) => x.o.title === 'VYNDR daily pulse');
expect(pulse.m).toContain('ledger rows yesterday: n/a');
});
});
+209
View File
@@ -0,0 +1,209 @@
'use strict';
// Session 8 (A1 board) — opsWatch: the pure ops-alarm logic.
const {
createFailureTracker,
isBadSnapshotResult,
zeroSettleAlarm,
morningHourUtc,
checkQuotaDaily,
countRecentSettles,
buildPulseMessage,
dateET,
} = require('../../src/services/opsWatch');
function memCache(seed = {}) {
const store = { ...seed };
return {
store,
cacheGet: async (k) => (k in store ? store[k] : null),
cacheSet: async (k, v) => { store[k] = v; return true; },
};
}
describe('isBadSnapshotResult', () => {
test('error and skipped/no-props are bad; ok and skipped/no-grades are not', () => {
expect(isBadSnapshotResult({ status: 'error', reason: 'down' })).toBe(true);
expect(isBadSnapshotResult({ status: 'skipped', reason: 'no props' })).toBe(true);
expect(isBadSnapshotResult({ status: 'ok' })).toBe(false);
expect(isBadSnapshotResult({ status: 'skipped', reason: 'no grades' })).toBe(false);
expect(isBadSnapshotResult(null)).toBe(false);
});
});
describe('createFailureTracker', () => {
test('pages exactly once at the third consecutive failure, stays quiet after', () => {
const t = createFailureTracker(3);
const bad = { status: 'error', reason: 'x' };
expect(t.record('mlb', bad).shouldPage).toBe(false); // 1
expect(t.record('mlb', bad).shouldPage).toBe(false); // 2
const third = t.record('mlb', bad);
expect(third.shouldPage).toBe(true); // 3 -> page once
expect(third.count).toBe(3);
expect(t.record('mlb', bad).shouldPage).toBe(false); // 4 -> no re-page
expect(t.count('mlb')).toBe(4);
});
test('any good outcome resets the counter and re-arms the pager', () => {
const t = createFailureTracker(3);
const bad = { status: 'skipped', reason: 'no props' };
t.record('mlb', bad); t.record('mlb', bad);
t.record('mlb', { status: 'ok' }); // reset
expect(t.count('mlb')).toBe(0);
t.record('mlb', bad); t.record('mlb', bad);
expect(t.record('mlb', bad).shouldPage).toBe(true); // re-armed -> pages again
});
test('counts are independent per sport', () => {
const t = createFailureTracker(3);
const bad = { status: 'error' };
t.record('mlb', bad); t.record('mlb', bad);
expect(t.record('wnba', bad).count).toBe(1);
expect(t.count('mlb')).toBe(2);
});
});
describe('zeroSettleAlarm', () => {
test('alarms when settleable rows existed and none settled', () => {
const z = zeroSettleAlarm([{ sport: 'mlb', settled: 0, pending: 12 }]);
expect(z).toEqual({ alarm: true, settled: 0, pending: 12 });
});
test('never alarms on a genuinely empty yesterday (0 rows fetched)', () => {
expect(zeroSettleAlarm([{ sport: 'mlb', settled: 0, pending: 0 }]).alarm).toBe(false);
expect(zeroSettleAlarm([]).alarm).toBe(false);
expect(zeroSettleAlarm(null).alarm).toBe(false);
});
test('no alarm when anything settled', () => {
expect(zeroSettleAlarm([{ sport: 'mlb', settled: 3, pending: 9 }]).alarm).toBe(false);
});
test('ignores sports without a settled-result feed (WNBA pendings are honest)', () => {
const z = zeroSettleAlarm([
{ sport: 'mlb', settled: 0, pending: 0 },
{ sport: 'wnba', settled: 0, pending: 40 },
]);
expect(z.alarm).toBe(false);
});
});
describe('morningHourUtc', () => {
test('first configured hour >= 06 UTC (1,3 are late-night ET of the prior day)', () => {
expect(morningHourUtc([14, 19, 22, 1, 3])).toBe(14);
expect(morningHourUtc([19, 14])).toBe(14);
});
test('falls back to the smallest hour when nothing is daytime', () => {
expect(morningHourUtc([1, 3])).toBe(1);
expect(morningHourUtc([])).toBe(14);
});
});
describe('checkQuotaDaily', () => {
const notifyCollector = () => {
const msgs = [];
return { msgs, notify: async (m, o) => { msgs.push({ m, o }); return { sent: true }; } };
};
test('below 80% -> no alert', async () => {
const { msgs, notify } = notifyCollector();
const cache = memCache();
const r = await checkQuotaDaily({
getStatus: async () => ({ pct: 0.5, used: 250, limit: 500 }),
...cache, notify, now: () => new Date('2026-07-11T15:00:00Z'),
});
expect(r.alerted).toBe(false);
expect(msgs).toHaveLength(0);
});
test('>= 80% alerts once, then dedupes for the rest of the day', async () => {
const { msgs, notify } = notifyCollector();
const cache = memCache();
const deps = {
getStatus: async () => ({ pct: 0.82, used: 410, limit: 500, quotaType: 'monthly' }),
...cache, notify, now: () => new Date('2026-07-11T15:00:00Z'),
};
expect((await checkQuotaDaily(deps)).alerted).toBe(true);
const second = await checkQuotaDaily(deps);
expect(second.alerted).toBe(false);
expect(second.deduped).toBe(true);
expect(msgs).toHaveLength(1);
expect(msgs[0].m).toContain('82%');
expect(msgs[0].m).toContain('410/500');
expect(msgs[0].o.priority).toBe('high');
expect(msgs[0].m).not.toContain('!');
expect(cache.store['ops:quota_day:odds-api:2026-07-11']).toBe('1');
});
test('fires again the next day (date-keyed dedupe)', async () => {
const { msgs, notify } = notifyCollector();
const cache = memCache();
const base = {
getStatus: async () => ({ pct: 0.9, used: 450, limit: 500 }),
...cache, notify,
};
await checkQuotaDaily({ ...base, now: () => new Date('2026-07-11T15:00:00Z') });
await checkQuotaDaily({ ...base, now: () => new Date('2026-07-12T15:00:00Z') });
expect(msgs).toHaveLength(2);
});
test('never throws — a broken status probe returns { alerted: false }', async () => {
const r = await checkQuotaDaily({ getStatus: async () => { throw new Error('redis gone'); } });
expect(r.alerted).toBe(false);
expect(r.error).toBe('redis gone');
});
});
describe('countRecentSettles', () => {
test('counts settledAt inside the window across multiple sport logs', () => {
const nowMs = Date.parse('2026-07-11T13:00:00Z');
const logs = [
[ { settledAt: '2026-07-11T14:05:00Z' }, { settledAt: '2026-07-09T14:00:00Z' } ], // 1 in, 1 out
[ { settledAt: '2026-07-10T14:30:00Z' }, { noSettledAt: true } ], // 1 in
];
expect(countRecentSettles(logs, nowMs)).toBe(2);
expect(countRecentSettles([], nowMs)).toBe(0);
expect(countRecentSettles(null, nowMs)).toBe(0);
});
});
describe('buildPulseMessage', () => {
test('one message with every field, real numbers rendered', () => {
const msg = buildPulseMessage({
dateEt: '2026-07-11',
ledgerRows: 42,
settles24h: 38,
quota: { pct: 0.12, used: 61, limit: 500 },
health: { disk_pct: 41, mem_pct: 63 },
});
expect(msg).toContain('VYNDR pulse — 2026-07-11');
expect(msg).toContain('ledger rows yesterday: 42');
expect(msg).toContain('settles last 24h: 38');
expect(msg).toContain('odds-api quota: 12% (61/500)');
expect(msg).toContain('disk 41% · mem 63%');
expect(msg).toContain('desk pack: see /desk');
});
test('missing data renders n/a — never a fabricated zero', () => {
const msg = buildPulseMessage({ dateEt: '2026-07-11', ledgerRows: null, settles24h: null, quota: null, health: null });
expect(msg).toContain('ledger rows yesterday: n/a');
expect(msg).toContain('settles last 24h: n/a');
expect(msg).toContain('odds-api quota: n/a');
expect(msg).toContain('disk n/a · mem n/a');
});
test('VOICE v1.1 — no exclamation points, ever', () => {
const loud = buildPulseMessage({ ledgerRows: 999, settles24h: 999, quota: { pct: 0.99, used: 495, limit: 500 }, health: { disk_pct: 99, mem_pct: 99 } });
expect(loud).not.toContain('!');
expect(buildPulseMessage({})).not.toContain('!');
});
});
describe('dateET', () => {
test('renders the America/New_York calendar date (late UTC rolls back)', () => {
// 03:00 UTC Jul 11 = 11:00 PM ET Jul 10.
expect(dateET(new Date('2026-07-11T03:00:00Z'))).toBe('2026-07-10');
expect(dateET(new Date('2026-07-11T15:00:00Z'))).toBe('2026-07-11');
});
});
+94
View File
@@ -0,0 +1,94 @@
// A1 Session 3 — partner ref capture: ?ref=CODE → first-party vyndr_ref
// cookie (90d, first-touch) → signup metadata partner_ref.
const fs = require('fs');
const path = require('path');
const {
REF_COOKIE,
sanitizeRefCode,
parseRefFromSearch,
readRefCookie,
buildRefCookie,
captureRef,
} = require('../../web/src/lib/partnerRef');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('sanitizeRefCode', () => {
it('uppercases and accepts A-Z 0-9 - _', () => {
expect(sanitizeRefCode('hoopspod')).toBe('HOOPSPOD');
expect(sanitizeRefCode(' the-wire_22 ')).toBe('THE-WIRE_22');
});
it('rejects empty, too-long, and unsafe codes', () => {
expect(sanitizeRefCode('')).toBeNull();
expect(sanitizeRefCode(null)).toBeNull();
expect(sanitizeRefCode('a'.repeat(33))).toBeNull();
expect(sanitizeRefCode('bad code')).toBeNull();
expect(sanitizeRefCode('<script>')).toBeNull();
expect(sanitizeRefCode('a;b')).toBeNull();
});
});
describe('parseRefFromSearch / readRefCookie / buildRefCookie', () => {
it('parses ?ref= out of a search string', () => {
expect(parseRefFromSearch('?ref=hoopspod&utm_source=x')).toBe('HOOPSPOD');
expect(parseRefFromSearch('?utm_source=x')).toBeNull();
expect(parseRefFromSearch('')).toBeNull();
});
it('builds a 90-day first-party cookie string', () => {
const c = buildRefCookie('hoopspod');
expect(c).toContain(`${REF_COOKIE}=HOOPSPOD`);
expect(c).toContain(`Max-Age=${90 * 24 * 60 * 60}`);
expect(c).toContain('Path=/');
expect(c).toContain('SameSite=Lax');
expect(c).not.toContain('Secure');
expect(buildRefCookie('hoopspod', { secure: true })).toContain('; Secure');
expect(buildRefCookie('bad code')).toBeNull();
});
it('reads the cookie back out of a document.cookie string', () => {
expect(readRefCookie('foo=1; vyndr_ref=HOOPSPOD; bar=2')).toBe('HOOPSPOD');
expect(readRefCookie('foo=1; bar=2')).toBeNull();
expect(readRefCookie('')).toBeNull();
});
});
describe('captureRef — first touch wins', () => {
const fakeWindow = (search, cookie = '') => {
const doc = { cookie };
return { location: { search, protocol: 'https:' }, document: doc };
};
it('sets the cookie on a first visit with ?ref=', () => {
const w = fakeWindow('?ref=hoopspod');
expect(captureRef(w)).toBe('HOOPSPOD');
expect(w.document.cookie).toContain('vyndr_ref=HOOPSPOD');
expect(w.document.cookie).toContain('; Secure'); // https origin
});
it('does NOT overwrite an existing ref cookie (first touch)', () => {
const w = fakeWindow('?ref=newpartner', 'vyndr_ref=ORIGINAL');
expect(captureRef(w)).toBeNull();
expect(w.document.cookie).toBe('vyndr_ref=ORIGINAL');
});
it('no-ops without a ref param or without a window', () => {
const w = fakeWindow('?utm_source=x');
expect(captureRef(w)).toBeNull();
expect(w.document.cookie).toBe('');
expect(captureRef(null)).toBeNull();
});
});
describe('wiring (source-asserted)', () => {
it('PartnerRefCapture is mounted in the layout (GlobalHosts pattern)', () => {
const layout = read('app/layout.tsx');
expect(layout).toContain('<PartnerRefCapture />');
expect(layout).toContain("from '@/components/vyndr/PartnerRefCapture'");
});
it('signUp forwards the cookie as partner_ref signup metadata', () => {
const auth = read('contexts/AuthContext.tsx');
expect(auth).toContain('readRefCookie(document.cookie)');
expect(auth).toContain('partner_ref: partnerRef');
});
});
+99
View File
@@ -0,0 +1,99 @@
// A1 Session 3 — internal partner attribution report.
// Pure report math + route behavior with an injected Supabase client.
const express = require('express');
const request = require('supertest');
const partnersRouter = require('../../src/routes/partners');
const { sanitizePartnerCode, buildPartnerReport, _setClientForTests } = partnersRouter;
const KEY = 'test-internal-key';
function makeApp() {
const app = express();
app.use('/api/partners', partnersRouter);
return app;
}
describe('sanitizePartnerCode', () => {
it('uppercases and accepts the cookie convention (A-Z 0-9 - _, ≤32)', () => {
expect(sanitizePartnerCode('hoopspod')).toBe('HOOPSPOD');
expect(sanitizePartnerCode('THE-WIRE_22')).toBe('THE-WIRE_22');
});
it('rejects unsafe input', () => {
expect(sanitizePartnerCode('a b')).toBeNull();
expect(sanitizePartnerCode('a'.repeat(33))).toBeNull();
expect(sanitizePartnerCode('')).toBeNull();
expect(sanitizePartnerCode("x';drop")).toBeNull();
});
});
describe('buildPartnerReport', () => {
it('counts signups, active paid conversions, and founder-aware MRR', () => {
const rows = [
{ tier: 'free', subscription_status: 'none', founder_pricing: false },
{ tier: 'analyst', subscription_status: 'active', founder_pricing: false }, // 19.99
{ tier: 'analyst', subscription_status: 'active', founder_pricing: true }, // 14.99
{ tier: 'desk', subscription_status: 'active', founder_pricing: false }, // 49.99
{ tier: 'desk', subscription_status: 'canceled', founder_pricing: false }, // not active
];
expect(buildPartnerReport(rows)).toEqual({ signups: 5, conversions: 3, mrr_attributed: 84.97 });
});
it('empty / bad input → zeros', () => {
expect(buildPartnerReport([])).toEqual({ signups: 0, conversions: 0, mrr_attributed: 0 });
expect(buildPartnerReport(null)).toEqual({ signups: 0, conversions: 0, mrr_attributed: 0 });
});
});
describe('GET /api/partners/report/:code', () => {
const OLD_KEY = process.env.VYNDR_INTERNAL_KEY;
beforeAll(() => { process.env.VYNDR_INTERNAL_KEY = KEY; });
afterAll(() => {
if (OLD_KEY === undefined) delete process.env.VYNDR_INTERNAL_KEY;
else process.env.VYNDR_INTERNAL_KEY = OLD_KEY;
_setClientForTests(null);
});
const fakeClient = (result) => () => ({
from: () => ({ select: () => ({ eq: () => Promise.resolve(result) }) }),
});
it('401s without the internal key', async () => {
const res = await request(makeApp()).get('/api/partners/report/HOOPSPOD');
expect(res.status).toBe(401);
});
it('400s an invalid code', async () => {
const res = await request(makeApp())
.get('/api/partners/report/bad%20code')
.set('x-internal-key', KEY);
expect(res.status).toBe(400);
});
it('returns real attribution when the partner_ref column exists', async () => {
_setClientForTests(fakeClient({
data: [
{ tier: 'analyst', subscription_status: 'active', founder_pricing: true },
{ tier: 'free', subscription_status: 'none', founder_pricing: false },
],
error: null,
}));
const res = await request(makeApp())
.get('/api/partners/report/hoopspod')
.set('x-internal-key', KEY);
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true, code: 'HOOPSPOD', signups: 2, conversions: 1, mrr_attributed: 14.99 });
});
it('degrades to zeros + note when the column is not migrated yet', async () => {
_setClientForTests(fakeClient({ data: null, error: { message: 'column user_profiles.partner_ref does not exist' } }));
const res = await request(makeApp())
.get('/api/partners/report/HOOPSPOD')
.set('x-internal-key', KEY);
expect(res.status).toBe(200);
expect(res.body.signups).toBe(0);
expect(res.body.conversions).toBe(0);
expect(res.body.mrr_attributed).toBe(0);
expect(res.body.note).toContain('docs/PARTNERS.md');
});
});
+85
View File
@@ -0,0 +1,85 @@
// Session 62 (A1-S1) — the feature-promise locks. Every pricing claim that
// was vapor is now real, and these tests keep it that way.
const { quarterKelly, americanToDecimal } = require('../../src/utils/kelly');
const { applyTierGating } = require('../../src/utils/tierGating');
const { getScanLimit } = require('../../src/config/tiers');
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('quarter-Kelly (pricing: "Quarter-Kelly sizing recommendations")', () => {
test('computes from real probability × real odds', () => {
// p=0.55 at -110: b=0.909, f=(0.909*0.55-0.45)/0.909 ≈ 0.055 → quarter ≈ 1.4%
const k = quarterKelly(0.55, -110);
expect(k.quarter).toBeCloseTo(0.0138, 3);
expect(k.pct).toBeCloseTo(1.4, 1);
});
test('no edge → null (Kelly says: no bet, never a fabricated size)', () => {
expect(quarterKelly(0.5, -110)).toBeNull();
});
test('missing inputs → null, never defaults', () => {
expect(quarterKelly(null, -110)).toBeNull();
expect(quarterKelly(0.6, null)).toBeNull();
expect(quarterKelly(1.2, -110)).toBeNull();
});
test('american conversion both signs', () => {
expect(americanToDecimal(-110)).toBeCloseTo(1.909, 3);
expect(americanToDecimal(150)).toBeCloseTo(2.5, 3);
});
});
describe('Desk-only gate at the API (alt ladder + kelly never leave the server below Desk)', () => {
const RESULT = { grade: 'A', confidence: 70, alt_lines: [{ line: 1.5, grade: 'A' }], kelly: { pct: 1.4 }, reasoning: { summary: 'x' }, kill_conditions_triggered: [] };
test('desk keeps both', () => {
const g = applyTierGating(RESULT, 'desk');
expect(g.alt_lines).toBeTruthy();
expect(g.kelly).toBeTruthy();
});
test('analyst loses both, keeps reasoning', () => {
const g = applyTierGating(RESULT, 'analyst');
expect(g.alt_lines).toBeUndefined();
expect(g.kelly).toBeUndefined();
expect(g.reasoning.summary).toBe('x');
});
test('free loses both AND gets locked reasoning', () => {
const g = applyTierGating(RESULT, 'free');
expect(g.alt_lines).toBeUndefined();
expect(g.kelly).toBeUndefined();
expect(g.reasoning.locked).toBe(true);
});
});
describe('read limits match the pricing page', () => {
test('free 3/day · africa 10/day · analyst unlimited (Founder promise) · desk unlimited', () => {
expect(getScanLimit('free')).toBe(3);
expect(getScanLimit('africa')).toBe(10);
expect(getScanLimit('analyst')).toBe(Infinity);
expect(getScanLimit('desk')).toBe(Infinity);
});
});
describe('no unverifiable factor-count claims anywhere', () => {
it.each([
'components/Pricing.tsx', 'app/help/page.tsx', 'app/about/page.tsx',
'app/upgrade/desk/page.tsx', 'components/GradeCard.tsx',
'components/vyndr/GradeResultCard.tsx', 'components/vyndr/ProcessingGrade.tsx',
])('%s carries no "40+" claim', (rel) => {
expect(read(rel)).not.toContain('40+');
});
});
describe('free tier sees the promised LOCKED PREVIEW of kill conditions', () => {
const { mapScanToGradeResult } = require('../../web/src/lib/gradeAdapter');
test('free gets code-only teaser chips; analyst gets reasons', () => {
const input = {
player: 'X', stat: 'hits', line: 1.5, direction: 'over', grade: 'B',
kill_conditions: [{ code: 'TRAP', reason: 'Multiple trap signals firing.' }],
};
const free = mapScanToGradeResult({ ...input, tier: 'free' });
expect(free.killConditions).toEqual(['TRAP — upgrade to see details']);
const paid = mapScanToGradeResult({ ...input, tier: 'analyst' });
expect(paid.killConditions).toEqual(['Multiple trap signals firing.']);
});
});
+101
View File
@@ -0,0 +1,101 @@
// Session 64 (A1-S5) — prop viability: lineup confirmation + injury wire +
// the NOT-IN grade kill. Real feeds only; absent → no chips, never guessed.
const { parseLineups, statusFor } = require('../../src/services/lineupService');
const { parseInjuries, chipFor } = require('../../src/services/injuryService');
const adapter = require('../../web/src/lib/slateAdapter');
// Real statsapi hydrate=lineups shape (verified live 2026-07-11).
const SCHEDULE_JSON = {
dates: [{
games: [
{
gamePk: 823357,
teams: { home: { team: { name: 'Milwaukee Brewers' } }, away: { team: { name: 'Pittsburgh Pirates' } } },
lineups: {
homePlayers: [
{ id: 663968, fullName: 'Jake Mangum' },
{ id: 664040, fullName: 'Brandon Lowe' },
],
awayPlayers: [{ id: 1, fullName: 'Bryan Reynolds' }],
},
},
{ gamePk: 823356, teams: { home: { team: { name: 'Detroit Tigers' } }, away: { team: { name: 'Tampa Bay Rays' } } }, lineups: {} },
],
}],
};
describe('lineupService — CONFIRMED / NOT_IN / PROJECTED', () => {
const parsed = parseLineups(SCHEDULE_JSON);
test('posted lineup → confirmed with the batting slot', () => {
expect(parsed.byPlayer['brandon lowe']).toEqual({ status: 'confirmed', slot: 2, team: 'Milwaukee Brewers' });
expect(parsed.postedTeams).toContain('Milwaukee Brewers');
});
test('team posted, player absent → NOT_IN', () => {
expect(statusFor('Christian Yelich', 'Milwaukee Brewers', parsed).status).toBe('not_in');
});
test('team not posted → PROJECTED (never guessed dead)', () => {
expect(statusFor('Riley Greene', 'Detroit Tigers', parsed).status).toBe('projected');
});
test('accented/variant names resolve through nameKey', () => {
expect(statusFor('Brandon Lowé', 'Milwaukee Brewers', parsed).status).toBe('confirmed');
});
});
describe('injuryService — OUT / GTD / PROB chips', () => {
test('parses the ESPN feed shape', () => {
const byPlayer = parseInjuries({
injuries: [{
displayName: 'Arizona Diamondbacks',
injuries: [
{ athlete: { displayName: 'Ketel Marte' }, status: 'Out', details: { type: 'Hamstring' } },
{ athlete: { displayName: 'Corbin Carroll' }, status: 'Day-To-Day', shortComment: 'wrist' },
],
}],
});
expect(byPlayer['ketel marte']).toMatchObject({ status: 'OUT', detail: 'Hamstring' });
expect(byPlayer['corbin carroll'].status).toBe('GTD');
});
test('unknown status text → no chip (never invented)', () => {
expect(chipFor('Active')).toBeNull();
expect(chipFor('')).toBeNull();
});
});
describe('slateAdapter — NOT_IN kills the graded props on the strip', () => {
const GAME = { home: 'Milwaukee Brewers', away: 'Pittsburgh Pirates' };
const props = [{ player: 'Christian Yelich', stat_type: 'hits', line: 0.5 }];
const gradeIndex = adapter.indexGrades([
{ player: 'Christian Yelich', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'A',
team: 'Milwaukee Brewers', gradedAt: { line: 0.5, timestamp: 'x' } },
]);
// Fixture keys computed via nameKey — the same folding BOTH real sides use
// (hand-written keys miss nickname resolution, e.g. jake→jacob).
const { nameKey } = require('../../web/src/lib/playerName');
const viability = {
lineups: { byPlayer: { [nameKey('Jake Mangum')]: { status: 'confirmed', slot: 1 } }, postedTeams: ['Milwaukee Brewers'] },
injuries: {},
};
test('graded prop for a NOT_IN player renders dead (grade preserved)', () => {
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME, viability);
expect(strips[0].lineup.status).toBe('not_in');
expect(strips[0].props[0].dead).toBe(true);
expect(strips[0].props[0].grade).toBe('A'); // struck through in UI, never deleted
});
test('confirmed player carries the slot; no viability feed → no chips', () => {
const confirmed = adapter.buildPlayerStripsFromProps(
[{ player: 'Jake Mangum', stat_type: 'hits', line: 0.5 }],
{}, {}, Date.now(), GAME, viability,
);
expect(confirmed[0].lineup).toEqual({ status: 'confirmed', slot: 1 });
const none = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME, null);
expect(none[0].lineup == null).toBe(true);
expect(none[0].props[0].dead == null).toBe(true);
});
});
+118
View File
@@ -0,0 +1,118 @@
// A1 Session 10 — public ledger profiles: /u/[handle] page + OG card +
// settings claim/publish UI + proxies + migration. The .tsx surfaces are
// asserted against source (plain-JS Jest, no TS transform) — same pattern as
// vyndrAppShell/socialPreview.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const WEB = path.join(ROOT, 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const exists = (rel) => fs.existsSync(path.join(WEB, rel));
const routes = require('../../web/src/lib/routes');
describe('/u routing — the share surface stays public', () => {
it('never gates /u (a public record must load anonymous)', () => {
expect(routes.isGatedRoute('/u')).toBe(false);
expect(routes.isGatedRoute('/u/kev')).toBe(false);
});
it('declares /u in OPEN_ROUTES', () => {
expect(routes.OPEN_ROUTES).toContain('/u');
});
});
describe('/u/[handle] server shell', () => {
const src = read('app/u/[handle]/page.tsx');
it('carries the CLV-verified metadata title', () => {
expect(src).toContain('CLV-verified record — @');
expect(src).toContain('· VYNDR');
});
it('renders the client record component', () => {
expect(src).toContain('PublicProfile');
expect(exists('app/u/[handle]/PublicProfile.tsx')).toBe(true);
});
});
describe('/u/[handle] client record', () => {
const src = read('app/u/[handle]/PublicProfile.tsx');
it('honors the n-gate: RECORD BUILDING under min_sample, never a small-sample %', () => {
expect(src).toContain('RECORD BUILDING');
expect(src).toMatch(/settled >= minSample/);
});
it('shows outcome + CLV chips on settled rows (ledger card patterns)', () => {
expect(src).toContain('✓ HIT');
expect(src).toContain('✕ MISS');
expect(src).toContain('BEAT CLOSE');
expect(src).toContain('clv_result');
});
it('states the uncurated contract and one not-found state for unknown AND unpublished', () => {
expect(src).toContain('Nothing curated');
expect(src).toContain('private by default');
expect(src).toMatch(/notfound/);
});
it('fetches through the Next proxy (S25 rule)', () => {
expect(src).toContain('/api/profiles/');
});
});
describe('/u/[handle] OG image', () => {
const og = read('app/u/[handle]/opengraph-image.tsx');
it('is a 1200x630 PNG record card', () => {
expect(og).toContain('width: 1200, height: 630');
expect(og).toContain("contentType = 'image/png'");
expect(og).toContain('PUBLIC LEDGER');
expect(og).toContain('misses included');
});
it('does NOT use the edge runtime (self-hosted standalone, S53 rule)', () => {
expect(og).not.toContain("runtime = 'edge'");
});
});
describe('settings — claim handle + publish toggle', () => {
const src = read('app/settings/page.tsx');
it('carries the explicit private-by-default publish copy verbatim', () => {
expect(src).toContain('Publishing puts your ENTIRE settled record on a public page — wins and misses. Private by default.');
});
it('reads and writes /api/profiles/me with the bearer token', () => {
expect(src).toContain("fetch('/api/profiles/me'");
expect(src).toMatch(/method: 'POST'[\s\S]*?\/api\/profiles\/me|\/api\/profiles\/me[\s\S]*?method: 'POST'/);
});
it('sanitizes the handle input to the API shape (a-z 0-9 _ , max 20)', () => {
expect(src).toContain("replace(/[^a-z0-9_]/g, '')");
expect(src).toContain('.slice(0, 20)');
});
});
describe('Next proxies (S25 rule — Express is not browser-reachable)', () => {
it('me proxy forwards GET/POST with Authorization', () => {
const src = read('app/api/profiles/me/route.ts');
expect(src).toContain('BACKEND_URL');
expect(src).toContain('/api/profiles/me');
expect(src).toContain('authorization');
expect(src).toContain('export async function POST');
});
it('handle proxy forwards the public read and passes the 404 through untouched', () => {
const src = read('app/api/profiles/[handle]/route.ts');
expect(src).toContain('BACKEND_URL');
expect(src).toContain('/api/profiles/');
expect(src).toContain('status: upstream.status');
});
});
describe('migration 022 — public_profiles (committed, applied by the founder)', () => {
const sql = fs.readFileSync(path.join(ROOT, 'supabase', 'migrations', '022_public_profiles.sql'), 'utf8');
it('creates the table with the handle CHECK + private-by-default published', () => {
expect(sql).toContain('public.public_profiles');
expect(sql).toContain("handle ~ '^[a-z0-9_]{3,20}$'");
expect(sql).toMatch(/published\s+boolean NOT NULL DEFAULT false/);
expect(sql).toContain('REFERENCES auth.users');
});
it('RLS: published rows readable by anyone, own row by owner, writes service-role only', () => {
expect(sql).toContain('ENABLE ROW LEVEL SECURITY');
expect(sql).toContain('USING (published = true)');
expect(sql).toContain('USING (user_id = auth.uid())');
expect(sql).not.toMatch(/FOR (INSERT|UPDATE|DELETE)/);
});
});
+6 -4
View File
@@ -61,16 +61,18 @@ describe('scanLimit middleware', () => {
expect(lastHeaders['X-Scans-Limit']).toBe('3');
});
test('analyst tier gets 15 scans/day', () => {
// Session 62 (A1-S1) — analyst is UNLIMITED now (the Founder card promises
// "Unlimited reads"; the 15/day cap made the pricing page false).
test('analyst tier is unlimited (Founder promise)', () => {
const next = jest.fn();
let blockedAt = null;
for (let i = 0; i < 16; i += 1) {
for (let i = 0; i < 30; i += 1) {
const { req, res } = mockReqRes({ tier: 'analyst', userId: 'u-analyst' });
mw(req, res, next);
if (res.statusCode === 429 && blockedAt == null) blockedAt = i;
}
expect(blockedAt).toBe(15);
expect(next).toHaveBeenCalledTimes(15);
expect(blockedAt).toBeNull();
expect(next).toHaveBeenCalledTimes(30);
});
test('desk tier is unlimited — never blocks', () => {
+107
View File
@@ -0,0 +1,107 @@
// A1 Session 3 — best available price detection. Honesty rules: a best
// price only exists when ≥2 books post the SAME line for the side and
// their prices differ. Absent beats wrong.
const { detectBestBook, buildPlayerStripsFromProps } = require('../../web/src/lib/slateAdapter');
describe('detectBestBook', () => {
const rows = [
{ book: 'draftkings', line: 1.5, over_odds: -115, under_odds: -105 },
{ book: 'fanduel', line: 1.5, over_odds: -105, under_odds: -115 },
{ book: 'betmgm', line: 1.5, over_odds: -120, under_odds: 100 },
];
it('picks the best over price at the shared line', () => {
const out = detectBestBook(rows, 'over', 1.5);
expect(out).toEqual({ book: 'fanduel', odds: -105 });
});
it('picks the best under price independently', () => {
const out = detectBestBook(rows, 'under', 1.5);
expect(out).toEqual({ book: 'betmgm', odds: 100 });
});
it('returns null for a single book (a lone price is not "best")', () => {
expect(detectBestBook([rows[0]], 'over', 1.5)).toBeNull();
expect(detectBestBook([], 'over', 1.5)).toBeNull();
expect(detectBestBook(null, 'over', 1.5)).toBeNull();
});
it('returns null when all books post the identical price', () => {
const flat = [
{ book: 'draftkings', line: 1.5, over_odds: -110 },
{ book: 'fanduel', line: 1.5, over_odds: -110 },
];
expect(detectBestBook(flat, 'over', 1.5)).toBeNull();
});
it('returns null when books only post DIFFERENT lines (odds are not comparable)', () => {
const split = [
{ book: 'draftkings', line: 1.5, over_odds: -200 },
{ book: 'fanduel', line: 2.5, over_odds: 120 },
];
expect(detectBestBook(split, 'over', 1.5)).toBeNull();
expect(detectBestBook(split, 'over', 2.5)).toBeNull();
});
it('without a reference line, compares at the modal line', () => {
const mixed = [
{ book: 'draftkings', line: 1.5, over_odds: -115 },
{ book: 'fanduel', line: 1.5, over_odds: -105 },
{ book: 'betmgm', line: 2.5, over_odds: 200 },
];
expect(detectBestBook(mixed, 'over')).toEqual({ book: 'fanduel', odds: -105 });
});
it('ignores rows with missing book / line / side odds', () => {
const dirty = [
{ book: 'draftkings', line: 1.5, over_odds: -115 },
{ book: null, line: 1.5, over_odds: -100 },
{ book: 'fanduel', line: 1.5, over_odds: null },
{ book: 'betmgm', over_odds: -105 },
];
expect(detectBestBook(dirty, 'over', 1.5)).toBeNull();
});
});
describe('buildPlayerStripsFromProps — bestBook attachment', () => {
const books = [
{ book: 'draftkings', line: 1.5, over_odds: -115, under_odds: -105 },
{ book: 'fanduel', line: 1.5, over_odds: -105, under_odds: -115 },
];
it('attaches bestBook + book to a graded prop (graded side)', () => {
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', book: 'draftkings', books }];
const gradeIndex = {
'aaron judge|total_bases': {
player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5,
direction: 'over', grade: 'A', gradedAt: { line: 1.5, odds: -115, timestamp: new Date().toISOString() },
},
};
const strips = buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips).toHaveLength(1);
const p = strips[0].props[0];
expect(p.book).toBe('draftkings');
expect(p.bestBook).toEqual({ book: 'fanduel', odds: -105 });
});
it('attaches bestBook on awaiting props too (still real market data)', () => {
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'under', book: 'draftkings', books }];
const strips = buildPlayerStripsFromProps(props, {}, {});
const p = strips[0].props[0];
expect(p.awaiting).toBe(true);
expect(p.bestBook).toEqual({ book: 'draftkings', odds: -105 });
});
it('leaves bestBook null when only one book posted the prop', () => {
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', book: 'draftkings', books: [books[0]] }];
const strips = buildPlayerStripsFromProps(props, {}, {});
expect(strips[0].props[0].bestBook).toBeNull();
});
it('leaves bestBook null when props carry no books array (legacy shape)', () => {
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', book: 'draftkings' }];
const strips = buildPlayerStripsFromProps(props, {}, {});
expect(strips[0].props[0].bestBook).toBeNull();
});
});
+220
View File
@@ -0,0 +1,220 @@
// A1 S9 — Slip Reader parser suite. PURE parsers on realistic OCR-text
// fixtures per book. The contract under test: correct extraction on the
// rigid slip grammars, per-field confidence, and the NEVER-GUESS rule
// (unreadable → null + needs_review, never a fabricated value).
const fs = require('fs');
const path = require('path');
const slipReader = require('../../src/services/slipReader');
const { parseSlipText, detectBook, CONFIDENCE_THRESHOLD } = slipReader;
const { normalizeStat, splitPlayerStat, parseOdds, buildLeg } = slipReader.__internals;
const fixture = (book) =>
fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'slips', `${book}.txt`), 'utf8');
describe('slipReader — book detection', () => {
it.each([
['draftkings'], ['fanduel'], ['betmgm'], ['caesars'],
])('detects %s from its fixture', (book) => {
expect(detectBook(fixture(book))).toBe(book);
});
it('returns null for unbranded text', () => {
expect(detectBook('Aaron Judge Over 1.5 Total Bases')).toBeNull();
});
});
describe('slipReader — DraftKings layout', () => {
const result = parseSlipText(fixture('draftkings'));
it('extracts all three legs completely', () => {
expect(result.book).toBe('draftkings');
expect(result.legs).toHaveLength(3);
expect(result.needs_review).toBe(false);
expect(result.source).toBe('user_slip');
});
it('extracts player/stat/line/side/odds on leg 1', () => {
const leg = result.legs[0];
expect(leg.player).toBe('Aaron Judge');
expect(leg.player_key).toBe('aaron judge');
expect(leg.stat).toBe('total_bases');
expect(leg.line).toBe(1.5);
expect(leg.side).toBe('over');
expect(leg.odds).toBe(-115);
expect(leg.needs_review).toBe(false);
});
it('normalizes book market labels through the stat vocabulary', () => {
// "Strikeouts Thrown" (DK pitcher label) → strikeouts
expect(result.legs[1].stat).toBe('strikeouts');
expect(result.legs[1].line).toBe(7.5);
// plus-odds leg
expect(result.legs[2].stat).toBe('home_runs');
expect(result.legs[2].odds).toBe(320);
});
it('carries per-field confidences at or above threshold on clean legs', () => {
for (const leg of result.legs) {
for (const field of ['player', 'stat', 'line', 'side', 'odds']) {
expect(leg.confidence[field]).toBeGreaterThanOrEqual(CONFIDENCE_THRESHOLD);
}
}
});
});
describe('slipReader — FanDuel layout', () => {
const result = parseSlipText(fixture('fanduel'));
it('parses To Record N+ / Any Time / Over grammars', () => {
expect(result.book).toBe('fanduel');
expect(result.legs).toHaveLength(3);
// "To Record 2+ Total Bases" → line 1.5 over
expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over' });
// "Any Time Home Run" → home_runs 0.5 over
expect(result.legs[1]).toMatchObject({ player: 'Giancarlo Stanton', stat: 'home_runs', line: 0.5, side: 'over' });
// plain Over grammar
expect(result.legs[2]).toMatchObject({ player: 'Gerrit Cole', stat: 'strikeouts', line: 6.5, side: 'over' });
});
it('SGP legs without per-leg odds → odds null + needs_review (never guessed)', () => {
for (const leg of result.legs) {
expect(leg.odds).toBeNull();
expect(leg.confidence.odds).toBeLessThan(CONFIDENCE_THRESHOLD);
expect(leg.needs_review).toBe(true);
}
expect(result.needs_review).toBe(true);
});
});
describe('slipReader — BetMGM layout', () => {
const result = parseSlipText(fixture('betmgm'));
it('parses inline "@ odds" and trailing-odds legs', () => {
expect(result.book).toBe('betmgm');
expect(result.legs).toHaveLength(3);
expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', odds: -115 });
expect(result.legs[1]).toMatchObject({ player: 'Shohei Ohtani', stat: 'strikeouts', line: 7.5, side: 'under', odds: 105 });
expect(result.legs[2]).toMatchObject({ player: 'Mookie Betts', stat: 'hits', line: 1.5, side: 'over', odds: 140 });
expect(result.needs_review).toBe(false);
});
});
describe('slipReader — Caesars layout', () => {
const result = parseSlipText(fixture('caesars'));
it('splits the ambiguous player/stat head on known stat aliases', () => {
expect(result.book).toBe('caesars');
expect(result.legs).toHaveLength(3);
// "Pete Alonso Home Runs" must NOT become player "Pete Alonso Home" + stat "runs"
expect(result.legs[1]).toMatchObject({ player: 'Pete Alonso', stat: 'home_runs', line: 0.5, side: 'over', odds: 340 });
expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', odds: -115 });
expect(result.legs[2]).toMatchObject({ player: 'Freddie Freeman', stat: 'hits', side: 'under', odds: -105 });
});
it('unknown stat head → stat AND player null (boundary unknowable), needs_review', () => {
const r = parseSlipText('Caesars\nAaron Judge Fantasy Score Over 32.5 (-115)');
expect(r.legs).toHaveLength(1);
expect(r.legs[0].stat).toBeNull();
expect(r.legs[0].player).toBeNull(); // boundary uncertain → below threshold → nulled
expect(r.legs[0].needs_review).toBe(true);
// The readable fields still come through — nothing over-nulled.
expect(r.legs[0].line).toBe(32.5);
expect(r.legs[0].odds).toBe(-115);
});
});
describe('slipReader — never guess', () => {
it('garbage text → zero legs, needs_review envelope, no fabrication', () => {
const r = parseSlipText('completely unrelated text\nnothing to see 12345\nlorem ipsum');
expect(r.legs).toEqual([]);
expect(r.needs_review).toBe(true);
expect(r.book).toBeNull();
});
it('empty/nullish input is safe', () => {
expect(parseSlipText('').legs).toEqual([]);
expect(parseSlipText(null).legs).toEqual([]);
expect(parseSlipText(undefined).legs).toEqual([]);
});
it('a below-threshold field is nulled, not passed through', () => {
const leg = buildLeg({
player: 'judge', // lowercase single token → low confidence
statLabel: 'Total Bases',
line: 1.5,
side: 'over',
odds: -115,
});
expect(leg.player).toBeNull();
expect(leg.player_key).toBeNull();
expect(leg.needs_review).toBe(true);
expect(leg.stat).toBe('total_bases'); // readable fields survive
});
it('book hint routes to the hinted layout parser', () => {
const dk = fixture('draftkings');
const hinted = parseSlipText(dk, 'draftkings');
expect(hinted.book).toBe('draftkings');
expect(hinted.legs).toHaveLength(3);
});
it('unknown book → best layout wins without inventing values', () => {
const unbranded = fixture('draftkings').replace(/DraftKings Sportsbook\n/, '');
const r = parseSlipText(unbranded);
expect(r.legs.length).toBeGreaterThanOrEqual(3);
for (const leg of r.legs) {
expect(leg.player).not.toBeNull();
expect(leg.stat).not.toBeNull();
}
});
});
describe('slipReader — vocabulary + helpers', () => {
it('normalizeStat maps slip labels to canonical stat_types', () => {
expect(normalizeStat('Total Bases')).toBe('total_bases');
expect(normalizeStat('Strikeouts Thrown')).toBe('strikeouts');
expect(normalizeStat('Alt Total Bases')).toBe('total_bases');
expect(normalizeStat('Total Bases O/U')).toBe('total_bases');
expect(normalizeStat('Pts + Reb + Ast')).toBe('pra');
expect(normalizeStat('3 Pointers Made')).toBe('threes');
expect(normalizeStat('Runs Batted In')).toBe('rbi');
expect(normalizeStat('Fantasy Score')).toBeNull(); // unknown → null, never a guess
});
it('canonical stat_types match the scan-route vocabulary exactly', () => {
// Mirror of VALID_STAT_TYPES in src/routes/scan.js — a drifted alias
// here would 400 at the grade gate.
const VALID = new Set([
'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers',
'goals', 'shots_on_target', 'shots', 'tackles', 'cards', 'corners', 'saves',
'goals_conceded', 'passes', 'clean_sheet',
'strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases', 'walks', 'runs',
'earned_runs', 'innings_pitched', 'hits_allowed', 'stolen_bases', 'doubles', 'outs',
]);
for (const stat of Object.values(slipReader.__internals.STAT_ALIASES)) {
expect(VALID.has(stat)).toBe(true);
}
});
it('splitPlayerStat prefers the longest alias suffix', () => {
expect(splitPlayerStat('Pete Alonso Home Runs')).toMatchObject({ player: 'Pete Alonso', statLabel: 'Home Runs' });
expect(splitPlayerStat('Aaron Judge Total Bases')).toMatchObject({ player: 'Aaron Judge', statLabel: 'Total Bases' });
expect(splitPlayerStat('Aaron Judge Alt Total Bases')).toMatchObject({ player: 'Aaron Judge', statLabel: 'Total Bases' });
});
it('parseOdds normalizes OCR minus glyphs and rejects non-odds', () => {
expect(parseOdds('-115')).toBe(-115);
expect(parseOdds('115')).toBe(-115); // unicode minus
expect(parseOdds('+320')).toBe(320);
expect(parseOdds('-15')).toBeNull(); // |odds| < 100 is a line, not odds
expect(parseOdds('banana')).toBeNull();
expect(parseOdds(null)).toBeNull();
});
it('player names normalize through playerName (display + key)', () => {
const r = parseSlipText('DraftKings\nA.J. Ewing Over 1.5\nTotal Bases\n-115');
expect(r.legs[0].player).toBe('AJ Ewing');
expect(r.legs[0].player_key).toBe('aj ewing');
});
});
+58
View File
@@ -0,0 +1,58 @@
'use strict';
// Session 8 (A1 board) — systemHealth: box vitals, pure + injectable.
const { getSystemHealth, healthIssues, THRESHOLDS } = require('../../src/services/systemHealth');
describe('getSystemHealth', () => {
test('computes disk_pct from statfs and mem_pct from os', async () => {
const fsImpl = { statfs: async () => ({ blocks: 1000, bavail: 400 }) }; // 60% used
const osImpl = { totalmem: () => 100, freemem: () => 25 }; // 75% used
const h = await getSystemHealth({ fsImpl, osImpl });
expect(h).toEqual({ disk_pct: 60, mem_pct: 75 });
});
test('a failed statfs degrades disk_pct to null, mem still reports', async () => {
const fsImpl = { statfs: async () => { throw new Error('EACCES'); } };
const osImpl = { totalmem: () => 10, freemem: () => 1 };
const h = await getSystemHealth({ fsImpl, osImpl });
expect(h.disk_pct).toBeNull();
expect(h.mem_pct).toBe(90);
});
test('zero/invalid totals degrade to null rather than dividing by zero', async () => {
const h = await getSystemHealth({
fsImpl: { statfs: async () => ({ blocks: 0, bavail: 0 }) },
osImpl: { totalmem: () => 0, freemem: () => 0 },
});
expect(h).toEqual({ disk_pct: null, mem_pct: null });
});
test('real (uninjected) call returns numbers on this box', async () => {
const h = await getSystemHealth();
expect(h.disk_pct === null || (h.disk_pct >= 0 && h.disk_pct <= 100)).toBe(true);
expect(h.mem_pct).toBeGreaterThanOrEqual(0);
expect(h.mem_pct).toBeLessThanOrEqual(100);
});
});
describe('healthIssues', () => {
test('thresholds: disk > 85, mem > 90 — strictly greater', () => {
expect(healthIssues({ disk_pct: 85, mem_pct: 90 })).toEqual([]);
expect(healthIssues({ disk_pct: 86, mem_pct: 90 })).toEqual(['disk at 86% (threshold 85%)']);
expect(healthIssues({ disk_pct: 40, mem_pct: 91 })).toEqual(['memory at 91% (threshold 90%)']);
expect(healthIssues({ disk_pct: 99, mem_pct: 99 })).toHaveLength(2);
});
test('null probes never flag; copy carries no exclamation points', () => {
expect(healthIssues({ disk_pct: null, mem_pct: null })).toEqual([]);
expect(healthIssues(null)).toEqual([]);
for (const line of healthIssues({ disk_pct: 99, mem_pct: 99 })) {
expect(line).not.toContain('!');
}
});
test('exported thresholds match the spec', () => {
expect(THRESHOLDS).toEqual({ disk_pct: 85, mem_pct: 90 });
});
});
+2 -2
View File
@@ -58,7 +58,7 @@ describe('tiers config', () => {
test('analyst opens reasoning + kill conditions + alerts', () => {
const a = TIERS.analyst;
expect(a.scans_per_day).toBe(15);
expect(a.scans_per_day).toBe(Infinity); // Session 62 — Founder 'Unlimited reads' promise
expect(a.reasoning_visible).toBe(true);
expect(a.kill_conditions_detail).toBe(true);
expect(a.alerts).toBe(true);
@@ -87,7 +87,7 @@ describe('tiers config', () => {
test('getScanLimit returns the per-tier daily cap', () => {
expect(getScanLimit('free')).toBe(3);
expect(getScanLimit('analyst')).toBe(15);
expect(getScanLimit('analyst')).toBe(Infinity);
expect(getScanLimit('desk')).toBe(Infinity);
});
+9 -3
View File
@@ -41,7 +41,9 @@ describe('Phase D — grade adapter (engine → §7 contract)', () => {
it('FREE tier teases 3 signals, hides kill conditions and the alt ladder', () => {
const out = adapter.mapScanToGradeResult({ ...base, tier: 'free' });
expect(out.signals.length).toBe(3);
expect(out.killConditions).toEqual([]);
// Session 62 (A1-S1) — free gets the pricing-promised LOCKED PREVIEW
// (code-only teaser), not silence.
expect(out.killConditions.every((k) => k.includes('upgrade to see details'))).toBe(true);
expect(out.altLadder).toEqual([]);
});
@@ -150,8 +152,12 @@ describe('Phase D — scan + landing wiring', () => {
expect(src).toContain('mapScanToGradeResult');
expect(src).not.toContain("from '@/components/GradeCard'");
});
it('scan keeps sportsbook deep-links safe (noopener noreferrer)', () => {
expect(read('app/scan/page.tsx')).toContain('noopener noreferrer');
it('scan keeps sportsbook deep-links safe (A1 S3: BOOK_LINK_REL = sponsored noopener noreferrer)', () => {
const src = read('app/scan/page.tsx');
expect(src).toContain('rel={BOOK_LINK_REL}');
// the constant itself carries noopener noreferrer (+ sponsored)
const { BOOK_LINK_REL } = require('../../web/src/lib/bookLinks');
expect(BOOK_LINK_REL).toContain('noopener noreferrer');
});
it('landing mounts the ClaimMeter', () => {
expect(read('app/page.tsx')).toContain('ClaimMeter');
+1 -1
View File
File diff suppressed because one or more lines are too long
+32 -8
View File
@@ -1,9 +1,16 @@
import SectionHead from '@/components/vyndr/SectionHead';
import { Wordmark } from '@/components/vyndr';
export const metadata = { title: 'About' };
export const metadata = {
title: 'About',
description:
'VYNDR is a sports data intelligence platform built in Detroit. The Ledger is the public record — misses included.',
};
// Brand page in system voice (§3). Server component.
// Session 2 (A1 board) — audited against NORTH STAR v1.1: data intelligence
// platform, the Ledger as public proof, built in Detroit. VOICE v1.1 —
// deadpan, numbers first, no exclamation points.
export default function AboutPage() {
return (
<section style={{ maxWidth: 680, margin: '0 auto', padding: '40px 16px 96px' }}>
@@ -17,23 +24,40 @@ export default function AboutPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
<p className="mono" style={{ fontSize: 14.5, color: 'var(--text-1)', lineHeight: 1.7 }}>
VYNDR is prop intelligence a proprietary terminal that reads the context the books hide.
Beyond the box score: form, matchup, usage, rest, the cascade when a star sits. Then a single
grade, earned across 40+ factors, that tells you whether the number is worth taking.
VYNDR is a sports data intelligence platform. The free layer is the complete picture
schedule, stats, game logs, streaks with the context they were built against, line
comparison across books always populated, always interpreted. The paid layer is the
read: a letter grade earned across the model&apos;s full feature pipeline, archetypes,
correlation-aware parlays, and closing-line intelligence.
</p>
<p className="mono" style={{ fontSize: 14.5, color: 'var(--text-1)', lineHeight: 1.7 }}>
We are an analytics tool, not a sportsbook. We don&apos;t take wagers. We hand you the read and
the kill conditions, and let you decide.
We are an analytics tool, not a sportsbook. We take no wagers and hold no funds. We hand
you the read and let you decide.
</p>
<div>
<SectionHead style={{ marginBottom: 12 }}>THE PROOF</SectionHead>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 18 }}>
<p className="mono" style={{ fontSize: 13.5, color: 'var(--text-1)', lineHeight: 1.65, margin: 0 }}>
Every grade the model produces is locked at grade time line, odds, timestamp and
settled against the real box score on{' '}
<a href="/ledger" style={{ color: 'var(--text-0)' }}>the Ledger</a>, misses included,
in the same format as the wins. No percentage renders until at least 20 grades have
settled. When the model has no projection, it refuses to grade rather than guess.
How all of it works is written down, in plain language, on{' '}
<a href="/methodology" style={{ color: 'var(--text-0)' }}>the methodology page</a>.
</p>
</div>
</div>
<div>
<SectionHead style={{ marginBottom: 12 }}>BUILT BY</SectionHead>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 18 }}>
<div style={{ fontSize: 17, fontWeight: 800 }}>Kevon Butler</div>
<div className="mono" style={{ fontSize: 12.5, color: 'var(--text-1)', marginTop: 4 }}>Founder · Detroit</div>
<p className="mono" style={{ fontSize: 13.5, color: 'var(--text-1)', lineHeight: 1.65, marginTop: 12 }}>
A glitch between a tech-genius mind and a love for sports. Built in Detroit, for everyone the
books count on staying in the dark.
The books employ floors full of quants. This is one terminal, built in Detroit, for
everyone the books count on staying in the dark. Seems fair.
</p>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Desk pack proxy (Session 63 / A1-S4) — forwards the founder's auth. */
export async function GET(req: NextRequest) {
const auth = req.headers.get('authorization');
if (!auth) return NextResponse.json({ error: 'auth required' }, { status: 401 });
try {
const upstream = await fetch(`${BACKEND_URL}/api/desk/pack`, {
headers: { Accept: 'application/json', Authorization: auth },
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ error: 'desk unavailable' }, { status: 200 });
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Newsletter subscribe proxy (Session S7, a1) — S25 rule: the browser hits
* the Next origin, never Express directly. Forwards to the backend, which
* forwards to the self-hosted Listmonk (double opt-in).
*
* Failure posture: any upstream problem degrades to the calm
* { ok: false, reason: 'not configured' } shape — the capture component
* renders "Signups open soon", never an error wall.
*/
export async function POST(req: NextRequest) {
let body: unknown = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ ok: false, error: 'Enter a valid email.' }, { status: 400 });
}
try {
const upstream = await fetch(`${BACKEND_URL}/api/newsletter/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({ ok: false, reason: 'not configured' }));
return NextResponse.json(data, { status: upstream.status === 400 ? 400 : 200 });
} catch {
return NextResponse.json({ ok: false, reason: 'not configured' }, { status: 200 });
}
}
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Public profile proxy (A1 Session 10) — forwards GET /api/profiles/:handle.
* Express owns the privacy rules: unpublished and unknown handles come back
* as the SAME 404 body (no existence leak) — this proxy passes it through
* untouched.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ handle: string }> },
) {
const { handle } = await params;
try {
const upstream = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({ error: 'Profile not found' }));
return NextResponse.json(data, {
status: upstream.status,
headers: upstream.ok ? { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' } : undefined,
});
} catch {
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Own-profile proxy (A1 Session 10) — forwards GET/POST /api/profiles/me
* with the caller's Authorization header (Express requireAuth resolves the
* user; the publish toggle + handle claim live behind it).
*/
function authHeaders(req: NextRequest): HeadersInit {
const auth = req.headers.get('authorization');
return { Accept: 'application/json', 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) };
}
export async function GET(req: NextRequest) {
if (!req.headers.get('authorization')) return NextResponse.json({ profile: null }, { status: 401 });
try {
const upstream = await fetch(`${BACKEND_URL}/api/profiles/me`, { headers: authHeaders(req), cache: 'no-store' });
const data = await upstream.json().catch(() => ({ profile: null }));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ profile: null }, { status: 200 });
}
}
export async function POST(req: NextRequest) {
const body = await req.text();
try {
const upstream = await fetch(`${BACKEND_URL}/api/profiles/me`, { method: 'POST', headers: authHeaders(req), body });
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ error: 'Profile service unreachable.' }, { status: 502 });
}
}
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Injury-wire proxy (Session 64 / A1-S5). */
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
try {
const upstream = await fetch(`${BACKEND_URL}/api/schedule/${encodeURIComponent(String(sport).toLowerCase())}/injuries`, {
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({ byPlayer: {} }));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ byPlayer: {} }, { status: 200 });
}
}
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Lineup-confirmation proxy (Session 64 / A1-S5). */
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
try {
const upstream = await fetch(`${BACKEND_URL}/api/schedule/${encodeURIComponent(String(sport).toLowerCase())}/lineups${req.nextUrl.search}`, {
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({ byPlayer: {}, postedTeams: [] }));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ byPlayer: {}, postedTeams: [] }, { status: 200 });
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* A1 S9 — Slip Reader proxy. Forwards POST /api/slips/parse (base64 image
* or slip text) to Express with the Authorization header (S25 rule: the
* browser never reaches Express directly). Body can be ~5MB of base64 —
* stream it through as text.
*/
export async function POST(req: NextRequest) {
const body = await req.text();
try {
const upstream = await fetch(`${BACKEND_URL}/api/slips/parse`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(req.headers.get('authorization')
? { Authorization: req.headers.get('authorization')! }
: {}),
},
body,
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ error: 'The reader is offline. Try again shortly.' }, { status: 502 });
}
}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
/**
* /desk (Session 63 / A1-S4c) — the founder's daily copy-paste arsenal.
* Auth: gated route + backend email allowlist (DESK_OWNERS). Everything on
* this page is assembled from the pipeline by the VOICE v1.1 template
* engine; per-tweet copy buttons + character counts; the DATA BRIEF block
* pastes into claude.ai for freeform writing. Nothing auto-posts.
*/
interface Variant { label: string; text: string; tweets: string[] }
interface DeskPack {
generated_at: string | null;
formats: {
morning_wire?: Variant[];
signals?: Variant[][];
streak_watch?: Variant[];
settle?: Variant[];
receipts?: Variant[][];
archetype_watch?: Variant[];
line_dispatches?: Variant[][];
};
data_brief?: unknown;
error?: string;
}
function CopyBtn({ text, label = 'COPY' }: { text: string; label?: string }) {
const [done, setDone] = useState(false);
return (
<button
onClick={() => {
void navigator.clipboard.writeText(text).then(() => {
setDone(true);
setTimeout(() => setDone(false), 1200);
});
}}
className="mono"
style={{ cursor: 'pointer', background: done ? 'var(--g-a)' : 'transparent', color: done ? '#06060B' : 'var(--text-1)', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '4px 10px', fontSize: 10, fontWeight: 700, letterSpacing: '0.06em' }}
>
{done ? 'COPIED' : label}
</button>
);
}
function VariantBlock({ v }: { v: Variant }) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl">{v.label.toUpperCase()}</span>
<CopyBtn text={v.text} label="COPY ALL" />
</div>
{v.tweets.map((t, i) => (
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '8px 10px', background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 8, marginBottom: 6 }}>
<pre className="mono" style={{ margin: 0, flex: 1, whiteSpace: 'pre-wrap', fontSize: 12.5, lineHeight: 1.55, color: 'var(--text-0)' }}>{t}</pre>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end', flexShrink: 0 }}>
<CopyBtn text={t} />
<span className="mono" style={{ fontSize: 9.5, color: t.length > 280 ? 'var(--miss)' : 'var(--text-2)' }}>{t.length}/280</span>
</div>
</div>
))}
</div>
);
}
function FormatCard({ title, variants }: { title: string; variants?: Variant[] }) {
if (!variants || variants.length === 0) return null;
return (
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
<div className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--g-a)', marginBottom: 12 }}>{title}</div>
{variants.map((v, i) => <VariantBlock key={i} v={v} />)}
</section>
);
}
export default function DeskPage() {
const { session } = useAuth();
const [pack, setPack] = useState<DeskPack | null>(null);
const [status, setStatus] = useState<'loading' | 'denied' | 'ready' | 'error'>('loading');
useEffect(() => {
const token = session?.access_token;
if (!token) return;
let active = true;
fetch('/api/desk/pack', { headers: { Authorization: `Bearer ${token}` } })
.then(async (r) => {
if (!active) return;
if (r.status === 403 || r.status === 401) { setStatus('denied'); return; }
const data = (await r.json()) as DeskPack;
setPack(data);
setStatus(data && data.formats ? 'ready' : 'error');
})
.catch(() => { if (active) setStatus('error'); });
return () => { active = false; };
}, [session]);
if (status === 'denied') {
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
<p className="mono" style={{ color: 'var(--text-1)', fontSize: 13 }}>The desk is the publisher&rsquo;s surface. DESK_OWNERS grants access.</p>
</section>
);
}
if (status !== 'ready' || !pack) {
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>{status === 'loading' ? 'Assembling the pack…' : 'Pack unavailable. Check back after the first pipeline run.'}</p>
</section>
);
}
const f = pack.formats;
return (
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 120px' }}>
<header style={{ marginBottom: 20 }}>
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>THE DESK</div>
<h1 className="mono" style={{ margin: 0, fontSize: 26, fontWeight: 800 }}>Today&rsquo;s arsenal</h1>
{pack.generated_at && (
<p className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 6 }}>
Assembled {new Date(pack.generated_at).toLocaleString('en-US', { timeZone: 'America/New_York' })} ET · nothing auto-posts
</p>
)}
</header>
<FormatCard title="THE MORNING WIRE" variants={f.morning_wire} />
{(f.signals || []).map((v, i) => <FormatCard key={`sig-${i}`} title={`SIGNAL ${i + 1}`} variants={v} />)}
<FormatCard title="STREAK WATCH" variants={f.streak_watch} />
<FormatCard title="ARCHETYPE WATCH" variants={f.archetype_watch} />
{(f.line_dispatches || []).map((v, i) => <FormatCard key={`ld-${i}`} title={`LINE DISPATCH ${i + 1}`} variants={v} />)}
<FormatCard title="THE SETTLE" variants={f.settle} />
{(f.receipts || []).map((v, i) => <FormatCard key={`rc-${i}`} title={`RECEIPT ${i + 1}`} variants={v} />)}
{/* DATA BRIEF — paste into claude.ai for freeform Read Room threads. */}
{pack.data_brief != null && (
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border-hi)', borderRadius: 10, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--amber)' }}>DATA BRIEF</span>
<CopyBtn text={JSON.stringify(pack.data_brief, null, 2)} label="COPY BRIEF" />
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>paste into claude.ai for Read Room + freeform</span>
</div>
<pre className="mono" style={{ margin: 0, maxHeight: 320, overflow: 'auto', fontSize: 11, lineHeight: 1.5, color: 'var(--text-1)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(pack.data_brief, null, 2)}</pre>
</section>
)}
</section>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import SectionHead from '@/components/vyndr/SectionHead';
import TerminalInput from '@/components/vyndr/TerminalInput';
const FAQ: { cat: string; q: string; a: string }[] = [
{ cat: 'GRADES', q: 'What does a VYNDR grade mean?', a: 'A grade is our confidence that a prop hits, weighed across 40+ factors. A+ is the rarest and strongest; D means stay away. The letter is earned, not opinion.' },
{ cat: 'GRADES', q: 'What does a VYNDR grade mean?', a: 'A grade is our confidence that a prop hits, weighed across every factor the model computes for that prop. A+ is the rarest and strongest; D means stay away. The letter is earned, not opinion.' },
{ cat: 'GRADES', q: 'What is a kill condition?', a: 'A red flag we detect that can sink a prop regardless of the stats — a blowout risk, a minutes cap, a brutal matchup. Analyst and Desk see them in full.' },
{ cat: 'READS', q: 'How many free reads do I get?', a: 'Five reads every calendar month on the Free tier. The counter resets at the start of each month.' },
{ cat: 'READS', q: 'What counts as a read?', a: 'Grading one prop. Viewing the same prop twice in a session only counts once.' },
+3
View File
@@ -9,6 +9,7 @@ import Footer from '@/components/Footer';
import AuthGate from '@/components/AuthGate';
import HashRedirect from '@/components/vyndr/HashRedirect';
import GlobalHosts from '@/components/vyndr/GlobalHosts';
import PartnerRefCapture from '@/components/vyndr/PartnerRefCapture';
// Session 50 — the Parlay Lab supersedes the legacy ParlayTray.
import ParlayPanel from '@/components/vyndr/ParlayPanel';
import BottomTabBar from '@/components/BottomTabBar';
@@ -156,6 +157,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<SentryInit />
{/* Session 38 — prefs apply + paywall/checkout/prefs globals (§9/§10/§12) */}
<GlobalHosts />
{/* A1 S3 — first-visit ?ref=CODE → vyndr_ref cookie (90d, first-touch) */}
<PartnerRefCapture />
</ParlayProvider>
</ExplainModeProvider>
</AuthProvider>
+173
View File
@@ -0,0 +1,173 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Methodology',
description:
'How VYNDR grades a prop, what archetypes are, how the Ledger settles, and why the misses are public. Plain language, no hype.',
};
// Session 2 (A1 board) — the approval-pack methodology page. Server component.
// Plain-language description of the real pipeline (feature vector → engine →
// letter grade → locked ledger entry → box-score settle → CLV). Every claim
// here matches the code; if the pipeline changes, change this page in the
// same commit.
const h2: React.CSSProperties = { fontSize: 20, fontWeight: 700, marginBottom: 12, color: 'var(--text-0)' };
const body: React.CSSProperties = { color: 'var(--text-1)', lineHeight: 1.75, fontSize: 15, marginBottom: 12 };
const card: React.CSSProperties = {
background: 'var(--bg-1)',
border: '1px solid var(--border)',
borderRadius: 10,
padding: '16px 18px',
};
function Mono({ children }: { children: React.ReactNode }) {
return <span className="mono" style={{ color: 'var(--text-0)' }}>{children}</span>;
}
export default function MethodologyPage() {
return (
<section style={{ maxWidth: 720, margin: '0 auto', padding: '48px 16px 120px' }}>
<header style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8, color: 'var(--text-0)' }}>
How the grades work.
</h1>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', letterSpacing: '0.05em' }}>
METHODOLOGY · PLAIN LANGUAGE · NO HYPE
</p>
</header>
<p style={{ ...body, fontSize: 16 }}>
This page describes what actually happens between a sportsbook posting a line and a letter
grade appearing on VYNDR. It is written so a claim made here can be checked against the
Ledger. If a sentence on this page ever stops being true, the page is wrong and we fix it.
</p>
{/* ── The pipeline ─────────────────────────────────────────── */}
<section style={{ marginTop: 36 }}>
<h2 style={h2}>The pipeline</h2>
<p style={body}>
Grades are produced by a scheduled pipeline, not on demand. Several times a day the
system pulls the current prop lines from sportsbook feeds, then builds a feature vector
for each prop the numbers the model reasons over. Depending on the sport, that includes
recent form (last-5 and last-20 averages, variance), opponent strength against the exact
stat, pace, home or away, rest days, schedule density, batter-versus-pitcher history for
MLB, and per-90 rates with expected-goals context for soccer.
</p>
<p style={body}>
The engine compares the model&apos;s projection to the posted line in both directions.
It grades the over and the under separately and keeps whichever side it has more
conviction in. The output is an 11-step letter grade, <Mono>F</Mono> through{' '}
<Mono>A+</Mono>, with a confidence score behind it. The letter is a probabilistic
assessment of one side of one line. It is not a prediction and it is never a guarantee.
</p>
<div style={card}>
<p className="mono" style={{ fontSize: 12.5, color: 'var(--text-1)', lineHeight: 1.8, margin: 0 }}>
LINE FEED FEATURE VECTOR PROJECTION vs LINE (BOTH SIDES) LETTER GRADE LOCKED TO
THE LEDGER SETTLED vs THE BOX SCORE
</p>
</div>
</section>
{/* ── Refusals ─────────────────────────────────────────────── */}
<section style={{ marginTop: 36 }}>
<h2 style={h2}>When the model refuses</h2>
<p style={body}>
If the pipeline cannot build a real projection for a prop not enough recent games, no
per-90 baseline, a data source down the model refuses to grade it. No letter renders,
the prop shows an absent state, and a free read is not consumed. A refusal is not a
failure mode we hide; it is the design. A grade with nothing behind it would still look
like a grade, and that is worse than no grade.
</p>
<p style={body}>
The same rule applies to every number on the platform: market values (lines, odds,
closes) are real book numbers captured at a timestamp, never computed by us. Model values
are labeled as model output. Where data is missing, the surface says less.
Absent beats wrong.
</p>
</section>
{/* ── Archetypes ───────────────────────────────────────────── */}
<section style={{ marginTop: 36 }}>
<h2 style={h2}>Archetypes the VYNDR Originals</h2>
<p style={body}>
Archetypes are VYNDR&apos;s proprietary classification of how a player produces 41
originals across NBA, WNBA, MLB, and soccer, built by scoring each player&apos;s real
season statistics against production patterns. A <Mono>TORCH</Mono> is a high-usage,
shot-dependent scorer whose points track his touches. A <Mono>BOMBER</Mono> is a power
bat whose home-run and total-base props behave differently from a contact hitter&apos;s.
A <Mono>GHOST</Mono> beats you with speed. A <Mono>CONDUCTOR</Mono> creates for everyone
else first.
</p>
<p style={body}>
The point is practical, not decorative: different archetypes have different reliable and
volatile props. Points for a TORCH and assists for a CONDUCTOR are stable; the inverse
legs swing with game script. Each classification carries a primary archetype, an optional
secondary, and a weighted blend because most players are not one thing. The
classification comes from the same stats pipeline that feeds the grades, so an archetype
is re-earned from real numbers, not assigned once and left to rot.
</p>
</section>
{/* ── The Ledger ───────────────────────────────────────────── */}
<section style={{ marginTop: 36 }}>
<h2 style={h2}>How the Ledger settles</h2>
<p style={body}>
Every grade the pipeline produces is written to the Ledger at the moment it is made,
locked with the exact line, the odds, and a timestamp. That entry never changes. If the
line moves hard against a graded side later in the day, the system may re-grade at the
real new line and when it does, the original letter stays on the record, struck
through, next to the revision. Nothing is revised silently.
</p>
<p style={body}>
After the games end, entries settle against the real box score the stat the player
actually produced, from league data. Hit, miss, or push, recorded once, idempotently.
Alongside the result we record closing line value: the pipeline captures the closing
line for each graded prop (the last pre-game snapshot of the book&apos;s number) and
measures whether the market moved toward or away from the graded side between our
timestamp and the close. Beating the close consistently is the standard evidence that a
model is finding real edge rather than getting lucky, which is why we track it on every
entry instead of just counting wins.
</p>
<div style={card}>
<p className="mono" style={{ fontSize: 12.5, color: 'var(--text-1)', lineHeight: 1.8, margin: 0 }}>
THE n20 RULE no hit percentage or CLV percentage renders anywhere on VYNDR until at
least 20 grades have settled in that window. Below that, the surface says
RECORD BUILDING. A percentage on a small sample is a marketing number, and we do not publish
marketing numbers.
</p>
</div>
</section>
{/* ── Misses ───────────────────────────────────────────────── */}
<section style={{ marginTop: 36 }}>
<h2 style={h2}>Why the misses are public</h2>
<p style={body}>
Every settled miss stays on the Ledger, by name, in the same format and the same
placement as the wins, permanently. There are two reasons. The honest one: a record you
curate is not a record, and the entire value of this product rests on the record being
real. The practical one: nobody else in this industry can afford to do it, because their
numbers do not survive it. Ours are built to. The model is wrong regularly the Ledger
shows exactly how regularly, and that transparency is the product working as intended,
not a bug in it.
</p>
</section>
{/* ── What we never do ─────────────────────────────────────── */}
<section style={{ marginTop: 36 }}>
<h2 style={h2}>What we never do</h2>
<ul style={{ display: 'grid', gap: 8, ...body, paddingLeft: 18, listStyle: 'disc' }}>
<li>Fabricate a line, an odd, or a result. Market numbers are captured, never invented.</li>
<li>Ship a grade the model has no projection for.</li>
<li>Render a percentage on fewer than 20 settled grades.</li>
<li>Edit or delete a settled entry, win or miss.</li>
<li>Take a bet. VYNDR is an analytics tool, not a sportsbook.</li>
</ul>
<p style={{ ...body, marginTop: 8 }}>
The record is at <a href="/ledger" style={{ color: 'var(--text-0)' }}>vyndr.app/ledger</a>.
Draw your own conclusion.
</p>
</section>
</section>
);
}
+6
View File
@@ -23,6 +23,8 @@ import Features from '@/components/Features';
import HowItWorks from '@/components/HowItWorks';
import Pricing from '@/components/Pricing';
import FAQ from '@/components/FAQ';
// Session S7 (a1) — THE VYNDR REPORT capture (double opt-in via Listmonk).
import NewsletterCapture from '@/components/NewsletterCapture';
// Footer is mounted globally in the root layout (Session 34) — no per-page import.
// Session 60 (6.1) — the 34s "LOADING THE SLATE" first paint was THIS
@@ -91,6 +93,10 @@ export default function Home() {
<HowItWorks />
<Pricing />
<FAQ />
{/* Session S7 (a1) — the daily wire, by email. Sits above the global footer. */}
<div style={{ maxWidth: 640, margin: '0 auto', padding: '8px 16px 40px' }}>
<NewsletterCapture />
</div>
</>
);
}
+17 -11
View File
@@ -5,21 +5,25 @@ export const metadata: Metadata = {
description: 'How VYNDR handles your data. Plain English.',
};
// Session 2 (A1 board) — approval-pack draft. Entity details are placeholder
// tokens ([ENTITY NAME], [CONTACT EMAIL]) for Kev to fill before this ships
// as the legal document.
const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
{
title: 'We never sell your personal data.',
body: [
'VYNDR Intelligence LLC does not sell, rent, or trade your personal data to anyone. Ever. Our business is a subscription to an analytics product — not a data resale operation.',
'[ENTITY NAME], the company that operates VYNDR, does not sell, rent, or trade your personal data to anyone. Our business is a subscription to an analytics product — not a data resale operation.',
],
emphasized: true,
},
{
title: 'Data we collect',
body: [
'Account data: email, password hash, age confirmation, signup timestamp.',
'Account data: email, password hash (managed by Supabase Auth — we never see your plaintext password), age confirmation, signup timestamp.',
'Usage data: reads you run (player, stat, line, sport, grade), parlays you build, page views.',
'Payment data: Stripe processes all card data — we never see or store your card number, CVC, or any other payment instrument detail. We retain a Stripe customer ID, Stripe subscription ID, and your subscription status (active, canceled, grace period, expired) so we can gate paid features and respond to renewal/cancellation events from Stripe webhooks.',
'Device data: IP address, browser type, basic device info (for fraud prevention and analytics).',
'Payment data: Stripe processes all card data — we never see or store your card number, CVC, or any other payment instrument detail. We retain a Stripe customer ID, Stripe subscription ID, and your subscription status (active, canceled, grace period, expired) so we can gate paid features and respond to renewal and cancellation events from Stripe webhooks.',
'Device data: IP address, browser type, basic device info (for fraud prevention, error monitoring, and analytics).',
],
},
{
@@ -27,7 +31,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
body: [
'To provide the service: sign you in, gate reads by tier, deliver grades.',
'To improve the model: aggregate, anonymized read data helps calibration. Individual reads are never used to identify you to third parties.',
'Aggregate trend features: "Most Read Tonight" and "Most Parlayed Tonight" use anonymized counts across all users. Your individual reads are not exposed.',
'Aggregate trend features: surfaces like "Most Read Tonight" use anonymized counts across all users. Your individual reads are not exposed.',
'To communicate: account confirmation, payment receipts, renewal reminders, critical service notices.',
],
},
@@ -51,17 +55,18 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
title: 'Cookies and analytics',
body: [
'Essential cookies: auth session, read counter (cannot be disabled — the service does not work without them).',
'Analytics: PostHog (anonymized IPs, no third-party trackers). You can opt out by setting your browser to "Do Not Track" or contacting us.',
'Analytics: PostHog, with autocapture disabled — we record only the product events we name (page views, feature usage), never keystrokes or form contents, and no third-party ad trackers. The cookie notice on first visit is a disclosure of this. To opt out of analytics entirely, contact us at [CONTACT EMAIL].',
],
},
{
title: 'Sub-processors',
body: [
'We rely on a small set of third-party processors. They handle data on our behalf under their own privacy commitments; we do not give them permission to use your data for their own purposes.',
'Stripe — payment processing and subscription management. Receives: name (if you provide), email, billing address (if you provide), and the card details you enter on their hosted checkout page. We never see your card number.',
'Stripe — payment processing and subscription management. Receives: name (if you provide it), email, billing address (if you provide it), and the card details you enter on their hosted checkout page. We never see your card number.',
'Supabase — authentication, database, and file storage. Receives: everything in the "Data we collect" section above. Supabase is our primary backend.',
'PostHog — product analytics. Receives: anonymized event data (page views, button clicks). IPs are anonymized before storage.',
'PostHog — product analytics. Receives: the named product events above (page views, feature usage) with basic device context. Autocapture is off.',
'Resend — transactional email (account confirmations, payment receipts, renewal reminders). Receives: your email address and the message contents.',
'Sentry — error monitoring. Receives: technical error reports (browser type, page, stack trace), which may include your IP address in transit. Used only to find and fix bugs.',
],
},
{
@@ -74,9 +79,10 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
title: 'Your rights',
body: [
'Access: request a copy of your data at any time.',
'Deletion: request account and data deletion at privacy@vyndr.app — we comply within 30 days.',
'Deletion: request account and data deletion at [CONTACT EMAIL] — we comply within 30 days.',
'Correction: update your email and other profile data from the profile page.',
'Export: request a JSON export of your read history.',
'If you are in a jurisdiction with statutory privacy rights (for example the EEA, UK, or California), those rights apply to you and the requests above are how you exercise them with us.',
],
},
{
@@ -94,7 +100,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
{
title: 'Contact',
body: [
'Privacy questions or data requests: privacy@vyndr.app',
'[ENTITY NAME]. Privacy questions or data requests: [CONTACT EMAIL].',
],
},
];
@@ -105,7 +111,7 @@ export default function PrivacyPage() {
<header style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8 }}>Privacy Policy</h1>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
EFFECTIVE: MAY 2026
DRAFT: JULY 2026
</p>
</header>
{SECTIONS.map((s) => (
+182 -37
View File
@@ -2,70 +2,215 @@ import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Responsible Gambling',
description: 'VYNDR provides analytics, not bets. If gambling stops being fun, stop.',
description:
'VYNDR is an analytics tool, not a sportsbook. 21+. If gambling stops being fun, stop. Call or text 1-800-GAMBLER.',
};
// Session 2 (A1 board) — rebuilt sincerely per NORTH STAR v1.1: "Responsible
// gaming built sincerely. The company that lasts is the one users don't
// resent in five years." Server component. No marketing adjacency on this
// page — no pricing links, no upsell, no product pitch.
const STATE_RESOURCES: { state: string; phone: string; tel: string }[] = [
{ state: 'Arizona', phone: '1-800-NEXT-STEP', tel: '18006398783' },
{ state: 'Colorado', phone: '1-800-522-4700', tel: '18005224700' },
{ state: 'Illinois', phone: '1-800-GAMBLER', tel: '18004262537' },
{ state: 'Indiana', phone: '1-800-994-8448', tel: '18009948448' },
{ state: 'Kansas', phone: '1-800-522-4700', tel: '18005224700' },
{ state: 'Louisiana', phone: '1-877-770-7867', tel: '18777707867' },
{ state: 'Maryland', phone: '1-800-GAMBLER', tel: '18004262537' },
{ state: 'Massachusetts', phone: '1-800-327-5050', tel: '18003275050' },
{ state: 'Michigan', phone: '1-800-270-7117', tel: '18002707117' },
{ state: 'Nevada', phone: '1-800-522-4700', tel: '18005224700' },
{ state: 'New Jersey', phone: '1-800-GAMBLER', tel: '18004262537' },
{ state: 'New York', phone: '1-877-846-7369', tel: '18778467369' },
{ state: 'North Carolina', phone: '1-877-718-5543', tel: '18777185543' },
{ state: 'Ohio', phone: '1-800-589-9966', tel: '18005899966' },
{ state: 'Pennsylvania', phone: '1-800-GAMBLER', tel: '18004262537' },
{ state: 'Tennessee', phone: '1-800-889-9789', tel: '18008899789' },
{ state: 'Virginia', phone: '1-888-532-3500', tel: '18885323500' },
];
const WARNING_SIGNS = [
'Betting more than you planned, or more than you can afford to lose.',
'Chasing losses — betting bigger to win back what you dropped.',
'Hiding bets, or lying about them, to people you care about.',
'Betting with money meant for rent, bills, or debts.',
'Feeling restless or irritable when you try to cut back.',
'Gambling to escape stress, anxiety, or a bad day.',
'Borrowing money to bet.',
];
const h2: React.CSSProperties = { fontSize: 18, fontWeight: 700, marginBottom: 12, color: 'var(--text-0)' };
const body: React.CSSProperties = { color: 'var(--text-1)', lineHeight: 1.7, fontSize: 15 };
export default function ResponsibleGamblingPage() {
return (
<section style={{ maxWidth: 680, margin: '0 auto', padding: '48px 16px 120px' }}>
<header style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8 }}>
If gambling stops being fun, stop.
<header style={{ marginBottom: 28 }}>
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8, color: 'var(--text-0)' }}>
If it stops being fun, stop.
</h1>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
RESPONSIBLE GAMBLING
<p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', letterSpacing: '0.05em' }}>
RESPONSIBLE GAMBLING · 21+
</p>
</header>
<div style={{ color: 'var(--text-secondary)', fontSize: 16, lineHeight: 1.7 }}>
<div style={{ ...body, fontSize: 16 }}>
<p style={{ marginBottom: 16 }}>
VYNDR provides sports analysis for informational purposes only. We do not take bets, facilitate wagers, or encourage gambling. We exist to help bettors make more informed decisions including the decision to walk away.
VYNDR is a sports data intelligence platform. We do not take bets, hold funds, or pay out
winnings. We are not a sportsbook. You must be 21 or older to use this product, and nothing
on it is a promise that any bet wins. Our own ledger shows the model missing, in public,
every week.
</p>
<p style={{ marginBottom: 16 }}>
Sports betting is entertainment. It should never feel like a job, a financial plan, or an escape. If you find yourself chasing losses, hiding bets from people you love, or betting more than you can afford to lose, those are warning signs that gambling has stopped being fun.
Sports betting is entertainment with a real cost attached. It should never feel like a job,
an income plan, or an escape. If it has started to feel like any of those, the most useful
thing this page can do is get you to the right phone number.
</p>
</div>
<section className="surface diagonal-cut" style={{ padding: 24, marginTop: 32, borderColor: 'var(--grade-a)' }}>
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>Get help</h2>
<ul style={{ display: 'grid', gap: 12, fontSize: 15 }}>
{/* National helpline — the load-bearing element of this page. */}
<section
style={{
padding: 24,
marginTop: 24,
background: 'var(--bg-1)',
border: '1px solid var(--border)',
borderLeft: '3px solid var(--amber)',
borderRadius: 10,
}}
>
<h2 style={h2}>Get help now</h2>
<p style={{ ...body, marginBottom: 14 }}>
The National Problem Gambling Helpline. Call or text, 24/7, free, confidential. It routes
to trained counselors in your state.
</p>
<a
href="tel:18004262537"
className="mono"
style={{ color: 'var(--amber)', fontSize: 28, fontWeight: 700, textDecoration: 'none', letterSpacing: '0.02em' }}
>
1-800-GAMBLER
</a>
<div className="mono" style={{ fontSize: 13, color: 'var(--text-1)', marginTop: 4 }}>
(1-800-426-2537)
</div>
<ul style={{ display: 'grid', gap: 10, fontSize: 14, marginTop: 18, color: 'var(--text-1)' }}>
<li>
<strong style={{ color: 'var(--text-primary)' }}>National Council on Problem Gambling Helpline</strong>
<br />
<a href="tel:18005224700" style={{ color: 'var(--grade-a)', fontSize: 24, fontWeight: 700, fontFamily: 'var(--mono)' }}>
1-800-522-4700
</a>
<br />
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>24/7, free, confidential.</span>
</li>
<li>
<a href="https://www.ncpgambling.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)' }}>
<a href="https://www.ncpgambling.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-0)' }}>
ncpgambling.org
</a>
<br />
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Resources, support groups, self-assessment tools.</span>
</a>{' '}
National Council on Problem Gambling. Screening tools, chat, treatment directory.
</li>
<li>
<a href="https://www.gamblersanonymous.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)' }}>
<a href="https://www.gamblersanonymous.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-0)' }}>
gamblersanonymous.org
</a>
<br />
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Local meetings nationwide.</span>
</a>{' '}
free local meetings nationwide.
</li>
<li>
<a href="https://www.gamtalk.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-0)' }}>
gamtalk.org
</a>{' '}
free, moderated peer support, no signup required.
</li>
</ul>
</section>
<section style={{ marginTop: 32 }}>
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>Self-exclusion</h2>
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: 15 }}>
Most US sportsbooks support a "self-exclusion" registry. You can voluntarily ban yourself from accessing sportsbook accounts for a period of months or years. Your state's gaming control board can help — search "[your state] gaming control board self-exclusion" to start.
<section style={{ marginTop: 36 }}>
<h2 style={h2}>Warning signs</h2>
<p style={{ ...body, marginBottom: 12 }}>
None of these mean you are a bad person. They mean the odds have started working on you
instead of for you.
</p>
<ul style={{ display: 'grid', gap: 8, ...body, paddingLeft: 18, listStyle: 'disc' }}>
{WARNING_SIGNS.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
<p style={{ ...body, marginTop: 12 }}>
If two or more of these sound familiar, call the number above. The call costs nothing and
commits you to nothing.
</p>
</section>
<section style={{ marginTop: 32 }}>
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>Our position</h2>
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: 15 }}>
We built VYNDR because we believe sports bettors deserve better tools than the books give them. We do not believe gambling makes anyone&apos;s life better when it stops being fun. If you need to walk away from sports betting entirely, that&apos;s the right call. Cancel your subscription, delete the app, and reach out to one of the resources above.
<section style={{ marginTop: 36 }}>
<h2 style={h2}>State resources</h2>
<p style={{ ...body, marginBottom: 14 }}>
Every state with legal sports betting funds its own problem-gambling services. If your
state is not listed, 1-800-GAMBLER still routes you to the right place.
</p>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{STATE_RESOURCES.map((r, i) => (
<div
key={r.state}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
padding: '10px 16px',
borderTop: i === 0 ? 'none' : '1px solid var(--border)',
}}
>
<span style={{ fontSize: 14, color: 'var(--text-0)' }}>{r.state}</span>
<a href={`tel:${r.tel}`} className="mono" style={{ fontSize: 13, color: 'var(--text-1)', textDecoration: 'none' }}>
{r.phone}
</a>
</div>
))}
</div>
</section>
<section style={{ marginTop: 36 }}>
<h2 style={h2}>Self-exclusion</h2>
<p style={{ ...body, marginBottom: 12 }}>
Self-exclusion is a voluntary, binding ban from sportsbooks for a period you choose
usually one to five years, sometimes for life. It is run by your state, not by the books,
which is why it works.
</p>
<ul style={{ display: 'grid', gap: 8, ...body, paddingLeft: 18, listStyle: 'disc' }}>
<li>
Your state gaming board runs the registry. Search &quot;[your state] gaming board
self-exclusion&quot; in Michigan it is the Michigan Gaming Control Board&apos;s
Responsible Gaming Database.
</li>
<li>
Every licensed sportsbook also offers account-level tools: deposit limits, wager limits,
time-outs, and permanent closure. They are in the account settings the books keep quiet.
</li>
</ul>
</section>
<section style={{ marginTop: 36 }}>
<h2 style={h2}>Tools on VYNDR</h2>
<p style={{ ...body, marginBottom: 12 }}>
We are an analytics product, so our levers are smaller than a sportsbook&apos;s but they
exist, and they are yours.
</p>
<ul style={{ display: 'grid', gap: 8, ...body, paddingLeft: 18, listStyle: 'disc' }}>
<li>
<a href="/settings" style={{ color: 'var(--text-0)' }}>Settings Responsible Play</a>
set a weekly time limit on your account, or step away for a while.
</li>
<li>
<a href="/settings" style={{ color: 'var(--text-0)' }}>Settings Danger Zone</a>
delete your account permanently. Cancellation of any paid plan takes effect immediately
with it.
</li>
</ul>
</section>
<section style={{ marginTop: 36 }}>
<h2 style={h2}>Our position</h2>
<p style={body}>
We publish every grade our model produces, misses included, because we think honesty is
the only durable way to run this business. The same honesty applies here: no analytics
tool makes gambling safe, and no grade makes a bet a sure thing. If you need to walk away
from sports betting entirely, that is the right call and we mean it. Cancel the
subscription, delete the account, and use the number at the top of this page. It will
still be here.
</p>
</section>
</section>
+22 -22
View File
@@ -17,6 +17,7 @@ import {
trackUpgradeClicked,
} from '@/lib/analytics';
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot';
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -44,7 +45,8 @@ interface ScanResponse {
confidence?: number;
sample_size?: number;
factors?: Record<string, string>;
alt_lines?: { line: number; grade: string; hit_rate?: number; edge_pct?: number }[];
alt_lines?: { line: number; grade: string; hit_rate?: number; edge_pct?: number; base?: boolean }[];
kelly?: { pct: number; quarter: number; full: number; odds: string };
kill_conditions?: { code: string; reason: string }[];
reasoning?: string;
historical_hit_rate?: number;
@@ -103,15 +105,8 @@ const SPORT_ACCENT: Record<Sport, string> = {
WNBA: '#FFB347',
};
// Sportsbook deep-links — preserved from the legacy GradeCard so the new
// design keeps the book hand-off. target=_blank + noopener,noreferrer.
const SPORTSBOOKS = [
{ id: 'draftkings', label: 'DraftKings', host: 'sportsbook.draftkings.com' },
{ id: 'fanduel', label: 'FanDuel', host: 'sportsbook.fanduel.com' },
{ id: 'betmgm', label: 'BetMGM', host: 'sports.betmgm.com' },
{ id: 'caesars', label: 'Caesars', host: 'sportsbook.caesars.com' },
];
const deepLink = (host: string, player: string) => `https://${host}/?search=${encodeURIComponent(player)}`;
// Sportsbook deep-links — A1 S3: built by lib/bookLinks (organic until the
// affiliate config flips a book on). rel is BOOK_LINK_REL on every anchor.
export default function ScanPage() {
const router = useRouter();
@@ -733,6 +728,7 @@ export default function ScanPage() {
sample_size: result.sample_size,
factors: result.factors,
alt_lines: result.alt_lines,
kelly: result.kelly,
kill_conditions: result.kill_conditions,
tier,
// Session 44 — forward the engine's intel fields so the grade
@@ -775,18 +771,22 @@ export default function ScanPage() {
{/* Sportsbook hand-off (preserved feature) */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
{SPORTSBOOKS.map((b) => (
<a
key={b.id}
href={deepLink(b.host, selectedPlayer)}
target="_blank"
rel="noopener noreferrer"
className="mono"
style={{ padding: '7px 13px', fontSize: 11, fontWeight: 700, borderRadius: 6, border: '1px solid var(--border-hi)', color: 'var(--text-1)', textDecoration: 'none' }}
>
{b.label}
</a>
))}
{SUPPORTED_BOOKS.map((b) => {
const link = buildBookLink({ book: b.id, player: selectedPlayer, sport });
if (!link) return null;
return (
<a
key={b.id}
href={link.url}
target="_blank"
rel={BOOK_LINK_REL}
className="mono"
style={{ padding: '7px 13px', fontSize: 11, fontWeight: 700, borderRadius: 6, border: '1px solid var(--border-hi)', color: 'var(--text-1)', textDecoration: 'none' }}
>
{b.label}
</a>
);
})}
</div>
{/* Free-tier nudge — full paywall treatment returns in Phase G */}
+88
View File
@@ -117,6 +117,54 @@ export default function SettingsPage() {
} catch { /* best-effort */ } finally { setPrefSaving(false); }
};
// A1 Session 10 — public ledger profile: claim a handle + the ONE explicit
// publish toggle. PRIVATE BY DEFAULT — nothing is public until the user
// flips the toggle and saves.
const [pfHandle, setPfHandle] = useState('');
const [pfPublished, setPfPublished] = useState(false);
const [pfSaving, setPfSaving] = useState(false);
const [pfMsg, setPfMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [pfLive, setPfLive] = useState<{ handle: string; published: boolean } | null>(null);
useEffect(() => {
if (!session?.access_token) return;
fetch('/api/profiles/me', { headers: { Authorization: `Bearer ${session.access_token}` } })
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
const p = d?.profile;
if (!p) return;
setPfHandle(p.handle || '');
setPfPublished(Boolean(p.published));
setPfLive({ handle: p.handle || '', published: Boolean(p.published) });
})
.catch(() => {});
}, [session]);
const saveProfile = async () => {
setPfSaving(true);
setPfMsg(null);
try {
const res = await fetch('/api/profiles/me', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}) },
body: JSON.stringify({ handle: pfHandle, published: pfPublished }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
setPfLive({ handle: pfHandle, published: pfPublished });
setPfMsg({ ok: true, text: pfPublished ? 'Published. Your settled record is live.' : 'Saved. Your record stays private.' });
} else if (res.status === 409) {
setPfMsg({ ok: false, text: 'That handle is taken.' });
} else {
setPfMsg({ ok: false, text: data?.error || 'Could not save profile.' });
}
} catch {
setPfMsg({ ok: false, text: 'Network error. Try again.' });
} finally {
setPfSaving(false);
}
};
const plan = tierLabel(tier || 'free');
const canDelete = deleteText === 'DELETE';
@@ -231,6 +279,46 @@ export default function SettingsPage() {
</div>
</Section>
{/* PUBLIC PROFILE (A1 Session 10) */}
<Section label="PUBLIC PROFILE">
<p style={{ margin: '0 0 14px', fontSize: 13, lineHeight: 1.55, color: 'var(--text-1)' }}>
Publishing puts your ENTIRE settled record on a public page wins and misses. Private by default.
</p>
<div style={{ fontSize: 13, color: 'var(--text-0)', marginBottom: 8 }}>Handle</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>vyndr.app/u/</span>
<input
value={pfHandle}
onChange={(e) => setPfHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20))}
placeholder="your_handle"
aria-label="Public profile handle"
className="mono"
style={{ width: 200, padding: '9px 12px', borderRadius: 8, background: 'var(--bg-2)', border: '1px solid var(--border-hi)', color: '#fff', fontSize: 13, outline: 'none' }}
/>
</div>
<Row>
<div>
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Publish my record</div>
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Every settled read, nothing curated</div>
</div>
<Toggle on={pfPublished} onClick={() => setPfPublished((v) => !v)} />
</Row>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12, flexWrap: 'wrap' }}>
<button type="button" onClick={saveProfile} disabled={pfSaving || pfHandle.length < 3} className="mono"
style={{ cursor: pfHandle.length >= 3 ? 'pointer' : 'not-allowed', padding: '9px 16px', borderRadius: 8, fontWeight: 700, fontSize: 11, letterSpacing: '0.04em', border: '1px solid var(--g-a)', background: pfHandle.length >= 3 ? 'var(--g-a)' : 'transparent', color: pfHandle.length >= 3 ? '#06060B' : 'var(--text-1)' }}>
{pfSaving ? 'SAVING…' : 'SAVE PROFILE'}
</button>
{pfMsg && (
<span className="mono" style={{ fontSize: 12, color: pfMsg.ok ? 'var(--g-a)' : 'var(--miss)' }}>{pfMsg.text}</span>
)}
{pfLive?.published && pfLive.handle && (
<a href={`/u/${pfLive.handle}`} className="mono" style={{ fontSize: 12, color: 'var(--g-a)', textDecoration: 'none' }}>
VIEW PUBLIC PAGE
</a>
)}
</div>
</Section>
{/* NOTIFICATIONS */}
<Section label="NOTIFICATIONS">
<Row>
+525
View File
@@ -0,0 +1,525 @@
'use client';
/**
* A1 S9 Slip Reader. Upload/paste a bet-slip screenshot; tesseract OCR
* (self-hosted, backend) extracts the legs; every field is pre-filled AND
* editable (needs_review fields amber); confirm grades each leg through
* the normal engine via POST /api/scan refusals render honestly; graded
* legs hand off to the Parlay Lab.
*
* DATA SEMANTICS: extracted numbers are USER-SLIP values (what the user's
* book printed), labeled as such. VYNDR never invents a line or odds an
* unreadable field arrives null and stays empty until the user fills it.
*/
import { useCallback, useRef, useState } from 'react';
import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn';
import GradeBadge from '@/components/vyndr/GradeBadge';
import { useParlay } from '@/contexts/ParlayContext';
type Sport = 'MLB' | 'NBA' | 'WNBA';
interface ParsedLeg {
player: string | null;
stat: string | null;
line: number | null;
side: 'over' | 'under' | null;
odds: number | null;
confidence?: Record<string, number>;
needs_review: boolean;
}
interface EditLeg {
player: string;
stat: string;
line: string;
side: 'over' | 'under';
odds: string;
needs_review: boolean;
flagged: { player: boolean; stat: boolean; line: boolean; side: boolean; odds: boolean };
}
interface LegGrade {
status: 'pending' | 'graded' | 'refused' | 'error';
grade?: string;
confidence?: number;
message?: string;
}
const STAT_OPTIONS: Record<Sport, string[]> = {
MLB: ['hits', 'total_bases', 'home_runs', 'rbi', 'runs', 'stolen_bases', 'doubles', 'walks', 'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched'],
NBA: ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'],
WNBA: ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'],
};
const label = (s: string) => s.replace(/_/g, ' ').toUpperCase();
function toEditLeg(l: ParsedLeg): EditLeg {
return {
player: l.player ?? '',
stat: l.stat ?? '',
line: l.line != null ? String(l.line) : '',
side: l.side === 'under' ? 'under' : 'over',
odds: l.odds != null ? (l.odds > 0 ? `+${l.odds}` : String(l.odds)) : '',
needs_review: l.needs_review,
flagged: {
player: l.player == null,
stat: l.stat == null,
line: l.line == null,
side: l.side == null,
odds: l.odds == null,
},
};
}
const inputStyle = (flag: boolean): React.CSSProperties => ({
width: '100%',
padding: '8px 10px',
background: 'var(--bg-2)',
border: `1px solid ${flag ? 'var(--amber)' : 'var(--border)'}`,
borderRadius: 6,
color: flag ? 'var(--amber)' : 'var(--text-0)',
fontSize: 13,
});
export default function SlipPage() {
const { addLeg, open } = useParlay();
const fileRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<'image' | 'text'>('image');
const [sport, setSport] = useState<Sport>('MLB');
const [preview, setPreview] = useState<string | null>(null);
const [slipText, setSlipText] = useState('');
const [parsing, setParsing] = useState(false);
const [error, setError] = useState('');
const [book, setBook] = useState<string | null>(null);
const [legs, setLegs] = useState<EditLeg[] | null>(null);
const [grades, setGrades] = useState<LegGrade[]>([]);
const [grading, setGrading] = useState(false);
const [graded, setGraded] = useState(false);
const [copied, setCopied] = useState(false);
const [added, setAdded] = useState(false);
const parse = useCallback(async (payload: { image?: string; text?: string }) => {
setParsing(true);
setError('');
setLegs(null);
setGrades([]);
setGraded(false);
setAdded(false);
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const res = await fetch('/api/slips/parse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'The reader could not process that slip.');
return;
}
setBook(data.book ?? null);
const parsed: ParsedLeg[] = Array.isArray(data.legs) ? data.legs : [];
if (parsed.length === 0) {
setError('No legs found on that slip. Try a sharper screenshot or paste the slip text.');
return;
}
setLegs(parsed.map(toEditLeg));
} catch {
setError('The reader is offline. Try again shortly.');
} finally {
setParsing(false);
}
}, []);
const onFile = useCallback((file: File | null) => {
if (!file) return;
if (file.size > 4 * 1024 * 1024) {
setError('Image too large. 4MB max.');
return;
}
const reader = new FileReader();
reader.onload = () => {
const url = String(reader.result || '');
setPreview(url);
void parse({ image: url });
};
reader.readAsDataURL(file);
}, [parse]);
const onPaste = useCallback((e: React.ClipboardEvent) => {
const item = Array.from(e.clipboardData?.items || []).find((i) => i.type.startsWith('image/'));
if (item) {
const f = item.getAsFile();
if (f) onFile(f);
}
}, [onFile]);
const setField = (i: number, field: keyof EditLeg['flagged'], value: string) => {
setLegs((prev) => {
if (!prev) return prev;
const next = [...prev];
const leg = { ...next[i], [field]: value } as EditLeg;
// A user-corrected field is no longer flagged.
leg.flagged = { ...leg.flagged, [field]: value.trim() === '' };
next[i] = leg;
return next;
});
setGraded(false);
};
const removeLeg = (i: number) => {
setLegs((prev) => (prev ? prev.filter((_, idx) => idx !== i) : prev));
setGraded(false);
};
const legReady = (l: EditLeg) =>
l.player.trim() !== '' && l.stat !== '' && l.line.trim() !== '' && Number.isFinite(Number(l.line));
const gradeAll = async () => {
if (!legs || grading) return;
setGrading(true);
setGrades(legs.map(() => ({ status: 'pending' })));
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const results: LegGrade[] = [];
for (const leg of legs) {
if (!legReady(leg)) {
results.push({ status: 'error', message: 'Fill player, stat and line first.' });
continue;
}
try {
const res = await fetch('/api/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
sport,
player: leg.player.trim(),
stat: leg.stat,
line: Number(leg.line),
direction: leg.side,
book: 'draftkings',
}),
});
const data = await res.json();
if (!res.ok) {
results.push({ status: 'error', message: data.error || 'The engine hit a wall on this leg.' });
} else if (data.insufficient_data || !data.grade) {
// Honest refusal — the engine has no model reference for this prop.
results.push({ status: 'refused', message: 'NO GRADE — insufficient data. The engine does not guess.' });
} else {
results.push({ status: 'graded', grade: data.grade, confidence: data.confidence });
}
} catch {
results.push({ status: 'error', message: 'The engine hit a wall on this leg.' });
}
setGrades([...results, ...legs.slice(results.length).map(() => ({ status: 'pending' as const }))]);
}
setGrades(results);
setGrading(false);
setGraded(true);
};
const gradedLegs = legs
? legs.map((l, i) => ({ leg: l, grade: grades[i] })).filter((x) => x.grade?.status === 'graded')
: [];
const addAllToParlay = () => {
for (const { leg, grade } of gradedLegs) {
addLeg({
sport,
player: leg.player.trim(),
stat: leg.stat,
line: Number(leg.line),
direction: leg.side,
grade: grade!.grade || '',
confidence: grade!.confidence ?? 0,
});
}
setAdded(true);
open();
};
const shareText = () => {
const rows = (legs || []).map((l, i) => {
const g = grades[i];
const mark = g?.status === 'graded' ? g.grade : 'NO GRADE';
return `${l.player} ${l.stat ? label(l.stat) : '?'} ${l.side === 'under' ? 'u' : 'o'}${l.line} · ${mark}`;
});
return ['VYNDR read my slip.', ...rows, 'vyndr.app/slip'].join('\n');
};
const copyShare = () => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(shareText());
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
return (
<section style={{ maxWidth: 720, margin: '0 auto', padding: '28px 16px 96px' }} onPaste={onPaste}>
<SectionHead accent="var(--g-a)"> SLIP READER</SectionHead>
<h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 8px' }}>READ MY SLIP</h1>
<p style={{ fontSize: 14, color: 'var(--text-1)', lineHeight: 1.6, marginBottom: 20 }}>
Drop a bet-slip screenshot. The reader extracts your legs, you correct anything it was unsure
about, and the engine grades each one. Free tier reads one slip a day.
</p>
{/* Input mode */}
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
{(['image', 'text'] as const).map((m) => (
<button
key={m}
className="mono"
onClick={() => setMode(m)}
style={{
padding: '7px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
background: mode === m ? 'var(--bg-2)' : 'transparent',
border: `1px solid ${mode === m ? 'var(--g-a)' : 'var(--border)'}`,
color: mode === m ? 'var(--g-a)' : 'var(--text-1)',
}}
>
{m === 'image' ? 'SCREENSHOT' : 'PASTE TEXT'}
</button>
))}
<div style={{ marginLeft: 'auto' }}>
<select
className="mono"
value={sport}
onChange={(e) => setSport(e.target.value as Sport)}
style={{ ...inputStyle(false), width: 'auto', fontSize: 12 }}
aria-label="Sport"
>
<option value="MLB">MLB</option>
<option value="NBA">NBA</option>
<option value="WNBA">WNBA</option>
</select>
</div>
</div>
{mode === 'image' ? (
<div
onClick={() => fileRef.current?.click()}
style={{
border: '1px dashed var(--border)', borderRadius: 8, padding: 28, textAlign: 'center',
cursor: 'pointer', background: 'var(--bg-1)', marginBottom: 16,
}}
>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
/>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="Slip preview" style={{ maxWidth: '100%', maxHeight: 260, borderRadius: 6 }} />
) : (
<div className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
CLICK TO UPLOAD · OR PASTE A SCREENSHOT (CTRL+V)
</div>
)}
</div>
) : (
<div style={{ marginBottom: 16 }}>
<textarea
className="mono"
value={slipText}
onChange={(e) => setSlipText(e.target.value)}
placeholder={'Aaron Judge Over 1.5\nTotal Bases\n-115'}
rows={7}
style={{ ...inputStyle(false), resize: 'vertical', fontSize: 13 }}
/>
<div style={{ marginTop: 10 }}>
<VBtn variant="primary" disabled={parsing || !slipText.trim()} onClick={() => void parse({ text: slipText })}>
{parsing ? 'READING…' : 'READ SLIP TEXT'}
</VBtn>
</div>
</div>
)}
{parsing && mode === 'image' && (
<div className="mono" style={{ fontSize: 13, color: 'var(--g-a)', marginBottom: 16 }}>READING THE SLIP</div>
)}
{error && (
<div className="mono" style={{ fontSize: 13, color: 'var(--miss, #ff6b6b)', marginBottom: 16 }}>{error}</div>
)}
{/* Parsed legs — manual-correct UI */}
{legs && (
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span className="label">
EXTRACTED LEGS{book ? ` · ${book.toUpperCase()}` : ''}
</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>
USER-SLIP VALUES your book&apos;s numbers, not ours
</span>
</div>
{legs.some((l) => l.needs_review) && (
<div className="mono" style={{ fontSize: 12, color: 'var(--amber)', marginBottom: 10 }}>
Amber fields were unreadable or uncertain. The reader does not guess check them before grading.
</div>
)}
{legs.map((leg, i) => {
const g = grades[i];
return (
<div
key={i}
style={{
border: `1px solid ${leg.needs_review && !legReady(leg) ? 'var(--amber)' : 'var(--border)'}`,
borderRadius: 8, padding: 12, marginBottom: 10, background: 'var(--bg-1)',
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1.6fr 0.9fr 1fr 1fr auto', gap: 8, alignItems: 'center' }}>
<input
className="mono"
value={leg.player}
placeholder="Player"
onChange={(e) => setField(i, 'player', e.target.value)}
style={inputStyle(leg.flagged.player)}
aria-label={`Leg ${i + 1} player`}
/>
<select
className="mono"
value={leg.stat}
onChange={(e) => setField(i, 'stat', e.target.value)}
style={inputStyle(leg.flagged.stat)}
aria-label={`Leg ${i + 1} stat`}
>
<option value="">STAT</option>
{STAT_OPTIONS[sport].map((s) => (
<option key={s} value={s}>{label(s)}</option>
))}
</select>
<input
className="mono"
value={leg.line}
placeholder="Line"
inputMode="decimal"
onChange={(e) => setField(i, 'line', e.target.value)}
style={inputStyle(leg.flagged.line)}
aria-label={`Leg ${i + 1} line`}
/>
<select
className="mono"
value={leg.side}
onChange={(e) => setField(i, 'side', e.target.value)}
style={inputStyle(leg.flagged.side)}
aria-label={`Leg ${i + 1} side`}
>
<option value="over">OVER</option>
<option value="under">UNDER</option>
</select>
<input
className="mono"
value={leg.odds}
placeholder="Odds"
onChange={(e) => setField(i, 'odds', e.target.value)}
style={inputStyle(leg.flagged.odds)}
aria-label={`Leg ${i + 1} odds`}
/>
<button
className="mono"
onClick={() => removeLeg(i)}
style={{ background: 'transparent', border: 'none', color: 'var(--text-1)', cursor: 'pointer', fontSize: 15 }}
aria-label={`Remove leg ${i + 1}`}
>
</button>
</div>
{g && g.status !== 'pending' && (
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 10 }}>
{g.status === 'graded' ? (
<>
<GradeBadge grade={g.grade || ''} />
{g.confidence != null && (
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>
{Math.round(g.confidence)}% confidence
</span>
)}
</>
) : (
<span className="mono" style={{ fontSize: 12, color: g.status === 'refused' ? 'var(--amber)' : 'var(--miss, #ff6b6b)' }}>
{g.message}
</span>
)}
</div>
)}
{g && g.status === 'pending' && grading && (
<div className="mono" style={{ marginTop: 10, fontSize: 12, color: 'var(--text-1)' }}>GRADING</div>
)}
</div>
);
})}
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
<VBtn variant="primary" disabled={grading || legs.length === 0 || !legs.every(legReady)} onClick={() => void gradeAll()}>
{grading ? 'GRADING…' : 'CONFIRM + GRADE'}
</VBtn>
{graded && gradedLegs.length >= 1 && !added && (
<VBtn variant="outline" onClick={addAllToParlay}>
+ ADD {gradedLegs.length} TO PARLAY LAB
</VBtn>
)}
{added && (
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)', alignSelf: 'center' }}>
In the Lab
</span>
)}
</div>
{!legs.every(legReady) && (
<div className="mono" style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 8 }}>
Every leg needs a player, stat and line before the engine will grade it.
</div>
)}
</div>
)}
{/* Share card — VYNDR read my slip */}
{graded && legs && (
<div
style={{
border: '1px solid var(--border)', borderRadius: 10, padding: 18,
background: 'var(--bg-1)', marginTop: 8,
}}
>
<div className="label" style={{ marginBottom: 10 }}>VYNDR READ MY SLIP</div>
{legs.map((l, i) => {
const g = grades[i];
return (
<div key={i} className="mono" style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '6px 0', borderBottom: i < legs.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span>
{l.player || '—'} · {l.stat ? label(l.stat) : '?'} {l.side === 'under' ? 'u' : 'o'}{l.line || '?'}
{l.odds ? ` · ${l.odds}` : ''}
</span>
<span style={{ fontWeight: 800, color: g?.status === 'graded' ? 'var(--g-a)' : 'var(--amber)' }}>
{g?.status === 'graded' ? g.grade : 'NO GRADE'}
</span>
</div>
);
})}
<div style={{ display: 'flex', gap: 10, marginTop: 14 }}>
<VBtn variant={copied ? 'primary' : 'outline'} onClick={copyShare}>
{copied ? '✓ Copied' : 'Copy share text'}
</VBtn>
</div>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-1)', marginTop: 10 }}>
Slip values are your book&apos;s numbers. Grades are model output. The Ledger keeps the receipts.
</div>
</div>
)}
</section>
);
}
+40 -18
View File
@@ -5,70 +5,93 @@ export const metadata: Metadata = {
description: 'VYNDR terms of service. Plain English. No tricks.',
};
// Session 2 (A1 board) — approval-pack draft. Entity details are placeholder
// tokens ([ENTITY NAME], [STATE OF FORMATION], [ARBITRATION VENUE],
// [CONTACT EMAIL]) for Kev to fill before this ships as the legal document.
const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
{
title: 'What this is',
title: 'What this is — and is not',
body: [
'VYNDR provides sports analysis for informational purposes only. VYNDR does not facilitate, process, or encourage any wagers. VYNDR is not a sportsbook.',
'VYNDR Intelligence LLC operates this product. We grade player props using statistical models, historical data, and contextual intelligence.',
'VYNDR is a sports data intelligence platform. We publish statistical analysis, grades, and context on player props for informational and entertainment purposes only.',
'VYNDR is not a sportsbook. We do not accept, place, facilitate, broker, or process wagers of any kind. We do not hold gambling funds, set odds for wagering, or pay out winnings. No money you send us is ever a bet.',
'These terms are a contract between you and [ENTITY NAME], the company that operates VYNDR ("we," "us"). By creating an account or using the service you accept them.',
],
emphasized: true,
},
{
title: 'Who can use this',
body: [
'You must be 21 years of age or older to create an account. You must use VYNDR only where sports betting and the use of sports analytics tools is legal in your jurisdiction. You are responsible for following your local laws.',
'You must be 21 years of age or older to create an account. You must use VYNDR only where the use of sports analytics tools is legal in your jurisdiction, and you are responsible for knowing and following your local laws. We may close accounts that misrepresent age or location.',
],
},
{
title: 'No guaranteed outcomes',
body: [
'A grade is a probabilistic assessment. An "A" grade is not a guarantee, prediction, or promise. The model is wrong all the time — we publish the ledger so you can see how often. Past performance does not guarantee future results. Do not bet what you cannot afford to lose.',
'A grade is a probabilistic assessment, not a prediction, promise, or guarantee. The model is wrong regularly — we publish the Ledger, misses included, so you can see exactly how often. When the model has no projection for a prop, it refuses to grade rather than guess. Past performance does not guarantee future results. Nothing on VYNDR is financial, investment, or gambling advice. Never bet money you cannot afford to lose.',
],
},
{
title: 'Data sources and accuracy',
body: [
'Odds, lines, schedules, and statistics are sourced from third-party providers and public league data. They can be delayed, incomplete, or wrong, and lines move constantly. Always confirm the current line at the sportsbook before acting on anything you see here. Where our data is missing we show nothing rather than a fabricated value; absence of a number is deliberate.',
],
},
{
title: 'Subscription terms',
body: [
'Paid tiers (Analyst, Desk) are billed monthly through Stripe, our payment processor. You may cancel at any time from your profile page. Cancellation takes effect at the end of the current billing period. We do not refund for partial months. If a payment fails, we honor a 48-hour grace period before reverting your account to the free tier.',
'Founder pricing ($14.99/mo Analyst, $44.99/mo Desk) is locked for the lifetime of your continuous subscription. Lapsed subscriptions revert to standard pricing ($24.99 Analyst, $49.99 Desk) on re-subscription. After the first 100 founder seats are taken, new subscribers pay standard pricing.',
'We may change regular pricing with 30 days notice. Founder pricing is locked.',
'Paid tiers (Analyst, Desk) are billed monthly through Stripe, our payment processor. You may cancel at any time from your profile page; cancellation takes effect at the end of the current billing period. We do not refund partial months. If a payment fails, we honor a 48-hour grace period before reverting your account to the free tier.',
'Founder pricing ($14.99/mo Analyst, $44.99/mo Desk) is locked for the lifetime of your continuous subscription. A lapsed subscription reverts to standard pricing ($24.99 Analyst, $49.99 Desk) on re-subscription. After the first 100 founder seats are taken, new subscribers pay standard pricing.',
'We may change standard pricing with 30 days notice. Founder pricing is locked.',
],
},
{
title: 'Acceptable use',
body: [
'Do not scrape, reverse engineer, or attempt to replicate the grading engine. Do not resell reads, share account credentials, or attempt to circumvent the read limit. Do not use the service to abuse, harass, or defraud others.',
'VYNDR does NOT offer API access at any tier. The grading engine is consumer-only — we do not provide programmatic access to grades, factor weights, model outputs, or any other engine surface, regardless of plan or pricing. Requests for API access will be declined.',
'Do not scrape, reverse engineer, or attempt to replicate the grading engine. Do not resell reads, share account credentials, or attempt to circumvent the read limit. Do not use the service to abuse, harass, or defraud others, and do not use it in connection with unlawful gambling.',
'VYNDR does not offer API access at any tier. The grading engine is consumer-only — we do not provide programmatic access to grades, factor weights, model outputs, or any other engine surface, regardless of plan or pricing. Requests for API access will be declined.',
],
},
{
title: 'Intellectual property',
body: [
'The grading engine, models, calibration data, factor weights, and surrounding analysis are proprietary to VYNDR Intelligence LLC. You are licensed to view grades for personal use. You are not licensed to redistribute, repackage, or sell VYNDR output.',
'The grading engine, models, calibration data, factor weights, archetype system, and surrounding analysis are proprietary to [ENTITY NAME]. You are licensed to view grades for personal use. You are not licensed to redistribute, repackage, or sell VYNDR output.',
],
},
{
title: 'Termination',
body: [
'You can delete your account at any time from Settings. We may suspend or terminate accounts that violate these terms, with notice where practical. On termination your license to use the service ends; provisions that should survive (intellectual property, liability limits, dispute terms) survive.',
],
},
{
title: 'Limitation of liability',
body: [
'VYNDR is not responsible for any financial losses you incur from sports betting decisions you make using our analytics. The service is provided "as is" with no warranty. Maximum liability is limited to what you paid us in the prior 12 months.',
'VYNDR is not responsible for any financial losses you incur from sports betting decisions, whether or not they were informed by our analytics. The service is provided "as is" and "as available," with no warranty of any kind. To the maximum extent permitted by law, our total liability is limited to the amount you paid us in the 12 months before the claim.',
],
},
{
title: 'Governing law',
title: 'Governing law and disputes',
body: [
'These terms are governed by the laws of the State of Michigan. Any disputes will be resolved through binding arbitration in Wayne County, Michigan.',
'These terms are governed by the laws of [STATE OF FORMATION]. Disputes will be resolved through binding arbitration in [ARBITRATION VENUE], except where the law gives you a non-waivable right to court.',
],
},
{
title: 'Responsible gambling',
body: [
'Sports betting can become a problem. If gambling stops being fun, stop. The National Council on Problem Gambling helpline is available 24/7, free and confidential: 1-800-522-4700. More resources at ncpgambling.org. See our Responsible Gambling page for self-exclusion guidance.',
'Sports betting can become a problem. If it stops being fun, stop. The National Problem Gambling Helpline is available 24/7, free and confidential — call or text 1-800-GAMBLER (1-800-426-2537). Our Responsible Gambling page lists state resources, warning signs, and self-exclusion guidance.',
],
},
{
title: 'Changes to these terms',
body: [
'If we make material changes, we will notify account holders by email at least 30 days before they take effect. Continued use after that date is acceptance of the new terms.',
],
},
{
title: 'Contact',
body: [
'VYNDR Intelligence LLC. Questions about these terms: legal@vyndr.app',
'[ENTITY NAME]. Questions about these terms: [CONTACT EMAIL].',
],
},
];
@@ -77,7 +100,7 @@ export default function TermsPage() {
return (
<LegalLayout
title="Terms of Service"
effective="Effective: May 2026"
effective="Draft: July 2026"
sections={SECTIONS}
/>
);
@@ -100,7 +123,6 @@ function LegalLayout({ title, effective, sections }: { title: string; effective:
...(s.emphasized
? {
borderLeft: '3px solid var(--grade-a)',
paddingLeft: 16,
background: 'rgba(0, 212, 160, 0.04)',
borderRadius: 4,
padding: '12px 16px',
+247
View File
@@ -0,0 +1,247 @@
'use client';
import { useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard';
/**
* PublicProfile (A1 Session 10) the public ledger record for one handle.
*
* DATA SEMANTICS: every row here is a REAL settled ledger entry outcome,
* actual value, closing-line value. Nothing curated: publishing shows the
* ENTIRE settled record, misses included. The header NEVER renders a
* percentage under min_sample (20) settles it shows RECORD BUILDING.
*
* Unknown and unpublished handles arrive as the same 404 the page renders
* one indistinguishable not-found state for both.
*/
interface ProfileRow {
id: string;
player_name: string;
sport: string;
stat: string;
line: number;
side: 'over' | 'under';
locked_odds?: string | null;
book?: string | null;
grade: string;
model_value?: number | null;
game_date: string;
closing_line?: number | null;
clv?: number | null;
clv_result?: 'beat' | 'faded' | 'flat' | null;
outcome?: 'hit' | 'miss' | 'push' | null;
actual_value?: number | null;
revised_from_grade?: string | null;
}
interface ProfileAggregate {
settled: number;
hits: number;
misses: number;
pushes: number;
hit_pct: number | null;
clv_sample: number;
clv_beat: number;
beat_close_pct: number | null;
pending: number;
}
interface ProfilePayload {
handle: string;
aggregate: ProfileAggregate | null;
entries: ProfileRow[];
min_sample?: number;
}
const SPORT_COLOR: Record<string, string> = {
nba: '#E94B3C',
mlb: '#1E90FF',
wnba: '#FFB347',
soccer: '#7BC96F',
};
export default function PublicProfile({ handle }: { handle: string }) {
const [data, setData] = useState<ProfilePayload | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'notfound'>('loading');
useEffect(() => {
let active = true;
setState('loading');
fetch(`/api/profiles/${encodeURIComponent(handle)}`)
.then(async (r) => {
if (!active) return;
if (!r.ok) { setState('notfound'); return; }
const d = await r.json();
if (!active) return;
setData(d);
setState('ready');
})
.catch(() => { if (active) setState('notfound'); });
return () => { active = false; };
}, [handle]);
if (state === 'loading') {
return (
<section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading record</p>
</section>
);
}
if (state === 'notfound' || !data) {
// Same state for unknown AND unpublished — the API never tells us which.
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '64px 16px 120px', textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--amber, #FFB347)', marginBottom: 10 }}>
NO RECORD HERE
</p>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>This profile does not exist or is not published.</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
VYNDR ledgers are private by default. A record only appears here when its owner publishes it.
</p>
</section>
);
}
const agg = data.aggregate;
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
return (
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
<header style={{ marginBottom: 20 }}>
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}>
PUBLIC LEDGER
</p>
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
@{data.handle}
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
Every settled read. Wins and misses. Nothing curated.
</p>
</header>
{/* Record header — never a percentage under min_sample settles. */}
<RecordHeader agg={agg} minSample={minSample} />
{data.entries.length === 0 ? (
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
NO SETTLED READS YET
</p>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Reads land here as they settle against real results.
</p>
</div>
) : (
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
{data.entries.map((row, i) => (
<ProfileCard key={row.id} row={row} index={i} />
))}
</div>
)}
</section>
);
}
function RecordHeader({ agg, minSample }: { agg: ProfileAggregate | null; minSample: number }) {
const ready = Boolean(agg && agg.settled >= minSample && agg.hit_pct != null);
return (
<div
className="surface diagonal-cut"
style={{ padding: 20, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
>
{ready && agg ? (
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'baseline' }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
RECORD · LAST 30D
</span>
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--g-a, #00D4A0)' }}>
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT
</span>
{agg.beat_close_pct != null && (
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>
{agg.beat_close_pct}% BEAT CLOSE
</span>
)}
<span className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{agg.pending} pending
</span>
</div>
) : (
<div>
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
RECORD BUILDING
</p>
<p style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
Percentages render at {minSample} settled reads.
{agg && (
<span className="mono" style={{ color: 'var(--text-primary)' }}>
{' '}{agg.settled} settled · {agg.pending} pending
</span>
)}
</p>
</div>
)}
</div>
);
}
function OutcomeChip({ row }: { row: ProfileRow }) {
if (!row.outcome) {
return <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>PENDING</span>;
}
const color = row.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
: row.outcome === 'miss' ? 'var(--miss, #FF6B6B)' : 'var(--text-secondary)';
const mark = row.outcome === 'hit' ? '✓ HIT' : row.outcome === 'miss' ? '✕ MISS' : ' PUSH';
return (
<span className="mono" style={{ fontSize: 12, fontWeight: 700, color }}>
{mark}{row.actual_value != null ? ` (${row.actual_value})` : ''}
</span>
);
}
function ClvChip({ row }: { row: ProfileRow }) {
if (!row.clv_result || row.clv == null) return null;
const color = row.clv_result === 'beat' ? 'var(--g-a, #00D4A0)'
: row.clv_result === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
return (
<span className="mono" title={`Closing line value: locked ${row.line}, closed ${row.closing_line}`}
style={{ fontSize: 10.5, fontWeight: 700, color, letterSpacing: '0.04em' }}>
CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()}
</span>
);
}
function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
return (
<article className={`surface diagonal-cut animate-fade-up stagger-${(index % 6) + 1}`} style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: `${sportColor}1F`, color: sportColor }}>
{row.sport.toUpperCase()}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{row.revised_from_grade && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
{row.revised_from_grade}
</span>
)}
<GradePill grade={row.grade} />
</span>
</div>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
<OutcomeChip row={row} />
<ClvChip row={row} />
</div>
</article>
);
}
@@ -0,0 +1,53 @@
import { ImageResponse } from 'next/og';
// A1 Session 10 — every shared /u/:handle link unfurls as a record card.
// Self-hosted standalone build → Node runtime (NOT edge; Session-53 rule —
// next/og breaks under edge off Vercel).
export const alt = 'VYNDR Public Ledger';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ handle: string }> }) {
const { handle } = await params;
const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle';
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
width: '100%',
height: '100%',
background: '#06060B',
color: '#E8E8F0',
padding: 72,
fontFamily: 'monospace',
}}
>
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
PUBLIC LEDGER
</div>
<div style={{ display: 'flex', fontSize: 84, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 18, color: '#FFFFFF' }}>
@{h}
</div>
<div style={{ display: 'flex', fontSize: 28, color: '#7A7A8E', marginTop: 20 }}>
CLV-verified record · every settled read · misses included
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', fontSize: 44, fontWeight: 900, letterSpacing: '0.08em' }}>
<span style={{ color: '#FFFFFF' }}>VYND</span>
<span style={{ color: '#00D4A0' }}>R</span>
</div>
<div style={{ display: 'flex', fontSize: 22, color: '#4A4A5E', letterSpacing: '0.12em' }}>
NOTHING CURATED · NOTHING DELETED
</div>
</div>
</div>
),
{ ...size },
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from 'next';
import PublicProfile from './PublicProfile';
/**
* /u/[handle] (A1 Session 10) a user's PUBLIC ledger profile. Thin server
* wrapper for metadata (+ the segment's opengraph-image.tsx share card);
* the record itself renders in the PublicProfile client component.
*
* PUBLIC route never gated. Publishing is the owner's explicit choice;
* once published, the whole settled record is the page. Misses included.
*/
export async function generateMetadata({ params }: { params: Promise<{ handle: string }> }): Promise<Metadata> {
const { handle } = await params;
const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase();
const title = `CLV-verified record — @${h} · VYNDR`;
const description = `@${h}'s settled betting record on VYNDR — every read, every result, closing-line value included. Nothing curated, nothing deleted.`;
return {
title,
description,
openGraph: { title, description },
twitter: { card: 'summary_large_image', title, description },
};
}
export default async function PublicProfilePage({ params }: { params: Promise<{ handle: string }> }) {
const { handle } = await params;
return <PublicProfile handle={decodeURIComponent(handle || '')} />;
}

Some files were not shown because too many files have changed in this diff Show More