Legacy web access gates
A source audit of which legacy StatsHub pages required payment, login, or accepted a limited guest experience.
Source audit of apps/statshub on 2026-08-28. This describes the legacy Pages
Router application, not the replacement web or Expo applications.
Bottom line
Legacy did not have one route-level access policy. Its middleware refreshed the
Supabase session and handled locale and basketball rewrites, then returned the
response. It did not classify routes as public, signed-in, or paid
(apps/statshub/src/middleware.ts:120-151). Pages and API handlers enforced access
independently.
The useful product contract is:
| Access level | Legacy behavior |
|---|---|
| Guest | Most pages and tools were public. PropHunter exposed only four blurred results. |
| Free account | Unlocked full PropHunter results, favourites, watchlists, saved filters, saved bookmaker preferences, and account pages. |
| Any active paid plan | Unlocked Value Bets, Bet Builder, Team Models, Super Sub Heroes, and alert activation. |
| Top paid plan | Added Value Bets raw model outputs and richer model controls. |
| Superadmin | Bypassed the normal subscription gate and unlocked a small number of admin-only calculations. |
Four page routes contain an explicit premium gate. Three full pages require a login without requiring a paid plan. One core tool, PropHunter, implements a real guest preview cap. Several other public pages gate only save, favourite, watchlist, or alert actions.
What counted as paid
The client considered a user subscribed when the latest subscription was
active or trialing and was not paused, or when the email belonged to the
configured superadmin list (apps/statshub/src/contexts/user-context.tsx:120-137).
The shared server helper accepted active or trialing and also bypassed the
check for a superadmin (apps/statshub/src/lib/require-subscription.ts:43-62).
The pricing page exposed two paid tiers:
| Display name | Stored tier | Intended access |
|---|---|---|
| Pro | starter | Ready-to-use scanner and standard Value Bets controls. |
| Pro+ All Access | pro | All models, time windows, thresholds, and early-access features. |
Evidence: apps/statshub/src/pages/pricing.tsx:66-97.
Fully paid-gated pages
| Route | Guest | Signed in without payment | Active or trialing | Enforcement and confidence |
|---|---|---|---|---|
/value-bets-v2 | Redirected to /login. | Rendered the paid-plan gate. | Opened the scanner and paid subviews. | High. Page gate at value-bets-v2.tsx:294-301,1970-1983; data API returns 401/403 at api/value-bets-v2.ts:95-131. |
/bet-builder | Rendered the paid-plan gate. | Rendered the same gate. | Opened the builder. | High for the UI. bet-builder.tsx:1005-1014,1597-1608. The route gate was client-side rather than middleware-based. |
/super-sub-heroes | Redirected to /login. | Rendered the paid-plan gate. | Opened the leaderboards. | High. Page at super-sub-heroes.tsx:114-140,190-208; API returns 401/403 at api/props/super-sub-heroes.ts:133-145. |
/value-bets-v3 | Redirected to /login. | Rendered the paid-plan gate. | Opened the experimental v3 page. | High for behavior, low for product relevance. value-bets-v3.tsx:98-123,820-833; no production navigation link was found. |
/value-bets-v2?view=team-shots was a paid subview rather than a separate
page. Its team-model API repeated the subscription check and returned 401 or
403 (apps/statshub/src/pages/api/team-shots-glm-predictions.ts:79-112).
Top-tier limitations inside Value Bets
The page-level test did not distinguish the two paid plans: either paid plan
passed hasAccess. The page separately treated stored tier pro as the top
plan (apps/statshub/src/pages/value-bets-v2.tsx:294-295).
Pro, stored as starter, received:
- the ready-to-use scanner;
- Team Models;
- watchlist and tracker access;
- a compact scanner-style watchlist.
Pro+ All Access, stored as pro, additionally received:
- Raw Model Outputs, described as all models, time windows, and bookmaker comparison;
- the richer Value Bet card treatment in the watchlist;
- model and threshold controls advertised on the pricing page.
Selecting Raw Model Outputs as a lower-tier subscriber sent the user to
/pricing, and a deep link was forced back to the scanner
(apps/statshub/src/pages/value-bets-v2.tsx:407-413,2127-2149). V3 repeated the
same raw-output restriction (apps/statshub/src/pages/value-bets-v3.tsx:164-172).
The under direction in the Value Bets API was not merely top-tier. It was
superadmin-only (apps/statshub/src/pages/api/value-bets-v2.ts:135-139).
Paid actions on otherwise login-only pages
/fouls-alerts required a login to render, but an account alone was not enough
to activate alerts. The API reported eligibility only for active or
trialing subscriptions and returned the feature disabled otherwise
(apps/statshub/src/pages/api/live-foul-card-alerts.ts:28-56,83-86). Value Bet
lineup alerts also required an active or trialing subscription and a linked
Telegram account (apps/statshub/src/pages/api/value-bet-lineup-alerts.ts:17-49).
What required login but not payment
| Route or capability | Guest behavior | Free-account behavior | Evidence |
|---|---|---|---|
/account | Redirected to / and rendered only a loading state during the redirect. | Account, subscription, Telegram, and alert settings rendered. | apps/statshub/src/pages/account.tsx:58-69,88-94 |
/favourites | Rendered an inline sign-in prompt. | Team and player favourites, notes, and add/remove controls rendered. | apps/statshub/src/pages/favourites.tsx:162-189,293-349 |
/fouls-alerts | Redirected to /. | Page rendered; paid eligibility still controlled whether alerts could be active. | apps/statshub/src/pages/fouls-alerts.tsx:11-30 |
| Team and player favourites | Public detail data remained visible; saving opened login or did nothing until a session existed. | Favourite mutations and notes were available. | apps/statshub/src/pages/player/[playerSlug]/[playerId].tsx:437-490; apps/statshub/src/pages/team/[teamSlug]/[teamId].tsx:527-528,695-728 |
| Prop watchlists | Public screener results remained visible; starring a result opened the auth drawer. | Personal prop watchlist was available. | apps/statshub/src/pages/prop-screener.tsx:251-280,469-486; apps/statshub/src/pages/100-club.tsx:134-162,269-282 |
| Saved filters and bookmaker preferences | Applying filters was public; saving or loading a saved configuration opened the auth drawer. | Saved filters and preferences were persisted through /api/props/saved-filters. | apps/statshub/src/components/saved-filters-button.tsx:68-103,230-258; apps/statshub/src/components/bookmaker-filter-dialog.tsx:68-116 |
The tipster preview authoring APIs also required a Supabase session and scoped
records to the author (apps/statshub/src/pages/api/tipster/preview/index.ts:8-30;
apps/statshub/src/pages/api/tipster/preview/[id].ts:8-32). Those pages were not
part of the primary product navigation and look like an ancillary authoring
workflow, so they should not define customer-facing parity.
What guests could see only in limited form
PropHunter had a real four-result preview
/prophunter was public enough to run the search, but the result component did
three things for a guest:
- overlaid a sign-up prompt when results existed;
- blurred the result grid;
- rendered only the first four results.
Evidence: apps/statshub/src/components/player-results-list.tsx:155-160,613-638.
The page passed the current user and auth-drawer callback into that result
component (apps/statshub/src/pages/prophunter.tsx:2816-2850). A free account
removed the cap. Saved PropHunter presets also required login
(apps/statshub/src/pages/prophunter.tsx:1197-1241). No paid subscription check
was present.
Prop Screener limited personal features, not the result set
/prop-screener did not cap or blur guest results. Guests could run the
screener and use its ordinary filters. Login was required for the watchlist,
saved filters, and saved bookmaker preferences.
The page displayed a create free account to unlock future fixtures banner
when future-dated props were present (apps/statshub/src/pages/prop-screener.tsx:759-790).
That banner was an upsell, not an enforced gate: the same props array,
including future events, was passed to the table for guests
(apps/statshub/src/pages/prop-screener.tsx:793-859), and the screener API had no
user or subscription check (apps/statshub/src/pages/api/props/enriched-screener.ts:300-308).
Treat the intended future-fixture restriction as unclear rather than
implemented.
Other public pages gated only personal actions
/100-club remained public and gated only watchlist mutations. Public team,
player, fixture, league, referee, trend, and screener pages similarly did not
become paid merely because they showed a favourite or save control.
Pages that looked paid but were public
The toolkit sidebar is not reliable access-control evidence. It placed
Outliers and Lineup Changes under the Pro heading and gave both a pro
badge (apps/statshub/src/components/toolkit-sidebar.tsx:26-49,160-185), but the
pages and their primary APIs had no auth or subscription checks:
| Route | Actual source behavior | Confidence |
|---|---|---|
/outliers | Public page fetched /api/props/outliers; the handler accepted any GET request. | High. outliers.tsx:1299-1327; api/props/outliers.ts:62-70. |
/lineups | Public page fetched /api/lineups/players; the handler accepted any GET request. | High. lineups/index.tsx:108-123,203-213; api/lineups/players.ts:83-92. |
The old /value-bets route also contained no user or subscription check, but
the active navigation linked /value-bets-v2, not /value-bets. Treat the old
route as archive-only rather than evidence that Value Bets was intended to be
free. /value-bets-v3 has the opposite problem: its gate is clear, but no
production navigation link was found.
Enforcement gaps in legacy
Paused subscriptions disagreed between client and server
The client denied a paused subscription even if its status remained active
or trialing (apps/statshub/src/contexts/user-context.tsx:134-137). The shared
server helper checked only the status and did not inspect
pauseCollectionResumesAt (apps/statshub/src/lib/require-subscription.ts:55-62).
A paused user could therefore be blocked by the page while still passing APIs
that used requireSubscription.
Some paid pages relied on client gates
Value Bets, Super Sub Heroes, and Team Models repeated the paid check in their data APIs. Bet Builder's route gate lived in React. Its underlying data came from several APIs with different access policies, including the paid-protected Value Bets API. Legacy did not provide a single server-side guarantee that all Bet Builder inputs were paid-only.
Personal APIs did not all repeat the page's paid rule
The Value Bets page was paid, but its watchlist and tracker APIs checked login,
not subscription (apps/statshub/src/pages/api/watchlist/value-bet.ts:48-59;
apps/statshub/src/pages/api/tracker/settings.ts:13-30). The main Value Bets data
API did enforce payment, so this did not expose the paid feed through the normal
UI, but it shows why page labels cannot stand in for endpoint policy.
Parity decisions to carry forward
| Legacy behavior | Porting interpretation | Confidence |
|---|---|---|
| Value Bets v2, Bet Builder, Team Models, Super Sub Heroes | Paid capability. Require login and an active entitlement; protect the data API as well as the screen. | High |
| Raw Model Outputs and advanced model controls | Top-tier capability in the two-tier Stripe model. Decide explicitly whether this distinction still exists under the current entitlement model. | High for legacy, unclear for current product intent |
| Account and Favourites | Login-only, not paid-only. | High |
| PropHunter full results | Free-account capability; guests get a four-row blurred preview. | High |
| Prop Screener results | Public. Login gates saved state and watchlists only. | High |
| Prop Screener future fixtures | Advertised as account-only but not enforced. Do not copy this contradiction. | High |
| Outliers and Lineup Changes | Access intent is ambiguous: navigation said Pro while code and APIs were public. Product must choose. | High |
| Paused subscription | Must be resolved once in the shared entitlement model and applied identically on client and server. | High |
Scope and caveats
The audit searched every non-API page under apps/statshub/src/pages for
PremiumGate, isSubscribed, user-dependent rendering, redirects, auth-drawer
calls, and result slicing. It separately traced the API handlers used by each
identified surface. Marketing, demos, test pages, superadmin pages, database
tools, and dead duplicate routes were excluded from the customer matrix.
This is a source audit of the current main working tree. The repository was
already heavily modified when the audit began. None of the access-control files
cited above were among the modified legacy files, but the report still records
source behavior, not a historical production deployment or runtime feature-flag
snapshot.
Go API query analysis
A source read of the Go API's database access. Its index findings were later measured and did not hold; the N+1 it found was real.
Go API performance against the legacy API
A client-ready comparison of throughput, latency, and memory from 46 controlled load-test runs against the Go and legacy Next.js API implementations.