Drops Dashboard (subscriber terminal) - Design Spec
Date: 2026-07-30 (revised after spec review) Status: Approved by Markus (conversation), pending re-reviewSummary
A live “trading terminal” style dropping-odds dashboard on the hub, for paying OddsNotifier subscribers only. Comparable to pinnacleoddsdropper.com and betunfair.io. Powered by our own odds-api.io: a newdropping-odds WebSocket
channel in the Go v3 service streams Pinnacle prematch drop events; the hub
renders them in a real-time feed filtered by user-defined profiles. The v3 API
also gains private, hub-only routes (backfill, token minting support) since the
public REST surface does not fit this use case.
Decisions made
- Drop rules are user-configurable (profiles), not hardcoded.
- All supported prematch markets (ML, Spread, Totals, Player Props, etc.).
- Prematch only in v1; event schema must not preclude in-play later.
- Pinnacle is the only drop source in v1; other books shown for comparison only.
- Gating: hub Kinde login + Stripe subscription check (policy below).
- Transport (REVISED after security review): the browser never connects to the raw odds-api WebSocket. It connects to a scoped realtime gateway (wss://stream.oddshub.io, a separate public facade on the existing Go WS service) that publishes sanitized, server-filtered view models. The raw odds-api WS stays server-to-server behind an ingestion worker feeding Redis Streams (durable drops) and Pub/Sub (transient scores).
- Multiple named profiles, active simultaneously.
- New flat profile schema; do NOT reuse the legacy nested
receiversbookmaker -> league -> market document shape. Reuse its filter vocabulary (percentage drop, interval, odds range, hours-before-kickoff, from-opening).
Drop windows (constraint from review)
ArbitrarywindowMinutes cannot be computed client-side from a single
server-emitted interval. Instead:
- Profiles choose from a fixed window set computed server-side:
15m, 30m, 60m, 3h, 6h, 12h, 24h, opening. The drop event payload carries a drop % per window (null when insufficient history), so any profile evaluates exactly, no client-side math against raw history required for matching. - Server-side emission floor: an event is emitted when ANY window shows a drop
= 1%. This is at or below the minimum configurable profile threshold (min
minDropPct= 1%), so profiles never silently miss events. The floor exists only to bound volume; validation enforcesminDropPct >= 1. - The payload additionally includes a compact timestamped price history array (last 24h, downsampled) for the sparkline only, not for matching.
- Note: the existing scanner pipeline computes only opening/12h/24h/48h (apps/scanner/src/apps/dropping-odds/worker.ts); the WS poller detection is new code in v3 and independent of that pipeline.
Architecture
1. oddsapi repo (Go, v3) - drop detection + WS channel + drop log
- The existing WS poller (
v3/websocket/) already observes every Pinnacle prematch tick. Add drop detection there per the window set above. - Drops are delivered via the dedicated gateway facade (section 3), NOT as a channel on the existing public hub/handler; the public /ws surface is untouched.
- Drop event payload (one canonical shape; the GATEWAY STREAM and REST
backfill both carry the SUMMARY subset of it, while
historyand the full bookmaker comparison are served ONLY by the protected detail endpoint):dropId(unique per emission, for dedup), event id, sport, league, home/away, kickoff (UTC), market key, line, side/label, player + prop type (player props), opening odds, current odds, per-window drop % map, timestamp, downsampled 24h price history, other-bookmaker current prices for comparison (each with the bookmaker deep-linkhrefthe REST API already produces), and the Pinnacle max bet:pinnacleMaxBet(number),maxBetCurrency(‘EUR’),maxBetCapturedAt(unix ms), withhistoryentries carrying{timestamp, odds, pinnacleMaxBet}. - Max bet semantics: the max bet is captured atomically with the triggering
price; backfill preserves the event-time value and a later limit change
never rewrites an old drop. History downsampling must retain limit-change
points, not only odds-change points. Missing limits are null, never zero.
Max-bet currency is read from env (
MAXBET_CURRENCY, default EUR) and its upstream provenance must be verified before launch; the display only shows a currency symbol once provenance is confirmed. Profiles with aminPinnacleMaxBetthreshold must NOT match drops with missing/zero limit data. - Outcome identity/collapse key:
eventId + marketKey + label + line + side(so different lines, sides, and player props never merge). - Append-only drop log (new; the existing replay store is state-oriented,
keyed
eventId|bookmaker, so later updates replace earlier ones and cannot serve history): Redis sorted set or stream of emitted drop events, 24h retention, cursor-paginated. Serves reconnect resync and initial backfill.
2. oddsapi private hub routes
The public REST dropping-odds endpoint is a capped snapshot with no time range, history, or comparison prices; it is not reused. New internal-only routes, authenticated server-to-server (shared secret or HMAC header, not a public API key), IP/network-restricted where possible:GET /internal/drops?since=<cursor|timestamp>&limit=- backfill from the drop log; returns the same payload shape as the WS channel plus a cursor.POST /internal/ws-token(or hub signs tokens itself with a shared key; pick at implementation time) - issues the browser WS token.
3. Gateway auth: single-use tickets
- Browser calls same-origin POST /api/realtime/ticket; the hub verifies Kinde session, entitlement, denylist, origin and rate limits, then returns an opaque single-use ticket valid ~30s. Claims: iss, aud, sub, sid, deviceSessionId, jti, scopes (my-drops, top-drops, matches), iat/nbf/exp. Tickets live only in memory, never localStorage.
- Browser opens wss://stream.oddshub.io/v1 and sends the ticket in the FIRST auth frame (never the URL). The gateway atomically consumes the jti; a second use is rejected. Unauthenticated sockets are closed after ~5s and receive no data before auth.
- Each connection gets a 10-minute server-enforced lease; the client reconnects with a fresh ticket before expiry. Cancellation/ban/logout cuts the stream within the lease + entitlement cache TTL.
- One connection per device via BroadcastChannel leader election across tabs; reconnect from the same deviceSessionId replaces its own connection; max 2 distinct devices per account, a third is rejected with a “disconnect another session” affordance. IP/ASN changes are risk signals, never hard binds.
3b. Minimized payloads (anti-scraping)
- Streams carry ROW SUMMARIES only: event/participant identity, score/status summary (no clock), market identity, current/opening odds, per-window drop %, Pinnacle max bet, best-preferred-price summary, cursor/seq. Full 24h history and the complete bookmaker comparison are fetched from a protected, rate-limited detail endpoint only when the drawer opens.
- My Feed profile matching happens SERVER-SIDE: a browser only receives drops matching its own profiles. Top Drops is a bounded leaderboard snapshot + deltas. Matches is score/status summaries + drop counts (clock via REST).
- Security controls per OWASP WS guidance: exact production Origin allowlist (never the hub’s permissive *.vercel.app CORS rule), WSS only, CSP connect-src pinned, heartbeats + dead-connection cleanup, inbound schema validation with small max payload, per-user connection/bytes/detail-request quotas, slow-consumer bounded queues, compression off by default, tickets and payloads never logged.
- Detection: per user+device track connect frequency, concurrent sessions, distinct IPs/ASNs, bytes/hour, detail views, distinct outcomes accessed, ticket failures, abnormal disconnects. Progressive enforcement: log -> Turnstile challenge -> restrict detail requests -> suspend.
3c. Live scores and clock (Matches board)
- REST /events/live is the authoritative snapshot AND the only clock /
participant source. Gateway
matchesframes are score/status nudges only (no clock payload); a 10-20s REST correction catches missed best-effort updates; playedSeconds advances client-side once per second only while running. Score/status messages are best-effort and not replayed, so reconnect rebuilds from a REST snapshot. - Participant crests are proxied same-origin (/api/participants//logo) with the server-side key and 24h caching; 404 falls back to a sport icon.
4. Hub (Next.js) - /dashboard route
- Server component: require Kinde session; entitlement check per policy below. Non-subscribers get a clean upsell page (not 404) with a “subscribed via Telegram with a different email?” support path. Account-linking flow is a later iteration.
- API routes: token minting and backfill proxy, both entitlement-checked.
- Client: connect to odds-api.io WS; on load, backfill the last ~60 min from
the backfill proxy so the feed starts full; dedup live vs backfill by
dropId. - Drop event x enabled profiles -> matched profile ids is a pure function, unit tested.
WS abuse prevention
Goal: nobody without an active subscription can consume the stream, and tokens cannot be shared or resold at scale. (A paying subscriber scripting their own client is not fully preventable for any browser-consumable stream; the controls below bound the damage and make revocation immediate.)- Tokens are subject-bound (Kinde user id), channel-scoped, ~10 min expiry,
single-use:
jtiis stored in Redis on first connect and a second connect with the samejtiis rejected, so a leaked token cannot fan out. - Per-user concurrent connection cap (2: desktop + mobile) enforced in the Go hub keyed by token subject; oldest connection is dropped on overflow.
- Origin allowlist (hub domains) checked on upgrade. Not sufficient alone (non-browser clients forge it) but blocks casual embedding in other sites.
- Server-enforced expiry disconnect forces re-auth through the hub every ~10
min, so entitlement loss (cancellation, refund, ban) cuts the stream within
minutes; the token route can also consult the Redis denylist
(
abuse:ipdeny-style) for known abusers. - Token-minting route is rate-limited per user and behind the same bot-mitigation posture as other gated hub endpoints.
- Internal routes (backfill, token support) are server-to-server only: HMAC auth + private networking; never CORS-exposed, never reachable with a browser credential.
- Per-user consumption telemetry (connect frequency, parallel attempts, distinct IPs/ASNs per subject) logged so resale patterns are detectable and the user can be suspended.
Entitlement policy (canonical)
Mirrors core-telegram (apps/core-telegram/src/stripe.js):
- Eligible: subscription whose price id is in
DROPPING_ODDS_PLAN_PRICE_IDS(extract this list to a shared location or copy with a sync comment). - Accepted statuses:
active,trialing,past_due. - Email matching: lowercase/trim both Kinde email and Stripe customer email; if multiple Stripe customers share the email, entitled if ANY has an eligible subscription.
- Caching (Redis): soft TTL 10 min, hard TTL 24 h. Within soft TTL serve cached. Between soft and hard TTL, refresh from Stripe; on Stripe failure serve the stale cached value (this is the fail-open behavior; a plain expiring key cannot provide it). No cache entry + Stripe down = deny (fail-closed). Subscription webhooks (if convenient) or the soft TTL bound staleness after cancellation.
Profiles - Mongo dashboardProfiles collection
- Index:
{ userId: 1, name: 1 }unique; max 20 profiles per user. - All writes validate ownership (
userIdfrom session, never from body). - A drop matching ANY enabled profile appears in the feed, tagged with the matching profile(s).
UI
Follows CLAUDE.md frontend rules: flat surfaces, 1px hairlines, brand gold as sparse accent, no gradients/glow/glass/pills/emoji, no em dashes in copy.- Desktop: full-height page under the hub shell. Left rail: profiles list (toggle/edit/create). Main column: live feed, newest first.
- Feed row: league + sport, teams, kickoff (“in X min”), market + line + side, opening odds in parentheses, old -> new odds, drop % for the matched window and from-opening, inline sparkline, time-ago, matched profile tag. Click opens the event’s existing hub event page in a new tab. Consecutive drops for the same outcome key collapse with a “+N more” expander.
- Top bar: connection status dot, pause (buffers incoming, “N new” resume chip), sound toggle, sort (time / drop %), lightweight search.
- Profile editor: modal/slide-over; essentials up front (name, sports/leagues, markets, min drop %, window), advanced collapsed (odds range, time-to-kickoff, mute-before-kickoff).
- Mobile: single column; profiles rail becomes a bottom sheet; rows condense with tap-to-expand; no hover-dependent interactions.
- States: skeleton on load; “no drops match your profiles yet” with a hint to loosen filters; visible reconnecting banner on WS loss.
Error handling
- WS auto-reconnect with backoff; resume via drop-log cursor; backfill proxy
covers cold starts;
dropIddedup prevents duplicates. - Stripe outage: stale-cache fail-open for known users, fail-closed for unknown users (see entitlement policy).
- Profile saves are optimistic with rollback on error.
Testing
- Go unit: drop detection (per-window math, pruning, opening), drop-log append/cursor/retention, token validation (expiry, audience, scope, key single-use consume semantics), channel test in the existing hub_* style.
- Go load: broadcast test with realistic payload sizes (history + comparison prices) across many connections, extending existing load_test patterns.
- Hub unit: profile matching, entitlement policy (statuses, price ids, email normalization, soft/hard TTL transitions), profile ownership on all CRUD.
- Integration: entitlement -> token issuance -> connect -> expiry-driven reconnect -> cursor resume without gaps or duplicates.
npx tsc --noEmitbefore any PR; billing verified with Stripe test clocks.- core-telegram is untouched (never boot it locally).
Rollout
- oddsapi repo PR: drop detection, drop log,
dropping-oddschannel, token validation, internal routes. - Hub PR: gated /dashboard, profiles CRUD, token + backfill routes, feed UI.
- Soft launch behind the subscriber gate; sound/polish follow-ups after real usage.