Skip to content

Native iOS app: merged App Store and Stripe entitlements (backend) - #141

Merged
Programcode merged 13 commits into
feat/native-iosfrom
native-ios/08a-entitlements
Oct 14, 2026
Merged

Programcode merged 13 commits into
feat/native-iosfrom
native-ios/08a-entitlements

Conversation

@Programcode

Copy link
Copy Markdown
Owner

Server-side entitlements that combine Stripe, App Store, promo and trial into one plan, plus the App Store purchase and notification endpoints. Backend only: the StoreKit UI waits for the development build and App Store Connect. Nothing is deployed.

Deploy order (read before this reaches main)

  1. The migration deploys first.
  2. Then the ai, ai-stream, voice and account functions.
  3. Then the frontend.

Without the migration, the AI functions fail closed and every signed-in user loses AI. The staging sync from main counts as a deploy.

What's in it

  • Migration 20261014123154_app_store_entitlements.sql.
    • app_store_products: product id to basic or pro. The ids are placeholders until App Store Connect has the real ones.
    • app_store_subscriptions: one row per original transaction. Users read their own rows; only the service role writes.
    • app_store_notifications: idempotency on notificationUUID.
    • effective_entitlement(user_id): the highest active tier across Stripe, App Store, promo, trial and legacy. A missing settings row still fails closed. It runs with the caller's privileges, so it reveals nothing about other users.
  • Every tier read now goes through the RPC: _shared/entitlements.ts (ai, ai-stream, voice), the website's use-subscription and settings store, and the app's subscription, feature-config and settings. packages/core/src/ai/pro-access.ts is removed.
  • JWS verification (_shared). This follows Apple's App Store Server Library offline checks: ES256, a three-certificate chain to the pinned Apple Root CA - G3, Apple's certificate markers, validity at signedDate, then bundle id, environment and app id. The root is passed as an argument; production code always passes Apple's root.
  • app-store-notifications. It handles V2 notifications and applies the signed transaction only when it was charged later than the stored one. An exact replay and a refund of an older period change nothing.
  • app-store-purchase. It's authenticated, and the signed appAccountToken must equal the caller's user id, otherwise it returns 403.
  • Account deletion. account_deletion_plan and the account function report appStoreSubscriptionActive, so the app can send the user to Apple's subscription settings.

Checks (local; GitHub Actions is out of minutes)

At this head (after merging feat), each run under the shared lock:

  • npm run verify: exit 0. Vitest 311/311, collaboration 30/30, edge green, build OK, smoke 30/30.
  • ./.superset/db-gate.sh --db-only: exit 0, pgTAP 20 files and 498 tests. The effective-tier cases cover:
    • each source alone and both at once
    • expiry, grace period, revocation and family sharing
    • purchase-date ordering and replay
    • isolation between users
  • The author's run before the merge also passed the full-stack browser suite: 71 passed, 1 skipped.
  • npm run typecheck, the mobile typecheck and npx knip: exit 0.
  • Both functions boot in the local edge runtime: purchase OPTIONS 200 and POST without a token 401; notifications POST 503 not_configured and GET 405.

Needs Adam

  • App Store Connect. Real product ids, set by a new migration or admin SQL, and the notification URL for each environment.
  • Function settings (not secrets). APP_STORE_BUNDLE_ID (com.adam.debatelab), APP_STORE_ENVIRONMENTS and APP_STORE_APP_APPLE_ID.
  • Sandbox in production. Whether production accepts Sandbox purchases. App Review and TestFlight buy in Sandbox against the production server.
  • Basic's limit. 20 requests a day is a placeholder.

Known limits

  • Family-shared transactions carry no appAccountToken, so they can't be linked to an account.
  • Deleting an account and signing up again can't restore a purchase, because the token is the old user id.
  • An unknown product id uses up the notification's UUID, so a replay won't apply it after the product is added.
  • There's no OCSP revocation check.

Codex review rounds (xhigh, billing is release-critical) follow as comments.

🤖 Generated with Claude Code

Programcode and others added 10 commits October 14, 2026 12:38
app_store_subscriptions records each App Store subscription with its
product, expiry, environment, revocation and family-sharing state, written
only by the service role through apply_app_store_transaction, which orders
updates by expiry and signedDate and records notificationUUIDs once.
effective_entitlement() returns the highest active tier across Stripe,
promo, trial, legacy and App Store sources; with no App Store row it keeps
the rules _shared/entitlements.ts applied. Product ids are placeholders
until App Store Connect has the real ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_shared/app-store.ts checks signed notifications, transactions and
renewal info the way Apple's App Store Server Library does offline: ES256,
a three-certificate x5c chain whose intermediate is signed by Apple Root
CA - G3 (pinned as a PEM constant), Apple's leaf and intermediate OIDs,
validity at signedDate, then bundle id, environment and, in Production,
the app's Apple id. Roots are an argument, never configuration; tests
sign with a chain they generate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getSubscriptionInfo, which the ai, ai-stream and voice quota checks use,
now calls effective_entitlement() instead of recomputing the tier from
user_settings and subscriptions, and still fails closed when the plan
cannot be read. Handler tests answer that RPC through one helper; the tier
rules they used to pin moved to the pgTAP suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
app-store-notifications takes App Store Server Notifications V2, verifies
the payload and the transaction and renewal info inside it, and applies
them through apply_app_store_transaction, which makes Apple's retries
idempotent on notificationUUID. app-store-purchase lets the signed-in app
send the signed transaction StoreKit returned; it is applied only when
its appAccountToken is the caller's user id, and the answer is the plan.
Both wait with 503 until APP_STORE_BUNDLE_ID (and APP_STORE_APP_APPLE_ID
for Production) are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useSubscription, the settings store's Pro-model check, and the app's
plan, default-model and feature-config reads all call
effective_entitlement through readEntitlement in @debatelab/core, so an
App Store subscription unlocks the website too. pro-access.ts, which
recomputed Pro access from user_settings and Stripe rows, is gone. Basic
gets its label in en, es and hu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A yearly-to-monthly upgrade is charged later but expires sooner, so
apply_app_store_transaction now orders transactions by purchaseDate, not
expiry. account_deletion_plan reports whether Apple may still bill the
user (not revoked, auto-renew not off, ended under 60 days ago), and the
account function returns it so the app can send the user to Apple's
subscription settings. Both changes came from an automated security
review of this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Billing in docs/architecture.md now describes effective_entitlement,
the App Store endpoints and the deletion caveat; supabase/README.md lists
both functions, their configuration names, the placeholder products and
that deploying them needs Adam's authorization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The security-hardening check looked for the old user_settings branch in
_shared/entitlements.ts; it now pins the NO_ACCESS fallback the RPC read
returns when the plan is missing or unreadable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ai, ai-stream, voice and the website now read effective_entitlement and
fail closed without it, so the migration has to reach a project before
those functions or the frontend do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 14, 2026 13:47
@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
debate-navigator Ready Ready Preview Oct 14, 2026 4:56pm UTC
debatelab-staging Ready Ready Preview Oct 14, 2026 4:56pm UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository YAML (base), Organization UI (inherited)
  • Review profile: CHILL
  • Plan: Free
  • Run ID: 259d0d93-d4c0-4209-8dba-c2d9d14fb82f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@Programcode

Copy link
Copy Markdown
Owner Author

Codex review r1 at c524ed2 (gpt-6.1-sol, xhigh): 3 findings, accepted; fixes in progress.

  1. High: settings-store hydration awaits the new entitlement RPC without an account guard; if the account changes meanwhile, A's settings can overwrite B's prompt and be saved to B's account.
  2. Medium: the verifier drops Apple's isUpgraded flag, so a superseded transaction with a future expiry can still grant access.
  3. Low: a malformed signature segment escapes the verifier's error handling, giving a 500 instead of the documented 400.
    Codex found no change to existing Stripe, promo, trial or legacy limits, and grants, RLS, ordering, deletion and auth/CORS sound.

Programcode and others added 3 commits October 14, 2026 14:05
Hydration now awaits the plan RPC before it applies the row. If trial user A's
load was still waiting when B signed in, it replaced B's local settings with
A's and saved A's migrated feature_configs to B's row, because the save looked
up the signed-in user again.

Hydration now reads the account once and passes it to every write, and a
generation counter that reset and each new hydration bump makes a superseded
run stop after every await.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A signature segment that is not base64url made atob throw
InvalidCharacterError, and a signed date past Date's range made toISOString
throw RangeError. Neither was the verifier's error type, so both functions
answered 500 instead of their documented 400. Both now raise a malformed
verification error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a customer upgrades within the subscription group, Apple re-signs the
replaced transaction with isUpgraded. The verifier dropped that flag, so if
the re-signed copy arrived before the upgrade itself, the replaced plan kept
granting access until its old expiry.

The verifier now passes isUpgraded through, apply_app_store_transaction stores
it in a new upgraded column, and effective_entitlement skips upgraded rows, as
StoreKit's currentEntitlements does. The account deletion flag still counts
them, because Apple keeps billing the subscription after an upgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

Round-1 fixes pushed: 71ee8dd (finding 1: settings hydration drops a run an account switch overtook), 04e9abf (finding 2: a transaction Apple marks isUpgraded grants nothing; new upgraded column, effective_entitlement skips it, deletion flag still counts it), 6f58fc2 (finding 3: undecodable signature or date returns 400, not 500). Local at 04e9abf: npm run verify exit 0 (vitest 312, collaboration 30, edge 371 passed, smoke 30), database gate 502 pgTAP PASS, knip and eslint apps/mobile exit 0. Codex r2 next.

@Programcode

Copy link
Copy Markdown
Owner Author

Codex review r2 at 04e9abf (gpt-6.1-sol, xhigh): no findings. Checked the three r1 fixes, auth, RLS, idempotency, transaction and renewal ordering, fail-closed reads, and migration/function/pgTAP consistency; 46 focused Deno tests passed.

Ready, but held from merging into feat/native-ios on purpose. Every tier read (website and app) moves to the effective_entitlement RPC, which staging doesn't have yet (staging syncs from main). The Expo Go app runs against staging, so merging first would fail closed and turn off AI there. Order needed: the migration on staging, then ai, ai-stream, voice and account functions, then merge; production follows the same order with Adam's authorization. Open items: real App Store product ids and notification URLs, APP_STORE_* function settings, whether production accepts Sandbox purchases, and Basic's 20/day placeholder.

@Programcode

Copy link
Copy Markdown
Owner Author

Round-1 fixes pushed at 04e9abf: 71ee8dd drops a settings hydration an account switch overtook, 6f58fc2 reports undecodable App Store data as malformed (400), 04e9abf grants nothing for a transaction Apple marks upgraded. The 8a agent's final local checks at 04e9abf passed (report in .verify/native-ios-research/pr8a-report-final.md). Codex r2 (xhigh) running.

@Programcode

Copy link
Copy Markdown
Owner Author

Codex review r2 at 04e9abf (gpt-6.1-sol, xhigh): no findings. All three r1 fixes checked (account-switch guard and captured-user writes; isUpgraded propagation, storage and exclusion; malformed-signature and invalid-date handling); 38 focused Deno tests and 7 settings probes passed. Merging into feat/native-ios. Nothing is deployed: production needs Adam's authorization, and the App Store product ids and secrets are placeholders until App Store Connect exists (see the PR description and .verify/native-ios-research/pr8a-report-final.md for the open items: family-shared transactions without appAccountToken, restore after delete and re-signup, unknown products consuming the notification UUID).

@Programcode
Programcode merged commit bb36239 into feat/native-ios Oct 14, 2026
8 of 19 checks passed
@Programcode
Programcode deleted the native-ios/08a-entitlements branch October 14, 2026 21:43
Programcode added a commit that referenced this pull request Oct 14, 2026
Brings in Sparring (#138), development-build prep (#142), App Store
entitlements (#141) and offline reading (#139).

- The push migration becomes 20261014130000_push_tokens.sql so it sorts
  after 20261014123154_app_store_entitlements.sql.
- auth.tsx and _layout.tsx keep both the push wiring (release on sign-out,
  refresh on launch and language change) and setCacheOwner/watchNetwork.
- package-lock.json is feat's lock plus `npm install` for expo-notifications.
- app.config.ts is unchanged: #142 already strips aps-environment from
  development builds. The mobile guide now says so, and that real push needs
  a profile with push and an APNs key from Adam's Apple Developer account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

2 active deployments
Preview – debate-navigator — 04e9abf2 Deployed Oct 14, 2026 by vercel[bot]
Preview – debatelab-staging — 04e9abf2 Deployed Oct 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants