App Store
What has to be true before a build goes to App Store review, and the reasoning behind the parts that are not obvious.
What has to be true before a build goes to review, and the reasoning for the
parts that are not obvious. Run /app-store-review for the audit itself; this
file is what the audit found and what was decided.
Account deletion — the one that is not optional
Guideline 5.1.1(v): an app that lets someone create an account has to let them delete it from inside the app. Not a mail link, not a web form, and signing out is not deleting. It is the most commonly cited rejection there is, and this app shipped without it until now.
The row is in Profile, under Account, behind a confirm. The call is
deleteAccountAndEndSession in lib/core/session, which deletes server-side
FIRST and clears the device only once that succeeds — the other order reports
success while the account still exists, which is the exact lie the guideline is
about.
The backend capability this depends on
StatsHub signs in against Supabase, but a person's saved data lives in the
API's own database, so deleting an account is an API call: DELETE /api/v2/me
(apps/api/internal/account). It reads whose account it is from the bearer
token — there is no id to pass — and deletes in this order:
- A web subscription is cancelled at Stripe, immediately. Nothing else would stop it billing an account that no longer exists.
- Every user-keyed row — favourites, saved picks, watchlists, tracked bets,
bankrolls, alerts, saved filters, the subscription record — in one
transaction. The list is
UserTablesininternal/account/store.go; none of these tables has a foreign key toauth.users, so a new per-user table must be added there before release or its rows outlive the account. - The Supabase auth user, through the admin API with
SUPABASE_SERVICE_ROLE_KEY. Without that variable the endpoint answers 503 before touching anything.
The auth user goes last so that any failure leaves a signed-in person who can tap Delete again and have the rest finish. An App Store subscription is not cancelled — only the person can do that — so the confirmation tells a subscriber to cancel it in Settings, which Apple requires.
The earlier delete_current_user database function
(supabase/migrations/20260902_delete_current_user.sql) was never applied to
the Supabase project and could not reach the API's tables; the app no longer
calls it.
Sign in with Apple token revocation is not done yet: the app signs in with Apple's identity token and never sends the authorization code to a server, so there is no Apple refresh token to revoke. That needs the code sent at deletion time and an Apple client secret on the API.
What is deleted locally: favourites, watchlist entries, notification
settings, pinned leagues and filter defaults. The server account is deleted
first; only then does clearLocalAccountData() remove those account-scoped
stores. Theme, onboarding and layout preferences remain because they describe
the device, not the deleted account.
Age rating and 5.3
The app shows odds, bookmakers, implied probability, edge and hit rates. It does not link out to any bookmaker — there is no path from the app to placing a bet, which is the line between "sports information" and "facilitating gambling". Keep it that way: a single deep link to a sportsbook changes the app's category, brings licensing and geo-restriction requirements with it, and is not a change anyone should make casually.
In App Store Connect the rating questionnaire still has to declare simulated gambling / contests references, which puts this at 17+. A wrong rating is itself a rejection, and it is answered in the portal rather than in code.
Permission strings
Bonjour and Local Network keys are absent from the StatsHub manifest. Metro discovery is a responsibility of the separately installed shared Pitsi Dev Client; carrying its permission into the store app would send customers to a setting StatsHub never uses.
NSPhotoLibraryUsageDescription says what the photo is for and where it goes.
The framework default — "Allow $(PRODUCT_NAME) to access your photos" — is
rejected regularly for not explaining the use.
The camera string is removed (cameraPermission: false on the
expo-image-picker plugin). The app never opens the camera, and a permission
declared but unused is a question at review with no good answer.
Sign in with Apple and Google
Better Auth owns the OAuth redirect and stores the resulting native session
through @better-auth/expo. Apple and
Google are shown only when their Convex deployment credentials are present.
Accounts first created with email/password can link either provider after one
password confirmation, preventing duplicate identities for the same email.
The standalone app redirects through the Convex site and returns to
statshub:///…. The shared Pitsi Dev Client deliberately does not open social
OAuth because it is a preview shell with a different installed identity; test
email/password and guest there, and test Apple/Google in a standalone/TestFlight
StatsHub build. The Apple capability remains enabled on the StatsHub App ID.
Purchases — what they cost at review
There is no analytics, so there is no ATT prompt. The app sends no product
events anywhere. Apple's word is TRACKING, and it means linking a person to data
from other companies' apps and sites, or handing data to a broker. The moment an
ad SDK, an attribution partner or the IDFA appears, both the prompt and
NSUserTrackingUsageDescription become mandatory.
RevenueCat is the source of StatsHub Pro access. The app has a custom paywall, purchase and restore flows, a Profile restore row, and Customer Center. The paywall reads localized products from the current RevenueCat offering and shows price/cadence, restore, privacy policy, and EULA before purchase. It adds Purchases and the app user id to the privacy questionnaire.
The RevenueCat project contains the StatsHub Pro entitlement and iOS product
records for com.panospitsi.statshub.yearly and
com.panospitsi.statshub.lifetime. This is not complete until matching products
exist in App Store Connect and the RevenueCat iOS app has its App Store Connect
API key and subscription key configured. Without those, a store build cannot
return purchasable packages even though the app UI is correct.
Native integration set
Notifications, Better Auth's Expo client (including expo-network), and RevenueCat
are mirrored by the shared Pitsi Dev Client. A
native rebuild is required only when that shared native dependency set changes;
ordinary StatsHub work runs with bun run dev and is selected from the client.
Drilled and clean
Checked against the published review skills' React-Native-specific list: no
eval or downloaded executable code (2.5.2), no expo-updates feature delivery
(3.3.1), no custom rate-us prompt, no advertising identifier or cross-app
tracking (5.1.2), no IPv4 literals (2.5.20), and no user-generated content to
moderate (1.2). Purchases use StoreKit through RevenueCat (3.1.1). Privacy and
terms open in in-app WebView routes with working close controls; the privacy
policy is StatsHub's published page and the terms default to Apple's standard
EULA.
Before submitting
-
SUPABASE_SERVICE_ROLE_KEYis set on the production API and the in-app delete works end to end on a real build - Apple and Google credentials are set in the Convex deployment and both providers complete in a standalone StatsHub/TestFlight build
- Matching yearly and lifetime products exist in App Store Connect and the RevenueCat StatsHub app has Apple API/subscription credentials
- Production EAS environment contains the public Convex and RevenueCat client variables
- Age rating declares simulated gambling / contests
- App Review Notes name the guest path ("Continue as guest" needs no credentials) and carry a demo account anyway
- Screenshots match the current UI
- Launch tested on the newest iOS, not just the team's simulators — see the
UIScene work in
plugins/with-ios-scene-lifecycle.js
Checked and fine
Privacy manifest (ios/StatsHub/PrivacyInfo.xcprivacy) declares UserDefaults,
FileTimestamp and BootTime; dependency manifests are merged by Xcode at archive
time. ITSAppUsesNonExemptEncryption: false
is set, so uploads do not stall on export compliance. Sheets are form sheets
with grabbers and swipe-to-dismiss, so nothing traps the reader. A guest path
exists, so review can get in without credentials.
Found in the second pass — verify before submitting
The EAS environments currently have no client variables. EXPO_PUBLIC_CONVEX_URL
and EXPO_PUBLIC_CONVEX_SITE_URL are read at BUILD time and .env.local is gitignored, so EAS
does not see it. The build profiles now select development, preview, and
production EAS environments explicitly, but those environments were inspected
on 2026-08-20 and were empty. If they remain empty, the app deliberately opens
the account-free core instead of trapping someone at a sign-in wall, but sign-in,
account sync, analytics, and paid access are absent. That is still not the app
described by its store listing and is a 2.1 completeness risk.
Upload the existing public client values to all three EAS environments before a cloud build. Development/preview use the RevenueCat Test Store key; production uses the StatsHub iOS public SDK key. Then install a TestFlight build and test email, Apple, Google, guest, purchase, and restore.
Every EAS build runs scripts/check-release-env.mjs before dependency install.
For production, testflight, and appstore it rejects missing Convex
variables and a RevenueCat Test Store key. Developer-tool visibility is
an explicit client-distribution choice and is not rejected by this guard. Run
bun run check:release-env to apply the same check to the current EAS production
environment without printing credential values.
"Match reminders" notified nobody — now built. The store recorded a
preference and nothing scheduled anything, so Profile showed a master switch and
a count that could only ever read zero; toggleFixtureNotify had no call sites
anywhere in the app, so a fixture could not even be SET to notify. That is 2.3.1
— the app promising what it does not do.
lib/core/match-reminders is the half that was missing: a local notification
fifteen minutes before kick-off, permission asked at the tap rather than on
launch, cancelled when un-toggled. The control is the bell in the fixture
screen's trailing slot, which is where fixture-stage always said it belonged.
Local rather than push, so there is no APNs certificate, no device token and no
remote-notification background mode to explain at review.
Open risks
Third-party sports IP — club crests, competition badges and player headshots come from the API. Apple rejects on 5.2.1/5.2.5 for this. The onboarding photos are Wikimedia-attributed with a credits screen, which is the right instinct; the question is whether the licence covers the crests too.