From 4616640ecb43d782c9bfd6f4c357c5ed1b638585 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 21 Apr 2026 04:08:54 +0900 Subject: [PATCH] feat(iapkit): route Horizon verification through IAPKit (horizon flavor only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets Quest apps use `verifyPurchaseWithIapkit` with just the IAPKit Bearer API key. Meta App Secret stays on the IAPKit server (per project), so the client never carries an Oculus access token. Isolation (matches the flavor split the rest of this SDK uses): - src/play/ — untouched (0 lines diff). - src/main/ — zero runtime changes. `verifyPurchaseWithIapkit` keeps its historical Google-only contract; only its stale "not yet supported" comment is updated to point at the new horizon-flavor file. - src/horizon/utils/ — NEW `PurchaseVerificationValidatorHorizon.kt` with `verifyPurchaseWithIapkitHorizon`: builds the {store, userId, sku} payload and posts it to IAPKit with the same Bearer-key auth the Google helper uses. HTTP plumbing is intentionally duplicated so the Play-flavor artifact never carries Meta/Quest code. - src/horizon/OpenIapModule.kt — `verifyPurchaseWithProvider` branches on `options.horizon`: horizon helper when the caller supplied a horizon payload, shared Google helper otherwise. A Quest app holding a Play purchase token still verifies via main. Schema (platform-agnostic, required everywhere): - New input `RequestVerifyPurchaseWithIapkitHorizonProps { userId, sku }`. - Extend `RequestVerifyPurchaseWithIapkitProps` with an optional `horizon` field so apple / google / horizon sit alongside each other. Regenerated types (unedited by hand): - TypeScript (react-native-iap, expo-iap, gql), Kotlin (google Types.kt, kmp-iap, gql), Swift (apple, gql), Dart (flutter, gql), Godot (godot-iap, gql) — all pick up the new input type automatically. Build: - `./gradlew :openiap:compileHorizonDebugKotlin` — success - `./gradlew :openiap:compilePlayDebugKotlin` — success (no regression; Play flavor sees zero Horizon code at compile time) Co-Authored-By: Claude Opus 4.7 (1M context) --- libraries/expo-iap/src/types.ts | 20 ++ .../flutter_inapp_purchase/lib/types.dart | 40 ++++ libraries/godot-iap/addons/godot-iap/types.gd | 37 +++- .../io/github/hyochan/kmpiap/openiap/Types.kt | 47 ++++- libraries/react-native-iap/src/types.ts | 20 ++ packages/apple/Sources/Models/Types.swift | 30 ++- .../java/dev/hyo/openiap/OpenIapModule.kt | 14 +- .../PurchaseVerificationValidatorHorizon.kt | 189 ++++++++++++++++++ .../src/main/java/dev/hyo/openiap/Types.kt | 47 ++++- .../utils/PurchaseVerificationValidator.kt | 4 +- packages/gql/src/generated/Types.kt | 47 ++++- packages/gql/src/generated/Types.swift | 30 ++- packages/gql/src/generated/types.dart | 40 ++++ packages/gql/src/generated/types.gd | 37 +++- packages/gql/src/generated/types.ts | 20 ++ packages/gql/src/type.graphql | 26 +++ 16 files changed, 639 insertions(+), 9 deletions(-) create mode 100644 packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/PurchaseVerificationValidatorHorizon.kt diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 4afb30b7..765f338b 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1579,11 +1579,29 @@ export interface RequestVerifyPurchaseWithIapkitGoogleProps { purchaseToken: string; } +/** + * Meta Horizon verification parameters for IAPKit. + * + * The App Secret used to call Meta's Graph API lives on the IAPKit server + * (per project), so the client only needs to identify the entitlement by + * (userId, sku). Authentication with IAPKit is the Bearer API key shared + * with apple / google. + */ +export interface RequestVerifyPurchaseWithIapkitHorizonProps { + /** The SKU for the add-on item, defined in the Meta Developer Dashboard. */ + sku: string; + /** The user ID of the user whose purchase you want to verify. */ + userId: string; +} + /** * Platform-specific verification parameters for IAPKit. * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The + * IAPKit server holds the Horizon App Secret, so the client only sends + * (userId, sku) — no Meta access token required here. */ export interface RequestVerifyPurchaseWithIapkitProps { /** API key used for the Authorization header (Bearer {apiKey}). */ @@ -1592,6 +1610,8 @@ export interface RequestVerifyPurchaseWithIapkitProps { apple?: (RequestVerifyPurchaseWithIapkitAppleProps | null); /** Google Play Store verification parameters. */ google?: (RequestVerifyPurchaseWithIapkitGoogleProps | null); + /** Meta Horizon (Quest) verification parameters. */ + horizon?: (RequestVerifyPurchaseWithIapkitHorizonProps | null); } export interface RequestVerifyPurchaseWithIapkitResult { diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index ea75b5b3..d7c733d0 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -4406,15 +4406,51 @@ class RequestVerifyPurchaseWithIapkitGoogleProps { } } +/// Meta Horizon verification parameters for IAPKit. +/// +/// The App Secret used to call Meta's Graph API lives on the IAPKit server +/// (per project), so the client only needs to identify the entitlement by +/// (userId, sku). Authentication with IAPKit is the Bearer API key shared +/// with apple / google. +class RequestVerifyPurchaseWithIapkitHorizonProps { + const RequestVerifyPurchaseWithIapkitHorizonProps({ + required this.sku, + required this.userId, + }); + + /// The SKU for the add-on item, defined in the Meta Developer Dashboard. + final String sku; + /// The user ID of the user whose purchase you want to verify. + final String userId; + + factory RequestVerifyPurchaseWithIapkitHorizonProps.fromJson(Map json) { + return RequestVerifyPurchaseWithIapkitHorizonProps( + sku: json['sku'] as String, + userId: json['userId'] as String, + ); + } + + Map toJson() { + return { + 'sku': sku, + 'userId': userId, + }; + } +} + /// Platform-specific verification parameters for IAPKit. /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The +/// IAPKit server holds the Horizon App Secret, so the client only sends +/// (userId, sku) — no Meta access token required here. class RequestVerifyPurchaseWithIapkitProps { const RequestVerifyPurchaseWithIapkitProps({ this.apiKey, this.apple, this.google, + this.horizon, }); /// API key used for the Authorization header (Bearer {apiKey}). @@ -4423,12 +4459,15 @@ class RequestVerifyPurchaseWithIapkitProps { final RequestVerifyPurchaseWithIapkitAppleProps? apple; /// Google Play Store verification parameters. final RequestVerifyPurchaseWithIapkitGoogleProps? google; + /// Meta Horizon (Quest) verification parameters. + final RequestVerifyPurchaseWithIapkitHorizonProps? horizon; factory RequestVerifyPurchaseWithIapkitProps.fromJson(Map json) { return RequestVerifyPurchaseWithIapkitProps( apiKey: json['apiKey'] as String?, apple: json['apple'] != null ? RequestVerifyPurchaseWithIapkitAppleProps.fromJson(json['apple'] as Map) : null, google: json['google'] != null ? RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(json['google'] as Map) : null, + horizon: json['horizon'] != null ? RequestVerifyPurchaseWithIapkitHorizonProps.fromJson(json['horizon'] as Map) : null, ); } @@ -4437,6 +4476,7 @@ class RequestVerifyPurchaseWithIapkitProps { 'apiKey': apiKey, 'apple': apple?.toJson(), 'google': google?.toJson(), + 'horizon': horizon?.toJson(), }; } } diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index f1b51f5a..b4420a8d 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -4096,7 +4096,30 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: dict["purchaseToken"] = purchase_token return dict -## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) +## Meta Horizon verification parameters for IAPKit. The App Secret used to call Meta's Graph API lives on the IAPKit server (per project), so the client only needs to identify the entitlement by (userId, sku). Authentication with IAPKit is the Bearer API key shared with apple / google. +class RequestVerifyPurchaseWithIapkitHorizonProps: + ## The user ID of the user whose purchase you want to verify. + var user_id: String = "" + ## The SKU for the add-on item, defined in the Meta Developer Dashboard. + var sku: String = "" + + static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitHorizonProps: + var obj = RequestVerifyPurchaseWithIapkitHorizonProps.new() + if data.has("userId") and data["userId"] != null: + obj.user_id = data["userId"] + if data.has("sku") and data["sku"] != null: + obj.sku = data["sku"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if user_id != null: + dict["userId"] = user_id + if sku != null: + dict["sku"] = sku + return dict + +## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The IAPKit server holds the Horizon App Secret, so the client only sends (userId, sku) — no Meta access token required here. class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null @@ -4104,6 +4127,8 @@ class RequestVerifyPurchaseWithIapkitProps: var apple: RequestVerifyPurchaseWithIapkitAppleProps ## Google Play Store verification parameters. var google: RequestVerifyPurchaseWithIapkitGoogleProps + ## Meta Horizon (Quest) verification parameters. + var horizon: RequestVerifyPurchaseWithIapkitHorizonProps static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitProps: var obj = RequestVerifyPurchaseWithIapkitProps.new() @@ -4119,6 +4144,11 @@ class RequestVerifyPurchaseWithIapkitProps: obj.google = RequestVerifyPurchaseWithIapkitGoogleProps.from_dict(data["google"]) else: obj.google = data["google"] + if data.has("horizon") and data["horizon"] != null: + if data["horizon"] is Dictionary: + obj.horizon = RequestVerifyPurchaseWithIapkitHorizonProps.from_dict(data["horizon"]) + else: + obj.horizon = data["horizon"] return obj func to_dict() -> Dictionary: @@ -4135,6 +4165,11 @@ class RequestVerifyPurchaseWithIapkitProps: dict["google"] = google.to_dict() else: dict["google"] = google + if horizon != null: + if horizon.has_method("to_dict"): + dict["horizon"] = horizon.to_dict() + else: + dict["horizon"] = horizon return dict ## Product-level subscription replacement parameters (Android) Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams Available in Google Play Billing Library 8.1.0+ diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index becd8523..e6b3e6c2 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -4464,11 +4464,50 @@ public data class RequestVerifyPurchaseWithIapkitGoogleProps( ) } +/** + * Meta Horizon verification parameters for IAPKit. + * + * The App Secret used to call Meta's Graph API lives on the IAPKit server + * (per project), so the client only needs to identify the entitlement by + * (userId, sku). Authentication with IAPKit is the Bearer API key shared + * with apple / google. + */ +public data class RequestVerifyPurchaseWithIapkitHorizonProps( + /** + * The SKU for the add-on item, defined in the Meta Developer Dashboard. + */ + val sku: String, + /** + * The user ID of the user whose purchase you want to verify. + */ + val userId: String +) { + companion object { + fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitHorizonProps? { + val sku = json["sku"] as? String + val userId = json["userId"] as? String + if (sku == null || userId == null) return null + return RequestVerifyPurchaseWithIapkitHorizonProps( + sku = sku, + userId = userId, + ) + } + } + + fun toJson(): Map = mapOf( + "sku" to sku, + "userId" to userId, + ) +} + /** * Platform-specific verification parameters for IAPKit. * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The + * IAPKit server holds the Horizon App Secret, so the client only sends + * (userId, sku) — no Meta access token required here. */ public data class RequestVerifyPurchaseWithIapkitProps( /** @@ -4482,7 +4521,11 @@ public data class RequestVerifyPurchaseWithIapkitProps( /** * Google Play Store verification parameters. */ - val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null + val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null, + /** + * Meta Horizon (Quest) verification parameters. + */ + val horizon: RequestVerifyPurchaseWithIapkitHorizonProps? = null ) { companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitProps { @@ -4490,6 +4533,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( apiKey = json["apiKey"] as? String, apple = (json["apple"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) }, google = (json["google"] as? Map)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) }, + horizon = (json["horizon"] as? Map)?.let { RequestVerifyPurchaseWithIapkitHorizonProps.fromJson(it) }, ) } } @@ -4498,6 +4542,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( "apiKey" to apiKey, "apple" to apple?.toJson(), "google" to google?.toJson(), + "horizon" to horizon?.toJson(), ) } diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 4afb30b7..765f338b 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1579,11 +1579,29 @@ export interface RequestVerifyPurchaseWithIapkitGoogleProps { purchaseToken: string; } +/** + * Meta Horizon verification parameters for IAPKit. + * + * The App Secret used to call Meta's Graph API lives on the IAPKit server + * (per project), so the client only needs to identify the entitlement by + * (userId, sku). Authentication with IAPKit is the Bearer API key shared + * with apple / google. + */ +export interface RequestVerifyPurchaseWithIapkitHorizonProps { + /** The SKU for the add-on item, defined in the Meta Developer Dashboard. */ + sku: string; + /** The user ID of the user whose purchase you want to verify. */ + userId: string; +} + /** * Platform-specific verification parameters for IAPKit. * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The + * IAPKit server holds the Horizon App Secret, so the client only sends + * (userId, sku) — no Meta access token required here. */ export interface RequestVerifyPurchaseWithIapkitProps { /** API key used for the Authorization header (Bearer {apiKey}). */ @@ -1592,6 +1610,8 @@ export interface RequestVerifyPurchaseWithIapkitProps { apple?: (RequestVerifyPurchaseWithIapkitAppleProps | null); /** Google Play Store verification parameters. */ google?: (RequestVerifyPurchaseWithIapkitGoogleProps | null); + /** Meta Horizon (Quest) verification parameters. */ + horizon?: (RequestVerifyPurchaseWithIapkitHorizonProps | null); } export interface RequestVerifyPurchaseWithIapkitResult { diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index a3547e12..0ecadf83 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1853,10 +1853,34 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { } } +/// Meta Horizon verification parameters for IAPKit. +/// +/// The App Secret used to call Meta's Graph API lives on the IAPKit server +/// (per project), so the client only needs to identify the entitlement by +/// (userId, sku). Authentication with IAPKit is the Bearer API key shared +/// with apple / google. +public struct RequestVerifyPurchaseWithIapkitHorizonProps: Codable { + /// The SKU for the add-on item, defined in the Meta Developer Dashboard. + public var sku: String + /// The user ID of the user whose purchase you want to verify. + public var userId: String + + public init( + sku: String, + userId: String + ) { + self.sku = sku + self.userId = userId + } +} + /// Platform-specific verification parameters for IAPKit. /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The +/// IAPKit server holds the Horizon App Secret, so the client only sends +/// (userId, sku) — no Meta access token required here. public struct RequestVerifyPurchaseWithIapkitProps: Codable { /// API key used for the Authorization header (Bearer {apiKey}). public var apiKey: String? @@ -1864,15 +1888,19 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { public var apple: RequestVerifyPurchaseWithIapkitAppleProps? /// Google Play Store verification parameters. public var google: RequestVerifyPurchaseWithIapkitGoogleProps? + /// Meta Horizon (Quest) verification parameters. + public var horizon: RequestVerifyPurchaseWithIapkitHorizonProps? public init( apiKey: String? = nil, apple: RequestVerifyPurchaseWithIapkitAppleProps? = nil, - google: RequestVerifyPurchaseWithIapkitGoogleProps? = nil + google: RequestVerifyPurchaseWithIapkitGoogleProps? = nil, + horizon: RequestVerifyPurchaseWithIapkitHorizonProps? = nil ) { self.apiKey = apiKey self.apple = apple self.google = google + self.horizon = horizon } } diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index 061de4f9..22c16b54 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -41,6 +41,7 @@ import dev.hyo.openiap.MutationValidateReceiptHandler import dev.hyo.openiap.MutationVerifyPurchaseWithProviderHandler import dev.hyo.openiap.PurchaseVerificationProvider import dev.hyo.openiap.utils.verifyPurchaseWithIapkit +import dev.hyo.openiap.utils.verifyPurchaseWithIapkitHorizon import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -722,8 +723,19 @@ class OpenIapModule( val options = props.iapkit ?: throw OpenIapError.DeveloperError( "Missing IAPKit verification parameters" ) + // Horizon-flavor-only routing: if the caller supplied a + // `horizon` payload, use the flavor-local helper that knows + // how to assemble the Meta-specific IAPKit request. Otherwise + // fall through to the shared Google path in main — a Quest + // app that happens to hold a Play purchase token still gets + // verified that way. + val iapkitResult = if (options.horizon != null) { + verifyPurchaseWithIapkitHorizon(options, TAG) + } else { + verifyPurchaseWithIapkit(options, TAG) + } VerifyPurchaseWithProviderResult( - iapkit = verifyPurchaseWithIapkit(options, TAG), + iapkit = iapkitResult, provider = props.provider ) } diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/PurchaseVerificationValidatorHorizon.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/PurchaseVerificationValidatorHorizon.kt new file mode 100644 index 00000000..3d395f42 --- /dev/null +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/PurchaseVerificationValidatorHorizon.kt @@ -0,0 +1,189 @@ +package dev.hyo.openiap.utils + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import dev.hyo.openiap.IapStore +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapLog +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitHorizonProps +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +/** + * Horizon-flavor IAPKit verification. + * + * Lives entirely under `src/horizon/` so the Play-flavor compiled + * artifact never carries Meta/Quest-specific code. The main-source + * `verifyPurchaseWithIapkit` keeps its historical Google-only + * contract; the Horizon module imports this function instead when + * the caller populated `props.horizon`. + * + * Authentication contract (matches the Google helper): + * - Client passes its IAPKit Bearer API key on `props.apiKey`. + * - No Meta credential ever lives on the device. IAPKit holds the + * Horizon App ID + App Secret server-side, composes + * `OC|APP_ID|APP_SECRET`, and calls + * `graph.oculus.com/{APP_ID}/verify_entitlement` on our behalf. + * + * The HTTP plumbing is intentionally duplicated from + * `PurchaseVerificationValidator.kt` rather than shared via an + * `internal` helper in main — keeping it here means the `main` + * source tree has no Horizon knowledge at all and the Play flavor + * build can't accidentally drift into Horizon code paths. + */ + +private const val IAPKIT_ENDPOINT = "https://kit.openiap.dev/v1/purchase/verify" +private val horizonGson = Gson() + +private fun horizonOpenConnection(url: String): HttpURLConnection = + URL(url).openConnection() as HttpURLConnection + +suspend fun verifyPurchaseWithIapkitHorizon( + props: RequestVerifyPurchaseWithIapkitProps, + tag: String, + connectionFactory: (String) -> HttpURLConnection = ::horizonOpenConnection, +): RequestVerifyPurchaseWithIapkitResult = withContext(Dispatchers.IO) { + val horizon = props.horizon + ?: throw IllegalArgumentException( + "IAPKit Horizon verification requires horizon options (userId, sku)", + ) + if (horizon.userId.isBlank() || horizon.sku.isBlank()) { + throw IllegalArgumentException( + "IAPKit Horizon verification requires userId and sku", + ) + } + + val payload = buildHorizonIapkitPayload(horizon) + val store = IapStore.Horizon + val connection = connectionFactory(IAPKIT_ENDPOINT).apply { + requestMethod = "POST" + doOutput = true + setRequestProperty("Content-Type", "application/json") + props.apiKey?.takeIf { it.isNotBlank() }?.let { apiKey -> + setRequestProperty("Authorization", "Bearer $apiKey") + } + } + + try { + val body = horizonGson.toJson(payload) + connection.outputStream.use { stream -> + stream.write(body.toByteArray(Charsets.UTF_8)) + } + + val statusCode = connection.responseCode + val responseBody = ( + if (statusCode in 200..299) connection.inputStream else connection.errorStream + ) + ?.bufferedReader() + ?.use { it.readText() } + ?: "" + + if (statusCode !in 200..299) { + OpenIapLog.warn( + "verifyPurchaseWithIapkitHorizon failed (HTTP $statusCode) [$store]", + tag, + ) + // Surface the deepest originalError if IAPKit returned a + // structured error body; fall back to HTTP status text. + val errorMessage = try { + val mapType = object : TypeToken>() {}.type + val errorJson = horizonGson.fromJson>(responseBody, mapType) + extractHorizonIapkitErrorMessage(errorJson) ?: "HTTP $statusCode" + } catch (e: Exception) { + "HTTP $statusCode" + } + throw OpenIapError.PurchaseVerificationFailed(errorMessage) + } + + try { + val mapType = object : TypeToken>() {}.type + val parsed = horizonGson.fromJson>(responseBody, mapType) + // IAPKit returns UPPER_SNAKE_CASE states; Types.kt expects + // lower-kebab-case. Mirror the normalization main's + // Google helper already does. + val normalizedParsed = parsed.toMutableMap().apply { + val state = this["state"] as? String + if (state != null) { + this["state"] = state.lowercase().replace("_", "-") + } + if (this["store"] == null) { + this["store"] = store.toJson() + } + } + RequestVerifyPurchaseWithIapkitResult.fromJson(normalizedParsed) + } catch (jsonError: Exception) { + OpenIapLog.warn( + "Failed to parse IAPKit Horizon verification response: ${jsonError.message}", + tag, + ) + throw OpenIapError.PurchaseVerificationFailed("Failed to parse response") + } + } catch (io: IOException) { + OpenIapLog.warn( + "Network error during IAPKit Horizon verification: ${io.message}", + tag, + ) + throw OpenIapError.PurchaseVerificationFailed("Network error: ${io.message}") + } finally { + connection.disconnect() + } +} + +/** + * Build payload for Meta Horizon verification via IAPKit. The Meta + * App Secret lives on the IAPKit server, so the client never carries + * an Oculus access token — IAPKit composes `OC|APP_ID|APP_SECRET` + * server-side and calls Meta's verify_entitlement on our behalf. + */ +private fun buildHorizonIapkitPayload( + horizon: RequestVerifyPurchaseWithIapkitHorizonProps, +): Map = + mutableMapOf( + "store" to IapStore.Horizon.rawValue, + "userId" to horizon.userId, + "sku" to horizon.sku, + ) + +/** + * Extract the deepest originalError from IAPKit's nested error body. + * Matches the shape `{ errors: [{ code, message, details: { originalError } }] }`. + */ +@Suppress("UNCHECKED_CAST") +private fun extractHorizonIapkitErrorMessage(json: Map): String? { + val errorsRaw = json["errors"] + if (errorsRaw is List<*>) { + val firstError = errorsRaw.firstOrNull() + if (firstError is Map<*, *>) { + return extractHorizonIapkitErrorMessage(firstError as Map) + } + } + + val detailsRaw = json["details"] + if (detailsRaw is Map<*, *>) { + val details = detailsRaw as Map + val originalError = details["originalError"] + if (originalError is String) { + return try { + val nested = horizonGson.fromJson(originalError, Map::class.java) as? Map + if (nested != null) { + extractHorizonIapkitErrorMessage(nested) ?: originalError + } else { + originalError + } + } catch (e: Exception) { + originalError + } + } + } + + val message = json["message"] as? String + if (message != null && !message.contains("{\"error\"")) { + return message + } + return json["error"] as? String +} diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index bc6306db..01447c8e 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -4373,11 +4373,50 @@ public data class RequestVerifyPurchaseWithIapkitGoogleProps( ) } +/** + * Meta Horizon verification parameters for IAPKit. + * + * The App Secret used to call Meta's Graph API lives on the IAPKit server + * (per project), so the client only needs to identify the entitlement by + * (userId, sku). Authentication with IAPKit is the Bearer API key shared + * with apple / google. + */ +public data class RequestVerifyPurchaseWithIapkitHorizonProps( + /** + * The SKU for the add-on item, defined in the Meta Developer Dashboard. + */ + val sku: String, + /** + * The user ID of the user whose purchase you want to verify. + */ + val userId: String +) { + companion object { + fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitHorizonProps? { + val sku = json["sku"] as? String + val userId = json["userId"] as? String + if (sku == null || userId == null) return null + return RequestVerifyPurchaseWithIapkitHorizonProps( + sku = sku, + userId = userId, + ) + } + } + + fun toJson(): Map = mapOf( + "sku" to sku, + "userId" to userId, + ) +} + /** * Platform-specific verification parameters for IAPKit. * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The + * IAPKit server holds the Horizon App Secret, so the client only sends + * (userId, sku) — no Meta access token required here. */ public data class RequestVerifyPurchaseWithIapkitProps( /** @@ -4391,7 +4430,11 @@ public data class RequestVerifyPurchaseWithIapkitProps( /** * Google Play Store verification parameters. */ - val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null + val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null, + /** + * Meta Horizon (Quest) verification parameters. + */ + val horizon: RequestVerifyPurchaseWithIapkitHorizonProps? = null ) { companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitProps { @@ -4399,6 +4442,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( apiKey = json["apiKey"] as? String, apple = (json["apple"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) }, google = (json["google"] as? Map)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) }, + horizon = (json["horizon"] as? Map)?.let { RequestVerifyPurchaseWithIapkitHorizonProps.fromJson(it) }, ) } } @@ -4407,6 +4451,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( "apiKey" to apiKey, "apple" to apple?.toJson(), "google" to google?.toJson(), + "horizon" to horizon?.toJson(), ) } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index cf829a81..45601625 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -171,7 +171,9 @@ suspend fun verifyPurchaseWithIapkit( val endpoint = DEFAULT_IAPKIT_ENDPOINT // On Android, only Google verification is supported via IAPKit - // Note: Horizon verification requires direct S2S API calls to Meta (not yet supported) + // Note: Horizon verification lives in the horizon flavor source + // tree (`src/horizon/.../PurchaseVerificationValidatorHorizon.kt`) + // so Play-flavor builds never carry Meta/Quest-specific code. if (props.google == null) { throw IllegalArgumentException("IAPKit verification on Android requires google payload") } diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index d5151235..bd20df5c 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -4462,11 +4462,50 @@ public data class RequestVerifyPurchaseWithIapkitGoogleProps( ) } +/** + * Meta Horizon verification parameters for IAPKit. + * + * The App Secret used to call Meta's Graph API lives on the IAPKit server + * (per project), so the client only needs to identify the entitlement by + * (userId, sku). Authentication with IAPKit is the Bearer API key shared + * with apple / google. + */ +public data class RequestVerifyPurchaseWithIapkitHorizonProps( + /** + * The SKU for the add-on item, defined in the Meta Developer Dashboard. + */ + val sku: String, + /** + * The user ID of the user whose purchase you want to verify. + */ + val userId: String +) { + companion object { + fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitHorizonProps? { + val sku = json["sku"] as? String + val userId = json["userId"] as? String + if (sku == null || userId == null) return null + return RequestVerifyPurchaseWithIapkitHorizonProps( + sku = sku, + userId = userId, + ) + } + } + + fun toJson(): Map = mapOf( + "sku" to sku, + "userId" to userId, + ) +} + /** * Platform-specific verification parameters for IAPKit. * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The + * IAPKit server holds the Horizon App Secret, so the client only sends + * (userId, sku) — no Meta access token required here. */ public data class RequestVerifyPurchaseWithIapkitProps( /** @@ -4480,7 +4519,11 @@ public data class RequestVerifyPurchaseWithIapkitProps( /** * Google Play Store verification parameters. */ - val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null + val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null, + /** + * Meta Horizon (Quest) verification parameters. + */ + val horizon: RequestVerifyPurchaseWithIapkitHorizonProps? = null ) { companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitProps { @@ -4488,6 +4531,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( apiKey = json["apiKey"] as? String, apple = (json["apple"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) }, google = (json["google"] as? Map)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) }, + horizon = (json["horizon"] as? Map)?.let { RequestVerifyPurchaseWithIapkitHorizonProps.fromJson(it) }, ) } } @@ -4496,6 +4540,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( "apiKey" to apiKey, "apple" to apple?.toJson(), "google" to google?.toJson(), + "horizon" to horizon?.toJson(), ) } diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index a3547e12..0ecadf83 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1853,10 +1853,34 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { } } +/// Meta Horizon verification parameters for IAPKit. +/// +/// The App Secret used to call Meta's Graph API lives on the IAPKit server +/// (per project), so the client only needs to identify the entitlement by +/// (userId, sku). Authentication with IAPKit is the Bearer API key shared +/// with apple / google. +public struct RequestVerifyPurchaseWithIapkitHorizonProps: Codable { + /// The SKU for the add-on item, defined in the Meta Developer Dashboard. + public var sku: String + /// The user ID of the user whose purchase you want to verify. + public var userId: String + + public init( + sku: String, + userId: String + ) { + self.sku = sku + self.userId = userId + } +} + /// Platform-specific verification parameters for IAPKit. /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The +/// IAPKit server holds the Horizon App Secret, so the client only sends +/// (userId, sku) — no Meta access token required here. public struct RequestVerifyPurchaseWithIapkitProps: Codable { /// API key used for the Authorization header (Bearer {apiKey}). public var apiKey: String? @@ -1864,15 +1888,19 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { public var apple: RequestVerifyPurchaseWithIapkitAppleProps? /// Google Play Store verification parameters. public var google: RequestVerifyPurchaseWithIapkitGoogleProps? + /// Meta Horizon (Quest) verification parameters. + public var horizon: RequestVerifyPurchaseWithIapkitHorizonProps? public init( apiKey: String? = nil, apple: RequestVerifyPurchaseWithIapkitAppleProps? = nil, - google: RequestVerifyPurchaseWithIapkitGoogleProps? = nil + google: RequestVerifyPurchaseWithIapkitGoogleProps? = nil, + horizon: RequestVerifyPurchaseWithIapkitHorizonProps? = nil ) { self.apiKey = apiKey self.apple = apple self.google = google + self.horizon = horizon } } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index ea75b5b3..d7c733d0 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -4406,15 +4406,51 @@ class RequestVerifyPurchaseWithIapkitGoogleProps { } } +/// Meta Horizon verification parameters for IAPKit. +/// +/// The App Secret used to call Meta's Graph API lives on the IAPKit server +/// (per project), so the client only needs to identify the entitlement by +/// (userId, sku). Authentication with IAPKit is the Bearer API key shared +/// with apple / google. +class RequestVerifyPurchaseWithIapkitHorizonProps { + const RequestVerifyPurchaseWithIapkitHorizonProps({ + required this.sku, + required this.userId, + }); + + /// The SKU for the add-on item, defined in the Meta Developer Dashboard. + final String sku; + /// The user ID of the user whose purchase you want to verify. + final String userId; + + factory RequestVerifyPurchaseWithIapkitHorizonProps.fromJson(Map json) { + return RequestVerifyPurchaseWithIapkitHorizonProps( + sku: json['sku'] as String, + userId: json['userId'] as String, + ); + } + + Map toJson() { + return { + 'sku': sku, + 'userId': userId, + }; + } +} + /// Platform-specific verification parameters for IAPKit. /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The +/// IAPKit server holds the Horizon App Secret, so the client only sends +/// (userId, sku) — no Meta access token required here. class RequestVerifyPurchaseWithIapkitProps { const RequestVerifyPurchaseWithIapkitProps({ this.apiKey, this.apple, this.google, + this.horizon, }); /// API key used for the Authorization header (Bearer {apiKey}). @@ -4423,12 +4459,15 @@ class RequestVerifyPurchaseWithIapkitProps { final RequestVerifyPurchaseWithIapkitAppleProps? apple; /// Google Play Store verification parameters. final RequestVerifyPurchaseWithIapkitGoogleProps? google; + /// Meta Horizon (Quest) verification parameters. + final RequestVerifyPurchaseWithIapkitHorizonProps? horizon; factory RequestVerifyPurchaseWithIapkitProps.fromJson(Map json) { return RequestVerifyPurchaseWithIapkitProps( apiKey: json['apiKey'] as String?, apple: json['apple'] != null ? RequestVerifyPurchaseWithIapkitAppleProps.fromJson(json['apple'] as Map) : null, google: json['google'] != null ? RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(json['google'] as Map) : null, + horizon: json['horizon'] != null ? RequestVerifyPurchaseWithIapkitHorizonProps.fromJson(json['horizon'] as Map) : null, ); } @@ -4437,6 +4476,7 @@ class RequestVerifyPurchaseWithIapkitProps { 'apiKey': apiKey, 'apple': apple?.toJson(), 'google': google?.toJson(), + 'horizon': horizon?.toJson(), }; } } diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index f1b51f5a..b4420a8d 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -4096,7 +4096,30 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: dict["purchaseToken"] = purchase_token return dict -## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) +## Meta Horizon verification parameters for IAPKit. The App Secret used to call Meta's Graph API lives on the IAPKit server (per project), so the client only needs to identify the entitlement by (userId, sku). Authentication with IAPKit is the Bearer API key shared with apple / google. +class RequestVerifyPurchaseWithIapkitHorizonProps: + ## The user ID of the user whose purchase you want to verify. + var user_id: String = "" + ## The SKU for the add-on item, defined in the Meta Developer Dashboard. + var sku: String = "" + + static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitHorizonProps: + var obj = RequestVerifyPurchaseWithIapkitHorizonProps.new() + if data.has("userId") and data["userId"] != null: + obj.user_id = data["userId"] + if data.has("sku") and data["sku"] != null: + obj.sku = data["sku"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if user_id != null: + dict["userId"] = user_id + if sku != null: + dict["sku"] = sku + return dict + +## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The IAPKit server holds the Horizon App Secret, so the client only sends (userId, sku) — no Meta access token required here. class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null @@ -4104,6 +4127,8 @@ class RequestVerifyPurchaseWithIapkitProps: var apple: RequestVerifyPurchaseWithIapkitAppleProps ## Google Play Store verification parameters. var google: RequestVerifyPurchaseWithIapkitGoogleProps + ## Meta Horizon (Quest) verification parameters. + var horizon: RequestVerifyPurchaseWithIapkitHorizonProps static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitProps: var obj = RequestVerifyPurchaseWithIapkitProps.new() @@ -4119,6 +4144,11 @@ class RequestVerifyPurchaseWithIapkitProps: obj.google = RequestVerifyPurchaseWithIapkitGoogleProps.from_dict(data["google"]) else: obj.google = data["google"] + if data.has("horizon") and data["horizon"] != null: + if data["horizon"] is Dictionary: + obj.horizon = RequestVerifyPurchaseWithIapkitHorizonProps.from_dict(data["horizon"]) + else: + obj.horizon = data["horizon"] return obj func to_dict() -> Dictionary: @@ -4135,6 +4165,11 @@ class RequestVerifyPurchaseWithIapkitProps: dict["google"] = google.to_dict() else: dict["google"] = google + if horizon != null: + if horizon.has_method("to_dict"): + dict["horizon"] = horizon.to_dict() + else: + dict["horizon"] = horizon return dict ## Product-level subscription replacement parameters (Android) Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams Available in Google Play Billing Library 8.1.0+ diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 4afb30b7..765f338b 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1579,11 +1579,29 @@ export interface RequestVerifyPurchaseWithIapkitGoogleProps { purchaseToken: string; } +/** + * Meta Horizon verification parameters for IAPKit. + * + * The App Secret used to call Meta's Graph API lives on the IAPKit server + * (per project), so the client only needs to identify the entitlement by + * (userId, sku). Authentication with IAPKit is the Bearer API key shared + * with apple / google. + */ +export interface RequestVerifyPurchaseWithIapkitHorizonProps { + /** The SKU for the add-on item, defined in the Meta Developer Dashboard. */ + sku: string; + /** The user ID of the user whose purchase you want to verify. */ + userId: string; +} + /** * Platform-specific verification parameters for IAPKit. * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - horizon: Verifies via Meta's S2S verify_entitlement endpoint. The + * IAPKit server holds the Horizon App Secret, so the client only sends + * (userId, sku) — no Meta access token required here. */ export interface RequestVerifyPurchaseWithIapkitProps { /** API key used for the Authorization header (Bearer {apiKey}). */ @@ -1592,6 +1610,8 @@ export interface RequestVerifyPurchaseWithIapkitProps { apple?: (RequestVerifyPurchaseWithIapkitAppleProps | null); /** Google Play Store verification parameters. */ google?: (RequestVerifyPurchaseWithIapkitGoogleProps | null); + /** Meta Horizon (Quest) verification parameters. */ + horizon?: (RequestVerifyPurchaseWithIapkitHorizonProps | null); } export interface RequestVerifyPurchaseWithIapkitResult { diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 60b2175d..aecb398d 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -300,11 +300,33 @@ input RequestVerifyPurchaseWithIapkitGoogleProps { purchaseToken: String! } +""" +Meta Horizon verification parameters for IAPKit. + +The App Secret used to call Meta's Graph API lives on the IAPKit server +(per project), so the client only needs to identify the entitlement by +(userId, sku). Authentication with IAPKit is the Bearer API key shared +with apple / google. +""" +input RequestVerifyPurchaseWithIapkitHorizonProps { + """ + The user ID of the user whose purchase you want to verify. + """ + userId: String! + """ + The SKU for the add-on item, defined in the Meta Developer Dashboard. + """ + sku: String! +} + """ Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) +- horizon: Verifies via Meta's S2S verify_entitlement endpoint. The + IAPKit server holds the Horizon App Secret, so the client only sends + (userId, sku) — no Meta access token required here. """ input RequestVerifyPurchaseWithIapkitProps { """ @@ -319,6 +341,10 @@ input RequestVerifyPurchaseWithIapkitProps { Google Play Store verification parameters. """ google: RequestVerifyPurchaseWithIapkitGoogleProps + """ + Meta Horizon (Quest) verification parameters. + """ + horizon: RequestVerifyPurchaseWithIapkitHorizonProps } """