> ## Documentation Index
> Fetch the complete documentation index at: https://docs.odds-api.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Specs drops dashboard design 2026 07 30

# Drops Dashboard (subscriber terminal) - Design Spec

Date: 2026-07-30 (revised after spec review)
Status: Approved by Markus (conversation), pending re-review

## Summary

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 new `dropping-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 `receivers`
  bookmaker -> league -> market document shape. Reuse its filter vocabulary
  (percentage drop, interval, odds range, hours-before-kickoff, from-opening).

## Drop windows (constraint from review)

Arbitrary `windowMinutes` 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 enforces `minDropPct >= 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 `history` and 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-link `href` the REST API
  already produces), and the Pinnacle max bet:
  `pinnacleMaxBet` (number), `maxBetCurrency` ('EUR'), `maxBetCapturedAt`
  (unix ms), with `history` entries 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 a `minPinnacleMaxBet` threshold 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.

The hub proxies backfill through its own API route (entitlement-checked) so the
internal routes are never exposed to browsers.

### 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 `matches` frames 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/{id}/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: `jti` is stored in Redis on first connect and a second connect
  with the same `jti` is 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

```ts theme={null}
{
  _id: ObjectId,              // profile id
  userId: string,             // Kinde user id
  name: string,               // 1-40 chars, unique per user
  enabled: boolean,
  sound: boolean,
  createdAt: Date,
  updatedAt: Date,
  sports: string[],           // empty = all
  leagues: string[],          // empty = all
  markets: string[],          // empty = all
  minDropPct: number,         // >= 1, <= 80
  window: '15m'|'30m'|'60m'|'3h'|'6h'|'12h'|'24h'|'opening',
  minPinnacleMaxBet?: number, // EUR; undefined = any; no match on missing limits
  minOdds?: number,           // 1.01-1000, minOdds <= maxOdds
  maxOdds?: number,
  maxHoursToKickoff?: number, // 0-336
  muteMinutesBeforeKickoff?: number,
}
```

* Index: `{ userId: 1, name: 1 }` unique; max 20 profiles per user.
* All writes validate ownership (`userId` from 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; `dropId` dedup 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 --noEmit` before any PR; billing verified with Stripe test clocks.
* core-telegram is untouched (never boot it locally).

## Rollout

1. oddsapi repo PR: drop detection, drop log, `dropping-odds` channel, token
   validation, internal routes.
2. Hub PR: gated /dashboard, profiles CRUD, token + backfill routes, feed UI.
3. Soft launch behind the subscriber gate; sound/polish follow-ups after real
   usage.
