Skip to content

kit: Horizon subscription lane is structurally broken and can corrupt Google Play subscriptions #310

Description

@hyochan

An internal audit of the per-store verification lanes found that the Meta Horizon asynchronous half (subscriptions + reconciler) has structural defects. The synchronous verify path (/v1/purchase/verify with store:"horizon") is wired correctly; everything below concerns what happens after.

Blockers

1. Reconciler can expire real Google Play subscriptions on mixed projects

listHorizonSubscriptions filters sub.platform === "Android" && !!sub.userId — the subscriptions table has no store discriminator. On a project with both Google Play verification and horizonEnabled, every user-bound Google Play subscription matches, and the reconciler POSTs each (userId, productId) to graph.oculus.com/{APP_ID}/verify_entitlement. If Meta answers HTTP 200 {success:false}, the transition logic marks the Google subscription Expired.

  • packages/kit/convex/subscriptions/horizonInternal.ts:156-165 (platform filter)
  • packages/kit/convex/subscriptions/horizon.ts:144-156 (expiry transition)

Fix direction: add a store/provenance column to subscriptions (or a Horizon-scoped table) and filter the reconciler on it. Requires a staged migration (see #241 for the pattern).

2. Horizon subscriptions are never created — the reconciler polls a phantom population

verifyMetaHorizonReceiptInternalV1 only calls saveReceiptInternal (purchases row). recordVerifiedSubscription's only callers are purchases/ios.ts and purchases/android.ts, and no Horizon webhook receiver exists, so no subscriptions row with Horizon provenance can exist. Horizon subscription tracking is effectively an unfinished feature.

  • packages/kit/convex/purchases/horizon.ts:108,132
  • packages/kit/convex/subscriptions/internal.ts:399-449

Major

  • Transient Meta failures overwrite ENTITLED → INAUTHENTIC: the verify action's catch-all persists state:INAUTHENTIC for every failure including network errors/5xx; the deterministic remoteId ({userId}:{sku}) makes the dedup path patch the existing row. The Google lane deliberately avoids this — mirror that pattern. (packages/kit/convex/purchases/horizon.ts:104-125, purchases/internal.ts:333-395)
  • Unvalidated Meta response-shape assumption: both parseHorizonResponse and checkHorizonEntitlement treat non-entitlement as HTTP 200 {success:false}. If Meta actually signals it as HTTP 400 with an {error:{...}} body (Graph API convention), the verify route returns a 400 error instead of {isValid:false, state:INAUTHENTIC}, and reconciler expiry detection never fires. (purchases/horizon.ts:31,89-102,182-202)
  • Events mislabeled as Android: recordHorizonStatus hard-codes platform:'Android' + environment:'Production' on webhookEvents, inflating Android dashboard renewals; revenueMetricsDaily inherits the same conflation. (subscriptions/horizonInternal.ts:233-245, convex/schema.ts:569,699,820)

Minor

  • reconcileHorizonNow is a dead public action (no callers in dashboard/server/mcp-server) duplicating ~85 lines of the cron probe loop — fold or remove.
  • No sandbox/test-environment concept in the lane; the first manual E2E will pollute Production metrics.

Manual E2E checklist (requires Meta developer account + Quest test user)

  • Confirm the actual verify_entitlement response for a user who does NOT own the SKU (200 {success:false} vs 400 error body) — both halves of the lane depend on this
  • Capture a real entitled response and turn it into a fixture-based test
  • Verify the token/auth flow (app access token) against a live app

Note: no first-party SDK currently sends store:"horizon" to kit (the GQL RequestVerifyPurchaseWithIapkitProps has no horizon member — SDKs call Meta S2S directly), so this lane is only reachable via raw REST today. Worth deciding whether to add the spec member or document the lane as backend-only.

🤖 Findings from an internal multi-agent audit; file:line references verified against main at the time of filing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    kitIAPKit (receipt-validation SaaS)🐛 bugSomething isn't working🕶️ meta

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions